同一句话,屏幕上出现了两次
「回声」上线消息撤回功能后,客服收到一张截图:同一个用户在同一分钟发了两条一模一样的话,连客户端生成的时间戳都一样。发送者坚持自己只点了一次发送。接收者的未读数却加了 2,通知也弹了两次。
服务端日志显示,第一次请求已经写入成功并返回了消息 ID,但响应在移动网络切换时丢了。客户端等 5 秒超时,用同一个本地消息 ID 重发。请求被负载均衡分到另一台网关实例,那台实例的内存去重表是空的,于是又写了一条,分配了新的服务端 ID 和序号。
这不是“客户端 bug”那么简单。只要网络会超时、网关会重试、消息队列会重新投递,重复就是常态。消息系统能承诺的是至少一次(at-least-once),要做的是让重复在入口被识别,而不是假设它不会发生。
重复消息从哪来
把重复来源列全,才能决定去重键放在哪一层、窗口开多大。
| 来源 | 触发条件 | 重试方 | 稳定幂等键 | 只靠客户端 ID 的问题 |
|---|---|---|---|---|
| 客户端重发 | 响应丢失、超时、弱网切换 | 客户端 | (会话, 发送者, 客户端消息 ID) | 客户端每次重试重新生成 ID 就失效 |
| 网关重试 | 上游超时、连接重置 | 网关或 SDK | 同上,透传原请求 | 网关改写请求体或丢字段 |
| 消费重试 | 队列至少一次、可见性超时 | 消费者 | 服务端消息 ID | 消费侧拿不到客户端 ID |
| 多设备补拉 | 重连后游标重叠 | 客户端同步 | (用户, 会话, 序号) 或消息 ID | 同一消息被拉两次 |
| 人工重放 | 运维补数据、回放工具 | 人 | 服务端消息 ID | 回放工具不生成客户端 ID |
这张表说明一件事:没有任何单一 ID 能覆盖所有来源。客户端 ID 能解决“同一次发送的重试”,服务端 ID 能解决“同一条已落库消息的再次投递”,两者都需要。
去重键不能只有客户端 ID
客户端生成的消息 ID 很适合做发送侧幂等:同一个会话、同一个发送者、同一个客户端 ID,重复提交应该返回第一次的结果。但它不能单独作为全局去重键。
第一,客户端可能每次重试都重新生成 UUID。这不是罕见 bug,很多网络库在重试拦截器里重新序列化请求,顺手换了 ID。第二,不同用户可能生成相同的 ID,尤其是在 ID 空间设计得比较短的时候;如果去重表不按发送者隔离,A 的消息会把 B 的消息吞掉。第三,恶意客户端可以复用 ID 来试探或污染去重状态。
更稳的组合是:入口用 (convID, senderID, clientMsgID) 做幂等键,服务端接受后分配自己的 msgID 和会话序号;落库时对服务端 msgID 建唯一约束,消费侧再按服务端 ID 去重。内存窗口只是第一道快速过滤,数据库唯一索引才是最终兜底。
TTL 要覆盖重试窗口
去重窗口的 TTL 不是随便设的。它至少要覆盖客户端重试、网关重试和队列重新投递的最长时间。
客户端通常重试 3 到 5 次,跨度几十秒;网关的超时可能是 30 秒;消息队列的可见性超时可能是几分钟,延迟重试队列甚至能到几小时。如果窗口只保留 60 秒,而队列在 5 分钟后重新投递,同一条消息会再次被接受。
TTL 太长会占内存。一个每秒一千条消息的会话,24 小时窗口就是八千多万条记录,单机放不下。实际做法是分层:入口窗口按会话分片、保留较短时间;持久层用唯一约束或幂等表保留更长时间;消费侧按消息 ID 做业务幂等。没有哪一层能单独解决全部问题。
时钟倒退会让 TTL 失效
去重依赖 now - seenAt 计算年龄。如果实例时钟被 NTP 往回拨,或者调用方传入了一个更早的时间,年龄会变成负数,消息可能被永久判为“太新”而拒绝,也可能在 Prune 时被误删。
因此 Accept 在任何写入之前检查窗口里是否已经存在比 now 更晚的时间戳;有就返回错误,不修改状态。这个检查不能替代统一时间源,但能把“时间倒退”从静默错误变成显式失败,让调用方决定重试还是切到备用实例。
失败场景:网络抖动触发重发
回到开头的事故。客户端发送消息,网关实例 A 写入成功,返回响应时连接被切换,客户端没收到。5 秒后客户端用同一个客户端 ID 重发,负载均衡把它分到实例 B。B 的 Window 是进程内 map,没见过这个 ID,Accept 返回 true,B 又写了一条。
第二条消息有新的服务端 ID 和更大的会话序号。接收者看到两条内容相同的消息,未读数加 2,通知弹两次;如果开了会话内搜索,两条都会命中。群聊里更麻烦:有人回复第一条,有人回复第二条,讨论被拆成两个线程。
第二个变种是客户端每次重试重新生成 ID。即使请求回到实例 A,窗口里也是两个不同的键,照样重复。第三个变种是 TTL 太短:网关重试在 30 秒内被挡住,但队列在 10 分钟后重新投递,窗口已经 Prune 掉记录,消费者又处理了一次。第四个变种是时钟倒退:Prune 用了一个偏早的 now,把刚写入的记录当成过期删掉,下一次重试直接通过。
这些故障的共同点是“去重状态没有被正确共享或保留”。单机内存窗口只能解决同一实例、短时间内的重复;跨实例、跨队列、跨重启的重复,必须靠共享存储和唯一约束。
用 Window 做入口去重
入口去重的目标很窄:在消息写入之前,用稳定键和有限时间窗挡掉明显的重复。它不负责业务幂等,也不替代数据库唯一索引。
// file: internal/delivery/dedupe.go
// tier: L1
// verify: go test ./internal/delivery/
package delivery
import (
"errors"
"fmt"
)
var (
// ErrEmptyMsgID 表示没有可去重的消息标识。空 ID 会把所有空消息
// 误判成同一条,必须在入口拒绝。
ErrEmptyMsgID = errors.New("delivery: empty message id")
// ErrClockBackwards 表示调用方传入的 now 小于窗口中已经记录的时间。
// 时间倒退会让 TTL 计算失效,不能让这种批次继续写窗口。
ErrClockBackwards = errors.New("delivery: now moved backwards")
// ErrNilWindow 表示没有可写入的去重窗口。
ErrNilWindow = errors.New("delivery: nil window")
)
// Window 是一段时间内的消息去重窗口。Seen 以消息 ID 为键,记录该 ID
// 第一次被接受的时间;TTL 是保留时长,单位与 now 一致。
//
// Window 不是并发安全的。调用方应当在单 goroutine 内使用,或者在外层
// 加锁;否则 Seen 的读写会同时发生。
type Window struct {
TTL int64
Seen map[string]int64
}
// Accept 判断 msgID 在 now 这一刻是否应该被接受。同一条消息在一个 TTL
// 内只接受一次,重复调用返回 false;距离第一次接受达到或超过 TTL 后,
// 同一条消息可以再次被接受,用于客户端合法重发或跨窗口的新一轮投递。
//
// now 必须单调不减。只要窗口里存在比 now 更晚的时间戳,就认为时钟倒退,
// 返回 ErrClockBackwards,并且不写入任何状态。空 msgID 返回 ErrEmptyMsgID。
func Accept(w *Window, msgID string, now int64) (bool, error) {
if w == nil {
return false, ErrNilWindow
}
if msgID == "" {
return false, ErrEmptyMsgID
}
for seenID, seenAt := range w.Seen {
if seenAt > now {
return false, fmt.Errorf("%w: %s seen at %d, now %d", ErrClockBackwards, seenID, seenAt, now)
}
}
if w.Seen == nil {
w.Seen = make(map[string]int64)
}
if prev, ok := w.Seen[msgID]; ok && now-prev < w.TTL {
return false, nil
}
w.Seen[msgID] = now
return true, nil
}
// Prune 删除已经超过 TTL 的记录,返回清理条数。年龄达到 TTL 的记录
// 视为过期,与 Accept 的判定保持一致。now 之前记录的将来时间戳不会
// 被删除,交给时钟倒退检测处理。
func Prune(w *Window, now int64) int {
if w == nil {
return 0
}
removed := 0
for msgID, seenAt := range w.Seen {
if now-seenAt >= w.TTL {
delete(w.Seen, msgID)
removed++
}
}
return removed
}
实现里有几个边界需要说明:
msgID是调用方拼好的稳定键,通常包含会话、发送者和客户端消息 ID。Accept不关心它怎么拼,只要求同一次发送的重试使用同一个值。- 空 ID 直接报错。空键会把所有空消息当成同一条,既挡掉正常消息,也无法排查。
- 重复判定用
now-prev < TTL。年龄正好等于 TTL 时视为过期,允许再次接受;这与Prune的>= TTL保持一致。 - 写入前扫描窗口里所有时间戳,只要有一个大于
now就返回ErrClockBackwards,并且不写入。这个检查是 O(n),只适合小窗口;窗口按会话分片后 n 通常不大。 Prune返回清理条数,方便指标观察。清理是惰性的,调用方可以定时跑,也可以在窗口超过阈值时触发。Window不是并发安全的。同一个会话的Accept应该串行,或者在外层加锁;跨会话可以分片并行。
Accept 返回 true 只代表“这个窗口第一次见”,不代表消息一定落库成功。写入失败后,调用方应该删除这条记录或让窗口自然过期,否则客户端重试会被误判为重复。更稳的顺序是:先落库并依赖唯一约束,再用窗口做前置削峰;或者接受窗口与存储之间短暂的不一致,由唯一约束兜底。
测试:TTL、时钟与清理
去重的边界集中在三处:TTL 的等于号、时钟倒退、清理范围。任何一个写反,都会在线上变成重复或误杀。
// file: internal/delivery/dedupe_test.go
// tier: L1
// verify: go test ./internal/delivery/
package delivery
import (
"errors"
"testing"
)
func TestAcceptFirstThenDuplicateWithinTTL(t *testing.T) {
w := &Window{TTL: 60, Seen: map[string]int64{}}
ok, err := Accept(w, "m1", 1000)
if err != nil || !ok {
t.Fatalf("first accept = %v, %v; want true, nil", ok, err)
}
ok, err = Accept(w, "m1", 1030)
if err != nil {
t.Fatalf("duplicate err = %v", err)
}
if ok {
t.Fatal("duplicate accepted within ttl; want false")
}
}
func TestAcceptAfterTTLExpires(t *testing.T) {
w := &Window{TTL: 60, Seen: map[string]int64{}}
if ok, _ := Accept(w, "m1", 1000); !ok {
t.Fatal("first accept rejected")
}
ok, err := Accept(w, "m1", 1060)
if err != nil {
t.Fatalf("accept after ttl err = %v", err)
}
if !ok {
t.Fatal("message still rejected at ttl boundary; want true")
}
}
func TestAcceptDifferentMessagesIndependent(t *testing.T) {
w := &Window{TTL: 60, Seen: map[string]int64{}}
if ok, _ := Accept(w, "m1", 1000); !ok {
t.Fatal("m1 rejected")
}
ok, err := Accept(w, "m2", 1000)
if err != nil {
t.Fatalf("m2 err = %v", err)
}
if !ok {
t.Fatal("m2 rejected; want accepted")
}
}
func TestAcceptClockBackwardsErrors(t *testing.T) {
w := &Window{TTL: 60, Seen: map[string]int64{}}
if ok, _ := Accept(w, "m1", 1000); !ok {
t.Fatal("setup accept failed")
}
ok, err := Accept(w, "m2", 900)
if !errors.Is(err, ErrClockBackwards) {
t.Fatalf("err = %v; want ErrClockBackwards", err)
}
if ok {
t.Fatal("backwards accept returned true")
}
if _, exists := w.Seen["m2"]; exists {
t.Fatal("backwards accept wrote state")
}
}
func TestAcceptRejectsEmptyIDAndNilWindow(t *testing.T) {
if _, err := Accept(&Window{TTL: 60}, "", 1000); !errors.Is(err, ErrEmptyMsgID) {
t.Fatalf("empty id err = %v; want ErrEmptyMsgID", err)
}
if _, err := Accept(nil, "m1", 1000); !errors.Is(err, ErrNilWindow) {
t.Fatalf("nil window err = %v; want ErrNilWindow", err)
}
}
func TestPruneRemovesExpiredOnly(t *testing.T) {
w := &Window{TTL: 60, Seen: map[string]int64{
"old": 900,
"edge": 940,
"fresh": 990,
"future": 1100,
}}
got := Prune(w, 1000)
if got != 2 {
t.Fatalf("pruned = %d; want 2", got)
}
if _, ok := w.Seen["old"]; ok {
t.Fatal("old entry not pruned")
}
if _, ok := w.Seen["edge"]; ok {
t.Fatal("boundary entry not pruned")
}
if _, ok := w.Seen["fresh"]; !ok {
t.Fatal("fresh entry pruned")
}
if _, ok := w.Seen["future"]; !ok {
t.Fatal("future entry pruned")
}
}
func TestPruneNilAndEmpty(t *testing.T) {
if got := Prune(nil, 1000); got != 0 {
t.Fatalf("nil prune = %d; want 0", got)
}
if got := Prune(&Window{TTL: 60}, 1000); got != 0 {
t.Fatalf("empty prune = %d; want 0", got)
}
}
TestAcceptFirstThenDuplicateWithinTTL 锁定“窗口内只接受一次”:第一次 true,30 秒后同一个 ID 返回 false。TestAcceptAfterTTLExpires 验证年龄正好 60 秒时重新接受,边界与 Prune 一致。
TestAcceptDifferentMessagesIndependent 防止实现把整个窗口当成一个布尔值;不同消息 ID 必须各自判定。TestAcceptClockBackwardsErrors 先写入 1000,再用 900 调 Accept,断言返回 ErrClockBackwards 且没有写入新状态。
TestAcceptRejectsEmptyIDAndNilWindow 覆盖两个入口错误。TestPruneRemovesExpiredOnly 同时放入过期、边界、新鲜和未来四种记录,断言只删除前两种,返回 2。TestPruneNilAndEmpty 保证空窗口不会 panic。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/delivery/ -run 'TestAccept|TestPrune' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestAcceptFirstThenDuplicateWithinTTL
--- PASS: TestAcceptFirstThenDuplicateWithinTTL (0.00s)
=== RUN TestAcceptAfterTTLExpires
--- PASS: TestAcceptAfterTTLExpires (0.00s)
=== RUN TestAcceptDifferentMessagesIndependent
--- PASS: TestAcceptDifferentMessagesIndependent (0.00s)
=== RUN TestAcceptClockBackwardsErrors
--- PASS: TestAcceptClockBackwardsErrors (0.00s)
=== RUN TestAcceptRejectsEmptyIDAndNilWindow
--- PASS: TestAcceptRejectsEmptyIDAndNilWindow (0.00s)
=== RUN TestPruneRemovesExpiredOnly
--- PASS: TestPruneRemovesExpiredOnly (0.00s)
=== RUN TestPruneNilAndEmpty
--- PASS: TestPruneNilAndEmpty (0.00s)
PASS
ok webapp/internal/delivery 0.359s
PASS 表示七个去重用例全部通过。这个窗口是单进程内的纯函数,不包含跨实例共享、数据库唯一约束和队列消费幂等;线上要覆盖完整的重复来源,还需要后面几层配合。
小结
重复消息是至少一次投递的必然产物,不是异常。去重键要同时覆盖客户端重试和服务端再投递:入口用 (会话, 发送者, 客户端消息 ID),落库用服务端消息 ID 的唯一约束,消费侧再做业务幂等。
Window 提供短时间、单实例的快速过滤,TTL 要覆盖重试窗口,时钟倒退要显式报错,Prune 与 Accept 的边界要一致。它不能单独保证“只出现一次”,但能把大部分重复挡在写库之前。到这里,IM 的三条主线——投递、已读、去重——已经闭环;下一章转到另一类触达:推送、短信与邮件。
阅读导航:上一节:9.2 多端已读与未读计数 · 下一节:10.1 渠道、模板与到达率 。
常见错误与边界
- 只依赖客户端生成的 ID。客户端重试时可能重新生成,恶意客户端还能复用,必须配合服务端 ID 和唯一约束。
- 去重键不带发送者或会话。不同用户、不同会话的 ID 碰撞会互相吞消息,键的作用域必须明确。
- TTL 短于重试窗口。队列几分钟后重新投递时窗口已经过期,消息会再写一次。
- 用单机内存窗口承担全局去重。跨实例、跨重启的重复挡不住,必须有共享存储或唯一索引。
- 年龄边界写反。
< TTL与>= TTL必须和Prune一致,否则同一条消息可能被永久拒绝或过早放行。 - 忽略时钟倒退。负年龄会让 TTL 计算失真,
Prune可能误删刚写入的记录。 Accept返回 true 就认为消息已落库。写入失败时要回滚窗口记录,否则客户端重试会被误判为重复。- 窗口无限增长。没有
Prune或按会话分片,长连接服务的内存会被历史消息 ID 撑爆。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。