点赞数涨了两次,又掉回去
「回声」的点赞功能有两个消费者:一个更新帖子的点赞计数,一个给作者发“某某赞了你的帖子”的通知。点赞接口写一条 likes 记录,然后发一条 post.liked 事件。计数服务收到事件后把 Redis 里的计数加一;通知服务收到事件后写一条站内信。
上线后偶发一种很难复现的现象:用户点赞,页面上的数字从 12 变成 14,刷新一下又回到 12;作者有时会收到两条一模一样的通知。客服收到的反馈是“点赞坏了”,但开发在日志里找不到任何错误。
原因在一次 Broker 的消费者组重平衡。计数服务处理完事件、还没来得及提交位点时实例重启,Broker 把同一条事件重新投递。计数服务没有记录“这条事件处理过”,于是又执行了一次 INCR。与此同时,一个定时任务按 likes 表重算计数并写回缓存,重算结果是 12,于是用户看到了 14 变回 12。
通知服务的问题更直接:重复投递导致两条站内信。它尝试用“消息位点”去重,但重平衡后位点会回退,位点本身并不能代表业务上的“已经处理过”。而且两个消费者各自维护位点,计数服务重复处理时,通知服务未必重复,反过来也一样。
团队最初的判断是“消息队列不可靠”,想换一个“保证只投递一次”的中间件。但至少一次投递是分布式系统的常态:生产者不知道消费者是否真的处理完,只能在超时后重发;消费者也不知道自己提交位点前是否已经产生副作用。真正能保证的只有“每条事件带一个稳定的 ID,消费者按 ID 做幂等”。
事件是事实,通知是动作
把 post.liked 和“给作者发一条站内信”混在一起,是这类问题的根源。事件描述“发生了什么”,通知描述“要对谁做什么”。两者的生命周期、失败语义和幂等键都不同。
| 维度 | 事件 | 通知 |
|---|---|---|
| 语义 | 已经发生的事实,例如 post.liked | 针对某个接收者的动作,例如站内信、短信、推送 |
| 生产者 | 业务写模型,和业务数据在同一事务里产生 | 通知服务,消费事件后生成 |
| 消费者 | 计数、索引、审计等多个下游 | 具体渠道和接收者 |
| 失败重试 | 可以重放,消费者必须幂等 | 可以重试,但要避免重复打扰用户 |
| 幂等键 | 事件 ID | 事件 ID + 渠道 + 接收者 |
| 版本 | 事件结构有版本,旧消费者要能识别 | 模板和渠道有版本,与事件版本解耦 |
| 保留 | 按审计和对账需要保留 | 按用户可见记录和合规要求保留 |
事件的第一条纪律是“事实不可变”。一条 post.liked 一旦发出,就代表某个用户在某个时刻点了赞;后续取消点赞应该产生 post.unliked,而不是修改原事件。这样消费者可以重放历史,计数可以从事件流重建,审计也能回答“当时发生了什么”。
第二条纪律是“事件 ID 由生产者在写业务数据时生成”,不能由消费者或 Broker 在投递时生成。否则生产者重发时会得到新的 ID,消费者无法识别这是同一条事实。ID 通常用业务主键加版本,或者一个全局唯一的 UUID;关键是它跨重试保持不变。
第三条纪律是“至少一次投递,消费者幂等”。这不是某个中间件的缺陷,而是“确认”这件事本身的限制:消费者处理完、写库成功、准备提交位点时崩溃,Broker 只能重发。消费者要把“处理副作用”和“记录已处理”放在同一个原子操作里,或者让副作用本身带唯一约束。
通知则要多考虑一层“用户感知”。事件重复处理可能只是计数多一,通知重复处理会让用户收到两条短信。通知的幂等键不能只用事件 ID,还要加上渠道和接收者;同一个事件发给作者和发给被提及的人,是两条不同的通知。很多短信和推送供应商支持幂等键,应该把 事件ID:渠道:接收者 传下去,而不是在应用层假设“重试不会重复发”。
最终一致还必须能回答“用户多久看到”。这不是一句“最终会一致”就能糊弄过去的。计数可以在 2 秒内一致,站内信可以在 30 秒内送达,搜索索引可以容忍 1 分钟,这些都是产品可接受的时间预算。超过预算要有告警,否则“最终”会变成“永远”。
失败场景:重复消费让计数抖动
把上面的时间线拆开,可以看到三个独立的问题叠加在一起。
第一个问题是计数服务的副作用不幂等。它收到事件后直接 INCR,没有记录事件 ID。重复投递时,Redis 计数变成 14;定时重算从 likes 表读到 12,又写回 12。用户看到数字跳动,以为点赞失败,反复点击,产生更多事件和更多重算。
第二个问题是“重算”和“增量”用了两个不同的权威源。增量路径相信事件,重算路径相信 likes 表;两条路径都写同一个 Redis 键,谁后写谁生效。只要事件重复或延迟,两个来源就会打架。正确做法是选一个权威源:要么计数完全由事件流投影,要么完全由表重算,增量只作为缓存优化并且可被重算覆盖。
第三个问题是通知服务用位点去重。位点在重平衡时会回退,不能代表“这条事件已经产生过通知”。通知服务应该用事件 ID 加接收者做唯一键,在写站内信时依赖数据库唯一约束;重复投递时插入失败,直接确认消息,而不是再发一条。
这三个问题还有一个共同的放大因素:没有版本门槛。计数服务升级到新的事件结构后,旧格式的 post.liked 仍然在队列里。新消费者把缺失字段当成零值,可能把计数加错,也可能把通知模板渲染成空白。版本不是可选的元数据,它是消费者判断“我能不能理解这条事实”的依据。
修复方案可以分成四步:
- 生产者在写
likes表的同一事务里写 outbox,事件 ID 使用点赞记录的主键,重发时保持不变。 - 计数服务用
SETNX event:<id>或一张幂等表记录已处理事件,只有第一次成功时才执行INCR。 - 通知服务用
(event_id, channel, recipient)唯一约束,重复插入直接忽略并确认。 - 消费者声明
MinVersion,低于门槛的事件转入死信并告警,而不是静默按零值处理。
改完之后,重复投递仍然会发生,但用户看到的计数不再跳动,通知也不再重复。系统没有变成“恰好一次”,而是变成了“重复无害”。这才是可运维的最终一致。
用事件 ID 做幂等账本
下面的实现把消费者需要记住的东西收敛成一个小结构:事件 ID、首次处理时间、首次看到的类型,以及消费者能理解的最低版本。Apply 返回一个布尔值,告诉调用方这次是否应该执行副作用。
// file: internal/async/event.go
// tier: L1
// verify: go test ./internal/async/
package async
import (
"fmt"
"strings"
"time"
)
// Event 是已经发生的事实。业务系统只描述发生了什么,不描述要通知谁。
type Event struct {
ID string
Type string
Version int
OccurredAt time.Time
}
// Consumer 是一个消费者的幂等账本。
// Seen 以事件 ID 为键,记录首次处理时间;Types 记录首次看到的类型,
// 用来发现同一个 ID 被不同事件复用的生产端错误。
type Consumer struct {
Name string
Seen map[string]time.Time
Types map[string]string
MinVersion int
}
// Apply 处理一个事件。返回 true 表示本次生效,false 表示重复投递。
// 版本过低、ID 与 Type 冲突、账本为空都会返回错误,且不改变状态。
func Apply(c *Consumer, e Event, now time.Time) (bool, error) {
if c == nil {
return false, fmt.Errorf("async: nil consumer")
}
if strings.TrimSpace(e.ID) == "" || strings.TrimSpace(e.Type) == "" {
return false, fmt.Errorf("async: event id and type are required")
}
if e.Version < c.MinVersion {
return false, fmt.Errorf("async: %s: event %s version %d below minimum %d", c.Name, e.ID, e.Version, c.MinVersion)
}
if _, ok := c.Seen[e.ID]; ok {
if first, typed := c.Types[e.ID]; typed && first != e.Type {
return false, fmt.Errorf("async: %s: event %s seen as %q, now %q", c.Name, e.ID, first, e.Type)
}
return false, nil
}
if c.Seen == nil {
c.Seen = make(map[string]time.Time)
}
if c.Types == nil {
c.Types = make(map[string]string)
}
c.Seen[e.ID] = now
c.Types[e.ID] = e.Type
return true, nil
}
Consumer.Seen 以事件 ID 为键,记录首次处理时间。这个时间有两个用途:一是判断重复,二是计算处理延迟。用 now.Sub(e.OccurredAt) 可以回答“这条事件从发生到被消费用了多久”,这正是最终一致预算需要的指标。重复投递时不覆盖首次时间,否则延迟统计会被最后一次重试污染。
Consumer.Types 是 Seen 的必要补充。只用 map[string]time.Time 无法发现“同一个事件 ID 被不同事件复用”的生产端错误:两次投递看起来都像重复,但其中一条其实是另一个事实。把首次类型也记下来,第二次出现不同类型时就能明确报错,而不是悄悄当成重复丢掉。
Apply 的顺序是:先校验消费者和事件基本字段,再检查版本,最后才查重。版本检查放在查重之前,是因为旧格式事件即使重复,也不应该被当成“已处理”而放行;它应该进入死信,让运维知道还有旧生产者。反过来,如果先查重,一条已经处理过的旧事件会被静默确认,版本问题永远暴露不出来。
版本相等是允许的,只有严格低于 MinVersion 才报错。MinVersion 的语义是“我能理解的最低结构版本”,不是“我只接受最新版本”。消费者升级时应该同时支持当前版本和上一个版本,等旧事件排空后再提高门槛。
重复事件返回 false, nil,而不是错误。重复是正常投递语义的一部分,不是异常;如果把它当错误,消费者会不断重试同一条消息,反而放大问题。类型冲突则必须返回错误,因为那说明生产端有 bug,继续处理会产生错误数据。
这段代码把账本放在内存里,只适合演示。生产环境要把 Seen 和 Types 落到数据库或 Redis,并且让“记录已处理”和“业务副作用”在同一个事务或同一个原子操作里完成。否则消费者在两者之间崩溃,仍然会重复执行。
测试:重复不报错,冲突要报错
测试要覆盖的不是“正常收到一条事件”,而是重复、旧版本、ID 复用和非法输入这四个边界。尤其是重复投递,它必须返回 false 且不报错,同时不能改写首次处理时间。
// file: internal/async/event_test.go
// tier: L1
// verify: go test ./internal/async/
package async
import (
"testing"
"time"
)
func TestApplyDeduplicatesByID(t *testing.T) {
first := time.Date(2026, 10, 28, 13, 40, 0, 0, time.UTC)
later := first.Add(time.Minute)
c := &Consumer{Name: "like-counter", MinVersion: 1}
e := Event{ID: "evt-1", Type: "post.liked", Version: 2, OccurredAt: first}
applied, err := Apply(c, e, first)
if err != nil {
t.Fatal(err)
}
if !applied {
t.Fatal("first delivery must apply")
}
if got := c.Seen["evt-1"]; !got.Equal(first) {
t.Fatalf("seen at %s, want %s", got, first)
}
applied, err = Apply(c, e, later)
if err != nil {
t.Fatalf("duplicate must not error: %v", err)
}
if applied {
t.Fatal("duplicate must not apply twice")
}
if got := c.Seen["evt-1"]; !got.Equal(first) {
t.Fatalf("duplicate moved first-seen time to %s", got)
}
}
func TestApplyRejectsOldVersion(t *testing.T) {
c := &Consumer{Name: "order-view", MinVersion: 3}
applied, err := Apply(c, Event{ID: "evt-2", Type: "order.paid", Version: 2}, time.Now())
if err == nil {
t.Fatal("want error for version below minimum")
}
if applied {
t.Fatal("old version must not apply")
}
if len(c.Seen) != 0 {
t.Fatalf("old version was recorded: %+v", c.Seen)
}
applied, err = Apply(c, Event{ID: "evt-3", Type: "order.paid", Version: 3}, time.Now())
if err != nil || !applied {
t.Fatalf("version equal to minimum should apply: applied=%v err=%v", applied, err)
}
}
func TestApplyRejectsIDTypeConflict(t *testing.T) {
now := time.Date(2026, 10, 28, 13, 40, 0, 0, time.UTC)
c := &Consumer{Name: "like-counter"}
if _, err := Apply(c, Event{ID: "evt-4", Type: "post.liked", Version: 1}, now); err != nil {
t.Fatal(err)
}
applied, err := Apply(c, Event{ID: "evt-4", Type: "post.unliked", Version: 1}, now.Add(time.Second))
if err == nil {
t.Fatal("want conflict error for reused ID")
}
if applied {
t.Fatal("conflicting event must not apply")
}
if got := c.Seen["evt-4"]; !got.Equal(now) {
t.Fatalf("conflict changed first-seen time to %s", got)
}
if got := c.Types["evt-4"]; got != "post.liked" {
t.Fatalf("first type = %q, want post.liked", got)
}
}
func TestApplyRejectsInvalidInput(t *testing.T) {
now := time.Now()
if _, err := Apply(nil, Event{ID: "e", Type: "t"}, now); err == nil {
t.Fatal("want error for nil consumer")
}
c := &Consumer{Name: "x"}
if _, err := Apply(c, Event{ID: " ", Type: "t"}, now); err == nil {
t.Fatal("want error for blank id")
}
if _, err := Apply(c, Event{ID: "e", Type: ""}, now); err == nil {
t.Fatal("want error for empty type")
}
if len(c.Seen) != 0 {
t.Fatalf("invalid events were recorded: %+v", c.Seen)
}
}
TestApplyDeduplicatesByID 先处理一次,再用更晚的时间处理同一条事件,断言第二次返回 false、没有错误,并且 Seen 里的时间仍然是第一次的值。最后一条断言很重要:如果实现为了方便直接覆盖时间,处理延迟会永远显示为“刚刚”,积压监控就失去意义。
TestApplyRejectsOldVersion 用 MinVersion=3 拒绝版本 2,并检查账本没有被写入;随后用版本 3 验证边界是“小于”而不是“小于等于”。这个测试防止实现把版本门槛写成 <=,导致当前版本也被拒绝。
TestApplyRejectsIDTypeConflict 先记录 post.liked,再用同一个 ID 发送 post.unliked。期望是错误、未生效、首次时间和首次类型都不变。这个场景在真实系统里通常意味着生产者把“点赞记录 ID”和“事件 ID”混用了,或者 ID 生成器发生碰撞;无论哪种,都不应该继续处理。
TestApplyRejectsInvalidInput 覆盖 nil 消费者、空白 ID 和空类型。非法输入不写入账本,避免一条坏事件污染后续判断。真实服务里还应该把这类错误计入指标,否则生产者会以为事件已经成功投递。
这些测试只验证单个消费者的幂等语义,不验证多个消费者之间的一致性,也不验证账本持久化。后者需要集成测试和故障注入;单元测试的职责是把 Apply 的返回值和时间语义固定下来。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/async/ -count=1 -run 'TestApply' -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestApplyDeduplicatesByID
--- PASS: TestApplyDeduplicatesByID (0.00s)
=== RUN TestApplyRejectsOldVersion
--- PASS: TestApplyRejectsOldVersion (0.00s)
=== RUN TestApplyRejectsIDTypeConflict
--- PASS: TestApplyRejectsIDTypeConflict (0.00s)
=== RUN TestApplyRejectsInvalidInput
--- PASS: TestApplyRejectsInvalidInput (0.00s)
PASS
ok webapp/internal/async 0.237s
输出里的 0.237s 是一次本机运行的耗时,会随机器和负载变化,不能当成性能结论。-run 'TestApply' 只匹配本节的事件测试,不包含同包的模式判断和重试测试。
小结
事件是事实,通知是动作。事实可以被重放,所以消费者必须幂等;动作会打扰用户,所以通知还要按渠道和接收者去重。至少一次投递不是中间件的缺陷,而是确认机制的限制;系统能承诺的是“重复无害”,不是“恰好一次”。
幂等账本至少需要三个信息:事件 ID、首次处理时间、首次类型。ID 用来识别重复,时间用来计算延迟和回答“用户多久看到”,类型用来发现生产端复用 ID 的错误。版本门槛则保证消费者不会用旧结构解释新事实,或者反过来。
下一节会把视角从“单条事件”拉到“整条链路”:重复、丢失和乱序同时存在时,重试、死信和对账如何组成最后一道防线。
阅读导航:上一节:9.1 哪些事情不该阻塞用户 · 下一节:9.3 重复、丢失与补偿的用户感知 。
常见错误与边界
- 把事件和通知混成一个消息。事件可以重放,通知重复会直接打扰用户,两者的幂等键不同。
- 用消息位点做业务去重。重平衡会让位点回退,位点只表示消费进度,不表示副作用是否发生。
- 事件 ID 在投递时生成。生产者重发会得到新 ID,消费者永远认不出这是同一条事实。
- 重复事件返回错误。重复是正常语义,报错会触发更多重试,放大积压。
- 重复时覆盖首次处理时间。延迟统计被最后一次重试污染,积压和慢消费无法被发现。
- 只记事件 ID 不记类型。同一个 ID 被不同事件复用时,会被误判成重复而静默丢弃。
- 版本门槛写成小于等于。当前版本也会被拒绝,升级期间消费者直接停摆。
- 账本只放内存。进程重启后幂等记录丢失,重复投递会再次执行副作用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。