31.1 事件、命令、消息的区别
一句话
命令是 “请你做”(点对点、可拒绝、失败要有结果),事件是 “已经发生”(广播、不可拒绝、只能补偿),消息是它们的传输载体;三者混用,就会出现 “该重试的不重试、不该重试的反复重试”。
三者对照
| 维度 | 命令(Command) | 事件(Event) | 消息(Message) |
|---|---|---|---|
| 语义 | 请求做某事 | 陈述已发生的事实 | 载体(信封) |
| 命名 | 祈使句:PlaceOrder | 过去式:OrderPlaced | — |
| 方向 | 点对点(指定处理者) | 广播(多个订阅者) | 由路由决定 |
| 能否拒绝 | 能(校验失败/业务拒绝) | 不能(已发生) | — |
| 失败处理 | 重试、报错、人工介入 | 补偿(发反向事件) | 重投/死信 |
| 典型误用 | 当事件广播(谁爱听谁听) | 当命令用(“请更新库存”) | 把语义塞进信封 |
代码:同一业务的三种写法
// 命令:点对点,带期望与拒绝语义
type PlaceOrder struct {
OrderID string
Items []Item
IdempotencyKey string
}
// 事件:过去式,带“发生了什么”(含因果 id)
type OrderPlaced struct {
OrderID string
Items []Item
PlacedAt time.Time
CausationID string // 由哪条命令/事件引发
}
// 消息信封:与业务无关,负责路由、追踪、重试
type Envelope struct {
ID, Type, TraceID string
SchemaVersion int
Payload []byte
}
实测:信封不是免费的
同一个业务负载,裸 payload vs 带 id/type/trace_id/causation_id/schema_version 的信封:
| 形态 | 字节数 | 序列化 10 万次 |
|---|---|---|
| 裸 payload | 55B | 138.6ms |
| 带信封 | 167B(+204%) | 224.6ms(+62%) |
结论:信封带来的追踪与版本能力,代价是体积翻三倍、CPU 增六成。所以:高频小事件(指标/心跳)要精简信封;业务事件则必须带 trace_id/causation_id——否则排障时无法还原因果链,省下的 CPU 会以 “查不出问题” 的形式还回来。
// 精简信封(高频小事件)
{"id":"e-1","t":"metric","p":{...}}
// 完整信封(业务事件)
{"id":"e-1","type":"OrderPlaced","trace_id":"t-1","causation_id":"c-1","schema_version":2,"payload":{...}}
反模式
| 反模式 | 后果 |
|---|---|
| 命令起名 “OrderCreated” | 语义混淆:到底是请求还是事实? |
| 事件里放 “请处理” 的指令 | 消费者被强耦合,且无法拒绝 |
| 命令发出去后不关心结果 | 失败静默丢失,没人知道没执行 |
| 事件消费者失败就无限重试 | 一条毒事件堵住订阅者 |
| 把事件当“远程调用”用(等回包) | 伪异步:延迟与失败模式都更差 |
| 命令不做幂等键 | 重试导致重复下单/重复扣款 |
| 所有事件都用 “完整信封” | 高频指标类事件体积翻倍,带宽与存储成本上升 | | 信封里放业务字段(如 amount) | 消费方开始依赖信封,schema 演进更复杂 |
三条铁律
🔑 动词决定类型:祈使句是命令、过去式是事件;命名错了,后面的设计全错。
🔑 命令有结果、事件只有补偿;事件消费者不能 “拒绝已发生的事实”。
🔑 幂等键在命令上,去重在事件上(按事件 ID/因果 ID)。
自检
Q1 为什么事件不能用 “请更新库存” 这种措辞?
▸ 答案:因为事件的语义是 “声明已发生”,不包含 “期待谁做什么”。一旦写成指令,生产者就对消费者产生了耦合(知道库存服务会做某事),且消费者无法拒绝;正确做法是发 OrderPlaced,库存服务自己订阅并决定 “预占或拒绝”,失败时发补偿事件。
Q2 命令被拒绝(业务校验失败)时,事件模型如何处理?
▸ 答案:命令层直接返回失败(同步)或发 OrderRejected 事件(异步),由发起方处理;而不是让 “失败” 变成一条 “已成功” 的事件。事件只记录 “确实发生的事”,失败本身也可以是事件(PaymentFailed),但语义必须准确。
Q3 消息信封里为什么要带 SchemaVersion 与 CausationID?
▸ 答案:SchemaVersion 让消费端能兼容新旧格式(见 31.4 事件 Schema 演进);CausationID 建立 “谁引发谁” 的因果链,排障时能还原 “这一串事件从哪来”,也是幂等与去重的依据。
Q4 命令为什么必须带幂等键?
▸ 答案:因为至少一次投递与客户端重试都不可避免:同一个 PlaceOrder 可能被执行两次。幂等键(如订单号 + 请求 ID)让服务端识别重复并返回同一结果,否则 “重试” 会变成 “重复下单”。
Q5 事件消费者失败了该重试还是补偿?
▸ 答案:先重试(瞬时故障:网络/超时),超过上限后进入死信并告警;如果是 “业务上已经不可能成功”(如库存已空),则发补偿事件回滚上游,而不是继续重试。判据是 “这个失败是暂时的还是终局的”。
Q6 什么时候用命令、什么时候用事件?
▸ 答案:需要 “明确结果、可能要拒绝” 时用命令(下单、扣款);需要 “通知多方、不关心谁处理” 时用事件(订单已创建 → 积分/通知/数仓各自处理)。同一业务常常两者都有:命令触发,成功后再发事件。
Q7 信封字段该由谁填、能改吗?
▸ 答案:id/type/trace_id/causation_id/schema_version 必须由生产者填写,且不可被中间件修改(否则因果链断裂、幂等键失效)。中间件只能追加自己的追踪字段(如 broker 的 offset),不能覆盖。这条规则要写进事件规范,并用 schema 校验强制。
故障复盘(典型模式,脱敏)
| 阶段 | 现象 | 判断 | 动作 |
|---|---|---|---|
| T+0 | 用户被重复扣款 2 次 | PaymentRequested 被当事件广播,两个消费者都执行 | 停消费者 |
| T+10m | 发现同一条 “命令” 被重试 3 次 | 命令无幂等键 | 暂停重试 |
| T+1h | 退款 12 笔,其中 3 笔重复 | 退款也无幂等 | 按支付单号去重 |
| 复盘 | 命令/事件语义混用 | 命名即设计 | 改名为 PaymentSucceeded 事件 + 命令点对点 |
根因:把命令写成事件名(PaymentRequested 既是请求又像事实),且无幂等键。代价:重复扣款 + 12 笔人工退款。根治:祈使句=命令(点对点)、过去式=事件(广播);命令必须带幂等键。