后端系统工程 09:多设备已读与未读状态
后端系统工程 09:多设备已读与未读状态
本批导航:00 技术方案怎样接受验证|01 API 契约与业务不变量|02 负载模型与容量预算|03 单库基线与故障实验工具|04 持久化边界|05 长连接接入|06 发送确认|07 排序与增量同步|08 群消息扇出|09 多设备已读|10 网关故障与路由收敛|11 撤回与集成验收
一个用户同时登录手机和电脑时,“这条消息看过了吗”有两个不同的问题:这台设备上有没有这条消息,以及这个人有没有读过它。前者属于设备,后者属于用户。用户在手机上读完消息,电脑上的未读数应该消失;但电脑并没有因此获得这些消息的数据,如果它此后离线,打开会话时仍然要从服务端拉取。
本篇把两类状态分开存储:送达游标按设备,已读位置按用户;未读数不存储,而是在查询时由已读位置和事件推导出来。重点处理三种异常:两台设备的已读上报乱序到达,设备重装后旧安装发来的确认,以及设备从旧的本地备份恢复后上报一个更小的游标。
1. 需求与不变量
| 编号 | 不变量 | 观察位置 |
|---|---|---|
| M1 | 送达游标按 (设备, 会话) 记录,单调,不超过 head |
device_cursors |
| M2 | 已读位置按 (用户, 会话) 记录,单调,不超过 head,也不超过该用户最后一个成员区间的终点 |
read_positions |
| M3 | 写已读位置不改变任何设备的送达游标;写送达游标不改变已读位置 | 两张表 |
| M4 | 未读数 = 该用户可见、未被撤回、由他人发送、序号大于已读位置的消息数 | 派生查询 |
| M5 | 设备重装后,旧安装的确认被拒绝;新安装的送达游标从 0 开始;用户已读位置不变 | devices.install_epoch |
M4 定义中的每一个限定都对应一个具体的错误情形:不排除自己发送的消息,用户每发一条消息自己的未读数就加一;不排除撤回的消息,未读数会包含一条已经看不到的消息(第 11 篇);不按成员区间过滤,退群期间的消息会被计入。
2. 规模假设
按第 02 篇,平均每用户 1.6 台在线设备。已读上报由用户行为触发(打开会话、滚动到底部),频率远低于送达确认,模型中没有单独计入。送达确认的频率与合并策略是数据库写入的主要来源,在第 06 篇已讨论。
未读数是派生值,每次 convs 请求都对每个会话计数一次。会话数很多、未读消息很多的用户,这个查询的代价随二者增长。本批没有在大数据量下测量(NOT_RUN),第 9 节讨论替代方案。
3. 接口
1 | |
read_ok.read 返回合并后的已读位置。设备上报的值小于服务端记录时,回复中是服务端的较大值,设备据此更新本地显示。
convs 的每一项从发起请求的设备的视角给出:delivered 是该设备的送达游标,read 和 unread 是用户级的。同一用户的两台设备得到的 read 与 unread 相同,delivered 可能不同。
4. 数据模型
1 | |
1 | |
install_epoch 每次重装加一。设备凭据(令牌)不变,所以网关鉴权时从 devices 表读出当前的 install_epoch,放进连接的身份信息里;之后这条连接发来的确认都带着这个纪元。
5. 正常路径
5.1 已读上报
1 | |
已读位置的含义是“用户已经读到这里”,客户端上报的是本次可见区域中最大的序号。手机读到 8、电脑稍后上报 5(它的屏幕停在更早的位置,上报晚到),结果仍是 8。
5.2 未读推导
1 | |
整条 convs 查询在 REPEATABLE READ 只读事务中执行,head、read、unread 来自同一快照,不会出现 unread 大于 head - read 这类不一致的组合。
5.3 重装
1 | |
拒绝旧安装的确认,是为了防止一种具体的错误:旧安装在重装之前发出一个 ack(seq=13),这个请求在网络中延迟,重装完成后才到达。如果接受它,新安装的送达游标会被设为 13,而新安装本地实际上什么都没有;重投扫描依据游标判断,会认为它已经收到了全部消息。
6. 方案比较
6.1 已读状态的粒度
| 方案 | 存储 | 多设备 | 问题 |
|---|---|---|---|
| 每条消息每用户一个已读标记 | 消息数 × 成员数 | 天然共享 | 存储与写入量随群规模放大;大群不可行 |
| 每设备一个已读位置 | 设备数 × 会话数 | 各设备独立,用户在手机上读过,电脑仍显示未读 | 不符合用户预期 |
| 每用户一个已读位置 | 用户数 × 会话数 | 共享 | 只能表达“读到哪里”,不能表达跳着读 |
本实现采用每用户一个位置。“跳着读”(先读最新一条,再回头读中间)无法精确表达,这是有意接受的简化:大多数会话界面按顺序展示消息,用户滚动到底部即视为全部已读。
6.2 已读能否从送达推导
一种省事的做法是把“用户任意一台设备已送达”当作已读,或者反过来,在用户已读时顺便推进所有设备的送达游标。两种做法都违反 M3:
- 送达推导已读:设备在后台收到推送并确认,用户根本没打开会话,未读数却被清零。负对照
09-control-read-from-delivery量化了这一点。 - 已读推进送达:用户在手机上读完,电脑的送达游标被推进,重投扫描不再给电脑发提示,电脑本地缺少这些消息,而它“以为”自己是完整的。检查
09-read-does-not-fake-delivery验证本实现不会这样做。
6.3 并发上报的合并
| 方案 | SQL | 乱序到达 |
|---|---|---|
| 后写者胜出 | UPDATE … SET read_seq = ? |
较慢的设备把已读位置拉回去,未读数回升 |
| 条件更新 | UPDATE … SET read_seq = ? WHERE read_seq < ? |
正确,但首次插入需要另外处理 |
upsert + GREATEST |
INSERT … ON CONFLICT DO UPDATE SET read_seq = GREATEST(…) |
正确,一条语句完成 |
已读位置和送达游标都是只增的量。对只增的量,取最大值的合并与到达顺序无关,任意交错都得到同一结果。这是让多设备状态不需要协调的关键性质。
6.4 未读:存储还是推导
| 方案 | 写入 | 读取 | 一致性风险 |
|---|---|---|---|
| 每用户每会话存一个计数器 | 每条消息给每个成员加一;读时清零 | 直接读 | 撤回、退群、成员变更都要修正计数器;任何遗漏都会让计数永久偏离 |
| 查询时推导 | 无 | 每个会话一次计数 | 无,但读取代价随未读数增长 |
推导方案在本批中实现。它的正确性只依赖于 M4 的定义,撤回(第 11 篇)只需设置 recalled_by,退群只需关闭区间,未读数自动正确。计数器方案的性能更好,但每一种会改变“哪些消息算未读”的操作都必须同步修改计数器,并且要与事件写入在同一事务中。
6.5 已读变化怎样传到其他设备
用户在手机上读完之后,电脑上的未读角标什么时候消失?本实现中,read 请求只写入 read_positions,不发出 pg_notify,也不向同一用户的其他连接推送任何帧。电脑上的数字只有在它下一次发起 convs 请求时才会更新。这是一个有意保留的简化,可选做法有三种:
| 方案 | 实时性 | 额外负载 | 新增的失败情形 |
|---|---|---|---|
| 不推送,其他设备按需查询(本实现) | 取决于客户端刷新时机 | 无 | 角标在一段时间内偏大 |
已读写入后发通知,网关向该用户的其他连接推送 read_changed |
一次往返 | 每次已读上报产生 (设备数 − 1) 个帧 | 推送丢失时与“不推送”相同,仍需查询兜底 |
| 把已读变化作为事件写入会话日志 | 与消息同等的可靠性,可增量同步 | 每次已读多写一行事件,并对所有成员可见 | 泄露已读状态;日志被个人状态填满 |
第三种方案把个人状态混进了共享日志:会话的事件日志由所有成员共享,而已读位置只属于一个用户。写入日志意味着其他成员也会同步到这条事件,除非为每个事件再引入一层“只对某人可见”的过滤,这会让第 07 篇的同步协议和第 08 篇的成员区间判定都变得更复杂。
第二种方案值得作为后续扩展。它的正确性不依赖推送本身:推送只是“提示查询”,即使丢失,客户端的下一次 convs 仍然得到正确值,这与第 06 篇“通知只是唤醒提示”的原则一致。帧数开销按第 02 篇的基线估算:每用户 1.6 台设备,每次已读上报额外产生约 0.6 个帧,与送达确认相比可以忽略。真正需要控制的是上报频率:如果客户端在滚动过程中连续上报,就要在客户端做合并,例如停止滚动后再上报,或者只在已读位置跨过新的消息时上报。本批没有实现这一推送,也没有测量上报频率,相关结论属于设计推导。
还有一个容易忽略的细节:read_ok 回复中返回的是合并后的值。电脑如果上报一个较小的位置(例如它的屏幕停在旧消息处),服务端返回手机已经写入的较大值,电脑据此更新本地显示。因此即使没有推送,只要电脑上的用户有任何已读动作,角标也会立即被校正。
7. 验证
运行方式:真实 PostgreSQL 16、网关 JVM,测试进程内模拟同一用户的两台设备 u-ben-d1(手机)与 u-ben-d2(电脑)。会话先写入 10 条来自 u-ana 的消息,head=12(含 2 个 join 事件)。结果见 evidence/im-09/result.json。
| 类型 | 检查 | 预期 | 观测 |
|---|---|---|---|
| 正常 | 09-delivery-per-device |
手机同步并确认,送达 12;电脑送达 0;两者未读都是 10 | 符合 |
| 正常 | 09-read-max-wins |
手机上报已读 8(head−4),电脑随后上报 5(head−7);已读保持 8,未读 4 | 符合 |
| 边界 | 09-read-does-not-fake-delivery |
用户在手机上读到 8 后,电脑送达游标仍为 0 | 0 |
| 边界 | 09-own-messages-not-unread |
u-ben 自己发送一条消息(head 变为 13),未读仍为 4 | 4 |
| 边界 | 09-read-ahead-rejected |
已读上报 10000 → CURSOR_AHEAD |
符合 |
| 恢复 | 09-reinstall |
电脑重装:纪元变为 1;旧连接确认得到 STALE_INSTALL;新安装送达从 0 开始,同步后持有 11 条消息、送达 13;已读仍为 8 |
符合 |
| 失败 | 09-cursor-rollback |
新安装模拟恢复旧的本地备份:上报 ack(2),本地删去 3 之后的事件;服务端送达游标保持 13,回复中 delivered=13;设备从自己的游标 2 重新同步,本地重新完整 |
符合,本地 11 条 |
| 负对照 | 09-control-read-from-delivery |
以“任一设备的最大送达游标”作为已读 | 推导出 13,实际已读 8 |
| 负对照 | 09-control-lww-read |
直接 UPDATE read_seq = 5 |
已读回退为 5 |
| 边界 | 09-read-requires-membership |
非成员上报已读 → NOT_MEMBER |
符合 |
09-cursor-rollback 体现了服务端游标与客户端游标的分工。服务端的送达游标用于决定“是否需要提示这台设备”,它保持 13 意味着扫描不会再提示;客户端的游标用于决定“从哪里开始同步”,它回退到 2 后,客户端自己发起同步就能补齐。二者不一致是允许的,客户端始终以本地游标为同步起点,所以本地数据不会因为服务端游标偏大而缺失。这里存在一个窗口:恢复备份后、客户端发起同步之前,若没有新消息到达,服务端不会主动提示。客户端在启动时对所有会话执行一次同步,可以关闭这个窗口;测试客户端由调用方负责这一步。
8. 故障与恢复
| 故障 | 处理 | 结果 |
|---|---|---|
| 两台设备的已读上报乱序 | GREATEST 合并 |
已读不回退 |
| 已读上报丢失 | 客户端下次打开会话时重报 | 未读暂时偏大,最终正确 |
| 旧安装的延迟确认 | install_epoch 比较 |
STALE_INSTALL,游标不受影响 |
| 设备恢复旧本地备份 | 服务端游标不回退;客户端从本地游标同步 | 本地重新完整 |
| 退群用户上报已读 | 上限为退群序号 | 不会读到区间之外 |
| 撤回一条未读消息 | recalled_by 非空,计数排除 |
未读数减一(第 11 篇) |
| 设备丢失(不再上线) | 送达游标停留 | 不影响其他设备与已读;设备注销不在范围内 |
9. 限制
- 未读推导的查询代价随会话数与未读数增长,大数据量下未测量。可选的改进包括:只计数到上限(例如显示“99+”时计数到 100 即停止),或在会话行上维护按序号排列的辅助索引。
- 已读位置不能表达跳读。
- “消息已被对方读到”(已读回执)需要对外暴露他人的已读位置,涉及隐私设置,本批未实现。
- 已读变化不向同一用户的其他设备推送(第 6.5 节),其他设备的角标在下一次
convs请求或自身的read_ok回复时才校正。 - 重装通过直接调用存储层的
reinstall模拟,设备注册流程本身不在范围内。
下一篇处理网关进程整体失效:大量设备同时断线时,怎样回收失效网关的路由、限制重连速率,以及怎样防止迟到的旧注册覆盖新的归属。
参考资料
- PostgreSQL 16 文档,INSERT 的 ON CONFLICT 子句。https://www.postgresql.org/docs/16/sql-insert.html
- PostgreSQL 16 文档,9.18.3 GREATEST and LEAST。https://www.postgresql.org/docs/16/functions-conditional.html
- PostgreSQL 16 文档,13.2.2 Repeatable Read Isolation Level。https://www.postgresql.org/docs/16/transaction-iso.html






