《互联网产品的服务端架构·产品、流量与基础能力》9.2 事件、通知与最终一致

点赞事件重复投递让计数先涨两次又掉回去,问题不在消息队列,而在消费者没有幂等账本。文章区分事件与通知的失败语义,说明至少一次投递下如何用事件 ID 去重、用版本门槛拒绝旧格式、用首次处理时间回答最终一致要多久,并附 Apply 的 Go 实现与测试。

点赞数涨了两次,又掉回去

「回声」的点赞功能有两个消费者:一个更新帖子的点赞计数,一个给作者发“某某赞了你的帖子”的通知。点赞接口写一条 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 被不同事件复用时,会被误判成重复而静默丢弃。
  • 版本门槛写成小于等于。当前版本也会被拒绝,升级期间消费者直接停摆。
  • 账本只放内存。进程重启后幂等记录丢失,重复投递会再次执行副作用。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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