编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 面向对象设计

  • 常见设计原则

  • 巧学设计模式

  • 系统架构设计

    • README
    • 分层架构设计详解
    • 六边形架构设计
    • 命令查询职责分离
      • 1. 案例引入
        • 1.1 一段慢在哪
        • 1.2 顺藤摸到根因
        • 1.3 我们要回答什么
      • 2. 架构概览
        • 2.1 读写分裂总图
        • 2.2 为什么必须分
      • 3. CQS到CQRS
        • 3.1 CQS的原始公约
        • 3.2 CQRS的升级动机
        • 3.3 命令与查询契约
        • 3.4 单库CQRS与双库CQRS
      • 4. 写模型设计
        • 4.1 聚合根的边界
        • 4.2 命令处理器责任
        • 4.3 写库表结构精炼
        • 4.4 写侧事务与一致性
      • 5. 读模型设计
        • 5.1 查询即视图
        • 5.2 反范式宽表
        • 5.3 查询处理器责任
        • 5.4 多读模型并存策略
      • 6. 读写同步机制
        • 6.1 同步双写陷阱
        • 6.2 异步投影主流派
        • 6.3 CDC变更捕获
        • 6.4 最终一致延迟管控
      • 7. 事件溯源融合
        • 7.1 事件即唯一真相
        • 7.2 快照加速回放
        • 7.3 读模型可重建
        • 7.4 CQRS+ES组合代价
      • 8. 演进与陷阱
        • 8.1 何时不要用CQRS
        • 8.2 读写模型版本演化
        • 8.3 跨模型一致性盲区
        • 8.4 五大反模式集锦
      • 9. 落地实战剖析
        • 9.1 Spring技术栈选型
        • 9.2 ES+Redis读链路
        • 9.3 投影器灰度上线
        • 9.4 监控四金指标
      • 10. 综合案例串讲
        • 10.1 案例真相揭晓
        • 10.2 一次下单与一次查询
        • 10.3 设计哲学回扣
        • 10.4 CQRS速查
    • 事件驱动架构设计
    • 微服务拆分策略
    • 领域驱动战略设计
    • 架构评审方法论
    • 架构演进实战指南
  • 编程
  • 系统架构设计
杨充
2022-06-22
目录

命令查询职责分离

# 03.命令查询职责分离

# 目录介绍

  • 1. 案例引入
    • 1.1 一段慢在哪
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 读写分裂总图
    • 2.2 为什么必须分
  • 3. CQS到CQRS
    • 3.1 CQS的原始公约
    • 3.2 CQRS的升级动机
    • 3.3 命令与查询契约
    • 3.4 单库CQRS与双库CQRS
  • 4. 写模型设计
    • 4.1 聚合根的边界
    • 4.2 命令处理器责任
    • 4.3 写库表结构精炼
    • 4.4 写侧事务与一致性
  • 5. 读模型设计
    • 5.1 查询即视图
    • 5.2 反范式宽表
    • 5.3 查询处理器责任
    • 5.4 多读模型并存策略
  • 6. 读写同步机制
    • 6.1 同步双写陷阱
    • 6.2 异步投影主流派
    • 6.3 CDC变更捕获
    • 6.4 最终一致延迟管控
  • 7. 事件溯源融合
    • 7.1 事件即唯一真相
    • 7.2 快照加速回放
    • 7.3 读模型可重建
    • 7.4 CQRS+ES组合代价
  • 8. 演进与陷阱
    • 8.1 何时不要用CQRS
    • 8.2 读写模型版本演化
    • 8.3 跨模型一致性盲区
    • 8.4 五大反模式集锦
  • 9. 落地实战剖析
    • 9.1 Spring技术栈选型
    • 9.2 ES+Redis读链路
    • 9.3 投影器灰度上线
    • 9.4 监控四金指标
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次下单与一次查询
    • 10.3 设计哲学回扣
    • 10.4 CQRS速查

# 1. 案例引入

# 1.1 一段慢在哪

先看一段在生产环境真实跑过的代码——一个看似"再正常不过"的订单详情接口,把一个日均 200 万订单的电商系统在大促当晚 19:30 拖到 P99 = 12 秒:

// OrderDetailService.java —— 订单详情接口
@Service
public class OrderDetailService {

