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 笔人工退款。根治:祈使句=命令(点对点)、过去式=事件(广播);命令必须带幂等键。

关联