多设备同步:同步游标、漫游与已读同步
多端登录(multi-device)是 IM 里最容易被低估的需求:同一账号在手机、桌面、Web 同时在线,任何一端发出的消息、标记的已读、执行的撤回,都要在其它端"看起来一致"。一致性不能靠"服务端推给所有在线端"实现——设备可能正在重连或根本没连上;正确做法是把它做成一条用户级的增量变更流(change feed),每台设备各自维护游标(cursor)。
三种序号,别混用
| 序号 | 作用域 | 分配方 | 用途 |
|---|---|---|---|
| msg_id | 全局 | 发号器(08 章) | 消息身份、去重、引用 |
| conv_seq | 单个会话 | 会话维度发号器 | 会话内排序、已读、断洞补拉 |
| inbox_seq(同步序) | 单个用户 | 收件箱分配器(11 章) | 跨会话增量同步的"时间轴" |
一句话记住分工:conv_seq 管"会话内有序",inbox_seq 管"跨会话我漏了什么"。客户端同步走 inbox_seq,进入某个会话后的渲染与补洞走 conv_seq。
同步模型:每设备一个游标
用户 U 的增量流: 101 msg(A) 102 msg(B) 103 read(A) 104 recall(B)
设备 P1 cursor=104 → 已全部消费;设备 P2 cursor=101 → 还需要 102、103、104
- 游标(cursor)必须"每设备一份",绝不能存在用户级:两台设备进度天然不同,共享游标会让快的设备把慢的设备"带过去",慢设备永久丢消息;
- 设备重装或新增设备时游标为 0,走一次全量漫游(roaming);
- 会话内的
conv_seq用于断洞(gap)检测:收到 10 号却发现本地最大才 7,说明 8、9 丢了,触发一次会话级补拉。
变更流:不要只同步消息
只同步新消息的话,剩下三类事件就只能靠轮询或全量刷新:
| 事件类型 | 载荷 | 客户端动作 |
|---|---|---|
| msg | conv_id, conv_seq, msg_id | 插入会话,未读 +1 |
| read | conv_id, read_seq, reader_uid | 更新对方或自己的已读位置 |
| recall | conv_id, msg_id, conv_seq | 把本地消息替换为"已撤回" |
| conv | conv_id, 动作(建群/退群/免打扰/置顶) | 更新会话列表与本地状态 |
把四类事件都塞进同一条 inbox_seq 递增的流里,客户端只需"拉一次、按 seq 顺序应用",同步逻辑从 N 个接口收敛成 1 个。服务端落地方式:消息写入 offline_msg(11 章),已读/撤回这类无正文事件写入同构的 sync_event,两张表共用同一个序号分配器。
CREATE TABLE sync_event (
uid BIGINT UNSIGNED NOT NULL COMMENT '接收方 uid',
seq BIGINT UNSIGNED NOT NULL COMMENT '与 offline_msg 共用的收件箱序号',
event_type TINYINT UNSIGNED NOT NULL COMMENT '1=msg 2=read 3=recall 4=conv',
conv_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
msg_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
payload VARBINARY(512) NOT NULL COMMENT '小载荷内联,避免回表',
created_at DATETIME(3) NOT NULL,
PRIMARY KEY (uid, seq)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
更省事的做法是把 msg 事件也放进这张表(payload 存 msg_id 与摘要),offline_msg 退化成它的一个索引,这样"同步"与"离线补拉"就是同一条读路径。
同步接口
type SyncRequest struct {
UID, DeviceID string
Cursor uint64 // 已应用的最大 inbox_seq,首次为 0
Limit int32 // 建议 100,硬上限 500
}
type SyncResponse struct {
Events []Event
NextCursor uint64 // 本次最后一条 seq,应用成功后再持久化
HasMore bool
}
SELECT seq, event_type, conv_id, msg_id, payload, created_at
FROM sync_event
WHERE uid = ? AND seq > ?
ORDER BY seq ASC
LIMIT ?;
客户端三条纪律:按 seq 顺序应用(乱序应用会算出错误的未读数);应用成功后持久化 NextCursor;应用失败不推进游标,下次重拉同一段并由 msg_id 去重。这三条合起来把同步做成"至少一次 + 幂等",而不是"尽量不丢"。
漫游(roaming)
漫游 = "换一台设备也能看到历史"。分两档实现,避免一上来就全量:
| 档位 | 内容 | 数据来源 | 适用 |
|---|---|---|---|
| 轻漫游 | 会话列表 + 每会话最近 20 条 | Redis 热数据 + MySQL 主表 | 常规换机、App 重装 |
| 全量漫游 | 每会话按 conv_seq 向前翻页 | MySQL + 冷存储(13 章) | 用户主动"同步全部聊天记录" |
漫游按需触发、异步执行,不要塞进登录接口:登录只返回会话列表骨架(conv_id、最后一条摘要、未读数),用户点开会话时再按 conv_seq 向前翻页。这样"十年老账号"的登录耗时与"新账号"一致。
已读同步
已读状态是每个 (uid, conv_id) 一个单调递增的 read_seq,多端取最大值:
CREATE TABLE conv_read (
uid BIGINT UNSIGNED NOT NULL,
conv_id BIGINT UNSIGNED NOT NULL,
read_seq BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已读到的 conv_seq',
PRIMARY KEY (uid, conv_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- 上报用
UPDATE ... SET read_seq = GREATEST(read_seq, ?),或先读后写加版本判断。回退覆盖是已读同步最常见的 bug:手机翻到第 100 条,一台延迟很久的平板把第 60 条上报上来,覆盖后未读数又冒出来; - 未读数用差值算:
unread = conv_max_seq - read_seq,不需要为每条消息维护已读标记; - 多端广播:已读事件写进
sync_event(type=read),其它端拉到后更新红点,比单独开一路下行通道简单;群聊已读人数按"每个成员一条 read_seq"聚合(19 章展开),不要在消息行上存已读名单数组。
Redis key 设计
| key | 类型 | 说明 | TTL |
|---|---|---|---|
im:sync:cursor:{uid}:{deviceID} | String | 游标缓存,避免每次同步都读 MySQL | 1 天 |
im:sync:hot:{uid} | ZSet | 最近 200 条事件的 seq → event_type 摘要 | 7 天 |
im:read:{uid}:{convID} | String | read_seq 缓存(写多读多) | 7 天 |
游标缓存只做读加速,写入仍以 MySQL 为准:缓存 miss 回源即可,不可把游标只放 Redis——重启丢位点等于全部设备重拉。
失败场景与兜底
| 场景 | 后果 | 兜底 |
|---|---|---|
| 应用事件后崩溃、游标未持久化 | 重复应用同一段 | 事件应用幂等:msg 按 msg_id 去重,read 取最大值 |
| 游标被多端共享(设计错误) | 慢设备永久丢消息 | 强制 per-device 游标,登录时下发本设备自己的游标 |
| 事件流与消息落库不同步 | 客户端看到 seq 空洞 | seq 允许空洞,客户端只做区间拉取、不回退位点 |
| 单设备积压太久(离线 30 天) | 一次同步几万条 | HasMore 分批拉取 + 前台限速,超阈值提示"同步中" |
| 会话 conv_seq 断洞 | 少了几条消息 | 客户端检测到洞,按 conv_id + seq 区间补拉 |
工程注意点
- 同步必须能"从 0 拉":所有增量方案都要保证
cursor=0时走通全量路径,否则新设备登录的问题永远只在线上暴露; - 事件类型要向后兼容:
event_type遇到未知值必须能跳过而不报错(协议版本兼容见 04 章),否则服务端发一种新事件,老客户端就崩; - 别把同步做成推送的镜像:推送是快路径(用户在场,实时性优先),同步是慢路径(保证最终一致);推送失败不影响正确性,这才是可以放心降级的地方;
- 未读数只能有一个算法:服务端接口与客户端本地都必须用
unread = max_seq - read_seq。
小结:多设备同步的骨架是"一条用户级增量变更流 + 每设备一个游标":用 inbox_seq 串起消息、已读、撤回、会话变更四类事件,客户端按 seq 顺序应用、成功才推进游标、失败则重拉并由 msg_id 去重;会话内用 conv_seq 检测断洞并补拉;已读状态用 (uid, conv_id, read_seq) 单调递增、多端取最大值,未读永远是 max_seq - read_seq 的差值而非逐条标记;漫游按需分档,登录只返回会话骨架,历史翻页交给历史接口。