私聊和群聊的消息已读怎么做?

私聊和群聊用同一套数据做已读。每个会话的消息按序号只存一份,每个用户在这个会话里只存一个水位线。私聊拿对方那一个数,把消息显示成已读或未读。群聊拿其余成员的水位线,数出「几人已读」。两边都不要把已读写回消息正文。

1. 先放好两份数据

已读不改消息。一个会话只维护两类数据:

1
2
消息    (conversation_id, seq, sender_id, body)
水位线 (conversation_id, user_id) → cursor

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
2
INCR  conv:{cid}:seq                 分配序号
HSET conv:{cid}:cursor {uid} {seq} 更新这个人的水位线

HSET 前要先比大小,避免旧请求把水位线盖小。

1
2
3
4
5
6
7
local cur = tonumber(redis.call('HGET', KEYS[1], ARGV[1]) or '0')
local nxt = tonumber(ARGV[2])
if nxt > cur then
redis.call('HSET', KEYS[1], ARGV[1], nxt)
return nxt
end
return cur

KEYS[1] 是 conv:{cid}:cursor,ARGV[1] 是用户 ID,ARGV[2] 是客户端上报的序号。

客户端不要按消息 ID 逐条上报。它只报一个数:这个会话里自己已经看到的最大 seq。打开会话、停在底部又收到新消息、向上翻完又继续向下看,都是在更新这一个数。

2. 私聊怎么做

私聊就是上面这套数据里只有 A、B 两个人。界面上不显示人数,只显示对方有没有越过这条消息。

发一条消息

A 给 B 发「你好」:

1
2
3
4
seq = INCR conv:{P}:seq          → 1
写入消息 (P, 1, A, 你好)
A 正在这个会话里,A 的水位线推进到 1
把消息推给 B

此刻:

1
2
3
消息 1  A  你好
A → 1
B → 0

A 看自己这条:B 的水位线是 0,0 >= 1 不成立,显示未读。B 的未读数是 1 - 0 = 1。

对方读到

B 打开会话,客户端上报看到的最大序号 1:

1
2
B 的水位线从 0 推进到 1
把 (P, B, 1) 推给正在看这个会话的 A

A 只改本地的 B。消息 1 满足 B >= 1,从未读变成已读。B 的未读数变成 0。

对方再回一条

B 回复「晚上开会」:

1
2
3
seq = 2
消息 (P, 2, B, 晚上开会)
B 的水位线推进到 2
1
2
3
4
消息 1  A  你好
消息 2 B 晚上开会
A → 1
B → 2

B 看自己的消息 2:A 还停在 1,显示未读。A 的未读数是 2 - 1 = 1。A 打开会话后,水位线推进到 2,消息 2 变成已读。

整个过程消息行只在发送时写。已读变化只写水位线:A 一条,B 一条。

3. 群聊怎么做

群聊还是这两份数据。成员从 2 个变成 N 个,回执从「过了没有」变成「过了几个人」。发消息和推进水位线的步骤与私聊相同。

群 G 里有 A、B、C,水位线初始都是 0。

发一条消息

A 发送「开会」:

1
2
3
seq = 1
消息 (G, 1, A, 开会)
A 的水位线 = 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
2
3
消息 1  A  开会
消息 2 A 改到三点
A → 2 B → 1 C → 0

两条消息分开数:

  • 消息 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 的人才算读过。私聊从会话建立时两人都在,没有这个分支。

另一个限制是阅读必须是前缀。水位线的意思是:这个序号之前的消息全部看过。产品如果允许跳过前面的消息、只把某一条标成已读,一个整数会把中间没看的也标掉。那种读法得按消息记状态,写放大也会回来。进会话就往后推进的已读,不需要这种记法。

几点理解

第一,私聊和群聊的写入是一样的。发消息就追加正文并分配序号,阅读就推进自己的水位线。

第二,私聊和群聊的差别在读法。私聊看对方一个数过没过。群聊数除发送者以外有多少个数已经过了。

第三,未读角标在两种会话里都只看自己的水位线,和群里有多少人无关。

第四,已读结果不要回写消息。消息行的写入停在发送那一次。

© 2026 DadaVinCi's Blog

Elegant theme by Shiro · Made by Acris with ❤️