私聊和群聊的消息已读怎么做?
私聊和群聊用同一套数据做已读。每个会话的消息按序号只存一份,每个用户在这个会话里只存一个水位线。私聊拿对方那一个数,把消息显示成已读或未读。群聊拿其余成员的水位线,数出「几人已读」。两边都不要把已读写回消息正文。
1. 先放好两份数据
已读不改消息。一个会话只维护两类数据:
1 | 消息 (conversation_id, seq, sender_id, body) |
seq 在一个会话内从 1 开始,每发一条加 1,只增不复用。cursor 表示这个用户已经读过 seq <= cursor 的消息。没读过时,cursor 是 0。
未读条数用自己的水位线:
1 | 未读数 = 最新 seq - 自己的 cursor |
这条减法成立,是因为序号连续,中间没有空洞。
**已读回执用别人的水位线。**一条序号为 S、发送者是 U 的消息:
1 | 已读人数 = 成员里满足 user_id ≠ U 且 cursor >= S 的人数 |
私聊的成员只有两个,这个人数只能是 0 或 1,界面对应未读和已读。群聊的成员有 N 个,这个人数就是「N 人已读」。写路径没有第二套。
进度和消息分开存。消息进消息库。水位线放 Redis:一个会话一个计数器(负责记号),一个会话一个 Hash(负责记每个人的水位线)。
1 | INCR conv:{cid}:seq 分配序号 |
HSET 前要先比大小,避免旧请求把水位线盖小。
1 | local cur = tonumber(redis.call('HGET', KEYS[1], ARGV[1]) or '0') |
KEYS[1] 是 conv:{cid}:cursor,ARGV[1] 是用户 ID,ARGV[2] 是客户端上报的序号。
客户端不要按消息 ID 逐条上报。它只报一个数:这个会话里自己已经看到的最大 seq。打开会话、停在底部又收到新消息、向上翻完又继续向下看,都是在更新这一个数。
2. 私聊怎么做
私聊就是上面这套数据里只有 A、B 两个人。界面上不显示人数,只显示对方有没有越过这条消息。
发一条消息
A 给 B 发「你好」:
1 | seq = INCR conv:{P}:seq → 1 |
此刻:
1 | 消息 1 A 你好 |
A 看自己这条:B 的水位线是 0,0 >= 1 不成立,显示未读。B 的未读数是 1 - 0 = 1。
对方读到
B 打开会话,客户端上报看到的最大序号 1:
1 | B 的水位线从 0 推进到 1 |
A 只改本地的 B。消息 1 满足 B >= 1,从未读变成已读。B 的未读数变成 0。
对方再回一条
B 回复「晚上开会」:
1 | seq = 2 |
1 | 消息 1 A 你好 |
B 看自己的消息 2:A 还停在 1,显示未读。A 的未读数是 2 - 1 = 1。A 打开会话后,水位线推进到 2,消息 2 变成已读。
整个过程消息行只在发送时写。已读变化只写水位线:A 一条,B 一条。
3. 群聊怎么做
群聊还是这两份数据。成员从 2 个变成 N 个,回执从「过了没有」变成「过了几个人」。发消息和推进水位线的步骤与私聊相同。
群 G 里有 A、B、C,水位线初始都是 0。
发一条消息
A 发送「开会」:
1 | seq = 1 |
1 | A → 1 B → 0 C → 0 |
消息 1 的已读人数:B、C 都还没到 1,是 0。B 的未读数是 1,C 的未读数是 1。A 自己的未读数是 0。
有人阅读
B 打开群,上报 1。服务端把 B 的水位线写成 1,并把 (G, B, 1) 推给当前开着这个群的客户端。
1 | A → 1 B → 1 C → 0 |
消息 1 再数一次:B 到了,C 没到,1 人已读。C 的未读数仍是 1。
又来一条新消息
A 再发「改到三点」,序号是 2,A 的水位线跟着到 2:
1 | 消息 1 A 开会 |
两条消息分开数:
- 消息 1:B 的 1 已经盖过,C 没有,仍是 1 人已读;
- 消息 2:B 停在 1,C 停在 0,0 人已读。
B 的未读数是 2 - 1 = 1,只有后一条。C 的未读数是 2。
C 随后打开群并看到底部,水位线推进到 2:
1 | A → 2 B → 1 C → 2 |
- 消息 1:B 和 C 都过了,2 人已读;
- 消息 2:只有 C 过了,1 人已读。
B 如果还停在消息 1,他本地也可以先收到 C 的水位线事件,把消息 1 从 1 人改成 2 人。他自己的未读数不变,因为未读数只看 B 自己的水位线。
五百人的群也是这三步。一个人阅读,只 HSET 自己那一个 field。一条消息被五百个人读完,是五百次不同 field 的更新,不是对消息行的五百次追加。展示人数时把这个 Hash 取出来,按 cursor >= seq 数一遍。五百个整数的比较,留在读路径上。
4. 不要把已读写进消息
另一套做法是在消息上挂状态。私聊给消息加一个已读标记,群聊给消息加一个已读数组,谁读了就追加一个用户 ID。
私聊这样往往也能跑。来把某条消息改成已读的,通常只有对方一个人,同一行上几乎没有并发更新。
群聊把这个标记按人数循环,就变成:
1 | message.read_by.push(userId) |
一条消息被五百个人读完,是对同一行的五百次并发追加,数组还越来越长。拉历史消息要读这行,和追加抢同一把行锁。正文从发出后就没变过,这行却变成进度写入的热点。人少的时候和私聊一样看不出来,人一多,等锁的事务会把连接池占满。
私聊和群聊如果已经用了水位线,就不要再把算出来的「已读」或「3 人已读」写回消息。那一笔写会把热点搬回消息行,人数一变还要再写一次。
5. 两个不能用一个整数硬套的情况
新成员入群时,如果把他的水位线直接设成当前最大序号,他的未读数是 0,这是对的。但他会满足 cursor >= 旧消息序号,旧消息的已读人数会把他算进去,这是错的。他没有看过入群前的消息。
计数时要多一个条件:这条消息发出时他已经是成员。实现上给成员记一个 join_seq,只有 join_seq <= 消息 seq <= cursor 的人才算读过。私聊从会话建立时两人都在,没有这个分支。
另一个限制是阅读必须是前缀。水位线的意思是:这个序号之前的消息全部看过。产品如果允许跳过前面的消息、只把某一条标成已读,一个整数会把中间没看的也标掉。那种读法得按消息记状态,写放大也会回来。进会话就往后推进的已读,不需要这种记法。
几点理解
第一,私聊和群聊的写入是一样的。发消息就追加正文并分配序号,阅读就推进自己的水位线。
第二,私聊和群聊的差别在读法。私聊看对方一个数过没过。群聊数除发送者以外有多少个数已经过了。
第三,未读角标在两种会话里都只看自己的水位线,和群里有多少人无关。
第四,已读结果不要回写消息。消息行的写入停在发送那一次。