跳转至

事件驱动、CQRS 与一致性边界

30 秒版(开场)

事件驱动与 CQRS 是可独立采用的模式:CQRS 只要求命令模型与查询模型分离,不必然等于 Event Sourcing;事件驱动也不必然所有数据都最终一致。采用异步投影时,要讲清 outbox 的原子边界、至少一次投递、读模型新鲜度与读己之写。

3 分钟版(精讲深度)

  1. 是什么:写侧处理命令改聚合并发事件;读侧订阅事件更新投影(Elasticsearch/Redis/宽表)。
  2. 为什么:读写在 10x 规模需求不同;同步链路过长(见 S-ARCH-10 MQ 语义)。
  3. 怎么做: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
完整历史、可回放 实现轻
存储与迁移复杂 丢历史需另做审计

深挖问答

  1. 读己之写? → 返回写模型结果;短时间回源写库;或让客户端携带 version,查询端等待投影达到该 version 后再返回/超时降级。
  2. 事件乱序? → 分区键 order_id;版本号丢弃旧事件。
  3. Outbox 堆积? → Relay 扩容;监控 outbox 表深度。
  4. 和分布式事务? → 优先 Saga + 事件,避免 2PC(S-DIST-05)。

反模式与事故

  • 无幂等 → 重复消费双扣库存
  • 巨型事件 → MQ 消息含全量订单 JSON 10MB
  • CQRS everywhere → 简单系统运维成本翻倍

代码示例

消费端幂等键通常为 consumer_id + event_id;若消费同时更新数据库投影,应把 inbox 去重记录与投影更新放在同一个数据库事务,单独先写 Redis 仍会留下双写窗口。

延伸阅读