系统设计 17:即时通信:连接恢复以后,消息怎样补齐
发送按钮变成勾号之前,究竟发生了哪件事:网关收到字节,服务器保存消息,接收设备拿到消息,还是用户已经读过?如果界面把这四个状态合并成“成功”,一次断线就会把系统承诺暴露出来。候选即时通信服务把每种确认绑定到具体对象和存储位置。
Facebook Messenger 在这里只表示私聊与群聊题型,不代表其内部实现。范围包括文本消息、离线保存、多设备同步和小群会话,音视频、端到端加密协议以及万人群不在本次实现范围。
一条消息至少有四个确认点
客户端发送 SEND(conversation_id, client_message_id, payload),服务端身份从认证连接取得,不接受用户自行声明发件人。网关收到消息只表示受理,持久消息库事务提交后才能返回 PERSISTED(message_id, seq)。接收设备把消息写入本地存储后上报 DELIVERED(device_id, seq);用户打开会话并实际消费后才更新 READ(user_id, seq)。
数据模型可为 messages(conversation_id, seq, sender_id, client_message_id, payload),会话内序号唯一,同时对 (sender_id, client_message_id) 唯一。重发同一消息键时返回原消息,正文不一致应拒绝,不能悄悄修改已经持久化的内容。会话成员关系是写入和读取授权的依据,退出群后是否还允许看历史,需要明确产品规则。
SYNC(conversation_id, after_seq, limit) 拉取离线消息。每个设备保留自己的已应用位置,服务端可记录送达进度用于回执,但不能用设备 A 的送达位置代替设备 B。用户级已读位置可以跨设备取最大值,而设备补齐位置仍然独立,否则新设备会错误跳过所有已读但本地没有的消息。
会话内序号表达服务端接收顺序,不承诺两台手机的物理发送时间完全排序。分配序号与持久写入需要同一权威边界,不能先确认序号再异步尝试保存。不同会话之间无需全局总序,这可以按会话 ID 分片,避免为了无关聊天建立一个全局瓶颈。
长连接容量和消息存储分别估算
教学假设峰值在线 100 万设备,每条连接用户态与协议缓冲合计 20 KiB,仅连接内存约 20.48 GB。每个设备每三十秒心跳一次,约 33333 次/秒;如果往返有效载荷按 200 B,约 6.67 MB/s。这里未计 TLS、内核缓冲和连接路由表,实际容量必须按网关实现测量。
每天 2 亿条文本消息,平均 1 KiB,平均写约 2314.81 messages/s,峰值六倍约 13888.89 messages/s。保留 30 天、三份副本,纯消息约 200000000 × 1024 × 30 × 3 = 18.43 TB,索引、成员表和回执另算。平均投递设备数为 2 时,网络发送至少扩大两倍;群消息按成员与在线设备数进一步放大。
心跳间隔从三十秒改为十秒,保活请求增三倍,却不自动把故障检测时间严格降到十秒,因为还涉及允许漏跳次数、网络抖动及检测调度。在线人数翻倍也不代表消息写入必然翻倍;只打开应用的连接和高频发言的群是不同负载,需要独立压测。
离线积压按“每设备未应用消息”而不是只按“全站消息数”观察。如果十万设备在一分钟内重连,各补一百条,就有一千万条补拉需求。恢复窗口内做分批分页和速率限制,避免连接恢复本身把存储打满。
网关路由可以丢,消息事实不能丢
flowchart LR
A[发送设备] --> G[长连接网关]
G --> R[会话路由与授权]
R --> D[(消息库 会话序号与去重)]
D --> E[提交后投递事件]
E --> N[在线设备路由]
N --> B[接收设备]
B --> L[(本地消息库)]
B -->|SYNC 游标补拉| R
在线路由映射 user/device → gateway/connection/epoch。连接迁移时增加代次,迟到的断线通知不能把新连接删掉。路由缓存可以过期并重建;消息库是事实源,即使实时推送失败,设备仍能通过序号补拉。把可靠性完全交给某条常驻连接,会在手机切网时失去恢复依据。
WebSocket 提供双向连接和帧协议,但不替应用保存离线消息或定义已读语义,应用确认与恢复协议仍需设计。RFC 6455 的协议行为不能用来证明消息已落盘。网关在客户端断开后,已经提交的消息不会回滚;客户端没有收到持久化回执时,用原 client_message_id 重试。
sequenceDiagram
participant S as 服务端消息库
participant C as 接收设备
participant L as 设备本地库
S->>C: seq 1 与 2
C->>L: 持久应用1与2
C--xS: seq 2确认丢失
Note over S,C: 连接断开 后续3到5继续保存
C->>S: 重连 after_seq=1
S->>C: 重放2 补发3到5
C->>L: 按消息唯一键写入
L-->>C: 2忽略 已展示列表为1到5
游标只能越过已应用的连续区间
消息先到 5、后到 4 时,不能仅因为看见 5 就把恢复游标记成 5,否则重连会永远跳过 4。客户端可以暂存乱序消息,但持久游标应推进到已经完整应用的连续水位。若会话序号存在合法空洞,协议需提供明确跳过范围或以服务端日志游标表达完整性,不能把“每个整数都必有消息”当作隐含前提。
删除或撤回消息使用墓碑事件;补拉老消息时仍要获得其当前可见性,不能先显示正文后等另一个清理事件抵达。服务端历史保留期届满后返回游标过旧状态,让客户端从可用快照重新同步。端到端加密会进一步改变服务端可查询字段和多设备密钥管理,但不能在没有密钥协议的设计中假称已经提供。
恢复风暴用随机退避、有界补拉页和并发限制处理。控制指标包括持久化延迟、投递滞后、重复投递比例、设备补齐水位和最老未送达消息年龄。重复传输可能是正常恢复动作,重复展示才是本篇要拒绝的用户可见错误;不能为了把重复传输归零而牺牲补齐。
磁盘持久化后的重连实验
examples/system-design/labs/17/chat.py 用两个真实 SQLite 文件分别表示服务端消息库和客户端本地库。服务端保存 1 到 5,客户端先应用 1、2,但设服务端只知道确认到 1。重连从 1 后面拉取,实际投递序列为 [1,2,2,3,4,5];客户端按序号唯一写入,关闭并重新打开本地数据库后,展示集合仍恰好为 [1,2,3,4,5]。
实验还尝试用相同客户端消息键插入新消息,唯一约束拒绝,服务端仍只有五条。默认退出 0,负例把每次收到消息直接追加展示,发现重复 2 后退出 2。
1 | |
证据位于 examples/system-design/evidence/17/,包括投递序列和重新打开数据库的结果。这里没有真实 WebSocket 连接,断线通过明确丢确认和补拉调用模拟;也未测试多会话、网络乱序、群授权或客户端 UI。它证明持久去重与游标重放相容,不证明某个移动 SDK 的传输可靠性。
限时面试先定义四种确认,再核算连接与消息,画网关和持久化边界,深入处理断线、双设备和乱序。追问“全局有序能否更简单”时,要说明代价是把互不相关的会话串行化;追问“服务端只发一次为什么还重复”,需要回到提交、回执和重连之间的结果未知窗口。
参考资料
- RFC 6455:The WebSocket Protocol:连接与帧协议;应用层消息持久化和确认由本设计另行定义。
- SQLite CREATE TABLE:实验消息键和本地展示键的唯一约束。






