消息 ID 与序列号:全局唯一与会话内有序

IM 里的消息有两套编号,职责完全不同:msg_id 负责全局唯一(跨会话、跨机房、跨分片都不重复),seq 负责会话内有序(同一个会话里谁先谁后)。只用其中一个都会出问题——只有 msg_id 就没法排序(雪花 ID 趋势递增但同毫秒内乱序、且不连续,客户端无法判断「缺了哪条」);只有 seq 就跨会话不可引用(撤回、回复、举报都需要一个全系统能唯一定位消息的标识)。

msg_id:全局唯一的雪花 ID

IM 场景的位分配要在「实例数」和「单机峰值」之间取平衡:

 0 | 41 bit 毫秒时间戳 | 5 bit 数据中心 | 5 bit 机器 ID | 12 bit 序列号
符号位   约可用 69 年      最多 32 个机房     每机房 32 台      每毫秒 4096 个

IM 的实例数比订单系统多(网关 + 逻辑层都要发号),所以机器位取满 10 位,很多团队会进一步把 10 位都当机器位交给配置中心统一分配,不再区分机房:

type Snowflake struct {
	mu       sync.Mutex
	epoch    int64 // 起始时间戳,如 1700000000000
	workerID int64 // 必须落在 [0, 1023],启动时校验
	last     int64
	seq      int64
}

func (s *Snowflake) Next() (int64, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	now := time.Now().UnixMilli()
	if now < s.last { // 时钟回拨
		if s.last-now > 5 { // 小幅自旋等待,大幅直接失败由上层重试
			return 0, fmt.Errorf("clock moved backwards: last=%d now=%d", s.last, now)
		}
		time.Sleep(time.Duration(s.last-now) * time.Millisecond)
		now = time.Now().UnixMilli()
	}
	if now == s.last {
		s.seq = (s.seq + 1) & 0xFFF
		if s.seq == 0 { // 本毫秒 4096 个用尽,等下一毫秒
			for now <= s.last {
				now = time.Now().UnixMilli()
			}
		}
	} else {
		s.seq = 0
	}
	s.last = now
	return (now-s.epoch)<<22 | s.workerID<<12 | s.seq, nil
}

msg_id 一旦分配就不允许改变含义。撤回、编辑、已读回执、举报、消息检索全部引用它,所以它必须写进消息表主键并且永不回收。

seq:会话内序号

seq 只需满足两条:同一会话内严格递增、不重复。它不需要全局唯一,也不需要连续。分配方式有三种,选择依据是会话的消息频率:

// NextSeq 分配会话内序号,返回从 1 开始递增的值。
func (a *SeqAllocator) NextSeq(ctx context.Context, convID string) (int64, error) {
	key := "seq:conv:" + convID // 该键绝对不能设置过期时间
	seq, err := a.rdb.Incr(ctx, key).Result()
	if err != nil {
		return 0, fmt.Errorf("incr %s: %w", key, err)
	}
	if seq%1000 == 0 { // 每 1000 个持久化一次水位,便于 Redis 丢键后恢复
		if err := a.saveFloor(ctx, convID, seq); err != nil {
			log.Printf("save seq floor failed conv=%s seq=%d: %v", convID, seq, err)
		}
	}
	return seq, nil
}

seq:conv:{convID} 是一个要命的键:绝不能给它设 TTL。 一旦过期,INCR 会从 1 重新开始,新消息的 seq 与历史消息撞车,唯一键冲突会让整个会话写不进去。恢复链路只有一条:从消息表取 MAX(seq),再把 Redis 水位设成 MAX(seq) + 1000(跳号安全,因为 seq 只要求递增不要求连续)。

方案连续性依赖热点风险适用
Redis INCR除落库失败外连续Redis超大群单键 QPS 高大多数会话,首选
数据库号段(每次领 1000)段内连续、跨段跳号MySQL领号 QPS 可控Redis 不可用时降级
会话单写者内存自增连续会话路由(按 conv_id 哈希)无对连续性要求极高的场景