    public OrderDetailVO getDetail(Long orderId, Long userId) {
        // 1. 主单
        Order order = orderRepo.findById(orderId);                          // SELECT * FROM orders
        if (!order.getUserId().equals(userId)) throw new ForbiddenException();

        // 2. 行项
        List<OrderItem> items = itemRepo.findByOrderId(orderId);            // SELECT * FROM items
        // 3. 商品快照
        Map<Long, Sku> skus = skuRepo.findByIds(items.stream()
            .map(OrderItem::getSkuId).toList());                            // SELECT * FROM sku
        // 4. 物流轨迹
        List<Logistics> tracks = logisticsClient.fetch(orderId);            // RPC
        // 5. 售后状态
        AfterSale afterSale = afterSaleRepo.findByOrderId(orderId);         // SELECT
        // 6. 优惠券
        List<Coupon> coupons = couponRepo.findByOrderId(orderId);           // SELECT
        // 7. 发票
        Invoice invoice = invoiceRepo.findByOrderId(orderId);               // SELECT
        // 8. 用户基础信息
        User user = userRepo.findById(userId);                              // SELECT
        // 9. 收货地址
        Address addr = addressRepo.findById(order.getAddressId());          // SELECT

        return assembler.toVO(order, items, skus, tracks, afterSale, coupons, invoice, user, addr);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

现象:

  • 日常 QPS 800:P99 = 280ms,勉强可看
  • 大促 QPS 8000:P99 = 12 秒,下游 RPC 雪崩、数据库 CPU 100%、订单列表整页打不开
  • 紧急扩容订单库从 4 节点扩到 16 节点:没用——查询打的是同一张主表,写主库的事务把读 buffer pool 全冲掉了

直觉怀疑:是不是缺索引?打开慢日志一看:所有 9 个 SQL 都走索引,单条平均 30ms。问题不在单条,是每个详情接口要 9 次 IO,QPS 8000 时数据库被 7.2 万 QPS 的读穿透。

慢查询统计:
SELECT FROM orders   WHERE id=?           平均 35ms (索引命中)
SELECT FROM items    WHERE order_id=?     平均 28ms
SELECT FROM sku      WHERE id IN (...)    平均 42ms
... 等共 9 条 SQL
合计 RT ≈ 280ms · QPS 8000 → 数据库连接池 (300) 全部排队
1
2
3
4
5
6

再翻业务监控,更扎心的现象出现了——同一张 orders 表上:

  • 下单写事务正在做 INSERT orders + INSERT items + UPDATE stock + UPDATE balance,每个事务持锁 80~200ms
  • 详情读查询疯狂打到主库的 orders 表,争抢同一行的行锁、同一页的 buffer
  • 行锁队列堆积——一边写、一边读、互相等待,整库吞吐崩塌

# 1.2 顺藤摸到根因

带着这条线往下挖:

  • 假设 1:是不是缺索引?—— 慢日志显示所有 SQL 都走索引,每条单看都 < 50ms
  • 假设 2:是不是连接池小?—— 调到 500,还是排队——因为下游 SQL 本身就慢,连接被业务持有
  • 假设 3:是不是缓存少?—— 加 Redis 缓存详情,但详情数据由 9 个表组成,任何一表变更都要失效缓存,命中率只有 40%
  • 假设 4:是不是分库分表?—— 已经分了 16 库,读写打在同一片,写事务还是阻塞读
  • 假设 5:那能不能只读副本?—— MySQL 主从延迟在大促时高达 30 秒,用户付完款看不到订单
  • 假设 6:能不能把 9 个表"打平成一张表"?—— 那写入怎么办?事务一致性怎么保?

挖到这里,根因其实已经浮出水面:这个系统犯了一个架构级的错误——用同一份数据模型同时服务"写"和"读"两种完全不同的工作负载。

写需要规范化(多表、第三范式、行锁细粒度)来保证一致性; 读需要反范式(宽表、冗余、零 JOIN)来保证性能。

这两者天然对立。任何试图"一份数据满足所有人"的设计,在高并发下都必然撕裂。

这一段事故里至少藏着 7 个原理点:

① 为什么"同一份数据模型"在高并发下必然撕裂?           → 第 2 章
② CQS 和 CQRS 到底什么关系?为什么 1986 年就有的思想直到 │
   2010 年才在企业架构落地?                              → 第 3 章
③ 写模型应该长什么样?聚合根是不是过度设计?             → 第 4 章
④ 读模型应该长什么样?为什么宽表反而是好设计?           → 第 5 章
⑤ 读写两库怎么同步?同步双写为什么是地狱?              → 第 6 章
⑥ CQRS 必须配 Event Sourcing 吗?不配会失去什么?        → 第 7 章
⑦ 什么时候**不要**上 CQRS?误用代价有多大?             → 第 8 章
1
2
3
4
5
6
7
8

# 1.3 我们要回答什么

这个事故就是本篇的主线案例。我们带着上面 7 个问号往下走,每讲完一段原理就解开一两个;最后在第 10 章把案例彻底剖开,并给出三种修复方案与各自的代价。

本篇路线:

读写分裂总图 (第 2 章)
   ↓
CQS → CQRS 思想脉络 (第 3 章) ─→ 解开"为什么读写要分离"
   ↓
写模型 → 读模型 (第 4-5 章) ─→ 解开"两边各自怎么设计"
   ↓
读写同步 (第 6 章) ─→ 解开"两边怎么对齐"
   ↓
事件溯源融合 (第 7 章) ─→ 解开"CQRS 的最强组合"
   ↓
演进与陷阱 (第 8 章) ─→ 解开"什么时候别用"
   ↓
落地实战 (第 9 章) ─→ 工具与监控
   ↓
综合案例 (第 10 章) ─→ 案例彻底剖开
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

📌 本篇定位:CQRS 是从「分层架构」「六边形架构」走向「事件驱动架构」之间的关键过渡篇。读完本篇后,再看 04.事件驱动架构设计,你会发现 CQRS 就是事件驱动在"读写视角"下的必然产物。

# 2. 架构概览

# 2.1 读写分裂总图

我们在 64 位电商系统里看一个典型的 CQRS 架构(与第 1 章 9 表查询对照):

                     ┌─────────────────────────────┐
                     │        前端 / API 网关        │
                     └────────────┬────────────────┘
                                  │
                ┌─────────────────┴─────────────────┐
                │                                   │
        ┌───────▼────────┐                  ┌───────▼────────┐
        │   命令侧 Command │                  │   查询侧 Query  │
        │                │                  │                │
        │  PlaceOrderCmd │                  │  GetDetailQry  │
        │  PayOrderCmd   │                  │  ListOrderQry  │
        │  CancelOrderCmd│                  │  SearchOrderQry│
        └───────┬────────┘                  └───────┬────────┘
                │                                   │
        ┌───────▼────────┐                  ┌───────▼────────┐
        │ 命令处理器       │                  │ 查询处理器       │
        │ (聚合根 + 业务规则)│                  │ (零业务规则、纯组装)│
        └───────┬────────┘                  └───────┬────────┘
                │                                   │
        ┌───────▼────────┐                  ┌───────▼────────┐
        │  写模型 Write    │                  │  读模型 Read    │
        │ (规范化、多表)    │                  │ (反范式、宽表/缓存/ES)│
        │  orders         │   ① 写库         │  order_detail_view  │
        │  items          │ ──────────────►  │  (单表/Redis/ES)    │
        │  payments       │   ② 投影同步      │                    │
        │  ...            │                  │                    │
        └────────────────┘                  └────────────────┘
                │                                   ▲
                │ ③ 发事件                          │
                │  OrderPlaced / OrderPaid          │
                │  OrderCancelled ...               │
                └───────────┬───────────────────────┘
                            │
                  ┌─────────▼──────────┐
                  │  事件总线 / MQ      │
                  │  (Kafka / RocketMQ)│
                  └─────────┬──────────┘
                            │
                  ┌─────────▼──────────┐
                  │  投影器 Projector   │
                  │  把事件转成读模型行   │
                  └────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

读写两条链路的核心属性速查:

维度 命令侧 (Write) 查询侧 (Read)
目标 一致性、业务规则、原子性 性能、组装、零延迟
数据模型 规范化(3NF)、聚合根 反范式(宽表、视图、ES)
存储 MySQL/Postgres + 行锁 Redis / ES / 宽表 / 物化视图
一致性 强一致(本地事务) 最终一致(毫秒~秒级)
接口风格 命令对象(动词式) 查询对象(名词式)
失败处理 直接返回错误 走兜底(回源、降级)
扩展方向 写库分片 + 多写主 读副本 + 多读模型
缓存策略 几乎不缓存 全员缓存

# 2.2 为什么必须分

为什么把读和写"切开",而不是统一一份数据模型?

疑惑:直接给数据库加更多副本、堆更多缓存不行吗?

论证:

  1. 读写工作负载在物理上对立——写需要 行锁、事务、规范化 保证一致性;读需要 无锁、宽表、零 JOIN 保证速度。两者优化方向相反,硬塞到一个模型必然顾此失彼。第 1 章 9 表 JOIN 慢就是明证。
  2. 读写比例严重失衡——电商系统典型的读写比是 100:1~1000:1。如果两者用同一份资源,99% 的资源都被读吞掉,写入的事务延迟反而被读拖垮。让读侧独立扩容,是物理上的必然选择。
  3. 读写的失败容忍度不同——写失败用户立刻看到错误(必须强一致),读失败可以走缓存兜底或降级。用同一个模型就被迫用最严格那一档对待两边,浪费可用性。
  4. 读写的演化速度不同——业务规则(写)一年改 3 次,展示形态(读)一年改 30 次。读模型应该廉价到可以随时重建,写模型应该稳定到几年不动。
  5. 反向验证:如果不分会怎样?参考第 1 章——单库 9 表 JOIN,QPS 8000 时 P99 = 12 秒,加副本、加缓存、加索引都救不回来。根因是模型设计上就没区分读和写。

结论:分读写不是为了"好看",而是把一致性需求、扩展方向、失败语义、演化速度这四个独立维度同时编码进架构——一条链路一种使命,强一致写、最终一致读,两边各自演化。这是 CQRS 的根基哲学。

下面我们从最底层的"CQS 思想"开始,看它如何一步步演变成 CQRS。

# 3. CQS到CQRS

# 3.1 CQS的原始公约

疑惑:CQRS 不是什么新东西,它的祖宗 CQS 是 Bertrand Meyer 1986 年在《Object-Oriented Software Construction》里就提出的——为什么前 20 年没火?

论证:

  1. CQS(Command Query Separation)的核心只是一条编码纪律:

每个方法要么是命令(Command,改变状态、无返回值),要么是查询(Query,返回数据、不改状态),二者不可兼任。

// ❌ 违反 CQS:既改状态又返回值(看似方便,实则危险)
public Order popNextOrder() {
    Order o = queue.poll();      // 改状态
    return o;                    // 同时返回
}

// 调用方就会犯:
log.info("next = " + popNextOrder());     // 日志一开,订单就丢
if (popNextOrder() != null) ...           // 条件判断顺手把订单拿走

// ✅ 符合 CQS:拆成两个
public Order peek() { return queue.peek(); }       // 查询
public void pop()    { queue.poll(); }              // 命令
1
2
3
4
5
6
7
8
9
10
11
12
13
  1. CQS 在方法级别有效,但对大型系统的架构层面影响有限——因为同一个类里"命令方法"和"查询方法"还是共用同一份数据。系统层的读写性能问题仍然没解。

  2. 1986 年那个时代——单机 DB、QPS 几十、读写差 5 倍——根本不需要从架构层分离读写。CQS 待在方法级别就够用了。

结论:CQS 是编码纪律,不是架构模式。它解决"方法语义混乱",不解决"读写资源争抢"。

# 3.2 CQRS的升级动机

疑惑:那为什么 2010 年 Greg Young 把 CQS 升级成 CQRS,企业级架构才开始全员追捧?

论证:21 世纪 10 年代发生了三件事:

时代变化 后果
QPS 从几十涨到几万 单库扛不住,读写副本必须分开
多端涌现(App/Web/小程序) 同一个领域要 5 种以上展示形态
微服务 + 事件驱动兴起 写库扔事件,读库异步消费成为常态

CQRS(Command Query Responsibility Segregation)的升级点:把读写分离从"方法级"提升到"模型级",再到"数据库级":

CQS         (1986):同类内拆方法                  一份数据模型
                                                ↓
CQRS-轻量   (2010):同库内拆 Repository           两个 Repository + 一份 DB
                                                ↓
CQRS-双库   (2012):写库 + 读库,物理拆分          两套 Schema + 异步同步
                                                ↓
CQRS+ES     (2014):事件存储 + 投影              事件流是唯一真相
1
2
3
4
5
6
7

关键论证:Greg Young 在 2010 年 NDC 演讲里反复强调一句话——

"CQRS is not architecture. It's a pattern that can be applied within a service."

CQRS 不是整体架构方案,而是一种局部模式。它可以只在某个"读写极不平衡的限界上下文"里启用,其他模块照旧用 CRUD。这是它能落地的根本原因——可以渐进引入。

结论:CQRS 是 CQS 在大型系统下的工程化升级——把"方法不混"升级为"模型不混"再升级为"库不混",每升一级都是为了换取更高的读写各自演化的自由度。

# 3.3 命令与查询契约

CQRS 的代码层落地,核心是把传统的 Service.doXxx() 翻译成两套显式契约:

写侧契约——命令对象

// 命令是动词,过去式不通——它代表"我想做某件事"
public record PlaceOrderCmd(
    Long userId,
    List<OrderItemDTO> items,
    Long addressId,
    Long couponId,
    String idempotentKey       // 幂等键,重发去重
) {}

public interface CommandBus {
    <R> R send(Command<R> cmd);
}

// 处理器
@CommandHandler
public class PlaceOrderHandler implements CommandHandler<PlaceOrderCmd, Long> {
    @Override
    public Long handle(PlaceOrderCmd cmd) {
        Order order = Order.place(cmd.userId(), cmd.items(), ...);
        orderRepo.save(order);
        return order.id();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

读侧契约——查询对象

// 查询是名词,描述"我想看什么"
public record GetOrderDetailQry(Long orderId, Long userId) {}

public interface QueryBus {
    <R> R query(Query<R> qry);
}

// 处理器(零业务规则,纯组装)
@QueryHandler
public class GetOrderDetailHandler implements QueryHandler<GetOrderDetailQry, OrderDetailVO> {
    @Override
    public OrderDetailVO handle(GetOrderDetailQry qry) {
        // 直接读"宽表"或"读模型",零 JOIN
        return orderViewRepo.findById(qry.orderId());
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

关键纪律:

项 命令侧 查询侧
命名 动词 + Cmd(PlaceOrderCmd) 名词 + Qry(GetOrderDetailQry)
返回值 仅 ID 或 void,不返回业务对象 直接返回 VO/DTO
副作用 改写状态、发事件 绝对不改任何状态
失败语义 抛业务异常或返回 Result 兜底 + 降级
依赖 写仓储 + 领域服务 读仓储 + 缓存,不依赖领域服务

# 3.4 单库CQRS与双库CQRS

CQRS 落地形态有两档,强烈建议从单库 CQRS 起步:

┌──────────────────── 单库 CQRS(轻量级)─────────────────────┐
│                                                            │
│   Command ─────► CommandHandler ──┐                        │
│                                    ▼                       │
│                              ┌──────────┐                  │
│                              │  MySQL   │                  │
│                              │ (一份)    │                  │
│                              └────┬─────┘                  │
│                                   ▼                        │
│   Query   ─────► QueryHandler ──► 物化视图 / 宽表          │
│                                                            │
│   优点:没有最终一致性问题、零运维成本                       │
│   缺点:读写仍共享 IO,扩展上限有限                          │
│   适用:QPS < 5000、读写比 < 10:1                            │
└────────────────────────────────────────────────────────────┘

┌──────────────────── 双库 CQRS(重量级)─────────────────────┐
│                                                            │
│   Command ─────► CommandHandler ──┐                        │
│                                    ▼                       │
│                              ┌──────────┐    发事件          │
│                              │ 写库 MySQL│ ──────────────┐  │
│                              └──────────┘               │  │
│                                                          ▼  │
│                                                  ┌──────────┐
│                                                  │ MQ Kafka │
│                                                  └────┬─────┘
│                                                       │     │
│                              ┌────────────────────────┘     │
│                              ▼                              │
│                        ┌──────────┐                         │
│                        │ Projector│ ── 写入 ──┐             │
│                        └──────────┘            ▼            │
│                                          ┌──────────┐       │
│   Query   ─────► QueryHandler ─────────► │ 读库      │       │
│                                          │ ES/Redis │       │
│                                          └──────────┘       │
│                                                             │
│   优点:读写完全独立、可任意扩、可多读模型                    │
│   缺点:最终一致性、运维复杂度 10×                            │
│   适用:QPS > 5000、读写比 > 50:1、多读视图                  │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

演进路线(每一步独立可逆):

CRUD 单体  ─► 单库 CQRS  ─► 双库 CQRS  ─► 双库 CQRS + 事件溯源
   (起步)    (1 周改造)    (1 月改造)        (3 月改造)
1
2

生产建议:永远从单库 CQRS 起步。等读侧确实顶不住、且业务接受了"最终一致",再升双库。不要一上来就奔着"完整 CQRS+ES"的终点冲,95% 的项目永远走不到那里。

# 4. 写模型设计

# 4.1 聚合根的边界

写模型的核心组织单位是聚合(Aggregate)——一组紧密相关的对象,对外暴露唯一的"聚合根(Aggregate Root)"作为入口:

public class Order {                          // 聚合根
    private Long id;
    private OrderStatus status;
    private Long userId;
    private List<OrderItem> items;            // 聚合内实体(不允许外部直接持有)
    private Money totalAmount;
    private Address shippingAddress;          // 值对象(不可变)

    // 唯一对外入口
    public static Order place(Long userId, List<ItemDTO> dtos, ...) {
        // 业务规则校验(最少 1 个商品、库存够、金额合法等)
        if (dtos.isEmpty()) throw new BizException("订单必须包含商品");
        // 构造聚合
        Order o = new Order(/*...*/);
        // 发领域事件
        o.addEvent(new OrderPlacedEvent(o.id, ...));
        return o;
    }

    public void pay(PaymentMethod method) {
        if (status != OrderStatus.INIT) throw new BizException("订单状态不允许支付");
        this.status = OrderStatus.PAID;
        addEvent(new OrderPaidEvent(id, method));
    }

    public void cancel(String reason) {
        if (status == OrderStatus.SHIPPED) throw new BizException("已发货不能取消");
        this.status = OrderStatus.CANCELLED;
        addEvent(new OrderCancelledEvent(id, reason));
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

聚合根三大铁律:

  1. 外部只能通过聚合根操作内部对象——不允许 order.getItems().add(new Item()) 这种直接操作
  2. 一个事务只改一个聚合——跨聚合用领域事件 + 最终一致
  3. 聚合是一致性边界——聚合内强一致(数据库事务),聚合间最终一致(事件)

聚合的粒度怎么定?看"业务规则的传染范围":

✅ Order + OrderItem + ShippingAddress  → 一个聚合
   理由:下单时校验"金额=∑item·price",必须强一致
   
❌ Order + Payment                      → 不要塞一个聚合
   理由:支付可能晚 10 秒到 30 分钟,强一致代价大
   做法:Payment 独立聚合,通过 OrderPaidEvent 联动
1
2
3
4
5
6

# 4.2 命令处理器责任

命令处理器是 CQRS 写侧的"工作流编排者",它的责任有且只有四件:

@CommandHandler
public class PlaceOrderHandler {

    @Transactional
    public Long handle(PlaceOrderCmd cmd) {
        // 1. 幂等检查(防重)
        if (idempotentRepo.exists(cmd.idempotentKey())) {
            return idempotentRepo.getResult(cmd.idempotentKey());
        }

        // 2. 加载聚合 + 调用聚合方法(业务规则在聚合里,不在这里!)
        Order order = Order.place(cmd.userId(), cmd.items(), ...);

        // 3. 持久化聚合
        orderRepo.save(order);

        // 4. 发布领域事件
        eventPublisher.publish(order.pullEvents());

        // 5. 写幂等表
        idempotentRepo.save(cmd.idempotentKey(), order.id());

        return order.id();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

Handler 的反模式——把业务规则写在 Handler 里:

// ❌ 反模式:业务规则散落在 Handler,聚合退化成"贫血对象"
public Long handle(PlaceOrderCmd cmd) {
    if (cmd.items().isEmpty()) throw ...;        // 应在 Order.place() 里
    BigDecimal total = ...;                       // 应在 Order 里算
    Order order = new Order();                    // 用 setter 拼装
    order.setUserId(cmd.userId());
    order.setItems(...);
    order.setTotal(total);
    orderRepo.save(order);
}
1
2
3
4
5
6
7
8
9
10

贫血模型的代价:业务规则散落各处,加新需求改十处不漏一处几乎不可能。充血模型 + 聚合根是写模型质量的核心。

# 4.3 写库表结构精炼

写库的设计哲学:第三范式(3NF)、外键齐全、行锁细粒度:

-- 写库的订单相关表(精炼版)

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    status TINYINT NOT NULL,               -- INIT/PAID/SHIPPED/CANCELLED
    total_amount DECIMAL(18,4) NOT NULL,
    address_id BIGINT NOT NULL,            -- 外键
    coupon_id BIGINT,
    version INT NOT NULL DEFAULT 0,        -- 乐观锁
    created_at TIMESTAMP,
    INDEX idx_user_status (user_id, status),
    INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB;

CREATE TABLE order_items (
    id BIGINT PRIMARY KEY,
    order_id BIGINT NOT NULL,              -- 外键 orders.id
    sku_id BIGINT NOT NULL,
    qty INT NOT NULL,
    unit_price DECIMAL(18,4) NOT NULL,
    INDEX idx_order (order_id),
    CONSTRAINT fk_order FOREIGN KEY (order_id) REFERENCES orders(id)
);

CREATE TABLE order_events (              -- 领域事件出箱表 (Outbox)
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_id BIGINT NOT NULL,
    event_type VARCHAR(64),
    payload JSON,
    occurred_at TIMESTAMP,
    sent BOOLEAN DEFAULT FALSE,
    INDEX idx_sent (sent, id)
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

关键设计:

  • 主键 BIGINT(雪花 ID),分库分表稳定
  • 字段尽量瘦——长字符串、大 JSON 不放写表
  • 索引只为写时校验和定位服务,不为查询服务
  • version 字段做乐观锁,避免悲观 SELECT FOR UPDATE 拖累并发
  • order_events 是事件出箱(详见 04.事件驱动架构设计 第 6.2 节)

写表的规模目标:

  • 单表 < 5000 万行(超过就分表)
  • 单事务持锁时间 < 50ms
  • 写 TPS 单库 ~3000 上限

# 4.4 写侧事务与一致性

写侧严格遵守 "一个命令 = 一个本地事务 = 一个聚合" 的纪律:

@Transactional(isolation = REPEATABLE_READ)
public Long handle(PlaceOrderCmd cmd) {
    // 全部操作在同一个 DB 本地事务里
    Order order = Order.place(...);
    orderRepo.save(order);              // INSERT orders + INSERT items
    outboxRepo.save(order.events());    // INSERT order_events
    // COMMIT 时原子提交
    return order.id();
}
1
2
3
4
5
6
7
8
9

跨聚合一定不要塞同一事务:

// ❌ 反模式:试图原子完成"下单 + 扣库存 + 扣余额"
@Transactional
public void handle(PlaceOrderCmd cmd) {
    orderRepo.save(order);
    inventoryService.deduct(...);      // RPC,事务跨服务
    paymentService.charge(...);         // RPC,又跨一次
}
// 结果:分布式事务、XA、TCC、Seata...复杂度爆炸
1
2
3
4
5
6
7
8

正确做法:本地事务只保聚合内一致,跨聚合通过事件 + Outbox 最终一致:

@Transactional
public void handle(PlaceOrderCmd cmd) {
    orderRepo.save(order);
    outboxRepo.save(new OrderPlacedEvent(...));
    // COMMIT
}
// 异步流程:
// Outbox Relay → Kafka → InventoryService 消费 → 各自本地事务扣库存
1
2
3
4
5
6
7
8

写侧一致性等级速查:

一致性 实现 适用
强一致 本地事务(聚合内) 必选
最终一致 Outbox + 事件 + 幂等消费 跨聚合、跨服务
不一致也可以 异步通知 + 业务补偿 营销活动、积分

下面我们看读模型——它将和写模型在结构上几乎完全相反。

# 5. 读模型设计

# 5.1 查询即视图

写模型的核心是"聚合 + 规则",读模型的核心是**"视图(View)"——一份为某一种查询场景定制的快照**:

传统 CRUD:              CQRS 读模型:
┌──────────────┐       ┌─────────────────────────────────────┐
│  orders      │       │  order_detail_view (订单详情视图)     │
│  + items     │       │  - 一行一个订单                       │
│  + sku       │       │  - 把 items/sku/物流/优惠券全冗余进来 │
│  + logistics │  ──►  │  - 用 JSON 字段存"行项列表"           │
│  + ...       │       │  - 查询只需 SELECT 一次               │
│  9 张表 JOIN  │       │                                     │
└──────────────┘       │  order_list_view (订单列表视图)       │
                       │  - 一行一个订单                       │
                       │  - 只存列表页要展示的字段             │
                       │  - 按 user_id 分表                    │
                       └─────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13

关键观念翻转:

  • 写模型问"业务上这个东西是什么"
  • 读模型问"前端这个页面需要什么"

一个写模型可以对应N 个读模型——每种 UI 场景一份。订单列表页一份、订单详情页一份、订单搜索一份、订单统计一份。它们之间允许字段冗余,不允许字段缺失。

# 5.2 反范式宽表

读模型表结构与写表完全相反——第一范式都未必满足、外键全无、所有数据全部冗余成一行:

-- 订单详情读视图(面向"详情页"查询)
CREATE TABLE order_detail_view (
    order_id BIGINT PRIMARY KEY,
    user_id BIGINT,
    user_nickname VARCHAR(64),               -- 冗余自 users
    user_avatar VARCHAR(256),                -- 冗余自 users
    status TINYINT,
    status_text VARCHAR(32),                 -- 冗余文案,前端不用映射
    total_amount DECIMAL(18,4),
    items_json JSON,                          -- 行项+sku 冗余成 JSON
    address_text VARCHAR(512),                -- 收货地址展开为文本
    logistics_summary VARCHAR(256),           -- 物流摘要
    coupon_name VARCHAR(64),                  -- 优惠券名称
    invoice_no VARCHAR(64),
    after_sale_status TINYINT,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    INDEX idx_user_created (user_id, created_at DESC)
) ENGINE=InnoDB;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 订单列表读视图(面向"列表页"查询)
CREATE TABLE order_list_view (
    user_id BIGINT,
    order_id BIGINT,
    status TINYINT,
    total_amount DECIMAL(18,4),
    first_sku_image VARCHAR(256),             -- 列表只显示首图
    first_sku_title VARCHAR(128),
    item_count INT,
    created_at TIMESTAMP,
    PRIMARY KEY (user_id, order_id),
    INDEX idx_user_status (user_id, status, created_at DESC)
) ENGINE=InnoDB;
1
2
3
4
5
6
7
8
9
10
11
12
13

反范式的代价 vs 收益:

维度 代价 收益
存储 字段冗余、表数倍膨胀 没有 JOIN
一致性 用户改昵称要同步到所有 view 查询零延迟
写入 一次写要散发到 N 个 view 读 P99 < 10ms
维护 多套 schema 各自演化 各场景独立优化

生产经验:存储成本通常涨 3-5 倍,但查询性能涨 10-100 倍——这笔账几乎都划算。

# 5.3 查询处理器责任

读侧 Handler 的责任极其单纯——只做组装,零业务规则:

@QueryHandler
public class GetOrderDetailHandler {

    public OrderDetailVO handle(GetOrderDetailQry qry) {
        // 1. 鉴权(读侧的鉴权也尽量前置到网关/AOP)
        if (!authChecker.canRead(qry.userId(), qry.orderId())) {
            throw new ForbiddenException();
        }

        // 2. 查缓存
        OrderDetailVO cached = redisCache.get("od:" + qry.orderId());
        if (cached != null) return cached;

        // 3. 查读视图(单表)
        OrderDetailView view = orderViewRepo.findById(qry.orderId());
        if (view == null) {
            // 4. 兜底:回写库重建(极少触发)
            view = rebuildFromWriteDB(qry.orderId());
        }

        // 5. 转 VO + 写缓存
        OrderDetailVO vo = converter.toVO(view);
        redisCache.set("od:" + qry.orderId(), vo, Duration.ofMinutes(5));
        return vo;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

读 Handler 三大纪律:

  1. 不调用领域服务、不加载聚合——直接读读模型
  2. 不做任何状态变更——包括"顺便埋点"、"顺便统计",这些应通过事件总线异步
  3. 失败要降级——读视图挂了走缓存、缓存挂了回源写库(兜底重建)

# 5.4 多读模型并存策略

一个写模型可以投影出 N 个读模型——每种 UI 场景一份:

                 写模型(订单聚合)
                       │
        ┌──────────────┼──────────────┬──────────────┐
        ▼              ▼              ▼              ▼
   订单详情视图     订单列表视图      订单搜索 ES      订单统计 OLAP
   (MySQL 宽表)    (MySQL 宽表)     (Elasticsearch)  (ClickHouse)
        │              │              │              │
        ▼              ▼              ▼              ▼
   详情页 API       列表页 API      全文搜索 API     B 端报表 API
1
2
3
4
5
6
7
8
9

多读模型的设计要点:

读模型 存储 触发投影 一致性目标
详情视图 MySQL 宽表 OrderPlaced/Paid/... < 1s
列表视图 MySQL 宽表(按 user 分表) 同上 < 1s
搜索视图 Elasticsearch 同上 < 5s
统计视图 ClickHouse OrderPlaced(批量) < 1h
客服视图 MongoDB(完整轨迹) 全事件订阅 < 10s

关键约束:

  • 每个读模型独立部署、独立扩容、独立投影器
  • 删除读模型零代价——直接停投影器、删表,不影响写侧
  • 新增读模型只需新增 Projector——订阅事件、重建即可

渐进引入的红利:今天 UI 改版要新加"按收件人姓名搜索",传统系统要改主库、改索引、改 SQL;CQRS 体系下只需写一个新 Projector、起一个 ES 索引——写侧零改动。

# 6. 读写同步机制

# 6.1 同步双写陷阱

疑惑:写库一更新就立刻同步写读库,不就解决最终一致问题了吗?

论证:试试看:

// ❌ 同步双写——看似简单,实则地狱
@Transactional
public void handle(PlaceOrderCmd cmd) {
    Order order = Order.place(...);
    orderRepo.save(order);                          // 写库
    orderViewRepo.save(toView(order));              // 读库 #1 详情
    orderListViewRepo.save(toListView(order));      // 读库 #2 列表
    esClient.index(toEsDoc(order));                 // 读库 #3 ES
}
1
2
3
4
5
6
7
8
9

问题暴露:

  1. 跨库事务——写库是 MySQL,读库可能是 MySQL/ES/Redis,没有统一事务
  2. 任何一个挂,整个下单失败——可用性从 99.99% 降到 99.95%(× N 个读库)
  3. 延迟翻倍——RT 从 50ms 涨到 200ms+
  4. 耦合恶劣——加一个新读模型,写代码就要改

反验证:试图用 XA 解决?XA 在 ES/Redis 这种 NoSQL 上根本不支持;试图用 TCC?每个读库都要实现 try/confirm/cancel;试图用补偿?补偿失败怎么办?——任何"同步保证多端一致"的方案,复杂度都会撑爆你的团队。

结论:同步双写是 CQRS 落地的头号陷阱。正确做法只有一个——异步投影。

# 6.2 异步投影主流派

主流做法是把"写库"和"读库"解耦,通过事件总线异步同步:

┌─────────────────────────────────────────────────────────────┐
│                       命令处理器                              │
│  @Transactional {                                           │
│     orderRepo.save(order);          ── 写库主表              │
│     outboxRepo.save(event);         ── 出箱同事务            │
│  }                                                          │
└──────────────────────┬──────────────────────────────────────┘
                       │ COMMIT
                       ▼
            ┌──────────────────────┐
            │   Outbox Relay        │   ── 后台轮询 / CDC
            └──────────┬───────────┘
                       ▼
                  ┌──────────┐
                  │   MQ      │ Kafka
                  └─────┬────┘
                        │
        ┌───────────────┼────────────────┐
        ▼               ▼                ▼
   ┌─────────┐    ┌─────────┐      ┌─────────┐
   │Projector│    │Projector│      │Projector│
   │ Detail  │    │  List   │      │   ES    │
   └────┬────┘    └────┬────┘      └────┬────┘
        ▼              ▼                ▼
   detail_view    list_view         ES 索引
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

投影器 Projector 的核心代码:

@Component
public class OrderDetailProjector {

    @KafkaListener(topics = "order-events", groupId = "detail-projector")
    public void on(DomainEvent event) {
        // 幂等检查
        if (consumedRepo.exists(event.eventId())) return;

        // 按事件类型分发
        switch (event) {
            case OrderPlacedEvent e   -> handlePlaced(e);
            case OrderPaidEvent e     -> handlePaid(e);
            case OrderCancelledEvent e-> handleCancelled(e);
            case OrderShippedEvent e  -> handleShipped(e);
            default -> { /* 忽略 */ }
        }

        consumedRepo.save(event.eventId());
    }

    private void handlePlaced(OrderPlacedEvent e) {
        OrderDetailView v = new OrderDetailView();
        v.setOrderId(e.orderId());
        v.setUserId(e.userId());
        v.setStatus(OrderStatus.INIT.code());
        v.setStatusText("待支付");
        v.setItemsJson(toJson(e.items()));
        v.setUserNickname(userClient.getNickname(e.userId()));   // 跨服务调用合理(projector 已经异步)
        // ... 其他字段
        orderDetailViewRepo.upsert(v);
    }

    private void handlePaid(OrderPaidEvent e) {
        orderDetailViewRepo.updateStatus(e.orderId(),
            OrderStatus.PAID.code(), "已支付");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

异步投影的纪律:

  1. Projector 必须幂等——消息可能重复
  2. Projector 必须按 orderId 保序——靠 Kafka partition key
  3. Projector 失败不能阻塞业务——出错进死信队列,人工修
  4. Projector 可任意重启重消费——读模型应能从事件流完全重建

# 6.3 CDC变更捕获

如果不想引入 Outbox 表,还有一招——CDC(Change Data Capture)直接读 DB binlog:

┌─────────────────┐
│  业务代码        │
│  orderRepo.save │ ── 只写一次 DB
└────────┬────────┘
         ▼
    ┌──────────┐
    │  MySQL   │
    └────┬─────┘
         │ binlog
         ▼
    ┌──────────────┐
    │  Debezium /  │ ── 读 binlog 转 Kafka
    │  Canal       │
    └────┬─────────┘
         ▼
       Kafka
         │
         ▼
    Projector ──► 读模型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

CDC vs Outbox 对比:

维度 Outbox 表 CDC(Debezium)
业务侵入 业务代码要写 outbox 零侵入
事件语义 业务级(OrderPlaced) DB 级(orders.insert)
部署复杂度 简单(一个 Relay 进程) 中(Debezium + Connect 集群)
数据保真 业务定义事件结构 表结构变化要重做映射
适用 业务建模清晰、事件丰富 老系统改造、不想动业务代码

生产建议:

  • 新系统从设计阶段就上 Outbox + 业务事件——事件契约稳定、跨服务复用方便
  • 老系统改造期间用 CDC 过渡——零侵入,但要注意业务事件应该尽快取代 binlog 事件

# 6.4 最终一致延迟管控

疑惑:异步投影必然有延迟——用户付完款立刻打开订单详情,看到的还是"待支付",怎么办?

论证:分场景治理:

策略 1 · 业务可接受的延迟范围

场景 用户感知延迟 实现方式
订单列表新增 < 1 秒 异步投影即可
订单状态变化 < 500ms 投影器单消费者实例 + 高优先级 partition
库存数字变化 < 200ms 缓存预扣 + 异步同步
数据报表 < 1 小时 离线批处理

策略 2 · 写后读自己——用户改完立刻能看到自己的改动

// 用户改完订单地址后,立刻读
public AddressVO updateAddress(...) {
    commandBus.send(new ChangeAddressCmd(...));    // 1. 走写库

    // 2. 立刻读:从写库读(保证用户看到自己的改动)
    return readFromWriteDB(orderId);
}

// 其他人读:走读库(最终一致即可)
public AddressVO getAddress(...) {
    return orderViewRepo.findById(orderId);
}
1
2
3
4
5
6
7
8
9
10
11
12

策略 3 · 强一致兜底缓存——关键路径上 Projector 同步完成

@Transactional
public void handle(PayOrderCmd cmd) {
    Order order = orderRepo.load(cmd.orderId());
    order.pay(cmd.method());
    orderRepo.save(order);

    // 把"状态=已支付"立刻写一份到 Redis(强一致兜底)
    redisCache.set("order:status:" + order.id(), "PAID", Duration.ofMinutes(5));

    outboxRepo.save(new OrderPaidEvent(...));   // 异步投影继续
}

// 查询:先看 Redis 兜底,再看读视图
public OrderDetailVO getDetail(Long orderId) {
    OrderDetailView view = orderViewRepo.findById(orderId);
    String latestStatus = redisCache.get("order:status:" + orderId);
    if (latestStatus != null) {
        view.setStatus(latestStatus);     // 强一致状态覆盖
    }
    return converter.toVO(view);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

策略 4 · 延迟监控告警

四金指标:
  - Outbox 堆积:SELECT COUNT(*) FROM outbox WHERE sent=false
  - Projector lag:Kafka consumer-group describe
  - 投影 P99 延迟:从事件 occurredAt 到 view.updated_at
  - 投影失败率:DLQ 增长速度
1
2
3
4
5

生产经验:99.9% 的业务场景能接受秒级最终一致,只有 0.1% 真正需要强一致——把那 0.1% 用兜底缓存或同步路径单独处理,剩下 99.9% 走异步,整体收益远大于代价。

# 7. 事件溯源融合

# 7.1 事件即唯一真相

疑惑:CQRS 必须配 Event Sourcing 吗?

论证:不必须,但它们是天作之合。Event Sourcing(事件溯源)的核心是:

不存当前状态,只存事件序列。当前状态 = 所有事件按时序回放的结果。

传统 CRUD(存状态):              事件溯源(存事件):
┌────────────────────┐           ┌──────────────────────────┐
│ orders 表           │           │ event_store 表            │
│ id=1                │           │ #1 OrderPlaced  amount=100│
│ amount = 80         │ ◄──────► │ #2 ItemAdded    +20       │
│ status = SHIPPED    │           │ #3 ItemRemoved  -40       │
│                    │           │ #4 OrderPaid              │
│                    │           │ #5 OrderShipped           │
└────────────────────┘           └──────────────────────────┘
当前状态是"事实"                  事件序列是"事实",状态只是派生
1
2
3
4
5
6
7
8
9
10

事件溯源的红利:

  1. 完整审计——每一次状态变化都可追溯到原因
  2. bug 重放——发现 bug 后修代码,重放事件即可修复历史数据
  3. 多读模型同源——任何新的读视图都能从事件回放重建
  4. 时间旅行——任意回到过去某个时刻的系统快照
  5. 天然适合监管/金融场景——出问题能完整复盘

事件溯源的代价:

  1. 学习成本高——团队思维要从"改状态"转到"加事件"
  2. 查询复杂——任何"当前状态"都要重建,没快照就慢
  3. 事件契约不能变——事件已存就不能改格式(详见 04.事件驱动架构设计 第 8.1 节)
  4. 运维复杂——事件存储几乎不能删,存储增长不可逆

# 7.2 快照加速回放

疑惑:如果一个订单有 1000 个事件,每次查询都回放岂不爆炸?

论证:用快照(Snapshot)——每 N 个事件存一次状态快照,加载时只回放快照之后的事件:

事件序列:  #1 #2 #3 #4 #5 ... #100 #101 #102 #103 ...
                              ↑
                          快照 #100 (存全状态)
                                    ↓
                        加载时:load 快照 #100 + 回放 #101~#103

时间复杂度:从 O(N) 降到 O(快照间隔)
1
2
3
4
5
6
7

快照策略:

public Order loadOrder(Long orderId) {
    // 1. 找最新快照
    Snapshot s = snapshotRepo.findLatest(orderId);
    Order o = s != null ? Order.fromSnapshot(s) : new Order(orderId);

    // 2. 拉取快照之后的所有事件
    long fromVersion = s != null ? s.version() : 0;
    List<DomainEvent> events = eventStore.load(orderId, fromVersion);

    // 3. 依次 apply
    for (DomainEvent e : events) {
        o.apply(e);
    }

    // 4. 如果事件超过阈值,触发新快照
    if (events.size() > 100) {
        snapshotRepo.save(o.snapshot());
    }
    return o;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

快照间隔的工程经验:

  • 高频聚合:每 50~100 个事件存一次
  • 低频聚合:每 10~20 个事件即可
  • 永远不要存全状态而不存事件——事件才是真相

# 7.3 读模型可重建

事件溯源最强大的红利之一——任何读模型都可以从事件流完全重建:

# 场景:上线一个新的"按收件人姓名搜索"功能
#
# 传统 CRUD:
#   1. 设计新表
#   2. 写脚本从历史数据导
#   3. 写脚本停机灌一遍
#   4. 切流
#
# 事件溯源 + CQRS:
#   1. 写新 Projector
#   2. 从事件存储 offset=0 重放所有事件
#   3. 重放完追上实时事件
#   4. 切流
1
2
3
4
5
6
7
8
9
10
11
12
13

重建脚本示例:

public void rebuildOrderDetailView() {
    // 清空旧视图
    orderDetailViewRepo.truncate();

    // 从事件存储 offset=0 全量回放
    long offset = 0;
    while (true) {
        List<DomainEvent> batch = eventStore.loadAll(offset, 1000);
        if (batch.isEmpty()) break;
        for (DomainEvent e : batch) {
            orderDetailProjector.on(e);
        }
        offset = batch.getLast().offset();
    }

    // 切换到实时消费
    kafkaConsumer.seek("order-events", offset);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

关键约束:

  • Projector 必须纯函数——给定相同事件序列,必须得到相同结果
  • 任何外部依赖(用户昵称、商品名)要么事件中冗余,要么容忍最终一致
  • 重建过程对线上无影响——新表 + 灰度切流

# 7.4 CQRS+ES组合代价

CQRS + Event Sourcing 是最强组合,也是最复杂组合。完整代价:

维度 代价
团队学习 6 个月以上才能驾驭
基础设施 Event Store(EventStoreDB / Axon)+ MQ + 多读库 + 快照库
调试成本 事件链路追踪、事件回放、版本演化全要工具支撑
数据存储 事件不能删(监管要求),存储成本累积
业务变更 加字段、改契约都要走"新事件类型"路径

强烈建议的演进路径:

阶段 1:单库 CQRS    → 1 周改造,95% 项目止步于此
   ↓
阶段 2:双库 CQRS    → 1 月改造,2-3 个读模型
   ↓
阶段 3:CQRS + Outbox → 解决双写一致性
   ↓
阶段 4:CQRS + ES    → 仅当业务需要全审计、强追溯时上
1
2
3
4
5
6
7

不要一步到位。每一阶段都让团队消化 6 个月以上,再考虑下一步。强行上 ES 的项目,失败率超过 70%。

# 8. 演进与陷阱

# 8.1 何时不要用CQRS

CQRS 不是银弹,下面几种场景强烈不推荐:

场景 原因 替代
CRUD 简单后台 读写比 < 5:1,分离收益 < 改造成本 普通分层 + Repository
团队 < 5 人 维护两套模型成本太高 单模型 + 物化视图
QPS < 1000 单库副本足够 主从复制 + 读写分离
业务规则极简 没有聚合根的复杂度 简单 Service + DTO
强一致绝对要求 最终一致不能接受 单库事务 / TCC
写量极少(管理后台) 几乎只有查询 View + 缓存

判断纪律:能用单库 + 主从分离 + 缓存解决的,永远不要上 CQRS。CQRS 的运维和维护成本是普通 CRUD 的 3-5 倍。

# 8.2 读写模型版本演化

CQRS 落地几年后必然遇到——写模型字段加了,读模型怎么演化?

规则 1 · 加字段:先升 Projector,再升写模型

顺序:
1. Projector 增加新字段,默认 null
2. 读模型表 ALTER ADD COLUMN
3. Projector 升级——遇到老事件填默认值,遇到新事件填新值
4. 灰度上线写模型
5. 历史数据回填——重新 replay 老事件,由 projector 填新字段
1
2
3
4
5
6

规则 2 · 改字段类型:双写过渡 + 旧字段下线

不能直接改!必须:
1. 加新字段 amount_v2 DECIMAL(20,6)
2. Projector 同时写 amount 和 amount_v2
3. 查询逐步切到 amount_v2
4. 验证 N 天后,删除 amount
1
2
3
4
5

规则 3 · 删字段:先停查询,再删存储

反向:
1. 所有读侧代码停用该字段
2. Projector 停止写入该字段
3. N 周后 ALTER DROP COLUMN
1
2
3
4

# 8.3 跨模型一致性盲区

疑惑:写模型改了,读模型最终一致;但两个不同的写模型之间呢?

论证:CQRS 解决了"读 vs 写",没解决"写 vs 写"。跨聚合一致性仍然是难题:

场景:用户下单后扣余额
┌──────────────┐                ┌──────────────┐
│ Order 聚合    │   事件         │ Account 聚合 │
│ 下单成功      │ ──────────────►│ 扣余额        │
└──────────────┘                └──────────────┘
        ✓                              ✗ 失败
                                      (余额不够)
        ↓
   订单已成功,但用户余额没扣 → 状态撕裂
1
2
3
4
5
6
7
8
9

应对:

  1. Saga 模式——长事务用补偿(OrderCancelled 抵消 OrderPlaced)
  2. 业务约定可见性——下单成功后状态显示"待确认",扣款成功才转"已支付"
  3. 预占机制——下单时立刻预扣余额,超时未支付自动释放

详见 04.事件驱动架构设计 第 6.3 节 Saga 编排。

# 8.4 五大反模式集锦

实战中最常踩的坑:

反模式 1 · 读写共用 DTO

// ❌ 同一个 OrderDTO 既给写命令用,又给查询返回
public class OrderDTO {
    private Long id;
    private List<ItemDTO> items;
    private Address address;       // 写要 addressId,读要全地址
    private String userNickname;   // 写不需要,读必要
}
1
2
3
4
5
6
7

问题:DTO 变成"既不是命令也不是 VO"的四不像,加字段加注释,越改越乱。

正确:彻底分离 PlaceOrderCmd(写)和 OrderDetailVO(读)。

反模式 2 · 查询里塞业务逻辑

// ❌ Query Handler 里检查规则、改状态
public OrderDetailVO handle(GetOrderDetailQry qry) {
    OrderDetailView v = repo.findById(qry.orderId());
    if (v.getStatus() == INIT && now() - v.getCreatedAt() > 30min) {
        v.setStatus(CANCELLED);    // ✗ 查询里改状态
        repo.save(v);
    }
    return v;
}
1
2
3
4
5
6
7
8
9

正确:写定时任务发 OrderTimeoutCancelCmd,让命令侧改。

反模式 3 · Projector 调写库回查

// ❌ 投影器订阅事件后还要回查写库
public void on(OrderPlacedEvent e) {
    Order full = orderRepo.findById(e.orderId());   // ✗ 回查写库
    OrderDetailView v = toView(full);
    repo.save(v);
}
1
2
3
4
5
6

问题:事件传输有延迟,回查可能拿到比事件更新的状态;而且把"事件已自包含"的优势丢了。 正确:把投影需要的所有字段都塞进事件 payload。

反模式 4 · 命令侧返回大量数据

// ❌ 命令返回完整对象
public OrderDetailVO handle(PlaceOrderCmd cmd) {
    Order o = Order.place(...);
    orderRepo.save(o);
    return assembler.toVO(o, ...);    // ✗ 返回详情
}
1
2
3
4
5
6

问题:用户下单成功立刻看详情,详情还没投影过来——返回的数据可能比读模型还新但格式不一致;前端要写两套渲染逻辑。 正确:命令只返回 orderId,前端拿 ID 再走查询接口。

反模式 5 · 单库 CQRS 强行用最终一致

单库 CQRS 下,写完立刻能从同一个 DB 查到——还故意走 MQ 投影
→ 引入毫无必要的最终一致问题
1
2

正确:单库 CQRS 直接同步更新物化视图(数据库事务保证强一致),等业务规模上来再升双库异步。

# 9. 落地实战剖析

# 9.1 Spring技术栈选型

Java/Spring 生态下的 CQRS 落地组件:

层 自研 Axon Framework Spring + 手搓
Command Bus 30 行 内置 ApplicationEventPublisher
Query Bus 30 行 内置 直接 Handler 注入
Aggregate 持久化 JPA / MyBatis @Aggregate 注解 JPA + EventListener
Event Store 自建表 内置 自建表 + Outbox
Projector @KafkaListener @EventHandler @KafkaListener
Saga 自研状态机 @Saga Camunda / 自研

生产建议:

  • 轻量项目:Spring 原生 + 手搓 30 行 CommandBus,不引入额外框架
  • 中型项目:Spring + Kafka + 自建 Outbox + Projector
  • 大型 + ES 场景:才考虑 Axon Framework(学习曲线陡,但功能完整)

手搓 CommandBus 30 行示例:

public interface Command<R> {}
public interface CommandHandler<C extends Command<R>, R> {
    R handle(C cmd);
}

@Component
public class SimpleCommandBus {
    private final Map<Class<?>, CommandHandler<?, ?>> handlers = new HashMap<>();

    public SimpleCommandBus(List<CommandHandler<?, ?>> all) {
        for (var h : all) {
            Class<?> cmdType = resolveCmdType(h);
            handlers.put(cmdType, h);
        }
    }

    @SuppressWarnings("unchecked")
    public <C extends Command<R>, R> R send(C cmd) {
        CommandHandler<C, R> h = (CommandHandler<C, R>) handlers.get(cmd.getClass());
        if (h == null) throw new IllegalStateException("no handler for " + cmd.getClass());
        return h.handle(cmd);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 9.2 ES+Redis读链路

电商场景的典型读链路:

                ┌────────────────┐
请求            │  API Gateway   │
─────────────► │  鉴权 + 限流    │
                └────────┬───────┘
                         ▼
                ┌────────────────┐
                │ QueryHandler   │
                └────────┬───────┘
                         │
              ┌──────────┴───────────┐
              ▼                      ▼
         ┌─────────┐            ┌─────────┐
         │  Redis  │── miss ──► │ MySQL   │
         │ (热点)  │            │ 读视图  │
         └─────────┘            └────┬────┘
                                     │ miss
                                     ▼
                                ┌─────────┐
                                │ MySQL   │
                                │ 写库     │── 兜底回源
                                └─────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

典型缓存策略:

层 TTL 命中率目标 失效策略
Redis(详情) 5 min > 90% 事件触发主动失效
Redis(列表) 30 sec > 70% TTL 过期
本地缓存(Caffeine) 60 sec > 50% 仅热门订单

搜索场景额外加 Elasticsearch:

// Projector 同时写 MySQL 详情视图 + ES 搜索索引
public void on(OrderPlacedEvent e) {
    orderDetailViewRepo.upsert(toView(e));         // MySQL
    esClient.index(toEsDoc(e));                    // Elasticsearch
}

// 查询 Handler 按场景路由
public List<OrderListVO> handle(SearchOrderQry qry) {
    if (qry.isFullTextSearch()) {
        return esClient.search(qry);               // 全文搜索走 ES
    }
    return orderListViewRepo.findByCondition(qry); // 普通条件走 MySQL
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 9.3 投影器灰度上线

新 Projector 上线是 CQRS 最危险的环节。标准灰度流程:

阶段 1:影子运行
  - 新 Projector 订阅事件,写到"灰度表"
  - 查询仍走老表
  - 对比新老表数据差异 24 小时

阶段 2:双读对比
  - 查询时**同时**读老表和灰度表
  - 不一致打日志,但返回老表数据
  - 修 bug,跑 1 周

阶段 3:灰度切流
  - 1% / 10% / 50% / 100% 流量切到新表
  - 任何告警立刻回滚

阶段 4:老表下线
  - 双写 1 周
  - 老表停写、归档、删除
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

全量重建命令:

# 重置 Projector consumer group offset,从头消费
kafka-consumer-groups.sh --bootstrap-server $BROKER \
    --group order-detail-projector \
    --reset-offsets --to-earliest \
    --topic order-events --execute

# 监控重建进度
watch -n 5 "kafka-consumer-groups.sh --bootstrap-server $BROKER \
    --describe --group order-detail-projector"
1
2
3
4
5
6
7
8
9

# 9.4 监控四金指标

CQRS 系统必须监控的四个数字:

指标 含义 告警阈值
写读延迟(Lag) 事件 occurredAt → view.updated_at P99 > 业务 SLA
Projector 失败率 DLQ 增长 / 总消费 > 0.1%
读视图命中率 查询命中读视图 / 总查询 < 99%(兜底回源率)
缓存命中率 Redis 命中 / 总读 < 85%

典型 SQL 与告警:

-- 1. Outbox 堆积
SELECT COUNT(*) FROM outbox WHERE sent=false;
-- 持续 > 1000 → 告警

-- 2. 投影延迟(事件发生到投影完成)
SELECT MAX(NOW() - updated_at) FROM order_detail_view
WHERE updated_at > NOW() - INTERVAL '5 minutes';
-- > 5s → 告警

-- 3. 读模型与写库一致性抽查(每天跑一次)
SELECT w.id, w.status AS write_status, r.status AS read_status
FROM orders w LEFT JOIN order_detail_view r ON w.id = r.order_id
WHERE w.status <> r.status
LIMIT 100;
-- 任何不一致 → 立刻报警
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章的订单详情接口,七个疑问现在能逐条作答:

疑问 答案
① 为什么"同一份数据模型"在高并发下必然撕裂? 第 2.2:读写工作负载在物理上对立(行锁 vs 无锁、规范化 vs 反范式),读写比 100:1 时共用资源必崩
② CQS 和 CQRS 什么关系?为什么 2010 年才落地? 第 3.1/3.2:CQS 是方法级编码纪律,CQRS 是架构级模型分离;QPS 上涨 + 多端涌现 + 微服务三因素催熟
③ 写模型应该长什么样?聚合根是不是过度设计? 第 4:聚合根 + 充血模型;不是过度,是"业务规则有归属"的最小代价
④ 读模型应该长什么样?为什么宽表反而好? 第 5:一个查询场景一个视图,反范式宽表,零 JOIN,存储换性能
⑤ 读写两库怎么同步?同步双写为什么是地狱? 第 6.1/6.2:同步双写跨库无事务、可用性叠降;用 Outbox + 异步投影
⑥ CQRS 必须配 Event Sourcing 吗? 第 7:不必须,但天作之合;强烈建议先 CQRS 再 ES,不要一步到位
⑦ 什么时候不要上 CQRS? 第 8.1:QPS < 1000、读写比 < 5:1、团队 < 5 人、CRUD 简单后台

修复方案(按代价从小到大):

方案 A · 单库 CQRS + 物化视图(推荐起步)

-- 创建物化视图(MySQL 用宽表手动维护)
CREATE TABLE order_detail_view (
    order_id BIGINT PRIMARY KEY,
    user_id BIGINT,
    status TINYINT,
    items_json JSON,
    -- 字段全冗余进来
    ...
);

-- 触发器或同库事务里同步更新
DELIMITER //
CREATE TRIGGER after_order_update
AFTER UPDATE ON orders FOR EACH ROW
BEGIN
    UPDATE order_detail_view
    SET status=NEW.status, total_amount=NEW.total_amount, updated_at=NOW()
    WHERE order_id=NEW.id;
END//
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 查询 Handler 直接读宽表
public OrderDetailVO handle(GetOrderDetailQry qry) {
    return orderDetailViewRepo.findById(qry.orderId());   // 单表 SELECT
}
1
2
3
4

代价:1 周改造、零运维新组件; 收益:P99 从 12s 降到 80ms,写仍强一致,无最终一致问题。

方案 B · 双库 CQRS + 异步投影(QPS > 5000 升级)

写库 MySQL → Outbox → Kafka → Projector → 读库 (MySQL/ES/Redis)
1

代价:1 月改造,引入 Kafka + Outbox + Projector,业务方接受最终一致; 收益:读写完全独立扩容,QPS 10000+ 稳定,多读模型并存。

方案 C · CQRS + Event Sourcing(仅金融/审计场景)

所有写操作 → 事件存储(唯一真相) → 多读模型从事件回放重建
1

代价:3 月以上改造,团队学习曲线陡,存储成本累积; 收益:全审计、bug 重放、任意时间旅行、多读模型零成本新增。

生产建议:方案 A 永远是首选——95% 的电商/SaaS 场景靠物化视图就够了;超过 5000 QPS 或要多读模型时升方案 B;只有严格审计/金融才考虑方案 C。

# 10.2 一次下单与一次查询

把"用户下单"和"用户查订单"这两个动作的全过程串成一棵知识树:

用户点击「下单」
        │
        ├─ 命令链路 (Write Path)
        │   ├─ HTTP → OrderController
        │   ├─ 校验参数 → PlaceOrderCmd                  ─── 第 3.3 节
        │   ├─ CommandBus.send(cmd)
        │   ├─ PlaceOrderHandler.handle(cmd)
        │   │   ├─ 幂等检查                              ─── 第 4.2 节
        │   │   ├─ Order.place() (聚合内业务规则)         ─── 第 4.1 节
        │   │   ├─ INSERT orders + INSERT items
        │   │   ├─ INSERT outbox (OrderPlacedEvent)      ─── 第 6.2 节
        │   │   └─ COMMIT (本地事务原子)                  ─── 第 4.4 节
        │   └─ 返回 orderId(仅 ID,不返回详情)           ─── 第 8.4 反模式 4
        │
        ├─ 同步阶段
        │   └─ Outbox Relay 读 binlog → Kafka            ─── 第 6.2/6.3 节
        │
        ├─ 投影链路 (Sync Path)
        │   ├─ Detail Projector 消费 OrderPlaced         ─── 第 6.2 节
        │   │   ├─ 幂等检查
        │   │   ├─ 拉用户昵称(最终一致允许)
        │   │   └─ UPSERT order_detail_view
        │   ├─ List Projector 同步                       ─── 第 5.4 节
        │   ├─ ES Projector 同步                         ─── 第 9.2 节
        │   └─ 失败 → 重试 → DLQ                          ─── 第 7.3 节
        │
用户打开「订单详情」
        │
        ├─ 查询链路 (Read Path)
        │   ├─ HTTP → OrderQueryController
        │   ├─ 鉴权 → GetOrderDetailQry                  ─── 第 5.3 节
        │   ├─ QueryBus.query(qry)
        │   ├─ GetOrderDetailHandler.handle(qry)
        │   │   ├─ 查 Redis 缓存 (命中 90%+)              ─── 第 9.2 节
        │   │   ├─ 查 order_detail_view (单表 SELECT)
        │   │   ├─ 兜底:回源写库重建(< 0.1%)
        │   │   └─ 写回缓存
        │   └─ 返回 OrderDetailVO
        │
        └─ 异常路径
            ├─ 投影延迟 > SLA → 告警                      ─── 第 9.4 节
            ├─ Projector 失败 → DLQ + 人工修复            ─── 第 6.2 节
            └─ 读视图与写库不一致 → 灰度对比脚本            ─── 第 9.4 节
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

理解一次下单与一次查询的完整路径,就是理解 CQRS 体系的"事件从哪儿来、状态如何派生、最终如何呈现"。这是分布式系统设计的总抓手。

# 10.3 设计哲学回扣

整理本篇的四条跨架构适用的设计哲学:

哲学 1:读写本不同——把"看"和"做"彻底分开

传统 CRUD 用一份模型同时服务读和写,本质是用最严苛的写约束去拖累最频繁的读。CQRS 翻转了这个假设:读和写是两种完全不同的工作负载,应该用两种完全不同的模型去服务。这一翻转带来的红利远超表面——读侧可任意反范式、写侧可保持纯净,两边的演化速度、扩容方向、失败语义都解耦。承认对立,才能各自优化到极致。

哲学 2:一种查询一份视图——前端的需求才是读模型的设计依据

写模型由业务规则驱动("订单是什么"),读模型应由 UI 需求驱动("前端要展示什么")。一个写模型可以对应 5 个、10 个读视图——每种 UI 场景一份。这看似浪费,实则是把"通用模型"的债务前置到设计阶段——与其后期一个 SQL 跑遍所有场景跑得人神共愤,不如一开始就为每种查询定制最贴合的形态。

哲学 3:异步换解耦——用时间换扩展性

读写同步的诱惑很大("反正现在就一致了"),但它锁死了未来。CQRS 用异步投影 + 最终一致换取读写两侧完全独立的扩展自由——写库可以分片,读库可以多副本,多读模型可以独立部署。最终一致性的"延迟"是工程上的小代价,换来的是架构上的永久解耦红利。没有最终一致的承诺,就没有真正的分布式可用性。

哲学 4:聚合是边界——业务规则有家,可见性有界

聚合根不是 DDD 强加的复杂性,是业务规则的物理归属。所有"订单的业务规则"都应该被装进 Order 聚合——别人想改订单状态,必须通过 Order 聚合的方法走,而不是直接 SQL UPDATE。这一约束让 bug 范围、变更影响、并发控制都被聚合的边界圈住。聚合是写模型的根,边界是质量的本。

# 10.4 CQRS速查

一张图保存以备查:

层 命令侧 查询侧 同步机制
入口 CommandBus QueryBus —
契约 PlaceOrderCmd GetOrderDetailQry —
处理器 CommandHandler (业务规则) QueryHandler (纯组装) —
模型 聚合根 + 充血对象 视图 + DTO —
存储 MySQL 规范化 MySQL 宽表/ES/Redis Outbox + CDC + Kafka
一致性 强一致(本地事务) 最终一致 (< 1s) 异步投影 + 幂等
扩展 写库分片 读副本 + 多读模型 多 Projector
监控 写 TPS / 锁等待 读 P99 / 缓存命中 Lag / DLQ / 一致性抽查

演进决策树:

                  系统起步
                     │
                     ▼
              QPS < 1000?读写比 < 5:1?
                /                  \
              是                    否
              ↓                     ↓
         普通 CRUD              单库 CQRS + 物化视图
         (Service+Repo)              ↓
                                QPS 涨到 5000+?
                                /         \
                              否           是
                              ↓             ↓
                          维持             双库 CQRS + Outbox
                                                ↓
                                          需要全审计 / 金融监管?
                                          /              \
                                        否                是
                                        ↓                 ↓
                                      维持            CQRS + Event Sourcing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

60 秒诊断命令清单:

# 看 Outbox 堆积
mysql -e "SELECT COUNT(*) FROM outbox WHERE sent=false"

# 看 Projector lag
kafka-consumer-groups.sh --bootstrap-server $BROKER \
    --describe --group order-detail-projector

# 看读写一致性
mysql -e "SELECT w.id, w.status AS w, r.status AS r
          FROM orders w LEFT JOIN order_detail_view r ON w.id=r.order_id
          WHERE w.status <> r.status LIMIT 100"

# 看缓存命中率
redis-cli INFO stats | grep keyspace

# 看 ES 同步进度
curl http://es:9200/order-index/_count

# 看 P99 延迟
curl http://prom:9090/api/v1/query?query=histogram_quantile(0.99,query_handler_seconds)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

CQRS 设计黄金法则:

命令命名:    动词 + Cmd          (PlaceOrderCmd, 不是 OrderCommand)
查询命名:    名词 + Qry          (GetOrderDetailQry, 不是 OrderQuery)
命令返回:    仅 ID 或 void       不返回业务对象,前端再走查询
查询返回:    VO/DTO 直接          不走聚合根
写库设计:    第三范式 + 行锁细粒度
读库设计:    反范式宽表 + 零 JOIN
同步机制:    Outbox + 异步 Projector,绝不同步双写
一致性目标:  写强一致 + 读最终一致 (秒级)
演进路径:    单库 → 双库 → +ES,每阶段 6 个月消化
1
2
3
4
5
6
7
8
9

第 1 章案例:9 表 JOIN + 12s P99 → 单库 CQRS + 物化视图 → 单表 SELECT + 80ms P99,写仍强一致。这就是 CQRS 给读写不平衡系统的核心红利。


下一篇:我们已经知道了"读写如何分离",下一步进入 04.事件驱动架构设计——把"服务之间如何通过事件解耦"剖到投递语义级别。

#架构#CQRS
上次更新: 2026/06/17, 11:43:57
六边形架构设计
事件驱动架构设计

← 六边形架构设计 事件驱动架构设计→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式