Skip to content

未读数显示 3,点进去一条新消息也没有 ​

会话列表上有一个红色的 3。点进去,最后一条消息是昨天看过的。退出来,3 还在。另一个用户反馈的是反过来的情况:角标是 0,对话里却有一条从没见过的消息。

未读数有两种存法。一种是给每个用户的每个会话存一个计数器,来一条消息加一,读了就清零;另一种是只记「读到哪一条」,未读数在需要时现算。第一种直观,上面两个现象也都出自它:计数器是从事件里推导出来的状态,事件重复、丢失或者顺序错了,它就与事实对不上,而且没有办法自己纠正。

本文在 MySQL 8.4 上把两种存法各自的问题跑出来。实验见文末。

事务 A事务 B读者插入,ID = 1提交还没提交,别人看不到插入,ID = 2提交拉取消息只看到 ID 2已读位置 = 2ID 1 现在才可见,它小于已读位置未读数 = COUNT(ID > 已读位置) = 0,但 ID 为 1 的消息没有被看过按会话加锁分配序号(SELECT … FOR UPDATE)后,B 要等 A 提交才能拿到序号:序号小的一定先提交事务回滚还会留下空洞:不能靠「ID 是否连续」判断有没有漏
图 1 · 实测(MySQL 8.4.11):自增 ID 在插入时分配,不在提交时分配。事务 A 先插入、拿到较小的 ID,却后提交;读者在两次提交之间拉取,只看到 B 的消息,把已读位置记到 B 的 ID。A 提交之后,它的消息排在已读位置之前,按「ID 大于已读位置」计算的未读数是 0,这条消息读者从来没有见过。

一、计数器:加多了 ​

消息写入后,通常由一个异步的消费者给每个接收方的计数器加一。消息队列的投递是至少一次,同一个「新消息」事件可能被处理两次:

text
alice 发了 10 条,其中 3 条的通知被重复投递:计数器 = 13
同样的投递,按已读位置现算:未读 = 10

计数器是 13,实际只有 10 条。用户全部看完之后清零,这次的误差消失了;如果清零用的是「减去已读条数」,误差就永久留在那里,这就是开头那个去不掉的 3。

给加一操作做幂等(记录处理过的消息 ID)能解决重复,代价是每个接收方多一份去重记录。已读位置的存法没有这个问题:未读数是从消息表里数出来的,一条消息只会被数一次,事件重复多少次都不影响。

二、计数器:清零清掉了没看过的 ​

用户看完了前 10 条,客户端发出「已读」请求。请求在路上的时候,第 11 条消息到了:

text
bob 看完前 10 条后上报已读,请求到达前第 11 条已写入:计数器清零后 = 0,实际没看过的有 1 条
上报的是「读到第 10 条」:未读 = 1

「清零」这个操作没有说清楚「我看到了哪里」。服务端执行它的时候,计数器已经包含了第 11 条,一起被清掉。角标是 0,对话里有一条没见过的消息。

已读位置的上报带着具体的消息 ID:「我读到了第 10 条」。服务端不管什么时候执行它,含义都不变,第 11 条仍然算未读。

这是两种存法的根本差别:计数器记录的是一连串操作的结果,操作的顺序变了结果就变;已读位置记录的是一个事实,晚到、重复都不改变它的含义。

三、已读位置:多端上报会倒退 ​

已读位置也有自己的问题。用户在手机上读到了第 20 条,电脑上一个更早发出的请求这时才到:

text
手机上报读到第 20 条,随后电脑上一个迟到的请求上报第 12 条,直接覆盖:未读 = 8
写成 last_read_id = GREATEST(last_read_id, ?):未读 = 0

直接覆盖时,已读位置从 20 退回到 12,8 条已经看过的消息重新变成未读。已读位置只能前进:

sql
UPDATE read_watermark
SET last_read_id = GREATEST(last_read_id, ?)
WHERE user_id = ? AND conv_id = ?

这样写之后,请求的到达顺序不再影响结果,重复提交也没有副作用。

另外一处容易漏的细节:

text
alice 自己发了 20 条、从没上报过已读:不排除自己发的 = 20,排除后 = 0

自己发的消息不算未读。要么在计数时排除,要么在发送时顺便把自己的已读位置推进到这一条。

四、已读位置:ID 的顺序不是提交的顺序 ​

这是最隐蔽的一个问题。已读位置的前提是「ID 比它小的消息我都见过」。自增 ID 在执行插入时分配,而消息对别人可见是在事务提交时,两个顺序可以不一样:

text
事务 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 还会留下空洞:

text
一个事务插入后回滚,下一条消息的 ID 与上一条已提交消息的 ID 相差 2(回滚的那个 ID 是其间的 1 号位,不再出现)

所以也不能靠「ID 是否连续」来判断中间有没有还没提交的消息。

让序号的顺序等于提交的顺序 ​

在会话这个范围内,可以用一把行锁把「分配序号」和「提交」绑在一起:

sql
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;
text
事务 A 锁住会话行分配序号、尚未提交时,事务 B 来取序号:等待 1 秒后超时(Lock wait timeout exceeded; try restarting transaction)
A 提交后 B 重试:A 的序号 1,B 的序号 2;序号小的一定先提交

B 必须等 A 提交之后才能拿到序号,所以序号小的消息一定先可见。已读位置记会话内的序号,「比它小的我都见过」就成立了。

代价是同一个会话的消息写入被串行化。单聊和小群没有问题;几万人的大群里所有人抢同一把行锁,写入吞吐受它限制,需要别的办法(例如由单个写入者分配序号)。实验里 B 的超时时间设成了 1 秒,只是为了让「B 在等」这件事可以观察到。

五、群聊:写多少行 ​

text
1000 人的群里发一条消息,计数器方式要更新 999 行;已读位置方式要更新 0 行(未读数在读的时候算)

计数器的写入量是「消息数 × 群人数」。千人群里每条消息更新 999 行,其中大部分成员此刻并不在线。已读位置把成本挪到了读的一侧:谁打开会话列表,才为谁计算。

读的成本可以封顶。界面上超过 99 条只显示「99+」,没有必要数出确切的 5000:

text
m1 有 5000 条未读:完整计数 = 5000;只数到 100 就停(界面显示 99+)= 100
sql
SELECT 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 顺序与提交顺序相反、回滚留下的空洞、按会话加锁分配序号、千人群的写入行数、封顶计数(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权