多设备同步:同步游标、漫游与已读同步

多端登录(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 丢了,触发一次会话级补拉。

变更流:不要只同步消息

只同步新消息的话,剩下三类事件就只能靠轮询或全量刷新:

事件类型载荷客户端动作
msgconv_id, conv_seq, msg_id插入会话,未读 +1
readconv_id, read_seq, reader_uid更新对方或自己的已读位置
recallconv_id, msg_id, conv_seq把本地消息替换为"已撤回"
convconv_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游标缓存,避免每次同步都读 MySQL1 天
im:sync:hot:{uid}ZSet最近 200 条事件的 seq → event_type 摘要7 天
im:read:{uid}:{convID}Stringread_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 的差值而非逐条标记;漫游按需分档,登录只返回会话骨架,历史翻页交给历史接口。

笔记加载中…