手机响了,电脑上的会话缺了一条
「回声」的 IM 上线第二周,客服收到一个奇怪的反馈:同一条消息,手机收到了,电脑端没有。用户把两台设备都放在桌上,手机亮屏弹出通知,电脑上的会话窗口没有任何变化,也没有未读红点。刷新之后消息才出现。
复现很快:用户 B 手机和桌面同时在线,A 发来一条消息。服务端的投递网关会先查 B 的在线设备,然后按 LastSeen 排序,只取最近心跳的那一台。手机刚发过心跳,桌面 30 秒前发过,于是消息只投给了手机。桌面连接其实一直开着,但服务端再也没有向它推送。
这个故障不是“偶发丢消息”,而是投递模型错了:把“用户在线”理解成“用户有一台设备在线”,再把“在线”理解成“投递成功”。前者让另一台设备永远收不到,后者让半开连接吞掉消息。要修的不是重试次数,而是把在线投递和离线补偿分成两条路径。
在线投递只是提示,离线补偿才是可靠性来源
消息系统的可靠性来自“最终一定能取到”,而不是“推送一次就成功”。在线推送负责低延迟,离线补偿负责兜底。把两者混在一起,就会出现“推送返回成功,但用户没收到”的假象。
| 维度 | 在线投递 | 离线补偿 |
|---|---|---|
| 目标 | 低延迟送达当前连接 | 保证每个成员最终拿到 |
| 范围 | 该用户的全部活跃设备 | 所有未确认的成员 |
| 触发 | 新消息写入后立即推送 | 推送未确认、连接断开或心跳过期 |
| 失败处理 | 尽力而为,不承诺一次成功 | 持久化游标,重连后按序拉取 |
| 数据来源 | 心跳与连接状态 | 消息存储与每用户投递游标 |
| 典型错误 | 只投最近活跃设备 | 只记录“已推送”,不记录“已确认” |
在线状态是易失的。进程重启、网络切换、NAT 超时都会让“在线”变成过期信息。因此 Route 的返回值只能作为“现在可以尝试推给谁”的提示;真正判断消息有没有送达,要看客户端确认(ack)或者投递游标有没有推进。没有 ack 的消息,即使推送调用返回成功,也必须进入补偿队列。
多设备必须全部投递
一个用户可能同时有手机、平板、桌面和网页。服务端的“在线”是用户维度,但投递必须落到设备维度:只要该用户有一台设备在线,就应该给所有在线设备各发一份,而不是挑一台。
只投最近活跃设备会制造三类问题。第一,其他设备的本地会话出现空洞,用户滚动历史时看不到某条消息,但服务端存储里明明有。第二,未读计数按设备计算时会不一致,一端显示 3 条,另一端显示 2 条。第三,已读回执和通知会错位:手机弹了通知,桌面没有任何提示,用户以为消息没到。
设备维度的扇出也有代价:同一用户五台设备同时在线,一条消息要写五次推送。这仍然比“只投一台,其余靠用户手动刷新”便宜,因为后者会直接损失可靠性。真正的成本控制应该放在离线补偿的批量拉取和推送去重上,而不是减少在线目标。
心跳过期与半开连接
Online: true 不等于连接可用。移动网络切换、进程被杀、NAT 表项过期,都可能留下一条“看起来在线”的记录。服务端最后一次收到心跳的时间才是可判断的依据:超过 TTL 没有心跳,就按离线处理。
TTL 的取值要和心跳间隔匹配。心跳每 20 秒一次,TTL 可以设 60 秒,允许两次丢失;TTL 太短会频繁误判离线,太长会让半开连接持续吞消息。边界要写清楚:年龄正好等于 TTL 时仍算在线,超过才算离线。这样实现和文档、测试才能对上。
即使 TTL 判断正确,推送仍可能失败。所以在线列表只是“尝试推送”,推送结果要回写确认;超过确认窗口还没 ack 的用户,转入离线补偿。补偿不是异常分支,而是主链路的一部分。
失败场景:只投最近活跃的设备
回到开头的事故。B 的手机心跳在 1 秒前,桌面心跳在 30 秒前,两者都在 TTL 内。网关的 pickConnection 按 LastSeen 降序取第一条,只推手机。桌面连接没有断开,客户端也没有触发重连,因此不会主动拉取;服务端认为“已经投递”,补偿队列里也没有这条消息。
用户看到的现象是:手机响了一下,电脑毫无反应。几分钟后用户在电脑上继续对话,往上翻才发现了那条消息;如果消息在折叠的会话里,可能几天都不会被发现。更糟的是群聊:同一条消息对 A 只推了手机,对 C 只推了桌面,三个人的本地顺序不一致,引用回复会对不上。
第二个变种发生在成员列表上。用户退群或拉黑后,成员缓存没有及时失效,Route 仍然把消息投给已经不在会话里的人。投递成功,但对方打开会话是 403,消息进了死信。成员资格必须在调用 Route 之前由会话服务给出,并且带版本号;Route 本身只信任传入的 members。
第三个变种是时钟。两台网关的 now 相差几秒,一台认为心跳新鲜,另一台认为已过期,同一用户在不同批次里一会儿在线、一会儿离线。时间戳应该由统一的时间源给出,至少在同一批次内保持一致;Route 接收 now 而不是自己取时间,就是为了让测试和批处理可复现。
这些故障的共同后果是“消息存在,但用户看不到”。排查时只看推送成功率没有用,因为推送确实成功了;必须看每个成员的投递游标有没有推进,以及离线补偿有没有覆盖到没有 ack 的人。
用 Route 把在线与离线分开
修复的第一刀是把判定收敛成纯函数:输入成员、设备在线记录、当前时间和 TTL,输出在线用户和离线用户两个列表。调用方拿到在线列表去扇出推送,拿到离线列表去写补偿任务。
// file: internal/delivery/route.go
// tier: L1
// verify: go test ./internal/delivery/
package delivery
import "sort"
// Presence 是一条设备在线记录。LastSeen 是服务端最后一次收到该设备
// 心跳的时间,Online 表示连接在读取这一刻是否仍然打开。同一用户可能
// 同时有多条记录,手机、平板、桌面各一条。
type Presence struct {
UserID string
DeviceID string
LastSeen int64
Online bool
}
// Route 决定一条会话消息应该投递给哪些成员。members 是会话当前成员,
// presences 是全部成员中已知的设备在线记录。返回值按用户维度给出:
// online 里的用户应当向它的全部设备投递,offline 里的用户只能等离线补偿。
//
// 规则刻意保持简单:
// - 不在 members 里的用户不投递,拉黑和退群在调用前已经反映在 members;
// - 只要同一用户有一台设备在线且心跳未过期,该用户就算在线;
// - 心跳超过 ttl 的记录视为离线,避免半开连接把消息吞掉;
// - 两个结果都去重并升序,方便测试和日志比对。
//
// msgConv 是本批消息所属的会话,只用于调用方的批次语义,不参与判定。
func Route(msgConv string, members []string, presences []Presence, now int64, ttl int64) (online, offline []string) {
memberSet := make(map[string]struct{}, len(members))
for _, m := range members {
if m == "" {
continue
}
memberSet[m] = struct{}{}
}
onlineSet := make(map[string]struct{}, len(memberSet))
for _, p := range presences {
if _, ok := memberSet[p.UserID]; !ok {
continue
}
if !p.Online {
continue
}
if now-p.LastSeen > ttl {
continue
}
onlineSet[p.UserID] = struct{}{}
}
online = make([]string, 0, len(onlineSet))
offline = make([]string, 0, len(memberSet))
for m := range memberSet {
if _, ok := onlineSet[m]; ok {
online = append(online, m)
continue
}
offline = append(offline, m)
}
sort.Strings(online)
sort.Strings(offline)
return online, offline
}
几个刻意的选择:
members是白名单。拉黑、退群、被踢都应该在会话服务里先算好,Route不再重复判断关系。这样职责单一,也避免两套关系判断漂移。- 同一用户只要有一条记录满足
Online && now-LastSeen <= ttl,就进入在线列表。输出按用户去重,但调用方投递时要查该用户的全部设备,不能只用命中的那一条。 - 年龄等于 TTL 仍算在线,超过才离线。边界写死在一个比较符里,测试可以直接锁定。
- 不在
members里的 presence 被忽略,包括已经退群但连接还没断的设备。 - 两个结果都排序。排序不是为了展示,而是让日志、补偿任务和测试有稳定顺序,避免 map 遍历顺序造成偶发差异。
msgConv不参与判定。它是批次所属的会话,调用方用它做分片和日志;把它塞进路由条件只会让函数多一个隐式约束。
Route 不返回设备列表,也不负责推送。如果让它同时做扇出,纯函数就会依赖连接注册表,测试要起网络服务,边界也不再可控。保持“判定”和“投递”分离,才能分别测试、分别扩容。
测试:多设备、TTL 与白名单
路由 bug 大多出在组合条件:同一用户一新一旧两台设备、心跳正好卡在 TTL、非成员混在 presence 里、成员列表重复。测试要把这些组合固定下来。
// file: internal/delivery/route_test.go
// tier: L1
// verify: go test ./internal/delivery/
package delivery
import (
"reflect"
"testing"
)
func TestRouteAllDevicesOfOnlineUserDelivered(t *testing.T) {
members := []string{"u1"}
presences := []Presence{
{UserID: "u1", DeviceID: "phone", LastSeen: 990, Online: true},
{UserID: "u1", DeviceID: "desktop", LastSeen: 995, Online: true},
}
online, offline := Route("c1", members, presences, 1000, 60)
if !reflect.DeepEqual(online, []string{"u1"}) {
t.Fatalf("online = %v; want [u1]", online)
}
if len(offline) != 0 {
t.Fatalf("offline = %v; want empty", offline)
}
}
func TestRouteAnyFreshDeviceKeepsUserOnline(t *testing.T) {
members := []string{"u1", "u2"}
presences := []Presence{
{UserID: "u1", DeviceID: "phone", LastSeen: 1000, Online: true},
{UserID: "u1", DeviceID: "desktop", LastSeen: 800, Online: true},
{UserID: "u2", DeviceID: "phone", LastSeen: 800, Online: true},
}
online, offline := Route("c1", members, presences, 1000, 60)
if !reflect.DeepEqual(online, []string{"u1"}) {
t.Fatalf("online = %v; want [u1]", online)
}
if !reflect.DeepEqual(offline, []string{"u2"}) {
t.Fatalf("offline = %v; want [u2]", offline)
}
}
func TestRouteTTLBoundaryIsInclusive(t *testing.T) {
members := []string{"u1"}
fresh := []Presence{{UserID: "u1", DeviceID: "phone", LastSeen: 940, Online: true}}
if online, _ := Route("c1", members, fresh, 1000, 60); !reflect.DeepEqual(online, []string{"u1"}) {
t.Fatalf("age == ttl online = %v; want [u1]", online)
}
stale := []Presence{{UserID: "u1", DeviceID: "phone", LastSeen: 939, Online: true}}
if online, offline := Route("c1", members, stale, 1000, 60); len(online) != 0 || !reflect.DeepEqual(offline, []string{"u1"}) {
t.Fatalf("age > ttl = %v/%v; want []/[u1]", online, offline)
}
}
func TestRouteClosedConnectionIsOffline(t *testing.T) {
members := []string{"u1"}
presences := []Presence{{UserID: "u1", DeviceID: "phone", LastSeen: 1000, Online: false}}
online, offline := Route("c1", members, presences, 1000, 60)
if len(online) != 0 {
t.Fatalf("online = %v; want empty", online)
}
if !reflect.DeepEqual(offline, []string{"u1"}) {
t.Fatalf("offline = %v; want [u1]", offline)
}
}
func TestRouteNonMemberPresenceIgnored(t *testing.T) {
members := []string{"u1"}
presences := []Presence{
{UserID: "u1", DeviceID: "phone", LastSeen: 1000, Online: false},
{UserID: "removed", DeviceID: "phone", LastSeen: 1000, Online: true},
}
online, offline := Route("c1", members, presences, 1000, 60)
if len(online) != 0 {
t.Fatalf("online = %v; non-member leaked", online)
}
if !reflect.DeepEqual(offline, []string{"u1"}) {
t.Fatalf("offline = %v; want [u1]", offline)
}
}
func TestRouteDedupAndSort(t *testing.T) {
members := []string{"u3", "u1", "u3", "u2", "u1"}
presences := []Presence{
{UserID: "u3", DeviceID: "a", LastSeen: 990, Online: true},
{UserID: "u3", DeviceID: "b", LastSeen: 995, Online: true},
{UserID: "u1", DeviceID: "a", LastSeen: 995, Online: true},
{UserID: "u1", DeviceID: "b", LastSeen: 990, Online: true},
}
online, offline := Route("c1", members, presences, 1000, 60)
if !reflect.DeepEqual(online, []string{"u1", "u3"}) {
t.Fatalf("online = %v; want [u1 u3]", online)
}
if !reflect.DeepEqual(offline, []string{"u2"}) {
t.Fatalf("offline = %v; want [u2]", offline)
}
}
func TestRouteNoPresenceMeansAllOffline(t *testing.T) {
online, offline := Route("c1", []string{"u2", "u1"}, nil, 1000, 60)
if len(online) != 0 {
t.Fatalf("online = %v; want empty", online)
}
if !reflect.DeepEqual(offline, []string{"u1", "u2"}) {
t.Fatalf("offline = %v; want [u1 u2]", offline)
}
}
TestRouteAllDevicesOfOnlineUserDelivered 锁定“同一用户多设备全部覆盖”的语义:输出只有一个用户 ID,但调用方据此会向手机和桌面都投递。TestRouteAnyFreshDeviceKeepsUserOnline 则验证一台设备过期不会把整个用户判成离线。
TestRouteTTLBoundaryIsInclusive 是最容易写错的一条:年龄 60 秒算在线,61 秒才算离线。如果实现写成 < ttl,边界会整体提前一秒,心跳稍慢的用户就会被误判离线。
TestRouteNonMemberPresenceIgnored 防止退群用户继续收到消息。TestRouteDedupAndSort 把重复成员和多设备记录放在一起,断言输出稳定。TestRouteNoPresenceMeansAllOffline 覆盖新成员还没有心跳的情况:没有记录就是离线,进入补偿,而不是静默丢弃。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/delivery/ -run 'TestRoute' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestRouteAllDevicesOfOnlineUserDelivered
--- PASS: TestRouteAllDevicesOfOnlineUserDelivered (0.00s)
=== RUN TestRouteAnyFreshDeviceKeepsUserOnline
--- PASS: TestRouteAnyFreshDeviceKeepsUserOnline (0.00s)
=== RUN TestRouteTTLBoundaryIsInclusive
--- PASS: TestRouteTTLBoundaryIsInclusive (0.00s)
=== RUN TestRouteClosedConnectionIsOffline
--- PASS: TestRouteClosedConnectionIsOffline (0.00s)
=== RUN TestRouteNonMemberPresenceIgnored
--- PASS: TestRouteNonMemberPresenceIgnored (0.00s)
=== RUN TestRouteDedupAndSort
--- PASS: TestRouteDedupAndSort (0.00s)
=== RUN TestRouteNoPresenceMeansAllOffline
--- PASS: TestRouteNoPresenceMeansAllOffline (0.00s)
PASS
ok webapp/internal/delivery 0.235s
PASS 表示七个路由用例全部通过。这个输出只覆盖纯函数判定,不包含推送网络调用和补偿队列;线上一次投递还要算上连接查找、推送和 ack 等待,延迟以实际链路为准。
小结
在线投递是尽力而为的低延迟路径,离线补偿才是可靠性的来源。判定在线要看用户是否有任意一台新鲜心跳的设备;投递要覆盖该用户的全部在线设备;没有 ack 的消息必须进入补偿,而不是当作已送达。
Route 把成员白名单、TTL 边界、去重排序写成一个可测试的纯函数,调用方再负责扇出和补偿。下一节讨论另一个同样容易被“本地计算”带偏的值:多端已读之后,未读数到底该听谁的。
阅读导航:上一节:目录与学习路径 · 下一节:9.2 多端已读与未读计数 。
常见错误与边界
- 只推最近活跃的设备。同一用户其他在线设备会永久缺消息,直到用户手动刷新或重连。
- 把“在线”当成“送达”。半开连接和推送失败都不会自动变成未读,必须靠 ack 和补偿游标兜底。
- 心跳年龄用
< ttl判断。边界整体提前,心跳稍慢的用户会被误判离线,消息全部涌入补偿。 - 信任客户端传来的成员列表。拉黑和退群状态必须由会话服务计算,客户端可以伪造或使用过期缓存。
- 推送成功就删除补偿任务。推送调用成功不等于客户端收到,删除时机应该以 ack 或游标推进为准。
- 多设备只记录一条投递状态。每台设备需要自己的投递游标,否则离线设备无法判断缺了哪些消息。
- 用本机时间做批量判定。网关时钟漂移会让同一批消息的在线结果互相矛盾,
now应由统一时间源注入。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。