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),大对象用受控回查 + 批量接口。

关联