消息 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 一律字符串化下发,客户端生成的序号与时间戳只用于排查,不参与排序。