手机上读完了,电脑还显示三条未读
「回声」支持手机、桌面和网页同时登录。一位用户在地铁上用手机读完了和同事的对话,到公司打开桌面,会话仍然显示三条未读。他点进去,消息全是已读样式,红点却没有消失。再退出重进,红点还在。
排查发现,服务端按设备保存已读位置:手机上报了 95,桌面还停在 80。未读计数接口遍历记录时取了第一条,恰好是桌面的 80,于是 100 - 80 = 20;前端又按缓存里的旧值显示成 3。用户看到的不是“多算了”,而是“已经读过却还在提醒”。
另一个版本更隐蔽:存储层用 user_id 做 upsert 主键,没有带 device_id。桌面在离线一段时间后重连,把 80 写了回去,覆盖了手机的 95。之后无论手机怎么读,未读数都退回到 20。用户以为系统“忘了”他读过。
这类问题的根子是把未读数当成了真相。未读数只是一个派生值,真相是“会话当前最大序号”和“这个用户读到了哪里”。前者来自消息存储,后者来自多端上报;把两者混在一起存,迟早会对不上。
已读是事实,未读是派生
把相关概念拆开,才能决定每个值写在哪里、由谁负责。
| 概念 | 含义 | 存储形态 | 谁写入 | 常见误用 |
|---|---|---|---|---|
| 会话已读位置 | 用户在某个会话读到的最新序号 | (user, conv) -> seq,多端合并后取最大 | 客户端上报,服务端单调更新 | 按设备各存一份却不合并 |
| 设备同步位置 | 某台设备已经拉取到的最新序号 | (user, device, conv) -> seq | 同步协议 | 和已读位置混为一谈 |
| 未读数 | 当前最大序号减去已读位置 | 派生值,可缓存 | 读取时计算 | 把缓存值当真相回写 |
| 群聊已读回执 | 每个成员读到哪条 | 每个成员的已读位置 | 客户端上报 | 为每条消息存所有人的已读标记 |
“已读”是用户级事实:用户在手机上看过,就算他看过,不应该因为桌面没看而否认。“同步到哪”是设备级事实:桌面确实可能还没拉到 95,重连后要补拉 81 到 95。两个游标可以不同,但未读数应该按用户级已读位置算。
按会话还是按消息
按会话存一个游标,成本是每个用户每个会话一行,查询未读是 O(1)。按消息存“谁读过这条”,成本是消息数乘以成员数,群聊一大就爆炸。绝大多数未读红点只需要前者。
但“已读回执”是另一件事。群聊里显示“3 人已读”,需要知道每个成员读到哪条;这仍然可以用成员游标算,不必为每条消息存一份布尔值。只有当产品要求“精确到每条消息的已读名单”时,才需要消息级明细,而且通常要配合分页和冷存储。
把两者混在一起,常见结果是:为了做一个红点,给每条消息写 N 条已读记录;或者为了省存储,只存一个全局游标,导致多会话未读互相污染。先确定产品要回答哪个问题,再决定游标的粒度。
多端合并为什么取最大
已读位置天然单调:用户读到 95,不可能“没读过”81 到 95。多台设备各自上报,最远的那台代表用户已经覆盖的范围,所以合并规则是取最大,不是取最新、取第一或取最小。
“取最新”听起来合理,实际上依赖写入顺序。桌面离线三天后重连,补报一个旧位置,写入时间最新,值却最小;如果按时间覆盖,已读位置会倒退。正确做法是条件更新:只有新值大于旧值才写入,SQL 里就是 GREATEST(last_read_seq, ?) 或带 WHERE last_read_seq < ? 的更新。
“取第一”则是实现细节泄漏。记录顺序取决于存储引擎和查询计划,今天返回手机,明天可能返回桌面,未读数就会跳动。合并规则必须与行顺序无关。
未读数不是真相
未读数可以缓存,但不能反过来作为已读的依据。用户点开会话时,应该用“当前最大序号”更新已读位置,而不是“把未读数清零”。缓存里的未读数可能落后,也可能多算;如果拿它做写入,会把错误固化。
缓存 key 要带会话和用户,值要带版本或过期时间。消息写入、已读上报、成员变化都会让未读数变化;最稳妥的做法是读取时以存储的游标重新计算,缓存只承担短时间的读放大。推送红点时允许最终一致,但用户主动打开会话时必须读到最新值。
失败场景:一端已读,另一端未读数不减
回到开头的事故。存储里有两行:手机 (u1, c1, 95),桌面 (u1, c1, 80)。计数函数按查询顺序取第一条,得到 80,未读 20;前端缓存里还是上一次的 3,于是显示 3。用户点开会话,客户端本地把消息标成已读,但没有重新上报,服务端的 80 一直没变。
第二个版本是写入覆盖。桌面重连后补报 80,存储用 (user_id, conv_id) 做唯一键,直接 upsert,把 95 覆盖成 80。之后手机再读,未读数从 5 跳到 20;用户反复点开,红点反复出现。群聊里更明显:“已读 2/5”会退回到“已读 1/5”,因为某个成员的旧设备把游标写回去了。
第三个版本是越界。客户端为了即时反馈,先把本地序号推到 120,再异步上报;服务端当前最大序号只有 100。如果用 lastSeq - readSeq 直接算,得到 -20,前端可能显示负数,也可能因为无符号转换变成一个巨大的数。正确做法是把结果钳到 0,并且不要把客户端的乐观序号当成服务端最大序号。
这些故障的后果不只是红点不准。用户会重复点开会话、重复触发已读回执,通知系统可能据此判断“已读”而停止提醒;运营看到的未读率也会失真。排查时不能只看计数接口,要同时看已读上报的写入路径和存储的唯一键。
用 Count 把合并规则写成纯函数
修复的核心是把“读位置怎么合并、未读怎么算”收敛成一个不依赖存储的函数。输入是若干条已读记录、会话 ID、当前最大序号和用户 ID,输出未读数或错误。
// file: internal/delivery/unread.go
// tier: L1
// verify: go test ./internal/delivery/
package delivery
import (
"errors"
"fmt"
)
// ErrInvalidLastSeq 表示调用方给出的会话最大序号为负,无法计算未读。
var ErrInvalidLastSeq = errors.New("delivery: lastSeq must be non-negative")
// ReadState 是某个用户在某个会话上、某一台设备报告的已读位置。
// 同一用户可能有多条记录:手机读到 88,桌面只读到 80。存储层不保证
// 合并,计算未读时必须先取同一用户同一会话的最大已读序号。
type ReadState struct {
UserID string
ConvID string
LastReadSeq int64
}
// Count 返回 userID 在 convID 会话里的未读数。lastSeq 是该会话当前
// 最大消息序号,由消息存储给出。未读数是派生值:lastSeq 减去已读位置,
// 小于 0 时按 0 处理,因为客户端可能读过比服务端快照更新的消息。
//
// 没有已读记录等价于已读 0,也就是全部未读。记录里的负序号视为脏数据,
// 直接跳过,不能让一条错误记录把未读数抬高。lastSeq 为负是调用方错误,
// 返回 ErrInvalidLastSeq。
func Count(states []ReadState, convID string, lastSeq int64, userID string) (int64, error) {
if lastSeq < 0 {
return 0, fmt.Errorf("%w: got %d", ErrInvalidLastSeq, lastSeq)
}
var maxRead int64
found := false
for _, s := range states {
if s.UserID != userID || s.ConvID != convID {
continue
}
if s.LastReadSeq < 0 {
continue
}
if !found || s.LastReadSeq > maxRead {
maxRead = s.LastReadSeq
found = true
}
}
if !found {
return lastSeq, nil
}
if maxRead >= lastSeq {
return 0, nil
}
return lastSeq - maxRead, nil
}
实现里有几个边界是刻意写死的:
lastSeq < 0直接返回ErrInvalidLastSeq。负的最大序号说明调用方拿错了数据,继续算只会得到无意义的负数。- 只统计
UserID和ConvID都匹配的记录。多会话、多用户的记录混在一个切片里是常态,过滤不能省。 - 同一用户多条记录取最大。手机 95、桌面 80,结果是 15 而不是 20;这正是多端合并的核心。
- 没有记录时返回
lastSeq,等价于已读 0。新成员进群、老会话第一次打开都属于这种情况。 - 已读位置大于等于最大序号时返回 0。客户端乐观上报、消息被撤回或删除,都可能出现“读得比服务端还多”。
- 存储里的负序号被跳过。脏数据不能让未读数变大,也不能让整个计算失败。
Count 只做计算,不做写入。写入侧仍要保证单调:按 (user, conv) 条件更新,只有更大的值才能覆盖。否则函数再正确,存储里的游标已经倒退了。
测试:多端、越界与脏数据
未读计数的 bug 往往不在主路径,而在“两个设备”“读过头”“没有记录”“别的会话”这些组合里。测试要把它们固定下来。
// file: internal/delivery/unread_test.go
// tier: L1
// verify: go test ./internal/delivery/
package delivery
import (
"errors"
"testing"
)
func TestCountSubtractsLastRead(t *testing.T) {
states := []ReadState{{UserID: "u1", ConvID: "c1", LastReadSeq: 88}}
got, err := Count(states, "c1", 100, "u1")
if err != nil {
t.Fatalf("Count error = %v", err)
}
if got != 12 {
t.Fatalf("unread = %d; want 12", got)
}
}
func TestCountMultiDeviceTakesMax(t *testing.T) {
states := []ReadState{
{UserID: "u1", ConvID: "c1", LastReadSeq: 80},
{UserID: "u1", ConvID: "c1", LastReadSeq: 95},
{UserID: "u1", ConvID: "c1", LastReadSeq: 60},
}
got, err := Count(states, "c1", 100, "u1")
if err != nil {
t.Fatalf("Count error = %v", err)
}
if got != 5 {
t.Fatalf("unread = %d; want 5", got)
}
}
func TestCountNoRecordMeansAllUnread(t *testing.T) {
got, err := Count(nil, "c1", 42, "u1")
if err != nil {
t.Fatalf("Count error = %v", err)
}
if got != 42 {
t.Fatalf("unread = %d; want 42", got)
}
}
func TestCountClampsWhenReadAhead(t *testing.T) {
states := []ReadState{{UserID: "u1", ConvID: "c1", LastReadSeq: 120}}
got, err := Count(states, "c1", 100, "u1")
if err != nil {
t.Fatalf("Count error = %v", err)
}
if got != 0 {
t.Fatalf("unread = %d; want 0", got)
}
}
func TestCountIgnoresOtherConversationAndUser(t *testing.T) {
states := []ReadState{
{UserID: "u1", ConvID: "c2", LastReadSeq: 99},
{UserID: "u2", ConvID: "c1", LastReadSeq: 99},
}
got, err := Count(states, "c1", 100, "u1")
if err != nil {
t.Fatalf("Count error = %v", err)
}
if got != 100 {
t.Fatalf("unread = %d; want 100", got)
}
}
func TestCountSkipsNegativeStoredSeq(t *testing.T) {
states := []ReadState{
{UserID: "u1", ConvID: "c1", LastReadSeq: -5},
{UserID: "u1", ConvID: "c1", LastReadSeq: 90},
}
got, err := Count(states, "c1", 100, "u1")
if err != nil {
t.Fatalf("Count error = %v", err)
}
if got != 10 {
t.Fatalf("unread = %d; want 10", got)
}
}
func TestCountNegativeLastSeqErrors(t *testing.T) {
_, err := Count(nil, "c1", -1, "u1")
if !errors.Is(err, ErrInvalidLastSeq) {
t.Fatalf("err = %v; want ErrInvalidLastSeq", err)
}
}
TestCountSubtractsLastRead 是最基本的减法:100 减 88 等于 12。TestCountMultiDeviceTakesMax 锁定多端合并,三条记录里最大是 95,结果必须是 5;如果实现取第一条或取最小,这个用例会失败。
TestCountNoRecordMeansAllUnread 覆盖新成员:没有已读记录就返回全部消息数,而不是 0。TestCountClampsWhenReadAhead 覆盖客户端读得比服务端快的情况,结果必须是 0 而不是负数。
TestCountIgnoresOtherConversationAndUser 防止过滤条件写漏:别的会话读到 99、别的用户读到 99,都不应该影响当前用户的未读。TestCountSkipsNegativeStoredSeq 验证脏数据被跳过,而不是把未读数抬高。最后一条用例断言负的 lastSeq 返回 ErrInvalidLastSeq,让调用方能在入口发现数据错误。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/delivery/ -run 'TestCount' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestCountSubtractsLastRead
--- PASS: TestCountSubtractsLastRead (0.00s)
=== RUN TestCountMultiDeviceTakesMax
--- PASS: TestCountMultiDeviceTakesMax (0.00s)
=== RUN TestCountNoRecordMeansAllUnread
--- PASS: TestCountNoRecordMeansAllUnread (0.00s)
=== RUN TestCountClampsWhenReadAhead
--- PASS: TestCountClampsWhenReadAhead (0.00s)
=== RUN TestCountIgnoresOtherConversationAndUser
--- PASS: TestCountIgnoresOtherConversationAndUser (0.00s)
=== RUN TestCountSkipsNegativeStoredSeq
--- PASS: TestCountSkipsNegativeStoredSeq (0.00s)
=== RUN TestCountNegativeLastSeqErrors
--- PASS: TestCountNegativeLastSeqErrors (0.00s)
PASS
ok webapp/internal/delivery 0.303s
PASS 表示七个未读用例全部通过。这个函数是纯计算,不包含数据库往返和缓存访问;线上一次未读查询还要读消息最大序号和已读游标,延迟以实际存储为准。
小结
已读位置是用户级事实,多端上报后取最大;未读数是派生值,等于最大序号减去已读位置,越界时钳到 0。没有记录等价于已读 0,脏的负序号要跳过,负的最大序号是调用方错误。
Count 把合并规则写成纯函数,但可靠性还依赖写入侧的单调更新和缓存失效。下一节换一个方向:消息已经算出来了,怎么保证同一条消息不会因为重发和重试被投递两次。
阅读导航:上一节:9.1 在线投递与离线补偿 · 下一节:9.3 消息可靠性与去重 。
常见错误与边界
- 按设备分别算未读却不合并。手机读完、桌面未读,用户级红点应该消失;否则用户会以为没读。
- 用“最新写入”覆盖已读位置。离线设备补报旧值会让游标倒退,必须按值单调更新或在存储层取最大。
- 取查询结果的第一条。记录顺序不稳定,未读数会随查询计划跳动,合并必须与行顺序无关。
- 把未读数当真相。点开会话应该更新到当前最大序号,而不是简单把缓存计数清零。
- 忘记钳零。客户端乐观上报或消息删除会让已读位置超过最大序号,负未读会变成负数或巨大数。
- 把“同步位置”当“已读位置”。设备拉取到哪和用户看到哪是两件事,混用会让红点提前消失。
- 为每条消息存全员已读标记。群聊规模一大就爆炸,大多数场景用成员游标就能算已读回执。
- 脏数据让整个查询失败。单条负序号应跳过并记录,不能让一个用户的坏记录拖垮整个会话列表。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。