微服务中的领域事件应该如何建模?
简化版
领域事件描述已经发生、对领域有意义的事实,应由拥有该聚合的服务发布,并以过去式命名。事件要稳定表达业务语义,而不是复制数据库行或伪装成远程命令。
详细版
事件信封包含 eventId、eventType、aggregateId、aggregateVersion、occurredAt 和 Schema 版本,payload 只放消费者理解事实所需的数据。生产端用 Outbox 保证状态与事件同事务,消费端以 eventId 幂等,并按聚合版本处理重复、乱序和缺口。
事件一旦发布就是历史事实,修正应发布新事件;兼容演进优先新增可选字段,语义大改使用新事件类型或版本。不要把所有内部字段公开,否则服务的数据库结构会变成跨团队契约。
完整版教学
一、事件和命令的边界
OrderPaid 是事实,可以有零到多个订阅者;SendCoupon 是要求某个接收者执行的命令。命令失败需要明确责任,事件发布者不应知道所有响应者。把命令叫事件会掩盖耦合。
记忆钩子:事件用过去式说“发生了什么”,不替消费者决定“必须做什么”。
二、从不变量找事件
事件通常产生在聚合成功完成状态迁移后。订单从 CREATED 到 PAID 才产生付款事实;一次数据库字段更新不一定有业务意义,也不应机械发事件。
command -> validate invariant -> commit state + outbox -> publish event
三、事件信封怎么设计
| 字段 | 作用 | 边界 |
|---|---|---|
| eventId | 去重与追踪 | 重试保持不变 |
| aggregateId | 分区与归属 | 不放随机值 |
| aggregateVersion | 顺序校验 | 由权威写入分配 |
| occurredAt | 业务发生时间 | 区别发布时间 |
| schemaVersion | 解析路由 | 不代表聚合版本 |
例如同一订单 v12 先于 v11 到达,消费者不能让旧事件覆盖新状态;可忽略 v11 或按业务要求修复缺口。
四、Payload 放多少
只放 ID 会迫使所有消费者回调生产者,形成同步耦合;复制完整数据库行又会泄露内部结构和敏感字段。应围绕事实提供稳定、最小但足够的数据,并明确哪些字段是快照。
价格、币种这类历史事实通常应随事件保存;可随时变化且由其他域拥有的数据不应冒充权威值。
五、可靠发布与消费
先提交业务再发消息会漏事件,先发再提交会产生不存在的事实。Outbox 将状态与待发布记录写入同一本地事务,后台至少一次投递;消费者仍需唯一约束或幂等状态机。
Exactly-once Broker 语义不能覆盖外部数据库和第三方副作用,因此业务幂等仍不可省。
六、演进与治理
已发布事件不可原地改写。新增字段提供默认语义,枚举保留 UNKNOWN;删除字段至少等待消息保留、归档和 DLQ 重放窗口。金额从元改分属于语义变化,不能只改字段说明。
事件目录要记录 owner、Schema、示例、保留期和消费者。上线前运行兼容检查与消费者契约测试,避免未知订阅者被静默破坏。
事件名称与语义一旦公开就不应复用;已废弃的 OrderPaid 不能日后被赋予“付款申请已提交”的新含义。
目录还要记录数据分类,防止新增消费者把含个人信息的事件复制到不允许的区域。
七、常见误区与追问
- 误区:表发生变化就应发领域事件。 技术变更不一定是领域事实。
- 误区:事件越大越能避免查询。 会暴露内部模型并增加兼容成本。
- 误区:事件发布成功等于业务完成。 各消费者仍可能积压或失败。
- 误区:随机 eventId 能保证顺序。 唯一性和顺序是两种属性。
- 追问:事件能撤销吗? 历史不能抹去,应发布纠正或补偿事实。
- 追问:消费者缺少 v6 却收到 v7 怎么办? 有界等待、查快照或进入修复队列。
- 追问:谁能发布事件? 拥有该事实和不变量的服务。
八、加强记忆
领域事件沿“权威聚合产生事实—稳定信封—Outbox 可靠发布—幂等版本消费—兼容演进”设计。用过去式和业务语义防止命令伪装,用最小充分 payload 控制耦合,并把乱序、重放和 Schema 生命周期当成长期契约治理。