《互联网产品的服务端架构·内容、社交与实时互动》9.2 多端已读与未读计数

从「回声」手机读完消息、桌面未读数不降的事故写起:已读位置是多端上报的事实,未读数是派生值。文章把按会话已读、多设备取最大已读序号、越界钳零、无记录视为全部未读写成 Count 纯函数,并给出边界测试、真实运行输出与存储、缓存与投递的边界。

手机上读完了,电脑还显示三条未读

「回声」支持手机、桌面和网页同时登录。一位用户在地铁上用手机读完了和同事的对话,到公司打开桌面,会话仍然显示三条未读。他点进去,消息全是已读样式,红点却没有消失。再退出重进,红点还在。

排查发现,服务端按设备保存已读位置:手机上报了 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 消息可靠性与去重 。

常见错误与边界

  • 按设备分别算未读却不合并。手机读完、桌面未读,用户级红点应该消失;否则用户会以为没读。
  • 用“最新写入”覆盖已读位置。离线设备补报旧值会让游标倒退,必须按值单调更新或在存储层取最大。
  • 取查询结果的第一条。记录顺序不稳定,未读数会随查询计划跳动,合并必须与行顺序无关。
  • 把未读数当真相。点开会话应该更新到当前最大序号,而不是简单把缓存计数清零。
  • 忘记钳零。客户端乐观上报或消息删除会让已读位置超过最大序号,负未读会变成负数或巨大数。
  • 把“同步位置”当“已读位置”。设备拉取到哪和用户看到哪是两件事,混用会让红点提前消失。
  • 为每条消息存全员已读标记。群聊规模一大就爆炸,大多数场景用成员游标就能算已读回执。
  • 脏数据让整个查询失败。单条负序号应跳过并记录,不能让一个用户的坏记录拖垮整个会话列表。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查