事件驱动系统的规格模式:契约与消费语义

事件驱动系统的规格模式:契约与消费语义
Spec Coding 编辑部 · Spec-First 工程实践内容

我评审过的大多数事件驱动系统之所以出问题,并不是因为 broker 慢了,也不是处理器有 bug,而是因为规格把每一条消息都当成了同一种东西,把“这是请求、还是通知、还是一声招呼”这种判断留给了消费者自己去猜。下面这些范式,是我现在会在任何生产者被允许向共享 topic 发消息之前,强制要求规格先写清楚的东西。

发布于 2026-03-01 · ✓ 已更新 2026-05-06 · 阅读约 6 分钟 · 作者:Spec Coding 编辑部 · 审校:编辑与事实核查政策

内容复查说明

复查日期:2026-05-06。本文已作为可索引的专题参考重新放出,并与 API 契约 Hub 形成内链。内容保留了可复用的评审材料、失败模式和下一步阅读路径,适合直接用于实际团队讨论。

command 事件和 fact 事件的命名必须区分,永远如此

我看到的集成痛苦里,排名第一的根源是规格把 command 和 fact 揉进同一条流。command 事件说的是“请做这件事”,预期有且只有一个 owner 去执行。fact 事件说的是“这件事已经发生了”,假设零个或多个订阅者会做出反应。归属不同、重试语义不同、失败模式也不同。

我的规格要求 command 事件用祈使动词(ShipOrderChargePayment),fact 事件用过去时动词(OrderShippedPaymentCharged)。如果某个事件提案无法干净利落地归入其中一类,说明边界划错了,而不是说我们需要第三种事件类别。

choreography 还是 orchestration,按流程选,不按系统选

团队讨论 choreography 和 orchestration 时,经常像在争一种全平台通用的宗教。我的做法是按流程来选。带分支逻辑、补偿动作、并且只有一个 owner 的流程,属于 orchestrator;多个独立服务对一个业务事实做出反应的流程,属于 choreography。

规格必须写明选哪一种,以及为什么。如果选 orchestration,就要写出 orchestrator 是谁、状态机是什么、每一步的超时是多少。如果选 choreography,就要写出 fact 事件是哪个、有哪些订阅者、每个订阅者独立的 deadline 是多少。“我们先发个事件看看会怎么样”这种,我不会当成一个设计来接受。

Schema 版本化:默认加性变更,破坏性变更按政策走

每一个事件 schema 都活在 registry 里,并且版本号带在 envelope 中。政策是:加性变更(新增可选字段,在 flag 后面新增枚举值)不升版本;破坏性变更(删字段、改字段名、收紧 required 约束)升一个新 major 版本,并与旧版本并行运行一段明确的弃用窗口,通常是两个发布周期。

规格必须写明弃用窗口是多久、用哪个指标证明已经没有消费者停留在旧版本上、旧 topic 将在哪一天被删除。没有这些,“v2”就会变成一个永久存在的平行宇宙,registry 里堆满了没人敢动的僵尸。

把最终一致性边界明明白白说出来

我写的每一份事件驱动规格里,都有一节叫“一致性边界”,用大白话回答三个问题:事务边界在哪里结束?下游读的数据可以有多陈旧才会被用户察觉?在这个间隙里,UI 呈现的是什么?

“订单服务通过 outbox 在同一个事务里提交行并发布 OrderPlaced。库存投影在 p99 下最多滞后 2 秒。在这个窗口内,订单详情页显示的是‘正在锁定库存’的占位文案,而不是 spinner,也不是空行。”这句话替我省下的争论,比任何架构图都多。

correlation 和 causation ID,附一个可落地的例子

每个 envelope 都带三个 ID:event_id(每个事件唯一)、correlation_id(整条业务链路共享,由第一个生产者打上去)、causation_id(触发本事件的那个 event_id)。处理器原样传递 correlation_id,并把 causation_id 设成自己所响应的那个事件。这三个 ID 属于 envelope,不属于 payload,这样 tracing 才能跨过 schema 演进继续存活。三个 ID 都到位后,死信排查只需要一条查询。

下面是我在几乎每一次规格评审里都会拿出来的那个具体例子。用户提交一个订单。订单服务写行,并通过 outbox 发布 OrderPlaced(fact)。三个服务独立订阅:payments、inventory、notifications。

payments 扣款,发布 PaymentChargedPaymentFailed。inventory 锁库存,发布 StockReservedStockUnavailable。notifications 在看到相同 correlation_id 下的 PaymentChargedStockReserved 都到齐之后,发出确认邮件。

如果 PaymentFailed 到达,inventory 发布 StockReleased 作为补偿动作,订单服务把订单状态迁移到 payment_failed。没有分布式事务,没有两阶段提交,只有 fact 事件和补偿。规格写明每一个 topic、每一个 handler、每一个补偿动作,以及在多久超时之后订单服务把订单标记为 stuck 送人工审核。

用 Given/When/Then 写验收标准

