31.2 事件驱动架构 (EDA)
一句话
EDA 的价值不是 “解耦” 这个口号,而是 生产者不必知道消费者:新增一个消费方不用改动生产方;代价是流程从 “看得见的调用栈” 变成 “看不见的事件流”,可见性与一致性都要额外建。
三种事件风格
| 风格 | 事件里放什么 | 消费方拿到什么 | 耦合度 |
|---|---|---|---|
| 事件通知(Event Notification) | 只放 ID 与类型 | 需要回查详情 | 低(但要回查) |
| 事件携带状态(Event-Carried State Transfer) | 放完整变更后的状态 | 无需回查,直接可用 | 中(含数据冗余) |
| 事件溯源(Event Sourcing) | 事件即唯一真相 | 可重建任意状态 | 高(见 32.4 事件溯源) |
选型判据:消费方需不需要回查、能不能容忍数据冗余。通知型最松耦合但依赖回查接口;携带状态型最实用(多数业务事件用这种);事件溯源只在 “需要完整历史/可重放” 时用。
代码:一个可运维的事件消费者
func (h *Handler) Handle(ctx context.Context, e Envelope) error {
// ① 幂等:按事件 ID 去重(至少一次投递的必需品)
if h.seen.Exists(ctx, e.ID) { return nil }
// ② 处理:把副作用与“标记已处理”放在同一事务里
return h.db.Tx(ctx, func(tx Tx) error {
if err := h.apply(tx, e); err != nil { return err }
return h.seen.Mark(tx, e.ID) // 同事务提交,避免“处理了但没记”
})
}
实测:通知型要回查多少次
2 万条事件,“事件只带 ID” vs “事件携带状态”:
| 风格 | 回查次数 | 处理耗时 |
|---|---|---|
| 通知型(只带 ID) | 20,000 次 | 3.07ms |
| 携带状态型 | 0 次 | 0.69ms |
注意 3.07ms 是内存字典的代价;真实系统每次回查是一次 DB/HTTP(0.1–1ms),2 万条就是 2–20 秒——这就是 “通知型事件” 在高吞吐下的真实成本。
通知型:事件 → 消费方 → 回查详情(N 次 IO)
携带状态:事件(含状态)→ 消费方直接处理(0 次 IO,代价是数据冗余)
反模式
| 反模式 | 后果 |
|---|---|
| 事件只发 ID、却不提供回查接口 | 消费方拿不到数据,只能反查库(耦合回来了) |
| 事件里塞全表快照 | 体积膨胀、含敏感字段、schema 频繁变 |
| 消费方无幂等 | 重投导致重复扣款/重复发券 |
| 没有死信队列 | 一条毒事件卡住整个分区 |
| 事件流没有全局追踪 | 出问题无法回答 “卡在哪” |
| 用事件实现 “同步请求-响应” | 伪异步,延迟与失败模式更差 |
| 事件版本无兼容策略 | 升级即全量消费失败(见 31.4 事件 Schema 演进) |
| 通知型事件不控制回查并发 | 回查把下游数据库打满,形成二次故障 | | 携带状态型不脱敏 | 敏感字段随事件扩散到所有订阅方 |
三条铁律
🔑 事件是 “已发生”,消费方只能补偿,不能拒绝。
🔑 至少一次 + 幂等消费 是默认前提,别假设 “恰好一次”。
🔑 没有追踪与死信的事件流,等于把故障藏进黑洞。
自检
Q1 为什么 “事件只放 ID、让消费方回查” 会重新引入耦合?
▸ 答案:因为消费方必须依赖生产方的查询接口与数据模型:接口变了、字段删了,消费方就坏。它把 “事件耦合” 换成了 “查询契约耦合”,只适合 “消费方本来就需要最新详情” 的场景(如用户点击查看)。
Q2 事件携带状态(ECST)的优点与风险分别是什么?
▸ 答案:优点是消费方无需回查、可独立处理(低延迟、低耦合);风险是数据冗余与一致性:同一事实在多处存副本,schema 变更影响面更大,且可能包含不该外泄的字段(需要脱敏与字段白名单)。
Q3 为什么幂等标记要与业务副作用在同一事务?
▸ 答案:否则会出现 “处理成功但标记失败”(重启后重复处理)或 “标记成功但处理失败”(永久丢失)。把两者放进同一本地事务,靠数据库的原子性保证 “要么都成、要么都不成”,这是 “至少一次 + 幂等” 的正确落地方式。
Q4 事件流怎么做全局追踪?
▸ 答案:在信封里带 trace_id 与 causation_id,生产者与消费者都把这两个 ID 写进日志/链路;再用一张 “事件表” 记录 “谁消费成功”。这样任何一条业务失败都能反查到 “哪条事件、被谁、在什么时候处理失败”。
Q5 什么时候不该用 EDA?
▸ 答案:需要强一致、顺序严格、结果同步返回的场景(下单校验、扣款),用同步调用更简单可靠;团队没有事件治理能力(schema、死信、追踪)时,事件流会变成 “谁都不敢碰的黑盒”。EDA 适合 “通知型、可最终一致” 的旁路业务。
Q6 EDA 与 “微服务” 是绑定的吗?
▸ 答案:不是。模块化单体内部也可以用事件解耦(进程内事件总线,见 27.2 模块化单体);反过来,微服务也可以全同步调用。事件是解耦手段,不是部署形态的附属品。
Q7 什么时候必须用 “通知型” 而不是 “携带状态”?
▸ 答案:当状态很大或敏感(如完整用户档案、大文档)时:携带状态会让事件体积与泄露面同时爆炸。折中做法是 “携带关键字段(金额、状态)+ 大字段用引用”,或对大对象用 “通知 + 受控回查接口(带鉴权与缓存)”。
故障复盘(典型模式,脱敏)
| 阶段 | 现象 | 判断 | 动作 |
|---|---|---|---|
| T+0 | 下游数据库 CPU 打满,主库响应变慢 | 通知型事件引发回查风暴 | 限流消费者 |
| T+5m | 2 万条事件 × 每次 1 次回查 = 2 万次查询 | 实测口径:20000 次回查 | 加批量接口 + 缓存 |
| T+30m | 回查降下来,但消费延迟上涨 | 缓存未预热 | 预热热点 |
| 复盘 | 事件只带 ID,消费方必须回查 | 风格选错 | 关键字段随事件携带 |
根因:用 “通知型事件” 承载高吞吐业务,把事件流变成 “回查风暴”。代价:下游 DB 打满 30 分钟。根治:关键字段随事件携带(ECST),大对象用受控回查 + 批量接口。