事件驱动、CQRS 与一致性边界¶
30 秒版(开场)¶
事件驱动与 CQRS 是可独立采用的模式:CQRS 只要求命令模型与查询模型分离,不必然等于 Event Sourcing;事件驱动也不必然所有数据都最终一致。采用异步投影时,要讲清 outbox 的原子边界、至少一次投递、读模型新鲜度与读己之写。
3 分钟版(精讲深度)¶
- 是什么:写侧处理命令改聚合并发事件;读侧订阅事件更新投影(Elasticsearch/Redis/宽表)。
- 为什么:读写在 10x 规模需求不同;同步链路过长(见 S-ARCH-10 MQ 语义)。
- 怎么做:DB 事务内写业务表 + outbox 表;Relay 发 MQ;消费者幂等更新读模型。
10 分钟版(原理 + 图示)¶
flowchart LR
CMD[CreateOrder 命令] --> W[写模型 / 聚合]
W --> DBW[(OLTP)]
W --> Outbox[(outbox)]
Outbox --> Relay[Relay / Debezium]
Relay --> MQ[Kafka/RocketMQ]
MQ --> Proj1[订单查询投影]
MQ --> Proj2[报表投影]
Proj1 --> DBR[(读库/ES)]
Q[查询 API] --> DBR
请求驱动 vs 事件驱动
| 维度 | 请求驱动 | 事件驱动 |
|---|---|---|
| 耦合 | 同步依赖 | 时间解耦 |
| 一致性 | 同步调用不自动等于强一致 | 异步消费者通常形成最终一致窗口 |
| 适用 | 实时反馈 | 通知、审计、读模型刷新 |
CQRS 何时上
| 上 | 不上 |
|---|---|
| 读写比极端、查询模型复杂 | 简单 CRUD |
| 多视图(C 端列表 vs B 端报表) | 必须无延迟读己之写且无法接受回源/等待投影 |
| 与 Event Sourcing 配合审计 | 团队无 MQ 运维能力 |
Transactional Outbox(Go 伪代码)
func (s *OrderService) Create(ctx context.Context, cmd CreateOrderCmd) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := s.repo.Save(tx, order); err != nil { return err }
return s.outbox.Insert(tx, OutboxEvent{
Topic: "order.created", Payload: order.ToEvent(),
})
})
}
Outbox 保证的是“业务状态与待发布事件意图”在同一个本地事务提交;relay 仍可能重复发送,消费者必须幂等。对外发布前还应区分内部领域事件与稳定的 integration event,避免把聚合内部结构直接泄露成长期契约。
生产场景¶
- 订单创建后:同步返回 order_id;列表页 ES 索引 1~3s 延迟 → 产品需接受或 读己之写 走主库
- 跨域协作:支付成功发
PaymentCompleted,库存/积分各自订阅 - 与 S-ARCH-12 状态机 结合:状态变迁 = 事件
排查与工具¶
- 消费 lag、投影延迟 P99
- 对账:写侧事件数 vs 读侧更新数
- RocketMQ/Kafka 事务消息 vs Outbox 选型(见 middleware 专题)
架构取舍¶
| Event Sourcing | 仅 CQRS |
|---|---|
| 完整历史、可回放 | 实现轻 |
| 存储与迁移复杂 | 丢历史需另做审计 |
深挖问答¶
- 读己之写? → 返回写模型结果;短时间回源写库;或让客户端携带 version,查询端等待投影达到该 version 后再返回/超时降级。
- 事件乱序? → 分区键 order_id;版本号丢弃旧事件。
- Outbox 堆积? → Relay 扩容;监控 outbox 表深度。
- 和分布式事务? → 优先 Saga + 事件,避免 2PC(S-DIST-05)。
反模式与事故¶
- 无幂等 → 重复消费双扣库存
- 巨型事件 → MQ 消息含全量订单 JSON 10MB
- CQRS everywhere → 简单系统运维成本翻倍
代码示例¶
消费端幂等键通常为 consumer_id + event_id;若消费同时更新数据库投影,应把 inbox 去重记录与投影更新放在同一个数据库事务,单独先写 Redis 仍会留下双写窗口。