命令查询职责分离
# 03.命令查询职责分离
# 目录介绍
# 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);
}
}
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) 全部排队
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 章
2
3
4
5
6
7
8
# 1.3 我们要回答什么
这个事故就是本篇的主线案例。我们带着上面 7 个问号往下走,每讲完一段原理就解开一两个;最后在第 10 章把案例彻底剖开,并给出三种修复方案与各自的代价。
本篇路线:
读写分裂总图 (第 2 章)
↓
CQS → CQRS 思想脉络 (第 3 章) ─→ 解开"为什么读写要分离"
↓
写模型 → 读模型 (第 4-5 章) ─→ 解开"两边各自怎么设计"
↓
读写同步 (第 6 章) ─→ 解开"两边怎么对齐"
↓
事件溯源融合 (第 7 章) ─→ 解开"CQRS 的最强组合"
↓
演进与陷阱 (第 8 章) ─→ 解开"什么时候别用"
↓
落地实战 (第 9 章) ─→ 工具与监控
↓
综合案例 (第 10 章) ─→ 案例彻底剖开
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 │
│ 把事件转成读模型行 │
└────────────────────┘
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 为什么必须分
为什么把读和写"切开",而不是统一一份数据模型?
疑惑:直接给数据库加更多副本、堆更多缓存不行吗?
论证:
- 读写工作负载在物理上对立——写需要 行锁、事务、规范化 保证一致性;读需要 无锁、宽表、零 JOIN 保证速度。两者优化方向相反,硬塞到一个模型必然顾此失彼。第 1 章 9 表 JOIN 慢就是明证。
- 读写比例严重失衡——电商系统典型的读写比是 100:1~1000:1。如果两者用同一份资源,99% 的资源都被读吞掉,写入的事务延迟反而被读拖垮。让读侧独立扩容,是物理上的必然选择。
- 读写的失败容忍度不同——写失败用户立刻看到错误(必须强一致),读失败可以走缓存兜底或降级。用同一个模型就被迫用最严格那一档对待两边,浪费可用性。
- 读写的演化速度不同——业务规则(写)一年改 3 次,展示形态(读)一年改 30 次。读模型应该廉价到可以随时重建,写模型应该稳定到几年不动。
- 反向验证:如果不分会怎样?参考第 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 年没火?
论证:
- 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(); } // 命令
2
3
4
5
6
7
8
9
10
11
12
13
CQS 在方法级别有效,但对大型系统的架构层面影响有限——因为同一个类里"命令方法"和"查询方法"还是共用同一份数据。系统层的读写性能问题仍然没解。
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):事件存储 + 投影 事件流是唯一真相
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();
}
}
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());
}
}
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、多读视图 │
└─────────────────────────────────────────────────────────────┘
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 月改造)
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));
}
}
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
聚合根三大铁律:
- 外部只能通过聚合根操作内部对象——不允许
order.getItems().add(new Item())这种直接操作 - 一个事务只改一个聚合——跨聚合用领域事件 + 最终一致
- 聚合是一致性边界——聚合内强一致(数据库事务),聚合间最终一致(事件)
聚合的粒度怎么定?看"业务规则的传染范围":
✅ Order + OrderItem + ShippingAddress → 一个聚合
理由:下单时校验"金额=∑item·price",必须强一致
❌ Order + Payment → 不要塞一个聚合
理由:支付可能晚 10 秒到 30 分钟,强一致代价大
做法:Payment 独立聚合,通过 OrderPaidEvent 联动
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();
}
}
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);
}
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)
);
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();
}
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...复杂度爆炸
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 消费 → 各自本地事务扣库存
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 分表 │
└─────────────────────────────────────┘
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;
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;
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;
}
}
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 三大纪律:
- 不调用领域服务、不加载聚合——直接读读模型
- 不做任何状态变更——包括"顺便埋点"、"顺便统计",这些应通过事件总线异步
- 失败要降级——读视图挂了走缓存、缓存挂了回源写库(兜底重建)
# 5.4 多读模型并存策略
一个写模型可以投影出 N 个读模型——每种 UI 场景一份:
写模型(订单聚合)
│
┌──────────────┼──────────────┬──────────────┐
▼ ▼ ▼ ▼
订单详情视图 订单列表视图 订单搜索 ES 订单统计 OLAP
(MySQL 宽表) (MySQL 宽表) (Elasticsearch) (ClickHouse)
│ │ │ │
▼ ▼ ▼ ▼
详情页 API 列表页 API 全文搜索 API B 端报表 API
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
}
2
3
4
5
6
7
8
9
问题暴露:
- 跨库事务——写库是 MySQL,读库可能是 MySQL/ES/Redis,没有统一事务
- 任何一个挂,整个下单失败——可用性从 99.99% 降到 99.95%(× N 个读库)
- 延迟翻倍——RT 从 50ms 涨到 200ms+
- 耦合恶劣——加一个新读模型,写代码就要改
反验证:试图用 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 索引
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(), "已支付");
}
}
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
异步投影的纪律:
- Projector 必须幂等——消息可能重复
- Projector 必须按 orderId 保序——靠 Kafka partition key
- Projector 失败不能阻塞业务——出错进死信队列,人工修
- Projector 可任意重启重消费——读模型应能从事件流完全重建
# 6.3 CDC变更捕获
如果不想引入 Outbox 表,还有一招——CDC(Change Data Capture)直接读 DB binlog:
┌─────────────────┐
│ 业务代码 │
│ orderRepo.save │ ── 只写一次 DB
└────────┬────────┘
▼
┌──────────┐
│ MySQL │
└────┬─────┘
│ binlog
▼
┌──────────────┐
│ Debezium / │ ── 读 binlog 转 Kafka
│ Canal │
└────┬─────────┘
▼
Kafka
│
▼
Projector ──► 读模型
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);
}
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);
}
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 增长速度
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 │
└────────────────────┘ └──────────────────────────┘
当前状态是"事实" 事件序列是"事实",状态只是派生
2
3
4
5
6
7
8
9
10
事件溯源的红利:
- 完整审计——每一次状态变化都可追溯到原因
- bug 重放——发现 bug 后修代码,重放事件即可修复历史数据
- 多读模型同源——任何新的读视图都能从事件回放重建
- 时间旅行——任意回到过去某个时刻的系统快照
- 天然适合监管/金融场景——出问题能完整复盘
事件溯源的代价:
- 学习成本高——团队思维要从"改状态"转到"加事件"
- 查询复杂——任何"当前状态"都要重建,没快照就慢
- 事件契约不能变——事件已存就不能改格式(详见 04.事件驱动架构设计 第 8.1 节)
- 运维复杂——事件存储几乎不能删,存储增长不可逆
# 7.2 快照加速回放
疑惑:如果一个订单有 1000 个事件,每次查询都回放岂不爆炸?
论证:用快照(Snapshot)——每 N 个事件存一次状态快照,加载时只回放快照之后的事件:
事件序列: #1 #2 #3 #4 #5 ... #100 #101 #102 #103 ...
↑
快照 #100 (存全状态)
↓
加载时:load 快照 #100 + 回放 #101~#103
时间复杂度:从 O(N) 降到 O(快照间隔)
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;
}
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. 切流
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);
}
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 → 仅当业务需要全审计、强追溯时上
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 填新字段
2
3
4
5
6
规则 2 · 改字段类型:双写过渡 + 旧字段下线
不能直接改!必须:
1. 加新字段 amount_v2 DECIMAL(20,6)
2. Projector 同时写 amount 和 amount_v2
3. 查询逐步切到 amount_v2
4. 验证 N 天后,删除 amount
2
3
4
5
规则 3 · 删字段:先停查询,再删存储
反向:
1. 所有读侧代码停用该字段
2. Projector 停止写入该字段
3. N 周后 ALTER DROP COLUMN
2
3
4
# 8.3 跨模型一致性盲区
疑惑:写模型改了,读模型最终一致;但两个不同的写模型之间呢?
论证:CQRS 解决了"读 vs 写",没解决"写 vs 写"。跨聚合一致性仍然是难题:
场景:用户下单后扣余额
┌──────────────┐ ┌──────────────┐
│ Order 聚合 │ 事件 │ Account 聚合 │
│ 下单成功 │ ──────────────►│ 扣余额 │
└──────────────┘ └──────────────┘
✓ ✗ 失败
(余额不够)
↓
订单已成功,但用户余额没扣 → 状态撕裂
2
3
4
5
6
7
8
9
应对:
- Saga 模式——长事务用补偿(OrderCancelled 抵消 OrderPlaced)
- 业务约定可见性——下单成功后状态显示"待确认",扣款成功才转"已支付"
- 预占机制——下单时立刻预扣余额,超时未支付自动释放
详见 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; // 写不需要,读必要
}
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;
}
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);
}
2
3
4
5
6
问题:事件传输有延迟,回查可能拿到比事件更新的状态;而且把"事件已自包含"的优势丢了。 正确:把投影需要的所有字段都塞进事件 payload。
反模式 4 · 命令侧返回大量数据
// ❌ 命令返回完整对象
public OrderDetailVO handle(PlaceOrderCmd cmd) {
Order o = Order.place(...);
orderRepo.save(o);
return assembler.toVO(o, ...); // ✗ 返回详情
}
2
3
4
5
6
问题:用户下单成功立刻看详情,详情还没投影过来——返回的数据可能比读模型还新但格式不一致;前端要写两套渲染逻辑。 正确:命令只返回 orderId,前端拿 ID 再走查询接口。
反模式 5 · 单库 CQRS 强行用最终一致
单库 CQRS 下,写完立刻能从同一个 DB 查到——还故意走 MQ 投影
→ 引入毫无必要的最终一致问题
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);
}
}
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 │
│ 写库 │── 兜底回源
└─────────┘
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
}
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 周
- 老表停写、归档、删除
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"
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;
-- 任何不一致 → 立刻报警
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//
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
}
2
3
4
代价:1 周改造、零运维新组件; 收益:P99 从 12s 降到 80ms,写仍强一致,无最终一致问题。
方案 B · 双库 CQRS + 异步投影(QPS > 5000 升级)
写库 MySQL → Outbox → Kafka → Projector → 读库 (MySQL/ES/Redis)
代价:1 月改造,引入 Kafka + Outbox + Projector,业务方接受最终一致; 收益:读写完全独立扩容,QPS 10000+ 稳定,多读模型并存。
方案 C · CQRS + Event Sourcing(仅金融/审计场景)
所有写操作 → 事件存储(唯一真相) → 多读模型从事件回放重建
代价: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 节
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
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)
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 个月消化
2
3
4
5
6
7
8
9
第 1 章案例:9 表 JOIN + 12s P99 → 单库 CQRS + 物化视图 → 单表 SELECT + 80ms P99,写仍强一致。这就是 CQRS 给读写不平衡系统的核心红利。
下一篇:我们已经知道了"读写如何分离",下一步进入 04.事件驱动架构设计——把"服务之间如何通过事件解耦"剖到投递语义级别。