我为每个 handler 用 Given/When/Then 的形式写验收标准,让 QA 和 SRE 读的是同一份事实来源。

- Given OrderPlaced with correlation_id C1 has been published
  When the inventory handler receives it for the first time
  Then StockReserved is published with causation_id = event_id of OrderPlaced
  And the inventory row for the SKU is decremented exactly once

- Given the inventory handler has already processed OrderPlaced with event_id E1
  When the same event is redelivered (at-least-once retry)
  Then the handler detects the duplicate via the processed_events table
  And no additional StockReserved is published
  And the inventory row is unchanged

- Given PaymentFailed arrives with correlation_id C1 after StockReserved C1
  When the inventory handler processes PaymentFailed
  Then StockReleased is published within 5 seconds
  And the SKU count returns to its pre-reservation value

幂等那条是不容谈判的。我用过的每一个真实 broker 都默认 at-least-once,而“我们稍后再让 handler 幂等”就是重复扣款冲到生产环境的标准路径。

死信、毒消息和重放政策

死信队列是一个政策问题,不是一个功能。重试几次之后才进 DLQ?退避策略是什么?DLQ 深度越过阈值时,谁会被 page?有哪种工具可以让 operator 检查、修复并重放一条被毒化的事件?

我的默认做法是:三次重试,指数退避,然后进 DLQ。DLQ 深度大于零时,在 15 分钟内 page 所属团队。重放工具要求写明理由,会写一条审计日志,并且拒绝重放早于 schema 弃用窗口的事件。没有把这些规则落到文字上,DLQ 就会变成坟场。

event sourcing 不是 event-driven,规格写法也不同

规格里经常把这两件事混为一谈。event-driven 指的是服务之间通过事件通信。event sourcing 指的是某个服务把自己的状态存成事件流,通过重放来重建状态。如果规格写了“event sourcing”,我会追问快照节奏、针对旧版本的 event upcaster、从零重建的时间预算。如果写的是“event-driven”,我会追问 command/fact 的切分、一致性边界、幂等故事。把两套词汇混用,只会掩盖到底哪些问题真正被想过。

证明业务流程健康的可观测性指标

每一份事件驱动规格里我都要求三个指标:按事件类型分的消费者 lag,按 handler 分的成功率(排除幂等去重的重复投递),以及按 correlation_id 计算的、从第一个生产者 publish 到最后一个订阅者 commit 的端到端业务延迟。

broker 吞吐和 CPU 用来做容量规划是没问题的,但它们完全不说明业务流程到底跑得通不通。按 correlation_id 的端到端延迟说明。这个指标一旦漂移,就意味着有一个真实的用户正在等待,比规格承诺的时间更久——哪怕所有服务的监控都还是绿的。

事件规格要说清“这是事实还是命令”

很多事件系统变乱,是因为同一个 topic 里既有事实又有命令。事实描述已经发生的事,命令要求别人做事。消费者的重试、幂等和报警逻辑完全不同。

Event envelope:
{
  "event_id": "evt_123",
  "type": "invoice.payment_failed",
  "kind": "fact",
  "occurred_at": "2026-04-28T10:12:00Z",
  "producer": "billing-service",
  "schema_version": 3,
  "idempotency_key": "invoice_789:payment_failed:1",
  "data": { "invoice_id": "invoice_789", "attempt": 1 }
}

边界:不要用事件总线绕过 API 契约。跨服务事件也是公开契约,版本、字段和退役策略都要写。

版本升级要附消费者计划

事件 schema 升版时,不只写 producer 怎么发。还要列出消费者名称、当前版本、升级 owner、兼容窗口和回滚策略。一个无人认领的消费者,足够让“安全新增字段”变成生产事故。

事件表和 schema registry 也要有 owner。字段废弃、topic 退役、消费者升级和 replay 权限都写进规格。事件一旦被多个服务消费,就不再是某个服务的内部实现细节。

消费者迁移表

我会要求 producer 在规格里放一张消费者迁移表。它不需要漂亮,但必须能看出谁还没升级、谁能回滚、谁会被 breaking change 打到。

Consumer migration table

Consumer | Current schema | Target schema | Owner | Deadline | Rollback
billing-ledger | v2 | v3 | Billing Eng | 2026-05-20 | replay v2 topic
crm-sync | v2 | v3 | RevOps Eng | 2026-05-22 | keep v2 adapter
support-timeline | v1 | v3 | Support Tools | 2026-05-30 | read projection snapshot

Release gate:
- 没有 owner 的 consumer 不能进入发布
- 没有回滚路径的 consumer 不能进入发布
- v2 topic 删除日期必须晚于最后一个 consumer 升级完成

这张表能防止一个常见问题:producer 只验证自己发得出去,却没有验证别人接得住。事件驱动系统的风险通常不在第一条消息,而在第三个消费者。

关键词:事件驱动架构 · 事件 schema 版本化 · choreography vs orchestration · 幂等处理器 · correlation id · saga 模式 · 死信队列

编辑说明与免责声明

本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。