后端系统工程 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
2
3
4
5
6
{"t":"read","rid":"r4","conv":"c-ab","seq":8}
{"t":"read_ok","rid":"r4","conv":"c-ab","read":8}

{"t":"convs","rid":"r5"}
{"t":"convs","rid":"r5","items":[
{"conv":"c-ab","epoch":1,"head":12,"min_seq":1,"delivered":0,"read":8,"unread":4,"mode":"push"}]}

read_ok.read 返回合并后的已读位置。设备上报的值小于服务端记录时,回复中是服务端的较大值,设备据此更新本地显示。

convs 的每一项从发起请求的设备的视角给出:delivered 是该设备的送达游标,read 和 unread 是用户级的。同一用户的两台设备得到的 read 与 unread 相同,delivered 可能不同。

4. 数据模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
CREATE TABLE devices (
device_id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users,
token_sha256 text NOT NULL,
install_id text,
install_epoch integer NOT NULL DEFAULT 0
);

CREATE TABLE device_cursors ( -- 第 06 篇
device_id text NOT NULL REFERENCES devices,
conv_id text NOT NULL REFERENCES conversations,
install_epoch integer NOT NULL,
delivered_seq bigint NOT NULL CHECK (delivered_seq >= 0),
...
PRIMARY KEY (device_id, conv_id)
);

CREATE TABLE read_positions (
user_id text NOT NULL REFERENCES users,
conv_id text NOT NULL REFERENCES conversations,
read_seq bigint NOT NULL CHECK (read_seq >= 0),
PRIMARY KEY (user_id, conv_id)
);
1
2
3
4
5
6
图 9-1  三类状态的归属与推导关系
用户 u-ben ─────────────── read_positions(u-ben, c-ab).read_seq ──┐
├── 设备 u-ben-d1(手机) device_cursors(d1, c-ab).delivered_seq │ 互不推导
└── 设备 u-ben-d2(电脑) device_cursors(d2, c-ab).delivered_seq │
▼
events(c-ab) + membership_periods(c-ab, u-ben) + read_seq ──> unread(查询时计算,不存储)

install_epoch 每次重装加一。设备凭据(令牌)不变,所以网关鉴权时从 devices 表读出当前的 install_epoch,放进连接的身份信息里;之后这条连接发来的确认都带着这个纪元。

5. 正常路径

5.1 已读上报

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// examples/im/src/im/Store.java(节选)
public long markRead(Principal who, String convId, long seq) {
return db.tx(c -> {
long head = head(c, convId);
List<long[]> periods = periods(c, convId, who.userId());
if (periods.isEmpty()) throw new ImException("NOT_MEMBER", "conversation not visible");
if (seq < 0 || seq > head) throw new ImException("CURSOR_AHEAD", "seq=" + seq + " head=" + head);
long[] last = periods.get(periods.size() - 1);
long cap = last[1] < 0 ? head : last[1]; // 退群用户上限为退群序号
long value = Math.min(seq, cap);
// INSERT ... ON CONFLICT (user_id, conv_id)
// DO UPDATE SET read_seq = GREATEST(read_positions.read_seq, EXCLUDED.read_seq)
// RETURNING read_seq
...
});
}

已读位置的含义是“用户已经读到这里”,客户端上报的是本次可见区域中最大的序号。手机读到 8、电脑稍后上报 5(它的屏幕停在更早的位置,上报晚到),结果仍是 8。

5.2 未读推导

1
2
3
4
5
6
7
8
SELECT count(*) FROM events e
WHERE e.conv_id = cv.conv_id AND e.kind = 'msg'
AND e.seq > coalesce(rp.read_seq, 0)
AND e.recalled_by IS NULL
AND e.sender_user <> :user
AND EXISTS (SELECT 1 FROM membership_periods m
WHERE m.conv_id = e.conv_id AND m.user_id = :user
AND e.seq >= m.from_seq AND (m.to_seq IS NULL OR e.seq <= m.to_seq))

整条 convs 查询在 REPEATABLE READ 只读事务中执行,head、read、unread 来自同一快照,不会出现 unread 大于 head - read 这类不一致的组合。

5.3 重装

1
2
3
4
5
6
7
8
图 9-2  设备重装前后的状态变化
重装前: devices(d2).install_epoch = 0;device_cursors(d2, c-ab) = 0(未同步过)
旧安装的连接身份: (u-ben, d2, epoch 0)
reinstall(d2): UPDATE devices SET install_epoch = install_epoch + 1 → 1
DELETE FROM device_cursors WHERE device_id = d2
旧连接上的 ack(seq=3): currentInstallEpoch(d2)=1 ≠ 0 → STALE_INSTALL
新安装连接: 鉴权得到 (u-ben, d2, epoch 1) → sync(after=0) 拉取全部可见历史 → ack
read_positions(u-ben, c-ab) 始终不变

拒绝旧安装的确认,是为了防止一种具体的错误:旧安装在重装之前发出一个 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 模拟,设备注册流程本身不在范围内。

下一篇处理网关进程整体失效:大量设备同时断线时,怎样回收失效网关的路由、限制重连速率,以及怎样防止迟到的旧注册覆盖新的归属。

参考资料