IM 系统中离线消息、多端同步、消息顺序和断线重连如何设计?
阅读说明与事实边界:这不是一句“用 MQ 存离线消息”
面试官问“IM 离线消息怎么做”时,真正想听的通常不是选 Redis、MySQL 还是 MQ,而是下面这些问题能不能同时闭环:
- 用户离线期间的消息保存在哪里,保存多久?
- 手机和电脑先后上线,怎样各自拿到缺失消息?
- WebSocket 推送丢了,客户端怎样知道自己缺了哪一段?
- 消息在网络上乱序到达,界面为什么仍能按会话顺序展示?
- 客户端发出消息后收到成功响应,这个“成功”究竟证明了什么?
- 某台设备收到了消息,是否代表用户已经读过?
- 重装应用、长时间离线、日志已经归档时,增量同步还能不能继续?
- 群聊成员很多时,是否仍然给每个成员复制一份离线消息?
本文给出一套完整的设计基线,贯穿这样一条业务场景:
- 用户甲用手机给用户乙发送一条单聊消息;
- 用户乙的电脑在线,手机处于离线状态;
- 电脑先收到消息,手机稍后恢复网络并进行断线重连;
- 恢复过程中又有几条新消息到达,其中一条消息随后被撤回;
- 两台设备最终看到一致的会话顺序,但设备送达状态和用户已读状态仍然分别记录。
本文所有架构、数据表、接口和容量方法都是“设计案例”。没有配套代码、压测报告和生产监控证据,因此不能写成“已经支撑某个线上规模”。文中的序号只是为了说明算法,不代表真实业务数据。
0.1 四类事实必须分开
这四个节点分别对应不同证据:
| 事实 | 可以依赖的证据 | 不能推导出的结论 |
|---|---|---|
| 服务端已受理 | 消息事实和可靠事件已持久化 | 接收设备已经拿到 |
| 已进入同步范围 | 接收方用户事件已经建立或共享会话水位已推进 | 某台设备已经同步 |
| 设备已同步 | 设备累计 ACK 已推进且服务端仍保留证据 | 用户已经看到 |
| 用户已读 | 客户端按产品语义上报阅读游标 | 用户理解、回复或执行业务动作 |
“离线消息不丢”也不能写成无条件承诺。合理口径是:在明确的保留期、账号权限、存储副本和恢复流程内,服务端受理后的消息可通过增量同步、重试与对账恢复;超过保留期、账号被删除、客户端未持久化就错误 ACK、终端系统清理推送等情况都有边界。
一、项目基础:系统要解决的不是离线,而是不连续连接
1.1 业务矛盾
移动网络天然不连续:
- 应用可能被系统挂起,长连接并不真正存活;
- 地铁、电梯和网络切换会让连接短暂中断;
- WebSocket 显示已连接,也可能有部分数据包已经丢失;
- 一个账号可能同时登录手机、平板和电脑;
- 新设备第一次登录时,本地没有任何会话状态;
- 设备可能收到消息后崩溃,还没来得及保存到本地数据库。
因此,在线推送只能优化实时体验,不能成为消息一致性的唯一依据。系统需要一条可重放的事实链,让设备随时能够回答:
我上一次可靠保存到了哪里,从那个位置以后还有哪些变化?
1.2 参与者和边界
| 角色或模块 | 主要责任 | 明确不负责 |
|---|---|---|
| 发送端客户端 | 生成客户端消息 ID、保存本地发送态、重试 | 决定服务端最终顺序 |
| 长连接网关 | 鉴权、维持连接、在线推送、流量治理 | 保存消息最终事实 |
| 消息服务 | 幂等受理、分配会话序号、持久化消息 | 证明用户已读 |
| 同步服务 | 按用户同步游标返回变化、处理设备 ACK | 替代消息事实库 |
| 扇出服务 | 把消息变化映射到接收方同步空间 | 生成用户阅读事实 |
| 会话服务 | 会话列表、未读数、置顶静音等用户态 | 保存完整消息正文 |
| 离线推送服务 | 调用系统通知通道唤醒或提示 | 保证设备最终收到 |
| 客户端本地库 | 原子应用同步批次、去重、排序展示 | 成为跨设备真相源 |
1.3 本文的非目标
本文不展开音视频通话、端到端加密协议、跨组织合规留档、富媒体上传和消息内容审核模型。这些能力会影响消息链路,但不能在没有产品前提时全部塞进“离线消息”答案里。
端到端加密尤其会改变多端密钥分发和历史消息漫游能力。如果产品采用该模式,需要另外设计设备密钥、密钥轮换、设备撤销和密文历史恢复;本文只在安全章节说明接口边界,不假设已经实现。
1.4 一分钟项目介绍
这套方案以“会话消息日志 + 用户同步事件日志 + 设备独立游标”为核心。消息服务为每个会话分配单调递增的 msg_seq,解决会话内展示顺序;同步服务为影响某个用户会话视图的变化分配 sync_seq,解决设备离线期间的增量追赶。在线 WebSocket 和系统通知只负责提醒设备有新变化,设备无论正常上线还是断线重连,最终都要从服务端游标之后拉取、原子落入本地库,再提交累计 ACK。单聊和普通群可采用写扩散,大群则按共享消息日志加轻量会话变更标记收敛,避免把消息正文复制给每个成员。
二、业务闭环:从发送到离线手机补齐消息
2.1 典型业务过程
这条链路里没有“给离线用户单独塞进一个临时 List”这样的特殊分支。在线与离线设备共享同一套可重放同步协议,区别只是在线设备通常被推送立即唤醒,离线设备稍后主动追赶。
2.2 页面动作到数据变化
| 用户动作 | 接口或事件 | 核心数据变化 | 失败反馈 |
|---|---|---|---|
| 甲点击发送 | SendMessage | 创建消息事实和可靠事件 | 本地保持“发送中”,可重试 |
| 乙电脑收到在线提示 | NewSyncHint | 不直接改变服务端消息状态 | 提示丢失时由定时同步补齐 |
| 乙电脑拉取变化 | SyncChanges | 读取用户事件和消息 | 返回同一批次水位,可续拉 |
| 本地保存完成 | AckSyncCursor | 推进该设备同步游标 | ACK 丢失可重复提交 |
| 乙打开会话 | ReportReadCursor | 推进用户会话阅读游标 | 与设备同步 ACK 分开 |
| 手机恢复网络 | ResumeSession | 校验会话、设备和游标 | 游标过旧则返回重建指令 |
2.3 闭环必须守住的不变量
- 同一会话内,对客户端可见的消息变化按
msg_seq单调排列。 - 同一用户的同步事件按
sync_seq可重放,分页不能漏掉高水位以内的事件。 - 同一设备的
acked_sync_seq只能向前推进。 - 服务端不能因为设备 ACK 就把消息标成用户已读。
- 重复发送同一个
client_msg_id不能生成两条业务消息。 - 重复消费同一个消息事件不能把未读数增加多次。
- 客户端只有在本地批次原子保存成功后才能确认该批次。
- 撤回、成员变化等控制事件也必须参与同步,不能只同步消息正文。
- 用户只能补拉自己在相应成员有效区间内有权看到的消息。
- 设备重建会推进
sync_generation;旧代次的批次令牌、快照和延迟 ACK 不能推进新代次游标。 - 大群 dirty marker 只负责提示“需要追赶”,不能代替设备实际保存的会话
last_msg_seq。
三、技术架构与模块边界
3.1 总体架构
架构里有两条不同的序列:
msg_seq属于会话,回答“这个会话里的消息先后关系是什么”;sync_seq属于用户事件空间,回答“这个账号的某台设备还缺哪些变化”。
两者不能混用。用户的同步日志可能依次出现:单聊 A 新消息、群聊 B 撤回、会话 C 被置顶。它们各自拥有不同的会话序号,但在该用户的同步流里有连续的 sync_seq。
3.2 同步与异步边界
发送接口返回成功之前,设计上至少应完成:
- 鉴权和发送权限校验;
client_msg_id幂等检查;- 会话序号确定;
- 消息事实可靠持久化;
- 后续扇出所需的可靠事件与消息事实建立原子关系。
接收方同步日志构建、在线推送、离线系统通知和搜索索引可以异步执行。这样做意味着发送成功并不代表接收方的同步空间已经更新,但系统必须有重试、积压监控和对账把它推进到可同步状态。
3.3 为什么 MQ 不能直接当长期离线信箱
MQ 擅长解耦、缓冲和至少一次投递,但通常不适合直接承担所有客户端的长期随机补拉:
- 客户端可能离线很久,消费位点数量和保留期难以控制;
- 用户要在多台设备上独立同步,不能共享一个简单消费位点;
- 新设备需要会话快照,不只是从某个 Topic 一直回放;
- 消息查询、撤回、权限过滤和冷热分层需要业务索引;
- MQ 事件过期后仍可能需要历史漫游。
因此,MQ 是服务间可靠传播通道,用户事件日志和消息库才是客户端同步协议所依赖的业务数据面。
四、核心技术一:用两级序号解决顺序与同步
4.1 为什么时间戳不能决定消息顺序
客户端时钟可能漂移,两个网关的系统时间也可能存在微小差异。即使服务端统一打时间戳,并发写入仍可能拥有相同时间。数据库自增 ID 又只能表达某个表或分片的插入顺序,不能天然表达一个会话的业务顺序。
客户端展示时应以服务端确认的会话序号为准,时间戳只负责显示发送时间和辅助排查。
4.2 会话消息序号 msg_seq
对每个 conversation_id 维护单调递增序号:
conversation c_10086
msg_seq: 441, 442, 443, 444 ...
单聊会话的双方共享同一个序列;群聊的所有成员也读取同一条会话日志。系统不追求所有会话之间全局有序,因为这会引入无意义的全局竞争。
序号分配有几种方式:
| 方式 | 优点 | 风险与适用边界 |
|---|---|---|
| 数据库行锁或条件更新 | 语义清楚,适合中等热点 | 超热点会话竞争集中 |
| 会话固定路由到单写者 | 顺序天然集中 | 需要主从切换和脑裂防护 |
| 号段预分配 | 吞吐较高 | 会产生合法空洞,客户端不能把空洞都当丢消息 |
| 分区日志偏移 | 写入和顺序统一 | 依赖稳定分区和日志保留策略 |
本文采用“会话固定路由 + 持久化序号状态”的设计基线。具体实现要根据数据库和部署条件确定,不能只凭架构图声称已经解决热点。
群成员加入、退出、禁言和角色变化也必须经过同一个会话写入者,获得明确的会话边界,不能由另一个服务按墙上时间猜先后。例如退出事件在线性化点看到会话已经提交到 msg_seq=1204,就把本次成员周期的 leave_seq 固化为 1204;之后分配的 1205 对该周期不可见。消息写入与成员变化竞争时,固定写入者的持久日志顺序就是唯一裁决,异步扇出不能稍后再用“当前成员表”重新决定。
4.3 用户同步序号 sync_seq
用户事件日志保存所有会影响该用户视图的变化,例如:
sync_seq 9011: 会话 c_10086 新消息,msg_seq 442
sync_seq 9012: 群 g_77 新消息,msg_seq 1204
sync_seq 9013: 会话 c_10086 消息撤回,msg_seq 443
sync_seq 9014: 会话 g_77 被设为免打扰
设备保存的不是“最后收到的 message_id”,而是这个用户事件流的累计游标。它可以一次补齐多个会话的变化,也能同步会话属性和控制事件。
sync_seq 也不能由多个扇出工作线程各自在内存里 +1。本文基线是在用户分片内,用一个短事务完成:按 source_event_id + user_id + membership_period_id 查幂等记录,锁定或 CAS 推进该用户的同步序号头,插入 USER_INBOX_EVENT,再提交新的 committed_high_watermark。两个 worker 同时给同一用户投递时,只有持有同步头行锁或 CAS 成功的一方能使用下一个序号;失败方读取新头后重试。序号、事件和可见高水位一起提交,因此客户端不会先看见一个尚未落库的 sync_seq。事务失败可以重试;若实现允许预留号段,就必须像会话序号一样保存合法空洞证据。热点大群使用可合并 dirty marker,正是为了避免一个高频群把所有成员的用户序号头持续打热。
4.4 消息证据层级:不是一条全局状态机
这张图表达的是证据依赖,不是服务端给一条消息保存一个“所有设备统一状态”。SYNC_ACK 只证明某台设备已经原子应用用户事件;如果事件里只有消息引用,它还不能证明正文或必要载荷已经进入本地可用状态。只有达到产品定义的本地可用条件,才可以生成这台设备的 DELIVERY_ACK。电脑完成同步不会自动把手机标成已送达,任一设备送达也不会自动变成账号已读。
4.5 空洞不一定是丢消息
如果序号生成策略允许预分配或事务回滚,会话里可能出现 441、442、444。客户端看到 444 时不能永久等待 443。
服务端需要提供一种明确判断:
committed_high_watermark:高水位以内的提交状态已经确定;- 跳号记录或范围元数据:某些序号被取消,不会再出现;
- 拉取接口返回
next_seq和缺口原因; - 若序号必须严格连续,则只有消息事实提交成功时才暴露新高水位。
设计不能同时采用可能跳号的号段方案,又要求客户端仅凭整数连续性判断丢失。
五、数据模型:消息事实、用户事件和设备进度分开
5.1 核心实体关系
5.2 关键唯一约束
| 对象 | 建议约束 | 守住的风险 |
|---|---|---|
| 消息幂等 | UNIQUE(sender_id, client_msg_id) | 发送响应丢失后重试生成重复消息 |
| 会话顺序 | UNIQUE(conversation_id, msg_seq) | 并发分配出相同会话序号 |
| 用户事件 | UNIQUE(user_id, sync_seq) | 同步日志位置冲突 |
| 扇出幂等 | UNIQUE(user_id, source_event_id, membership_period_id) | MQ 重复消费导致重复事件,或再次入群覆盖旧周期证据 |
| 用户同步头 | 用户分片内同步头推进与 inbox event 插入同事务 | 多工作线程分配重复序号,或暴露尚未提交的高水位 |
| 同步快照 | snapshot_id 唯一,READY 后清单、base_sync_seq 和 sync_generation 不可改写 | 把不断变化的会话当前值冒充某个历史同步切面,或把旧快照装进新设备代次 |
| 恢复会话 | recovery_session_id 唯一,同一设备代次最多一个活动会话 | 本地回滚后从旧位置重放时,服务端仍按更高 ACK 清理所需日志 |
| 成员周期 | UNIQUE(conversation_id, user_id, join_seq) 且同一用户周期不重叠 | 再次入群覆盖旧授权区间,或一条消息同时命中两个周期 |
| 设备游标 | PRIMARY KEY(user_id, device_id),ACK 必须匹配当前 sync_generation 和活动设备状态 | 多设备进度互相覆盖,或旧批次延迟 ACK 推高重建设备的游标 |
| 用户会话态 | PRIMARY KEY(user_id, conversation_id),dirty target 只取大且清理使用行版本 CAS | 会话列表重复,或大群标记在追赶与新消息竞争时被错误清除 |
client_msg_id 应由客户端在首次点击发送时生成并持久化,重试时保持不变。若每次重试都生成新 ID,服务端再强的唯一约束也无法识别它们是同一次业务操作。
“成员周期不重叠”不能只写在注释里。支持区间排斥约束的数据库可直接约束 (conversation_id, user_id, [join_seq, leave_seq]);否则加入、退出事务必须锁定该用户在会话中的活动周期,确认不存在另一个 leave_seq IS NULL 的周期,并由同一会话写入者分配边界。扇出命中的 membership_period_id 必须非空;非成员型账号事件若复用同一 inbox 表,应使用独立、稳定且非空的 delivery scope,避免数据库对 NULL 唯一键的特殊语义绕过幂等。
5.3 索引和读取路径
- 消息翻页:
conversation_id + msg_seq范围索引; - 用户增量同步:
user_id + sync_seq范围索引; - 成员授权判断:
conversation_id + user_id + join_seq区间索引; - 设备进度查询:
user_id + device_id主键; - 会话列表:
user_id + last_activity_at或物化排序键; - 幂等查询:
sender_id + client_msg_id唯一索引; - 对账扫描:按分片和事件时间建立可控的增量索引。
索引设计必须围绕真实查询,而不是把每个字段都建索引。消息正文和大型附件元数据也不应全部塞进用户事件日志;同步事件保存引用和必要摘要,详细内容按消息 ID 或会话范围读取。
5.4 生命周期
消息事实、用户事件、设备游标和离线推送记录的保留期不必相同:
- 设备游标在设备注销后可以进入短期审计期,再按策略清理;
- 用户事件日志只需覆盖允许增量同步的窗口,过旧设备走快照重建;
- 消息正文按产品历史漫游和合规策略进入冷存储或删除;
- 成员周期至少覆盖消息历史可见期、Outbox/死信最大修复期和授权回放期;即使账号已退群也先归档不可变周期,不能只保留当前成员行;
- 推送请求与 Provider 回执只保留排障所需时间;
- 撤回墓碑至少要覆盖可能仍持有原消息的有效设备范围。
清理任务必须先判断服务端持久化的最慢有效设备 ACK 水位、活动 DEVICE_SYNC_RECOVERY.resume_from、未过期快照基线和产品保留规则,不能信任客户端请求里自报的本地游标,也不能因为某台活跃设备已经 ACK 就立即删除所有离线数据。恢复会话完成或过期后才能解除对应租约;过期但未完成的设备进入快照重建,不能静默按旧高 ACK 继续。
六、核心技术二:发送、受理和扇出的端到端时序
6.1 正常发送链路
消息表和 Outbox 在同一本地事务中提交,是为了处理这个失败窗口:消息已经保存,但服务进程在发布 MQ 前崩溃。如果只靠“写库后立即发 MQ”,这条消息可能永远没有进入接收方同步空间。
Outbox 发布器可以重复发送。普通群扇出不能在消费时查询“当前成员列表”,而要按消息的 msg_seq 查询满足 join_seq <= msg_seq 且 leave_seq 为空或 msg_seq <= leave_seq 的成员周期;每个生成的 fanout item 都携带命中的 membership_period_id。退出后任务才执行,也仍给退出前可见的消息补事件;重新入群产生新周期,不会覆盖旧周期的授权或幂等证据。
扇出服务以 user_id + source_event_id + membership_period_id 幂等。命中两个周期说明成员区间发生重叠,属于数据完整性错误,任务必须隔离并告警,不能给同一用户补两次。这里是“至少一次传播 + 历史成员周期裁决 + 幂等收敛”,不是声称整个系统实现了绝对的精确一次。
6.2 发送响应丢失
如果消息已经提交,但 SERVER_ACCEPTED 响应在网络中丢失,甲的客户端仍显示发送中。客户端使用同一 client_msg_id 重试:
- 服务端查询幂等键;
- 若原消息存在,返回原
message_id和msg_seq; - 若原请求没有提交,再执行新建;
- 客户端把本地临时消息与服务端消息合并,不能新增一条气泡。
错误做法是超时后直接生成新的客户端 ID,也不能用消息文本、时间戳近似去重,因为用户完全可能连续发送两条相同文字。
6.3 消息是否要等扇出完成再返回
同步等待所有接收者扇出可以让“成功”口径更靠后,但群成员越多,发送延迟和故障耦合越严重。本文选择可靠保存消息与 Outbox 后返回,接收方同步可见性异步推进。
相应地,系统必须暴露:
- Outbox 未发布数量和最老记录年龄;
- 消息受理到用户事件生成的延迟;
- 失败重试和死信恢复入口;
- 消息事实与用户事件之间的对账结果。
没有这些恢复能力,异步返回只是把问题藏到了后台。
七、核心技术三:多端同步和设备游标
7.1 为什么游标必须按设备保存
乙的电脑已经同步到 sync_seq=9014,手机仍停在 8970。如果系统只有一个用户级“最后消费位置”,电脑推进后会让手机误以为早期事件已经处理,从而漏消息。
正确模型是:
user_id = u_乙, device_id = desktop_1, acked_sync_seq = 9014
user_id = u_乙, device_id = phone_1, acked_sync_seq = 8970
设备同步游标按设备独立,用户阅读游标则可以按产品规则在账号多端间取最大值并同步。这两个维度不能合并成一个字段。
7.2 增量同步协议
客户端请求包含本地持久化进度,而服务端另外读取该设备已经确认的进度:
device_id
sync_generation
local_applied_sync_seq
client_session_id
page_limit
服务端先读取 DEVICE_SYNC_CURSOR.sync_generation + acked_sync_seq + recovery_state,并校验设备仍然有效。客户端携带的 generation 只用于识别自己落在哪一代,不能自行创建新代次;local_applied_sync_seq 也不能冒充已经 ACK。协议按以下规则选择起点:
- 客户端 generation 与服务端当前代次相同,且
local_applied_sync_seq >= server_acked_sync_seq时,从服务端 ACK 位点重放;若上一次 ACK 丢失,客户端会收到已经落过库的事件,但本地幂等应用后可以重新 ACK。 local_applied_sync_seq < server_acked_sync_seq、generation 缺失或不匹配时,说明客户端本地数据可能回滚、重装或损坏。服务端必须先推进设备的sync_generation,让此前签发的批次 token 和延迟 ACK 全部失效,再选择“小范围受保护重放”或RESET_REQUIRED快照重建,不能在旧代次下直接从更早位置继续。- 服务端校验两个游标都不超过曾向该设备签发的合理高水位,客户端不能用自报位置跳过未同步事件。
确定安全的 resume_from 后,服务端在请求开始时从 USER_SYNC_HEAD.committed_high_watermark 固定 batch_high_watermark,只返回 (resume_from, batch_high_watermark] 范围内的事件。第一页签发不可篡改的 batch_token,至少绑定 user_id + device_id + sync_generation + resume_from + batch_high_watermark + expiry;后续页和最终 ACK 都校验同一个 token、当前设备代次和不透明的翻页位置,不能让客户端自行换高水位或伪造页游标。响应同时带上 server_acked_sync_seq、resume_from、next_cursor 和 has_more。若一页装不下,客户端继续翻页。
本地位置低于服务端 ACK 时,如果差距很小且日志仍在,可以避免构建全量快照,但必须创建持久化 DEVICE_SYNC_RECOVERY:在推进 generation 的事务中冻结 resume_from 和 batch_high_watermark,状态置为 RECOVERING,并让日志清理任务把活动恢复会话的 resume_from 纳入保留栅栏。token 还要绑定 recovery_session_id。客户端完整应用并 ACK 后,服务端才把恢复会话置为 COMPLETED、游标推进到该批次高水位并恢复 ACTIVE。会话过期、日志已缺段或摘要不一致时转快照重建。否则服务端 ACK 明明在 9014、设备却从 8970 重放,清理任务可能在分页中途删掉 8971 之后的数据。
固定批次高水位很重要。如果设备一边翻页,新消息一边不断进入,而服务端每页都追最新尾部,设备可能永远完成不了当前批次,也难以定义哪个位置可以安全 ACK。
7.3 客户端原子应用
一个同步批次至少要在本地事务中完成:
- 按
message_id和msg_seq幂等写入消息; - 应用撤回、编辑、成员变化等控制事件;
- 更新会话摘要和本地未读展示;
- 保存本地
applied_sync_seq; - 事务提交后,再异步向服务端发送累计 ACK。
若客户端先 ACK、后写本地库,进程恰好在两者之间崩溃,重启后服务端会从更后的位置开始返回,客户端就可能永久漏掉已经错误确认的批次。
累计 ACK 还有一个容易忽略的前提:ACK 9014 必须证明不大于 9014 的每个用户事件都已被本地持久化处理。某个会话正文暂时取不到时,可以先把事件引用、缺口范围和重试任务放进同一个本地事务,再继续处理其他会话;这时 ACK 只证明“恢复责任已经可靠落地”,不构成正文送达证据。若客户端只是把异常留在内存里,就不能跨过该事件推进全局 applied_sync_seq。
7.4 多端漫游时序
电脑是否 ACK 与手机能否补拉没有直接依赖。服务端清理日志时要依据保留策略和有效设备集合,不能拿最快设备的位置覆盖最慢设备。
7.5 新设备登录不是普通断线重连
新设备没有可信游标,不能假装从 0 无限回放全部事件。旧设备的游标早于在线日志最小位置时,也不能返回空列表冒充“没有新消息”,而要返回 RESET_REQUIRED。开始重建时,服务端先以 CAS 推进该设备的 sync_generation 并置为 RESETTING;旧代次 token 从这一刻起全部失效。两者随后进入同一套“冻结快照 + 增量追赶”协议。
快照最危险的做法,是先读取不断变化的会话当前值,读完后再随手标一个 snapshot_sync_seq。假设快照把新消息 9023 读进来了,却标记为 9022;客户端从 9023 重放尚可依靠幂等收敛。更糟的是会话 A 读在 9022、会话 B 读在 9025,再统一标成 9025,客户端就可能永久跳过 A 在 9023 到 9025 的变化。因此快照必须先固定输入切面,再允许设备从该点之后增量同步:
完整协议需要守住以下规则:
- 创建快照代次时,在同一用户分片事务中锁定设备游标和用户同步头,条件推进
sync_generation=G、把设备置为RESETTING,并创建冻结base_sync_seq=S的BUILDING快照行;新事件可以继续写入,但序号一定大于 S。事务失败整体回滚,旧 generation 的正常批次 ACK 即使延迟到达也会被拒绝。 - 构建器只能物化“应用完所有
sync_seq <= S后”的用户视图。数据在同一事务库时可以使用受控 MVCC 切面;跨库时应从一个已封口快照基线重放保留事件到 S。不能混读多个服务的当前 head。 - 快照清单固定每个会话的版本、可见消息区间、成员周期、墓碑、对象 URI、条数和摘要。全部对象写完并校验后,才能把代次从
BUILDINGCAS 为不可变READY;缺页或构建失败的代次不得安装。 snapshot_token绑定snapshot_id + user_id + device_id + sync_generation + base_sync_seq + manifest_digest + expiry。客户端校验全部分页和摘要后,在一个本地事务里替换旧视图并写入 generation 与applied_sync_seq=S,随后才确认该快照。- 服务端收到快照 ACK 后,只有设备仍处于同一 generation 的
RESETTING,才能把恢复基线推进到 S 并改回ACTIVE;安装中断则重下同一不可变代次或再推进 generation 签发新代次,不能把半份快照当成功。 - 快照构建和安装期间必须保留 S 之后的增量事件。清理任务把所有未过期
BUILDING/READY代次的最小基线纳入保留栅栏,避免快照刚装完,后续增量已经被清掉。 - 新设备能看到多少历史仍由成员周期、隐私设置和保留策略决定。快照给出的是授权后的当前视图,不是绕过权限下载全量历史。
如果旧日志已经清理,系统必须存在可验证的已封口快照基线或可按版本读取的权威事实,才能重建到 S。两者都不存在时应明确返回恢复失败并告警,而不是拼一份无法证明切面的“当前数据”。
八、核心技术四:ACK 的准确语义
8.1 至少有四种 ACK
| ACK 名称 | 触发者 | 证明的事实 | 不证明什么 |
|---|---|---|---|
SEND_ACK | 消息服务 | 消息已按约定可靠受理 | 接收方设备已拿到 |
SYNC_ACK | 接收设备 | 该设备已原子应用到某个 sync_seq,服务端可推进设备恢复位点 | 消息正文或附件一定本地可用、用户已阅读 |
DELIVERY_ACK | 接收客户端,可选 | 指定消息已进入该设备本地可用状态 | 其他设备已收到、用户已读 |
READ_ACK | 用户行为触发 | 账号对某会话的阅读游标已推进 | 用户理解或回复 |
系统通知 Provider 返回“请求已接受”只能作为推送平台受理证据,不能冒充 DELIVERY_ACK。操作系统可能延迟、折叠或丢弃通知,设备也可能已卸载应用。
8.2 累计 ACK 与逐条 ACK
对有序同步流,累计 ACK 更合适:ACK 9014 表示该设备已经原子应用所有不大于 9014 的有效事件。它能显著减少设备到服务端的写放大,但默认不等于每条消息都生成了逐消息送达回执。
累计 ACK 的前提是设备已经处理了中间缺口。若同步事件携带完整正文,并且客户端在同一事务中保存正文与游标,产品可以明确规定该事件的 SYNC_ACK 同时构成这条消息的送达证据;若事件只保存消息引用,则必须等正文或产品要求的必要载荷本地可用后,才生成 DELIVERY_ACK。大附件可以另建下载任务,是否把“附件可打开”纳入送达语义也要单独定义。
逐条送达回执只适合确实有产品价值的场景,而且必须限制保留期和查询权限。否则每条消息乘以所有设备会产生高昂状态量。
8.3 ACK 也需要幂等和单调更新
设备可能先发出 ACK 9014,又因为网络乱序到达 ACK 9008。服务端条件更新应等价于:
UPDATE device_sync_cursor
SET acked_sync_seq = :new_seq,
acked_at = :server_time,
row_version = row_version + 1
WHERE user_id = :user_id
AND device_id = :device_id
AND sync_generation = :token_generation
AND recovery_state = 'ACTIVE'
AND acked_sync_seq < :new_seq;
执行条件更新前还要验证 batch_token 的签名、用户、设备、generation、resume_from、batch_high_watermark 和有效期,并要求 new_seq 不超过 token 固定的高水位。更新行数为零可能是同代次的旧 ACK,也可能是设备已撤销、正在重建或 generation 已变化;前者可以幂等返回当前水位,后三者必须返回明确的 STALE_GENERATION/DEVICE_REVOKED/RESET_REQUIRED,不能一律伪装成功。
恢复会话的最终 ACK 使用另一条 CAS:同时校验活动 recovery_session_id、RECOVERING 状态和相同 generation,再把设备基线推进到冻结高水位并切回 ACTIVE。这避免普通 ACK 绕过日志保留租约提前结束恢复。
九、核心技术五:推模式与拉模式如何配合
9.1 推送只通知“可能有变化”
在线长连接可以发送轻量提示:
user_sync_high_watermark = 9022
reason = NEW_MESSAGE
客户端收到后仍调用同步协议拉取事件。提示可以合并、重复或丢失,因为客户端在下列时机都会主动同步:
- 首次打开应用;
- 长连接鉴权成功;
- 网络从不可用恢复;
- 应用从后台进入前台;
- 检测到会话序号缺口;
- 周期性健康校验发现服务端水位更大。
9.2 纯推、纯拉和推拉结合
| 方案 | 优点 | 主要问题 |
|---|---|---|
| 纯推 | 在线延迟低 | 推送丢失后缺少自动恢复依据 |
| 纯轮询拉取 | 协议简单、容易重放 | 空轮询多,实时性和电量表现较差 |
| 推送提示 + 游标拉取 | 实时体验与恢复能力兼顾 | 需要维护同步日志和游标协议 |
本文选择第三种。即便 WebSocket 使用了可靠传输,也无法覆盖进程崩溃、本地事务失败、系统挂起和连接恢复之间的业务窗口,因此重连后补拉不可省略。
9.3 离线系统推送边界
离线推送服务消费新消息事件后,可以根据免打扰、敏感会话、设备令牌状态和通知频率决定是否调用系统通知 Provider。
通知负载应尽量少包含敏感正文,锁屏是否展示预览由用户设置决定。折叠通知不能折叠服务端消息事实:系统通知只显示“有新消息”也没有关系,设备上线后从同步日志取回完整变化。
9.4 背压与降级顺序
当事件积压或热点群爆发时,优先级应是:
- 保证消息事实和可靠传播证据;
- 保证设备能主动补拉;
- 降低在线提示频率并合并水位;
- 降低离线通知频率;
- 暂停非核心会话摘要、搜索索引等派生能力。
不能为了维持每条消息一次推送而压垮消息受理和同步查询主链路。
十、单聊、普通群和热点大群的差异
10.1 单聊:接收方集合稳定且很小
单聊消息受理后,可以为双方分别生成用户事件:发送方用于多端同步本机发出的消息,接收方用于新消息和未读。正文仍只保存一份,用户事件保存消息引用。
发送方自己的另一台设备也必须收到这条消息,否则手机发出的内容不会漫游到电脑。发送设备本身可以通过发送 ACK 合并本地临时消息,其他设备通过同步日志获得。
10.2 普通群:写扩散换简单同步
普通群可以按消息线性化点上的成员有效集合生成用户事件。这个“发送时快照”不要求把几千个成员 ID 复制进消息 Outbox;基线保存消息 msg_seq,扇出任务随后按该序号查询历史 CONVERSATION_MEMBERSHIP_PERIOD,命中每个覆盖该点的周期,并把 membership_period_id 带进 fanout item 和 USER_INBOX_EVENT。每个成员的设备仍共享该成员的用户日志,再按设备游标独立消费。
优点是用户同步查询简单、未读数容易物化;代价是每条群消息会生成与成员数量相关的事件。成员列表必须使用消息发送时的历史权限区间,不能用稍后的当前成员表随意补扇出。fanout item 的稳定身份至少包含 source_event_id + target_user_id + membership_period_id;消息与退群并发时,二者共享的会话写入者用 msg_seq 顺序裁决,异步 worker 无权按处理时间重判。
10.3 热点大群:共享日志加轻量变更标记
当群规模和消息频率使逐成员事件不可接受时,可以采用混合模式:
- 消息正文和顺序保存在群共享日志;
- 在线成员收到合并后的群水位提示;
- 用户会话空间只保存可合并的“该群已变更到哪个序号”标记;
- 客户端按自己的
last_msg_seq从群共享日志补拉; - 成员权限区间决定允许读取的序号范围;
- 精确未读和通知能力根据产品价值降级。
这里的 marker 必须是“电平触发”的恢复提示,不是只发一次就遗忘的边沿通知。对 user_id + conversation_id 保存 dirty_target_msg_seq + dirty_version:生产者只把 target 取大并递增版本;客户端实际从共享日志拉到目标位置、原子保存自己的 last_msg_seq 后,才携带观察到的版本尝试清理。若清理与新消息并发,CAS 因版本变化失败,marker 保持 dirty,并重新进入用户同步提示或会话列表返回值。不能先清 marker 再拉消息,也不能因为对应的用户 sync_seq 已 ACK 就把会话 last_msg_seq 推到 target。
marker 仍可能因为提示合并或投影故障而延迟,因此打开会话、应用回前台和周期健康检查都要读取共享会话的权威 head,并在成员周期允许范围内比较本地 last_msg_seq。这条慢路径保证可恢复;代价是大群会话列表的未读数和实时角标只能提供受控近似,不能继续宣称与普通群逐消息写扩散同等精确。marker 的窗口化合并降低的是提示写放大,不会删除共享消息事实。
这不是说“大群一定用读扩散”。阈值应由成员规模、活跃率、消息频率、离线比例、存储成本和未读精度要求共同决定,并通过压测验证。
10.4 进退群的历史权限
成员关系需要保存有效区间:
join_seq表示从哪个会话序号开始有权读取;leave_seq表示离开前最后可读取的位置,活动周期可暂时为空;- 授权区间按闭区间
[join_seq, leave_seq]判断; - 每次加入生成新的
membership_period_id,再次入群新增周期而不是覆盖旧记录; - 产品允许新成员查看部分历史时,要单独记录历史授权起点。
加入、退出和消息都由同一个会话写入者排进持久序列。若退出在线性化时看到最后已提交消息为 msg_seq=1204,就把旧周期 leave_seq 固化为 1204;随后消息从 1205 开始,对旧周期不可见。重新加入假设从 1301 生效,就新建另一个 membership_period_id 和 join_seq=1301,不能改写旧行。
同步服务和延迟扇出都不能只检查“现在是不是群成员”。否则退群后才执行的旧任务会漏掉退出前消息,已退群用户也可能补拉退出后的消息;重新入群时若复用旧周期,还会越权读取中间不在群内的 1205 到 1300。查询、扇出、对账和修复必须使用消息或请求序号命中的那一个周期;查不到就拒绝,查到多个说明周期重叠,要隔离而不是猜一个。
十一、断线重连:先恢复会话,再追赶数据
11.1 重连流程
重连时客户端上报本地 applied_sync_seq 是为了发现本地回滚和 ACK 丢失,真正的服务端清理证据仍是持久化 acked_sync_seq。正常情况下服务端从 ACK 位点重放;客户端本地更靠前时不能跳过差额,更靠后时也只会幂等接收重复事件并重新 ACK。
connection_epoch 用于识别同一设备的新旧连接。当移动网络恢复后,新连接已经建立,旧连接的延迟数据包才到达,客户端和网关可以丢弃旧代次上的控制消息,避免旧连接覆盖新连接状态。
11.2 心跳不是消息完整性证明
心跳只能帮助发现连接可能已经失效。即使最后一次心跳成功,也不能证明之后每条业务消息都到达并落入客户端数据库。重连后仍需比较同步水位并执行增量拉取。
心跳间隔、超时时间和重连退避需要根据移动网络、电量、网关容量和产品实时性做设计目标与压测,本文不虚构固定最优数字。
11.3 重连风暴
网关或运营商网络恢复时,大量客户端可能同时重连。客户端需要指数退避加随机抖动,服务端需要:
- 按账号和设备限制并发重连;
- 把鉴权、建连和历史补拉分离容量池;
- 同步接口分页和限流,不一次返回全部历史;
- 对相同会话的消息读取合并缓存;
- 优先恢复活跃会话,冷会话按需加载;
- 返回明确的重试时间,而不是让客户端紧密循环。
11.4 客户端如何处理网络切换
客户端不能只看操作系统的“网络已连接”。推荐状态机是:
只有进入 READY 才表示当前批次已经追平,不代表未来不会再有新事件。界面可在 SYNCING 时展示本地已有内容并提示正在同步,不必把整个应用阻塞成空白页。
十二、重复、乱序、缺口和撤回如何收敛
12.1 重复消息
重复可能来自三个位置:
- 发送端因响应丢失重试;
- MQ 至少一次投递导致扇出重复;
- 客户端同步请求超时后重复拉取同一页。
对应防线分别是:
sender_id + client_msg_id唯一约束;user_id + source_event_id + membership_period_id扇出幂等,重新入群的周期不能覆盖旧周期证据;- 本地
message_id、conversation_id + msg_seq和事件 ID 幂等写入。
短期 Redis 去重可以降低数据库冲突,但不能成为唯一正确性基础,因为去重键过期或 Redis 故障后重复仍会出现。
12.2 乱序到达
在线推送可能先到 msg_seq=444,随后才到 443。客户端不能按网络到达顺序直接追加到界面,而应:
- 把推送当成同步提示;
- 发现本地最大连续序号之后出现更大序号;
- 请求缺失范围或执行用户增量同步;
- 以服务端
msg_seq排序; - 缺口确认前允许暂存后续消息,但不错误推进连续水位。
12.3 缺口恢复决策
缺口恢复不能无限阻塞整个账号的同步。一个热点群异常时,可以隔离该会话,让其他会话的用户事件继续应用,但必须把该会话的事件引用、缺口范围、重试状态和目标版本持久化到本地修复队列。只有这份恢复责任与全局 applied_sync_seq 在同一事务提交后,累计 ACK 才能越过该事件;否则只能继续停在缺口之前。无论全局游标是否前进,该消息的 DELIVERY_ACK 都要等正文或必要载荷真正本地可用。
12.4 撤回是新事件,不是删除旧记录
撤回命令成功后,系统生成一个拥有更高 msg_seq 或明确事件序号的控制事件,引用目标 message_id。客户端同步到该事件后,把原消息变成撤回占位。
直接物理删除原消息会让离线设备无法知道发生过撤回。如果旧设备稍后从缓存或重试包拿到原内容,它还会重新展示。墓碑和控制事件必须覆盖允许离线设备恢复的窗口。
“删除自己的本地记录”与“对所有成员撤回”也是两种操作。前者可以生成用户私有事件,只改变该用户各设备视图;后者改变会话公共事实。
12.5 编辑和反应事件
消息编辑、表情反应、置顶和引用关系都应建模为可重放事件,而不是在线推送一次后就丢弃。客户端按目标消息 ID 和事件版本做幂等应用。
对同一目标的多个编辑,要定义版本或服务端顺序;对表情反应这类集合状态,要明确是发送增量操作还是最新快照。网络乱序时,不能用“最后到达客户端的包”覆盖更高服务端版本。
十三、未读数:它是用户会话状态,不是设备送达数
13.1 未读的基本口径
对普通连续可见会话,可以基于:
latest_visible_msg_seq
read_msg_seq
但两者直接相减只在每个序号都代表一条计入未读的可见消息时成立。系统通知、撤回事件、仅部分成员可见消息和不计未读的静默事件都会让简单相减失真。
更稳妥的做法是由会话服务维护用户维度物化状态:
- 新的、对该用户可见且计入未读的消息到达时幂等增加;
- 用户上报阅读游标时,根据消息索引或区间计数收敛;
- 撤回是否减少未读由产品规则决定;
- 定期从消息事实和阅读游标重建或抽样核对。
“幂等增加”不能只靠先插 USER_INBOX_EVENT、再单独修改 USER_CONVERSATION_STATE。若前者成功、后者失败,重试看到事件已存在就直接返回,会永久少算一次;若先改未读再写幂等记录,则可能多算。基线方案是让 inbox event、用户同步头和会话状态落在同一用户分片事务中,以同一个幂等键共同提交。若会话状态必须放在独立存储,就由 inbox 日志驱动独立投影器,并保存 projection_name + source_event_id + membership_period_id 的应用记录或连续投影水位;重复事件先核对投影幂等证据,失败重试继续完成未读更新,不能因为 inbox 已存在就跳过未完成的投影。
13.2 多端阅读同步
如果产品规定账号任一设备读过就全端已读,服务端用户会话阅读游标取各设备上报的最大值,再作为用户事件同步给其他设备。
这不会改变其他设备的 acked_sync_seq。电脑读过一条消息,可以让手机角标清零;但手机仍要真正同步消息和阅读变更事件,不能跳过正文下载。
13.3 未读更新的并发竞争
常见竞争是新消息和清零同时发生:
- 会话当前到
msg_seq=442; - 用户上报读到 442;
- 同时新消息 443 进入;
- 如果只执行无条件
unread_count=0,可能把 443 也错误清掉。
清零请求应携带 read_to_seq=442,服务端只清理由该游标覆盖的消息。用户状态更新使用版本或条件更新,并让未读物化结果可从事实重算。
十四、冷热存储和历史漫游
14.1 分层不是简单“Redis 七天、MySQL 永久”
存储选择要由查询和保留策略决定:
- 热层保存近期高频范围查询所需的消息和索引;
- 温层保存较久历史,允许较高读取延迟;
- 冷层保存归档对象及校验信息,按需恢复;
- 用户事件日志只承担增量窗口,不等于全部历史消息;
- 媒体文件使用对象存储并独立控制授权和生命周期。
冷热迁移必须保留 conversation_id + msg_seq 的逻辑连续查询能力。客户端不应知道某条消息在哪一种数据库里,服务端统一路由并返回稳定分页游标。
14.2 跨层分页
按 msg_seq 做范围游标,避免使用易受新增数据影响的页码偏移。查询从热层跨到温层时,服务端返回下一段逻辑游标,而不是让客户端自己拼接不同存储结果。
冷归档首次恢复可能较慢,接口可以返回“历史正在准备”状态和任务 ID,但不能返回空列表冒充历史不存在。是否允许异步恢复取决于产品体验和合规要求。
14.3 删除、归档和设备水位
清理用户事件日志前,要确认:
- 已超过定义的增量同步窗口;
- 过旧设备能够收到
RESET_REQUIRED并走快照; - 活动的小范围重放会话和
BUILDING/READY快照基线仍被保留栅栏覆盖; - 消息历史仍可按产品保留策略查询;
- 撤回与删除墓碑不会先于潜在旧副本失效;
- 法律保留、用户删除请求和审计策略没有冲突。
设备永远不上线时不能无限阻止所有数据清理。系统应定义有效设备、长期离线设备失效和重新授权重建的业务规则。
十五、分片、热点与容量设计方法
15.1 两种主分片键
消息事实按 conversation_id 分片,目的是让会话内序号分配和范围查询尽量局部;用户事件与设备游标按 user_id 分片,目的是让一个账号的多会话增量同步尽量局部。
从消息分片到用户分片天然是异步跨分片过程,不能用一个数据库事务覆盖。可靠事件、幂等扇出、状态监控和对账共同承担收敛。
15.2 热点会话
少数活跃大群会让 conversation_id 单分片变热。可按演进条件选择:
- 为热点群分配独立序号写者和资源池;
- 消息正文按序列范围分段存储,但保留单一逻辑序号;
- 在线提示按网关分区合并广播;
- 扇出按成员桶分区并控制每桶速率;
- 大群从逐成员事件切换为共享日志加变更标记;
- 热点隔离,避免拖慢普通单聊和小群。
如果把同一会话直接随机写到多个分片,再依赖时间戳合并,会把顺序问题转移给每个客户端,通常得不偿失。
15.3 容量模型需要哪些输入
本文不提供无来源的生产 QPS。设计和压测前至少收集:
设计目标:峰值同时在线设备数
设计目标:每秒消息受理量及单聊、群聊占比
设计目标:平均与高分位群成员数
设计目标:每条消息产生的用户事件放大倍数
设计目标:设备平均离线时长和增量补拉批次大小
设计目标:重连峰值及冷启动比例
设计目标:正文、事件、索引和媒体元数据平均字节数
设计目标:历史保留期和热数据窗口
压测要分别覆盖消息受理、扇出、在线提示、增量同步、会话消息补拉和重连风暴。只压一个返回空 JSON 的接口,不能证明系统能支撑真实 IM 链路。
15.4 限流不能破坏恢复
同步接口过载时可以减小页大小、返回重试时间、按用户公平排队,但不能让客户端在已保存部分数据后跳过游标。任何限流响应都要允许从上一次本地已提交位置继续。
热点群降级也应限制通知实时性或未读精度,不能删除已经受理的消息事实。
十六、可靠性保证与不保证
16.1 系统可以承诺到哪一层
| 场景 | 设计保证 | 明确不保证 | 恢复或证据 |
|---|---|---|---|
| 发送接口返回受理成功 | 消息事实和可靠传播记录已提交 | 接收方设备立刻收到 | 消息记录、Outbox、事件位点 |
| MQ 重复投递 | 扇出按源事件、用户和成员周期幂等收敛 | MQ 只投递一次 | 唯一约束、重复计数指标 |
| 群消息扇出晚于退群 | 按消息 msg_seq 命中的历史成员周期裁决,退出前消息仍可补齐 | 当前成员表能还原历史权限 | period ID、join/leave 序号和 fanout 对账 |
| 在线提示丢失 | 设备可按同步游标主动补拉 | 每个 WebSocket 提示必达 | 服务端高水位、重连同步日志 |
| 设备同步请求超时 | 可从本地已提交游标重试 | 超时等于失败或成功 | 重复拉取、幂等本地写 |
| 设备提交累计 ACK | 该设备服务端游标单调推进 | 用户已经阅读 | 设备游标记录 |
| 设备重装或本地回滚 | 推进 sync_generation,旧批次 ACK 失效;小范围重放有服务端保留租约 | 沿用旧代次并从客户端自报位置安全恢复 | generation、恢复会话、快照清单 |
| 大群 marker 合并 | dirty target 单调取大,清理用版本 CAS,打开会话仍核对权威 head | 角标与普通群一样逐消息精确 | marker 版本、共享 head、本地 last_msg_seq |
| 系统通知平台接受 | 已把请求交给外部平台 | 操作系统展示、用户看到 | Provider 请求 ID 和结果分类 |
| 用户事件日志过期 | 有快照重建入口 | 任意旧游标永久增量回放 | RESET_REQUIRED 和快照水位 |
| 冷数据归档 | 在保留规则内可通过统一查询恢复 | 所有历史永久保存 | 归档清单、校验和、恢复演练 |
16.2 主要失败窗口
每个箭头都对应不同的证据和恢复动作。只说“用了 Kafka,所以不会丢”没有覆盖数据库提交前、Outbox 发布、客户端本地事务和 ACK 丢失这些窗口。
16.3 系统明确不保证什么
- 不保证网络包只到达一次;
- 不保证在线推送按发送顺序到达;
- 不保证系统通知一定展示在设备上;
- 不把某台设备同步完成等同用户已读;
- 不保证超过保留期的任意旧设备仍能做纯增量恢复;
- 不保证所有会话之间存在有意义的全局消息顺序;
- 不保证仅靠 Redis、MQ 或客户端缓存就能恢复全部事实;
- 不保证设计目标未经压测就成为实际容量成绩。
十七、安全、隐私和权限
17.1 连接和设备身份
- 长连接建立和重连都要校验短期令牌,而不是永久携带账号密码;
user_id从登录态解析,客户端不能任意指定;- 设备注册、刷新令牌、注销和强制下线要有审计记录;
- 新旧连接用
connection_epoch隔离,撤销设备后旧连接不能继续同步; - 同一账号设备数量、异常地域和高频重连可以触发风控。
17.2 消息读取权限
同步事件里出现一个 conversation_id 不代表设备可以绕过消息服务直接读取全部历史。每次历史补拉仍需校验:
- 当前账号是否有效;
- 成员有效区间是否覆盖请求序号;
- 是否存在禁言以外的读取限制或会话封禁;
- 消息是否因合规、撤回或用户私有删除而不可见;
- 分享链接和媒体 URL 是否仍在授权期。
权限缓存可以提升性能,但缓存失效不能让退出成员继续读取新消息。高风险成员变化应主动失效缓存,并由数据版本在查询时兜底。
17.3 离线通知隐私
锁屏通知可能暴露联系人和消息正文。服务端应遵守用户预览设置和会话敏感级别,必要时只发送“你有一条新消息”。Provider 令牌属于敏感设备标识,需要加密存储、最小权限访问和失效清理。
日志、Trace 和告警不应记录完整消息正文、访问令牌或媒体签名 URL。排障通常只需要哈希化用户标识、消息 ID、会话 ID、序号和阶段状态。
17.4 内容加密边界
传输层需要加密,服务端存储加密和密钥管理按风险等级设计。若采用端到端加密,服务端只能同步密文,新增设备是否能获取历史取决于密钥备份和设备间授权协议,不能继续沿用“服务端随时恢复明文历史”的假设。
十八、可观测、对账和故障排查
18.1 核心指标
| 链路 | 指标 | 用途 |
|---|---|---|
| 受理 | 发送请求量、幂等命中、事务失败、受理延迟 | 判断入口和存储健康 |
| Outbox | 未发布数量、最老记录年龄、重复发布量 | 识别消息已存但传播停滞 |
| 扇出 | 源事件延迟、每类会话放大倍数、成员周期未命中/多命中、幂等冲突 | 识别普通群压力、历史权限损坏和延迟任务错判 |
| 同步日志 | 用户高水位、同步头 CAS 冲突、最小可用游标、分页读取延迟 | 判断多 worker 分配与增量窗口健康 |
| 设备同步 | 在线设备落后量、批次大小、ACK 推进速度、generation 冲突、恢复会话年龄 | 判断客户端是否追平,并发现旧 ACK 或卡住的重建 |
| 缺口 | 缺口发现数、自动修复耗时、重建次数 | 发现乱序或数据缺失 |
| 推送 | 在线提示合并率、Provider 结果分类、无效令牌 | 观察提示链路,不冒充送达率 |
| 存储 | 热温冷读取比例、归档失败、校验异常 | 判断历史漫游能力 |
| 不变量 | 游标倒退、ACK 超过签发水位、旧 generation ACK、重复序号、dirty marker 错误清理 | 直接发现正确性风险 |
所有延迟指标都要标明阶段。例如“消息延迟”必须说明是受理延迟、受理到用户事件可见、提示到设备,还是设备补拉完成,不能混成一个数字。
18.2 结构化日志和 Trace
一条消息建议贯穿:
trace_id
message_id
client_msg_id_hash
conversation_id
msg_seq
source_event_id
membership_period_id
user_id_hash
sync_seq
device_id_hash
connection_epoch
sync_generation
recovery_session_id
stage
result_code
不是每个阶段都必须记录所有字段,但要能从消息事实追到 Outbox、扇出事件、用户同步位置和设备 ACK。正文与敏感身份不应进入普通日志。
18.3 对账关系
后台任务可按分片和时间窗口核对:
- 已受理消息是否存在对应可靠事件;
- Outbox 已发布记录是否在事件流有可追踪位点;
- 普通群每条消息命中的成员周期集合,是否与 fanout item 及用户事件中的
membership_period_id精确对应; - 用户事件引用的
message_id + msg_seq是否存在或有合法墓碑; - 设备 ACK 是否超过用户事件高水位;
- 会话物化最新序号和消息事实最大序号是否一致;
- 未读物化值是否可由阅读游标和消息索引重算;
- 冷归档对象、索引和校验和是否对应。
修复任务必须按原 source_event_id + user_id + membership_period_id 幂等,并记录修复前后值、消息序号、成员周期和操作者。遇到成员历史缺失、周期重叠或权限不确定时,不能凭当前成员表自动补发敏感消息,应进入人工审核。
18.4 一条故障怎样排查
先确认事实在哪一层中断,再选择恢复动作。直接重发一条新消息可能暂时让用户看到内容,却会掩盖原消息的顺序、幂等和同步问题。
十九、测试、故障演练与验证证据
19.1 正确性测试
| 风险 | 正常例 | 反例或并发例 | 核心断言 |
|---|---|---|---|
| 发送幂等 | 首次发送创建消息 | 响应丢失后并发重试 | 只有一个 message_id 和 msg_seq |
| 会话顺序 | 顺序提交多条消息 | 多网关并发发送 | 序号唯一,展示按服务端序号 |
| 扇出幂等 | 消费一次源事件 | 同一事件重复投递 | 用户事件和未读只变化一次 |
| 多端游标 | 手机电脑分别同步 | 电脑先 ACK 更大位置 | 手机仍能从自己的旧位置补拉 |
| 本地原子性 | 完整应用一批 | 应用中途进程崩溃 | 未提交批次不会错误 ACK |
| 快照切面 | READY 快照完整安装 | 构建时会话继续变化、对象缺页或安装中断 | 清单只包含 base_sync_seq 切面,摘要一致,失败代次不推进设备游标 |
| ACK 乱序 | 正常递增 ACK | 先到 9014 后到 9008 | 服务端游标不倒退 |
| 重建代次栅栏 | 同代次批次完成后 ACK | generation 5 的旧 ACK 延迟到 generation 6 快照安装期间 | 旧 ACK 被拒绝,不能把新代次游标推过未安装事件 |
| 回滚重放保留 | 正常从服务端 ACK 重放 | 本地低于 ACK,从旧位置分页时并发运行日志清理 | 活动恢复会话固定范围并阻止所需日志被清理;过期后明确转快照 |
| 缺口恢复 | 连续消息到达 | 444 先于 443 到达 | 补拉后按序收敛 |
| 缺口与累计 ACK | 正文立即可用 | 单会话正文暂不可取但其他会话继续同步 | 缺口修复任务与全局游标同事务持久化;未持久化时 ACK 不越过 |
| 撤回 | 在线端立即同步 | 离线端先拿墓碑后拿旧消息 | 原内容不会重新显示 |
| 成员权限 | 有效成员补拉 | 退群后请求新序号 | 超出有效区间被拒绝 |
| 消息与退群竞争 | 先提交消息再退群 | 两个请求从不同网关并发到达 | 同一会话写入顺序唯一决定 leave_seq;消息只命中覆盖其 msg_seq 的周期 |
| 延迟扇出 | 群消息立即被消费 | 消息提交后用户退群,fanout 数分钟后才运行 | 仍按消息序号命中旧周期并补齐退出前消息,不按当前成员状态漏发 |
| 重新入群 | 一个连续成员周期 | 退出后再次加入,旧源事件又被重复投递 | 新旧 membership_period_id 独立,离群区间不可见,旧周期幂等记录不被新周期覆盖 |
| 大群 marker 竞争 | marker 指向共享 head 后正常追赶 | 客户端准备清理时 target 又被推进,或提示事件丢失 | 旧版本 CAS 不能清除新 dirty;打开会话核对权威 head 后仍能补齐 |
| 用户序号并发 | 单 worker 连续写 inbox | 多 fanout worker 同时给同一用户写事件 | 同步头锁或 CAS 让 sync_seq 唯一连续,事件与可见高水位同事务提交 |
| 游标过期 | 有效游标增量同步 | 长期离线落后于最小水位 | 明确返回快照重建,不假装无消息 |
19.2 合同与集成测试
- 网关与消息服务:发送身份、幂等键、错误码和服务端 ACK 语义;
- 消息服务与事件流:Outbox 事件结构、版本兼容和重复发布;
- 扇出与同步服务:源事件幂等、按
msg_seq命中的成员周期、用户同步头事务和用户水位; - 同步服务与客户端:固定批次高水位、分页、重试、
RESET_REQUIRED; - 设备恢复合同:
sync_generation、恢复会话租约、旧 token 错误码、快照安装 CAS; - 推送服务与 Provider Stub:接受、限流、无效令牌和结果未知;
- 热温冷存储:跨层分页、授权一致和归档恢复。
如果只是设计阶段,上述证据状态应记录为 NOT_RUN。实现后再用可重复脚本、测试报告和环境说明回填,不能把测试清单写成已验证成果。
19.3 并发与容量测试
至少构造以下数据分布:
- 大量低频单聊与少量热点群并存;
- 多设备账号和单设备账号混合;
- 在线即时同步、短时离线补拉和长期离线重建混合;
- 小消息与包含媒体引用的消息混合;
- 正常消费与人为制造事件积压混合;
- 网关恢复时集中重连和随机抖动重连对比。
报告要区分设计目标与压测结果,记录硬件、实例数、数据规模、消息大小分布、群成员分布、压测持续时间、错误率、P95/P99、队列积压和停止施压后的恢复时间。
19.4 故障演练
- 消息事务提交后立即终止进程,验证 Outbox 能恢复发布;
- 重复投递同一源事件,验证用户事件和未读不重复;
- 暂停扇出消费,验证消息仍可受理且恢复后逐步追平;
- 丢弃部分在线提示,验证设备主动同步能补齐;
- 客户端本地事务中途崩溃,验证重启后重拉同一批次;
- ACK 请求超时,验证重复 ACK 不倒退;
- 用户事件日志截断,验证旧设备进入快照重建;
- 热消息库不可用,验证历史查询的明确失败和恢复,不返回错误空结果;
- 单个大群产生热点,验证资源隔离和普通会话服务质量;
- 推送 Provider 限流,验证通知降级不影响服务端同步事实;
- 设备令牌被撤销,验证旧连接和补拉请求失效;
- 冷归档对象损坏,验证校验告警和副本恢复流程。
- 暂停普通群扇出,提交消息后执行退群再恢复消费,验证任务仍使用消息
msg_seq命中的旧成员周期; - 在旧周期事件积压期间让同一用户重新入群,验证旧、新 fanout item 不共享幂等记录,离群区间也不被补发;
- 并发启动多个 fanout worker 向同一用户分片写事件,验证同步头、inbox event 和 committed high watermark 没有重复或提前可见。
- 让成员周期归档任务与扇出死信修复并发,验证修复窗口内的周期不会被清理;历史授权缺失时停止自动补发并进入人工审核。
- 冻结快照基线后持续写入多个会话,验证 READY 清单只反映
base_sync_seq,安装完成后从下一序号补齐且不漏事件。 - 在快照对象缺页、摘要错误和客户端安装中断处注入故障,验证设备游标不推进;同时让日志清理任务运行,验证活动快照保留栅栏仍覆盖后续增量。
- 让 inbox event 提交后在未读更新点故障,验证同库事务整体回滚,或跨库存储的投影器能靠独立幂等水位恢复且最终只增加一次。
- 设备开始 generation 6 快照重建后,延迟提交 generation 5 的合法旧 batch token,验证游标不推进且返回
STALE_GENERATION。 - 让本地游标低于服务端 ACK 并选择小范围重放,同时运行日志清理,验证恢复租约覆盖完整范围;让租约过期则明确转快照,不能返回残缺分页。
- 大群客户端读取 dirty target 后并发写入新消息,再用旧
dirty_version清理,验证 CAS 失败并重新提示;丢弃提示后,打开会话仍按权威 head 补齐。
故障演练只能写成演练结果,不能描述成真实线上事故。执行前要定义预期不变量、观测点、终止条件和数据恢复步骤。
19.5 验证证据清单
| 证据 | 设计阶段状态 | 实现后应包含 |
|---|---|---|
| 消息幂等自动化测试 | NOT_RUN | 并发请求、数据库断言和日志 |
| 多端同步端到端测试 | NOT_RUN | 两台模拟设备独立游标和最终视图 |
| 设备代次与回滚恢复测试 | NOT_RUN | 旧 token、generation CAS、活动租约、日志清理并发和快照降级断言 |
| 快照一致性与安装故障测试 | NOT_RUN | 固定基线、并发新事件、对象摘要、安装事务、保留栅栏和快照后增量断言 |
| 缺口与撤回测试 | NOT_RUN | 乱序注入、墓碑应用和截图或断言 |
| Outbox 故障演练 | NOT_RUN | 进程终止点、恢复位点和重复检查 |
| 成员周期与延迟扇出演练 | NOT_RUN | 消息/退群并发顺序、周期命中集合、重新入群隔离和幂等断言 |
| 用户 sync_seq 并发测试 | NOT_RUN | 多 worker、同步头事务、唯一约束和连续高水位断言 |
| 大群 marker 竞争测试 | NOT_RUN | target 取大、版本 CAS、提示丢失和权威 head 慢路径断言 |
| 重连风暴压测 | NOT_RUN | 环境、模型、曲线、错误率和恢复时间 |
| 冷归档恢复演练 | NOT_RUN | 校验和、恢复日志和权限验证 |
二十、设计取舍与演进
20.1 核心方案对比
| 决策 | 当前设计 | 可行替代 | 选择原因和代价 |
|---|---|---|---|
| 实时与恢复 | 推提示 + 游标拉取 | 纯推或纯轮询 | 多一种日志和游标,但能覆盖丢推送和断线 |
| 顺序范围 | 会话内有序 | 全局有序 | 避免无意义的全局瓶颈 |
| 多端进度 | 设备独立同步游标 | 用户共用消费位点 | 状态更多,但不会让快设备跳过慢设备数据 |
| 群成员裁决 | 会话序号上的不可覆盖成员周期 | 扇出时查询当前成员表 | 多保存历史区间,但延迟任务、退群和重新入群都有确定权限证据 |
| 普通群分发 | 用户事件写扩散 | 全部读扩散 | 写放大换简单增量同步和未读物化 |
| 大群分发 | 共享日志 + 合并标记 | 逐成员逐消息事件 | 查询更复杂,但控制极端写放大 |
| 客户端确认 | 本地事务后累计 ACK | 收包即 ACK 或逐条 ACK | 要求客户端本地事务,但避免错误跳过 |
| 历史恢复 | 增量窗口 + 快照重建 | 永久保留全部用户事件 | 协议更复杂,但可控制日志规模 |
20.2 为什么不直接把消息放 Redis List
Redis List 可以做小规模临时队列,但单独使用会遇到:
- 多设备消费位置难以独立管理;
- ACK、重放、历史查询和撤回语义需要重新实现;
- 主从切换和淘汰策略会影响事实边界;
- 热点群复制和长期离线会增加容量压力;
- 无法自然连接消息事实、成员权限和冷归档。
Redis 可以作为热缓存、会话摘要或同步页缓存,但是否成为真相源必须明确持久化、复制、恢复和数据丢失边界,不能因为访问快就默认可靠。
20.3 演进路线必须有触发条件
阶段一:单聊和普通群。
采用会话消息日志、用户事件写扩散和设备游标。触发下一阶段的条件是扇出放大、用户事件存储或热点群延迟持续接近容量边界,而不是“以后可能用户很多”。
阶段二:热点识别和资源隔离。
按会话热度动态识别,给热点群独立队列、序号写者和限流策略。触发下一阶段的条件是即使隔离后,逐成员事件仍成为主要成本或恢复瓶颈。
阶段三:大群共享日志。
引入合并变更标记和按会话补拉,重新定义大群未读精度、历史列表和通知能力。上线前必须验证普通模式与大群模式切换时水位连续,不能让迁移窗口漏消息或重复清零未读。
20.4 最优先的演进项
如果初版已经实现基本增量同步,优先补齐的通常不是再增加一个中间件,而是“可验证的恢复链”:Outbox 对账、设备游标监控、游标过期快照、乱序缺口测试和客户端本地事务。这些能力直接决定系统出现部分失败时能不能解释和修复。
二十一、面试回答、追问与职责复盘
21.1 两分钟主回答
我会先区分四个事实:服务端受理、进入用户同步范围、某台设备同步完成和用户已读,它们不能用一个“已送达”状态代替。
消息顺序上,我给每个会话分配单调递增的 msg_seq,客户端按它排序;多端同步再建立用户维度的 sync_seq 事件流,每台设备保存独立的 acked_sync_seq 和 sync_generation。在线 WebSocket 或系统通知只发“水位有变化”的提示,设备无论在线还是断线重连,都从自己的游标之后分页拉取。服务端固定批次高水位,token 绑定设备代次;客户端把消息、撤回等事件和本地游标放在一个事务里,提交后才发累计 ACK。设备重建会推进代次,旧 token 不能覆盖新游标;本地回滚若从服务端 ACK 之前重放,还要有持久化保留租约,否则直接走快照。
发送端使用 client_msg_id 做业务幂等,服务端在消息和 Outbox 同事务提交后返回受理成功;后续用至少一次事件加消费幂等构建接收方同步空间。消息和进退群共用会话写入顺序,普通群延迟扇出按消息 msg_seq 命中的历史成员周期生成事件,并用 user_id + source_event_id + membership_period_id 收敛重复;多个 worker 给同一用户写事件时,由用户分片同步头事务分配唯一 sync_seq。大群在放大不可接受时再用共享消息日志加电平触发的 dirty marker,marker 只提示追赶,真正进度仍以客户端保存的会话序号为准。重复靠唯一键收敛,乱序靠会话序号排序,缺口靠范围补拉,游标过旧走快照重建。最后用消息到 Outbox、成员周期、用户事件、设备 ACK 的分层指标和对账证明链路,而不会承诺系统通知必达或设备同步就代表用户已读。
21.2 常见追问
-
为什么需要
msg_seq和sync_seq两套序号?msg_seq解决单个会话内容顺序,sync_seq解决一个用户跨会话、跨事件类型的增量变化,两者作用域不同。 -
WebSocket 已经是 TCP,为什么还会漏消息? TCP 只保证一条存活连接内的字节传输;进程崩溃、本地未落库、连接切换和离线窗口仍需业务游标恢复。
-
为什么设备 ACK 不能共用一个用户游标? 快设备会覆盖慢设备的位置,让慢设备跳过尚未同步的数据。
-
消息发送成功到底表示什么? 本设计表示消息事实和可靠传播记录已提交,不表示接收设备已经拿到。
-
怎样保证消息不重复? 不做绝对化承诺;发送、扇出和客户端应用各自用稳定幂等键与唯一约束,让重复请求和至少一次事件最终收敛。
-
乱序消息怎么处理? 按会话
msg_seq排序,发现连续水位缺口就拉取范围,并识别合法跳号或撤回墓碑。 -
长时间离线,用户事件已经清理怎么办? 返回
RESET_REQUIRED,先获取带水位的会话快照,再从快照水位之后增量同步。 -
大群为何不能简单给每人一份离线队列? 写放大随成员数增长;达到演进条件后用共享群日志和可合并标记,但要接受同步查询和未读计算更复杂。
-
系统通知返回成功能叫送达吗? 只能叫 Provider 已接受;操作系统展示、设备应用和用户已读都需要各自证据。
-
撤回为什么也要占事件位置? 离线设备必须通过可重放事件知道原消息失效,墓碑还能阻止旧消息稍后重新出现。
-
未读数为什么不能总用最新序号减已读序号? 控制事件、静默消息和个性化可见消息可能占序号但不计未读,需要物化计数或可见消息索引。
-
如何证明系统能恢复? 用消息、Outbox、用户事件、设备游标的对账,加上丢推送、重复事件、本地崩溃和游标过期的故障演练证据。
-
消息与退群并发,扇出时到底看谁? 消息与成员变化共用会话线性化顺序;扇出按消息
msg_seq命中的[join_seq, leave_seq]周期判断,不能按 worker 执行时的当前成员状态判断。 -
用户退群后任务才执行,退出前消息还发吗? 要发到该账号的同步日志,因为消息序号落在旧成员周期内;但补拉权限仍被旧周期的
leave_seq截断,退出后的消息不可见。 -
多个 fanout worker 怎样避免分到同一个 sync_seq? 在用户分片事务中锁定或 CAS
USER_SYNC_HEAD,把序号推进、inbox event 和 committed high watermark 一起提交;失败 worker 读取新头后重试。 -
设备重装时,旧 ACK 为什么不能继续取最大值? 旧 ACK 证明的是旧本地状态。重建开始必须推进
sync_generation,批次和快照 token 都绑定代次;否则旧 ACK 可能把新设备游标推过尚未安装的数据。 -
大群 dirty marker 已经 ACK,是否代表消息已经同步? 不代表。marker 只是可合并提示;客户端从共享日志拉取并原子保存到目标
msg_seq后才能清理,且清理要用版本 CAS。提示丢失时仍靠权威会话 head 恢复。
21.3 可选择的个人职责主线
主线 A:消息受理与顺序。
需要能拿出发送幂等实现、会话序号约束、消息与 Outbox 事务、并发测试和重复请求证据。不能把扇出、客户端同步和全部运维能力都说成自己独立负责。
主线 B:多端同步与客户端协议。
需要能解释用户事件、设备游标、固定批次高水位、本地事务、快照重建和协议兼容,并有两台模拟设备的端到端测试。若只负责服务端接口,应把客户端本地库实现描述为协作边界。
主线 C:可靠性与可观测。
需要能拿出 Outbox 积压、成员周期命中、扇出幂等、用户同步头并发、缺口修复、对账任务、告警和故障演练记录。只能讲“参与演练”时,不要认领整个消息架构设计。
21.4 项目口径:哪些能讲,哪些不能越界
这篇目前是设计案例,不是实现复盘。可以讲设计问题、状态、不变量、接口契约、失败窗口、取舍和验证计划;不能把以下内容讲成已经完成的事实:
- 已经在线上支撑某个精确 QPS 或设备规模;
- 已经完成大群共享日志、冷热归档和跨端全链路;
- 消息在所有边界下都不丢、也不重复;
- 系统通知成功等于设备送达;
- 设备同步 ACK 等于用户已读;
- 自己独立完成团队所有模块。
项目真正实现后,应回填真实模块路径、迁移文件、接口契约、测试类、压测环境、故障演练和监控截图。没有证据的部分继续标为设计目标或演进方向。
21.5 复盘自检
- 服务端返回发送成功前,究竟持久化了哪些事实?
- 消息事务成功但 MQ 尚未发布时怎样恢复?
- 同一客户端发送请求重试时使用哪个稳定幂等键?
msg_seq和sync_seq的作用域分别是什么?- 号段产生空洞时,客户端如何区分合法跳号和真正缺失?
- 为什么电脑推进同步游标不能让手机跳过数据?
- 客户端在哪个本地事务完成后才能发送 ACK?
- WebSocket 提示全部丢失时,设备靠什么恢复?
- 新设备与短暂断线设备为什么不能走完全相同的初始化流程?
- 普通群写扩散使用哪个时点的成员关系?
- 热点大群切换共享日志后,未读数和历史权限怎样定义?
- 一条撤回事件怎样影响尚未收到原消息的设备?
- 未读清零和新消息并发时如何避免误清?
- 用户事件日志过期后,服务端返回什么明确协议状态?
- 哪些指标能区分“消息已受理”和“手机同步完成”?
- 对账发现用户事件引用不存在消息时,能否直接补造消息?
- 系统通知 Provider 接受请求后,项目口径应该怎么说?
- 哪些压测输入会显著影响扇出放大和重连峰值?
- 设备撤销后,旧连接和历史补拉怎样同时失效?
- 当前方案最优先补齐的恢复证据是什么?
- 消息和退群从不同网关并发到达时,谁决定
leave_seq? - 为什么延迟扇出不能查询当前成员表?
- 重新入群为何必须生成新的
membership_period_id? - 多 worker 并发写同一用户日志时,怎样保证序号、事件和高水位原子可见?
- 设备重装或本地回滚时,怎样让旧批次 token 和延迟 ACK 失效?
- 从服务端 ACK 之前的小范围位置重放时,谁阻止清理任务删掉分页所需日志?
- 大群 dirty marker 的清理与新消息并发时,怎样避免错误变成 clean?
总结
IM 离线消息和多端同步的核心不是为离线用户准备一条临时队列,而是建立四组清楚的事实:
- 会话消息日志用
msg_seq定义业务顺序; - 成员周期用
join_seq + leave_seq + membership_period_id定义历史可见范围; - 用户事件日志用
sync_seq描述账号视图变化; - 带
sync_generation的设备独立游标描述每台终端当前代次真正应用到哪里。
在线推送负责快,游标拉取负责恢复;消息事实负责可重放,客户端本地事务负责不错误确认;设备代次阻断重建前的旧 ACK,恢复租约保护低位重放;单聊和普通群可以写扩散,热点大群在有证据的容量压力下演进到共享日志和可恢复 dirty marker。重复、乱序、缺口、撤回和断线都不是例外补丁,而是同步协议必须直接表达的正常失败模式。
最后,系统只能根据证据说话:服务端受理、设备同步和用户已读分别确认。把可靠性边界、恢复路径、对账和验证证据讲清楚,才算真正回答了“IM 离线消息与多端同步怎么做”。