发出去的是事实,不是命令
一句话总结
同步调用表达“请做这件事”,事件表达“发生了这件事”。后者不要求接收方此刻必须在线。
为什么需要了解这些
假设订单创建后,需要扣减库存、累积积分、发送通知并记录分析事件。如果用同步调用串联,订单 API 的响应时间会变成四个服务响应时间之和,可用性则变成四个服务可用性的乘积。每个服务可用性都是 99.9% 时,整体只有 99.6%。而且,通知服务宕机就无法接收订单,这是一种会让通知故障阻断营业收入的设计。
改用事件后,订单服务只发布一个“订单已创建”的事实就结束。其余四个服务按各自速度消费。即使通知服务宕机 10 分钟,订单仍能继续接收;服务恢复后,再处理积压事件。
工作原理
这里最重要的是命名。SendNotification 是命令,OrderCreated 是事实。把命令发送到队列,只是异步 RPC。发送方知道接收方是谁;接收方增加时,发送方代码也要改变。发布事实时,发送方不知道谁在监听;即使增加新的消费者,发布方仍保持不变。这才是真正降低耦合的位置。
事件载荷应包含足够信息,让消费者独立完成自己的工作。如果消费者必须回头查询发布方数据库,耦合就会重新出现。不过,包含太多内容会让事件本身变成模式,而模式就是契约,很难修改。实际折中方式是:携带标识符,以及不会改变的核心字段。
代价也很明确:最终一致性。订单已创建、库存却尚未扣减的时间窗口会出现。该窗口是 200ms 还是 30 秒,期间用户会看到什么,都必须由产品层面决定。页面上的“即将更新”这句话,实际上是一项架构决策。
事件名称决定设计
即使内容相同,名称也会改变系统设计。从表格中可以清楚看到差异。
| 名称 | 类型 | 发送方知道什么 | 消费者增加时 |
|---|---|---|---|
SendNotification |
命令 | 知道由谁处理 | 发送方代码改变 |
ReserveStock |
命令 | 知道由谁处理 | 发送方代码改变 |
OrderCreated |
事实 | 不知道任何接收方 | 发送方保持不变 |
PaymentApproved |
事实 | 不知道任何接收方 | 发送方保持不变 |
使用过去时动词,自然就会表达事实。 一条命名规则就能保护架构。
命令也有适用位置:当“现在执行这件事”必须准确发送给唯一消费者时,应使用队列(点对点);事实则通过主题(发布—订阅)发送。把二者混在同一频道,区别很快就会消失。
发布失败的位置
最危险的时刻是:数据库已经提交,事件发布却失败。 订单已经创建,却无人知晓。反过来,如果先发送事件,数据库操作随后失败, 系统中就会流传一个并不存在的订单事件。
❌ 위험한 순서
BEGIN; INSERT order; COMMIT;
publish(OrderCreated) ← 여기서 죽으면 이벤트가 영영 안 나간다
✅ 아웃박스 패턴
BEGIN;
INSERT order;
INSERT outbox(topic, payload); ← 같은 트랜잭션 안
COMMIT;
(별도 프로세스가 outbox 를 읽어 발행하고 표시한다)
二者在同一事务中提交,因此从根本上不可能出现“有订单却没有事件”的状态。 代价是发布会略有延迟,并且同一事件可能发送两次(如果发布后、标记前进程崩溃)。 因此,消费者必须始终具备幂等性。
模式就是契约
发布事件的那一刻,它就成为公开 API。由于不知道谁在监听,不能随意修改。 遵守两条规则,通常就能保持兼容。
- 字段只能增加。 删除字段或改名会破坏旧消费者。
- 把版本写入名称。 如果变更确实不兼容,就发布新的
OrderCreated.v2,并在一段时间内同时发布两个版本。
消费者侧也有规则:忽略未知字段。 如果使用严格反序列化(additionalProperties: false),发布方每增加一个字段,所有消费者都会崩溃。
生产现场中的常见情况
改为异步后,调试会变得困难。同步调用只需查看一个堆栈跟踪,事件的发布时刻与消费时刻却彼此分离。因此,事件中必须携带关联 ID。没有它就运行事件架构,无异于闭着眼睛开车。
此外,事件顺序可能不受保证。如果同一订单的 OrderCreated 与 OrderPaid 颠倒到达,消费者就会出错。把分区键设为聚合 ID(例如订单 ID),即可保证同一聚合内部的顺序。通常应放弃全局顺序。
下一次测验要确认什么
本模块只介绍概念。如何真正安全地发布事件,将在下一模块的 Outbox 实验中亲手完成。