落库失败会留下空洞(seq 分配了但消息不存在)。这是可以接受的:客户端补拉时发现某个 seq 查不到,应当把它标记为「已丢弃的号」而不是无限重试。反过来说,服务端必须保证「分配了 seq 就最终一定落库」,否则客户端会永久卡在等待那个号上——所以定序与落库要么在同一事务里,要么用重发表兜住。

消息表结构

CREATE TABLE im_message (
  msg_id        BIGINT UNSIGNED NOT NULL COMMENT '雪花 ID,全局唯一',
  conv_id       VARCHAR(64)     NOT NULL COMMENT '会话 ID,同时作为分片键',
  seq           BIGINT UNSIGNED NOT NULL COMMENT '会话内序号,从 1 递增',
  from_uid      BIGINT UNSIGNED NOT NULL,
  msg_type      SMALLINT        NOT NULL DEFAULT 1,
  body          BLOB            NOT NULL COMMENT '加密载荷',
  client_msg_id VARCHAR(64)     NOT NULL COMMENT '客户端 UUID,非空,用于上行去重',
  server_at     DATETIME(3)     NOT NULL,
  PRIMARY KEY (msg_id),
  UNIQUE KEY uk_conv_seq (conv_id, seq),
  UNIQUE KEY uk_from_client (from_uid, client_msg_id)
) ENGINE=InnoDB;

两个唯一键各管一件事:

  • uk_conv_seq (conv_id, seq):顺序性的骨架。任何重复的 seq 都写不进去,顺序问题在写入层就暴露,而不是留给客户端去猜。
  • uk_from_client (from_uid, client_msg_id):上行重发的幂等闸门。客户端超时重发时第二次插入直接命中唯一键,服务端返回第一次分配的 msg_id 与 seq 即可。

client_msg_id 必须非空:给它留 DEFAULT '' 是个陷阱——同一用户发的第二条「没带 ID」的消息会撞上第一条的唯一键,表现为莫名其妙的发送失败。

编号体系的分工

编号生成者唯一性范围是否连续用途
msg_id服务端雪花算法全局趋势递增、不连续引用、撤回、检索、主键
seq会话定序者会话内递增、允许空洞排序、补拉、已读位点
client_msg_id客户端用户 + 设备内不适用上行去重、客户端本地关联
server_at服务端不唯一—展示时间,不参与排序

客户端自己的 client_seq 与本地时间戳一律只用于排查,永远不要用客户端序号或时间戳做排序:多设备、改系统时间、离线重发都会让它错乱,而且服务端无法校验。

失败场景

场景现象处理
时钟回拨超过 5ms发号失败、上行报错返回可重试错误;部署 NTP 并监控回拨事件
两个实例配了同一 workerID极小概率 msg_id 冲突、主键报错机器 ID 由配置中心统一分配,进程启动时注册占位
seq 键被误设 TTL 或 Redis 主从切换丢键从 1 重新开始、唯一键冲突从 MAX(seq) 恢复并加跳号余量;该键单独监控存在性
msg_id 下发成数字Web 端精度丢失、消息对不上接口层统一序列化为字符串
分库后按 conv_id 分片同会话消息落同分片,顺序与查询都友好msg_id 做全局索引时需另建映射(分片与扩容不属于本章范围)

小结:IM 消息用两套编号——msg_id 用雪花算法保证全局唯一、作为永久引用标识;seq 用 Redis INCR(或号段、会话单写者)保证会话内递增、作为排序与补拉的依据。落地要点有三条:seq:conv:{convID} 绝不设 TTL 并具备从 MAX(seq) 恢复的能力,(conv_id, seq) 唯一键把顺序问题拦在写入层,(from_uid, client_msg_id) 唯一键让上行重发天然幂等;同时 msg_id 一律字符串化下发,客户端生成的序号与时间戳只用于排查,不参与排序。

笔记加载中…