未读数显示 3,点进去一条新消息也没有
会话列表上有一个红色的 3。点进去,最后一条消息是昨天看过的。退出来,3 还在。另一个用户反馈的是反过来的情况:角标是 0,对话里却有一条从没见过的消息。
未读数有两种存法。一种是给每个用户的每个会话存一个计数器,来一条消息加一,读了就清零;另一种是只记「读到哪一条」,未读数在需要时现算。第一种直观,上面两个现象也都出自它:计数器是从事件里推导出来的状态,事件重复、丢失或者顺序错了,它就与事实对不上,而且没有办法自己纠正。
本文在 MySQL 8.4 上把两种存法各自的问题跑出来。实验见文末。
一、计数器:加多了
消息写入后,通常由一个异步的消费者给每个接收方的计数器加一。消息队列的投递是至少一次,同一个「新消息」事件可能被处理两次:
alice 发了 10 条,其中 3 条的通知被重复投递:计数器 = 13
同样的投递,按已读位置现算:未读 = 10计数器是 13,实际只有 10 条。用户全部看完之后清零,这次的误差消失了;如果清零用的是「减去已读条数」,误差就永久留在那里,这就是开头那个去不掉的 3。
给加一操作做幂等(记录处理过的消息 ID)能解决重复,代价是每个接收方多一份去重记录。已读位置的存法没有这个问题:未读数是从消息表里数出来的,一条消息只会被数一次,事件重复多少次都不影响。
二、计数器:清零清掉了没看过的
用户看完了前 10 条,客户端发出「已读」请求。请求在路上的时候,第 11 条消息到了:
bob 看完前 10 条后上报已读,请求到达前第 11 条已写入:计数器清零后 = 0,实际没看过的有 1 条
上报的是「读到第 10 条」:未读 = 1「清零」这个操作没有说清楚「我看到了哪里」。服务端执行它的时候,计数器已经包含了第 11 条,一起被清掉。角标是 0,对话里有一条没见过的消息。
已读位置的上报带着具体的消息 ID:「我读到了第 10 条」。服务端不管什么时候执行它,含义都不变,第 11 条仍然算未读。
这是两种存法的根本差别:计数器记录的是一连串操作的结果,操作的顺序变了结果就变;已读位置记录的是一个事实,晚到、重复都不改变它的含义。
三、已读位置:多端上报会倒退
已读位置也有自己的问题。用户在手机上读到了第 20 条,电脑上一个更早发出的请求这时才到:
手机上报读到第 20 条,随后电脑上一个迟到的请求上报第 12 条,直接覆盖:未读 = 8
写成 last_read_id = GREATEST(last_read_id, ?):未读 = 0直接覆盖时,已读位置从 20 退回到 12,8 条已经看过的消息重新变成未读。已读位置只能前进:
UPDATE read_watermark
SET last_read_id = GREATEST(last_read_id, ?)
WHERE user_id = ? AND conv_id = ?这样写之后,请求的到达顺序不再影响结果,重复提交也没有副作用。
另外一处容易漏的细节:
alice 自己发了 20 条、从没上报过已读:不排除自己发的 = 20,排除后 = 0自己发的消息不算未读。要么在计数时排除,要么在发送时顺便把自己的已读位置推进到这一条。
四、已读位置:ID 的顺序不是提交的顺序
这是最隐蔽的一个问题。已读位置的前提是「ID 比它小的消息我都见过」。自增 ID 在执行插入时分配,而消息对别人可见是在事务提交时,两个顺序可以不一样:
事务 A 先插入(ID 排第 1)未提交,事务 B 后插入(ID 排第 2)先提交;A 的 ID 小于 B 的 ID = true
carol 此时拉取,只看到 B 的那条,已读位置记到 B 的 ID;A 提交之后:未读 = 0,而会话里 ID 不大于已读位置的消息有 2 条,carol 只看过 1 条读者在两次提交之间拉取,看到的最大 ID 是 B 的,把已读位置记在那里。随后 A 提交,它的消息 ID 比已读位置小,永远不会被算作未读,按 ID 增量拉取消息的客户端也永远拉不到它。
事务持有的时间越长(插入消息的同一个事务里还做了别的事),这个窗口越大。同样的问题出现在所有「按自增 ID 增量同步」的场景:数据同步、增量导出、按 ID 游标消费的发件箱表。快照与增量怎么衔接,见先拿快照再收增量,中间那几条去哪了。
自增 ID 还会留下空洞:
一个事务插入后回滚,下一条消息的 ID 与上一条已提交消息的 ID 相差 2(回滚的那个 ID 是其间的 1 号位,不再出现)所以也不能靠「ID 是否连续」来判断中间有没有还没提交的消息。
让序号的顺序等于提交的顺序
在会话这个范围内,可以用一把行锁把「分配序号」和「提交」绑在一起:
SELECT last_seq FROM conversations WHERE conv_id = ? FOR UPDATE;
UPDATE conversations SET last_seq = last_seq + 1 WHERE conv_id = ?;
INSERT INTO seq_messages (conv_id, seq, …) VALUES (?, ?, …);
COMMIT;事务 A 锁住会话行分配序号、尚未提交时,事务 B 来取序号:等待 1 秒后超时(Lock wait timeout exceeded; try restarting transaction)
A 提交后 B 重试:A 的序号 1,B 的序号 2;序号小的一定先提交B 必须等 A 提交之后才能拿到序号,所以序号小的消息一定先可见。已读位置记会话内的序号,「比它小的我都见过」就成立了。
代价是同一个会话的消息写入被串行化。单聊和小群没有问题;几万人的大群里所有人抢同一把行锁,写入吞吐受它限制,需要别的办法(例如由单个写入者分配序号)。实验里 B 的超时时间设成了 1 秒,只是为了让「B 在等」这件事可以观察到。
五、群聊:写多少行
1000 人的群里发一条消息,计数器方式要更新 999 行;已读位置方式要更新 0 行(未读数在读的时候算)计数器的写入量是「消息数 × 群人数」。千人群里每条消息更新 999 行,其中大部分成员此刻并不在线。已读位置把成本挪到了读的一侧:谁打开会话列表,才为谁计算。
读的成本可以封顶。界面上超过 99 条只显示「99+」,没有必要数出确切的 5000:
m1 有 5000 条未读:完整计数 = 5000;只数到 100 就停(界面显示 99+)= 100SELECT COUNT(*) FROM (
SELECT 1 FROM messages WHERE conv_id = ? AND sender <> ? AND id > ? LIMIT 100
) t会话列表一次要展示几十个会话的未读数,逐个查询太慢时再加缓存。这时候缓存里的数字是可以随时丢弃、随时重算的派生数据,正确性仍然由已读位置保证。
六、怎么选
| 计数器 | 已读位置 | |
|---|---|---|
| 事件重复投递 | 多计,需要自己做幂等 | 不受影响 |
| 已读请求与新消息交错 | 清掉没看过的 | 不受影响 |
| 多端乱序上报 | 取决于操作的顺序 | 取最大值即可 |
| 出错之后 | 无从校正,除非重新数一遍 | 本身就是数出来的 |
| 群聊每条消息的写入 | 群人数 | 0 |
| 读未读数 | 读一行 | 一次范围计数,可封顶、可缓存 |
| 额外的前提 | 无 | 序号的顺序等于可见的顺序 |
计数器适合不需要精确、也不会长期累积误差的场景,例如只显示「有没有新消息」的红点。需要与实际内容对得上的未读数,用已读位置,并且把计数器(如果为了性能还需要它)当成可以重建的缓存。
同样的取舍出现在点赞数、库存、余额上:存一个可以被推导出来的数字,还是存能推导出它的事实。存数字的那一种,必须回答「它错了怎么发现、怎么修」。
配套实验
- codesphere-labs/system-design/unread-count-watermark:MySQL 8.4.11(Connector/J 9.7.0,JDK 25.0.4.1),两条连接按固定顺序交替执行:通知重复投递、清零与新消息交错、多端乱序上报、自增 ID 顺序与提交顺序相反、回滚留下的空洞、按会话加锁分配序号、千人群的写入行数、封顶计数(验证记录)
参考资料
- MySQL 8.4 参考手册:AUTO_INCREMENT Handling in InnoDB(自增值的分配时机、回滚后的空洞)
- MySQL 8.4 参考手册:Locking Reads(
SELECT … FOR UPDATE) - MySQL 8.4 参考手册:GREATEST
- Matrix 规范:Receipts(以「读到哪个事件」表示已读的一个公开协议)