当单体应用的同一张表既要扛住高频写入、又要服务复杂报表查询时,索引争用与锁等待会迅速拖垮整体性能。CQRS(Command Query Responsibility Segregation,命令查询职责分离)把「写」和「读」拆成两套独立模型:写侧专注领域规则与数据一致性,读侧专注查询体验与吞吐。本文用一个订单场景,讲清如何用读写分离加事件投影(Event Projection)落地 CQRS,并给出可直接套用的代码骨架与踩坑清单。
一、为什么需要 CQRS:写读耦合的代价
在传统 CRUD 架构里,一张 orders 表同时承载下单、支付回调、状态流转等写操作,以及后台报表、用户中心列表等读操作。随着业务增长,三个矛盾会越来越突出:
- 写被读拖慢:报表查询喜欢建一堆联合索引,而这些索引会拖慢每一次 INSERT/UPDATE。
- 读被写锁住:长事务或热点行更新时,读请求排队等待,页面响应时间抖动剧烈。
- 无法独立扩容:写和读的压力曲线完全不同,却被迫部署在同一套实例上,资源永远顾此失彼。
CQRS 的核心思想不是「分库分表」这么简单,而是承认一个事实:为写而优化的模型和为读而优化的模型,本就不该是同一个。举个真实场景:一张订单表为了支撑运营后台的「近 30 天分城市销售额」多维报表,被迫建了 5 个联合索引;结果大促期间,每秒上千笔下单因为要同步维护这些索引,写延迟从 8 毫秒飙到 400 毫秒,而报表查询又因为行锁排队,页面要 3 秒才出数。读写互相绑架,谁都跑不快。CQRS 的做法是把这张表拆成「写聚合表」和「读宽表」两套,写表只留保证一致性的最小索引,读表则按查询视图量身定制。
二、CQRS 的四种核心构件
落地 CQRS 前,先厘清四个角色的职责边界。它们组成一个闭环:命令触发写、写产生事件、事件驱动投影、投影刷新读模型。
| 构件 | 职责 | 是否修改状态 |
|---|---|---|
| Command(命令) | 表达「想做什么」,如 PlaceOrder | 不直接改,交由 Handler |
| Query(查询) | 表达「要什么数据」,如 GetOrderSummary | 只读,绝不修改 |
| Event(事件) | 记录「已经发生什么」,如 OrderPlaced | 写侧产出,供投影消费 |
| Projection(投影) | 消费事件,维护读模型 | 只写读库/缓存 |
三、写侧:命令与领域模型
写侧追求「尽量少的事」:只负责业务规则与一致性,不关心页面要怎么展示。下面用 Java 风格给出一个最小可运行的命令分发骨架。
3.1 命令与命令总线
// 命令:表达意图,携带聚合 ID 与必要参数
public record PlaceOrderCommand(
String orderId, // 由 分布式 ID 生成器 预先分配,见内链
String userId,
List<String> items,
long timestamp
) {}
// 极简命令总线:按类型路由到对应 Handler
public class CommandBus {
private final Map<Class<?>, CommandHandler<?>> handlers = new ConcurrentHashMap<>();
public <C> void register(Class<C> type, CommandingHandler<C> h) {
handlers.put(type, h);
}
@SuppressWarnings("unchecked")
public void dispatch(Object command) {
CommandHandler<?> h = handlers.get(command.getClass());
((CommandHandler<Object>) h).handle(command);
}
}
3.2 聚合处理命令并发布领域事件
聚合(Aggregate)是写侧的唯一真相源。它校验业务规则后产出事件,而不是就地更新读模型。注意 orderId 在命令到达前就已由分布式 ID 生成方案分配好,避免写侧再来回查库。
public class Order {
private final String orderId;
private OrderStatus status = OrderStatus.CREATED;
private final List<String> events = new ArrayList<>();
public Order(String orderId, String userId, List<String> items) {
this.orderId = orderId;
// 业务规则:空订单直接拒绝,写侧只认规则
if (items == null || items.isEmpty()) {
throw new IllegalArgumentException("订单不能为空");
}
this.events.add(new OrderPlaced(orderId, userId, items, System.currentTimeMillis()));
}
public List<Object> drainEvents() {
List<Object> drained = new ArrayList<>(events);
events.clear();
return drained;
}
}
四、读侧:事件投影与查询模型
读侧完全不管业务规则,它只做一件事:把事件流转换成好查的表或缓存。这正是 CQRS 威力所在——你可以为不同查询场景维护多份投影。
4.1 投影监听事件,刷新读模型
// 投影:监听 OrderPlaced,落一张为列表页优化的宽表
@Component
public class OrderSummaryProjection {
@Autowired
private JdbcTemplate readDb;
@EventListener
public void on(OrderPlaced event) {
readDb.update(
"INSERT INTO order_summary(order_id,user_id,item_count,status,placed_at) VALUES(?,?,?,?,?)",
event.orderId(), event.userId(), event.items().size(), "CREATED", event.timestamp()
);
}
}
4.2 查询直击读模型,必要时加缓存
查询服务只面向读库或缓存,逻辑极简。高频列表可直接走Redis 缓存设计或Redis 核心数据结构做二级加速,把主库彻底隔离在查询链路之外。
public class OrderQueryService {
@Autowired
private JdbcTemplate readDb;
@Autowired
private RedisTemplate<String, String> redis;
public OrderSummary getSummary(String orderId) {
String cacheKey = "order:summary:" + orderId;
String cached = redis.opsForValue().get(cacheKey);
if (cached != null) return JSON.parseObject(cached, OrderSummary.class);
// 只读读库,绝不碰写侧聚合
OrderSummary s = readDb.queryForObject(
"SELECT * FROM order_summary WHERE order_id=?", MAPPER, orderId);
redis.opsForValue().set(cacheKey, JSON.toJSONString(s), 5, TimeUnit.MINUTES);
return s;
}
}
五、最终一致性与延迟权衡
CQRS 的读模型是异步更新的,这意味着用户刚下单,立刻查可能查不到——这是设计取舍,不是 bug。是否可接受取决于业务。
| 方案 | 一致性 | 写延迟 | 适用场景 |
|---|---|---|---|
| 单库事务 | 强一致 | 高(锁竞争) | 金融核心账务 |
| CQRS + 同步投影 | 读库准实时 | 中 | 内部运营后台 |
| CQRS + 异步投影 | 最终一致 | 低 | 电商订单、Feed 流 |
六、落地清单与三个必踩的坑
把 CQRS 跑顺,关键是把「异步」带来的副作用提前堵死。下面三点是生产环境最容易翻车的地方。
6.1 命令必须幂等
消息中间件可能重复投递同一条命令。务必在写侧用「命令 ID + 去重表」做幂等设计,否则重试一次就多下一单。去重表主键就是 orderId,INSERT 失败即判定重复。
6.2 投影必须可重放
当投影逻辑改了,需要清空读模型后从头回放事件。所以事件必须落库(Event Store),而投影本身要幂等——同一条 OrderPlaced 重放两次,结果应一致。
6.3 读侧故障不能拖垮写侧
投影消费方挂了,写侧仍要能正常下单。用独立队列解耦,并为读侧查询加微服务熔断降级兜底:缓存查不到时降级返回「数据同步中」,而不是把异常抛给下单链路。
| 坑 | 现象 | 解法 |
|---|---|---|
| 命令重复 | 重复下单/重复扣款 | 命令 ID 去重表 |
| 投影漂移 | 读模型与写模型对不上 | 事件落库 + 可重放 |
| 级联雪崩 | 读库慢拖垮写库 | 队列解耦 + 熔断 |
七、什么时候不该用 CQRS
CQRS 不是银弹。如果你的系统读少写少、查询和写入的数据形态高度一致、团队人数有限,强行拆读写只会增加事件、投影、幂等三套复杂度。判断标准很简单:只有当「写模型」和「读模型」的优化方向明显分歧时,CQRS 的收益才大于成本。典型信号包括报表 SQL 已严重影响线上写入、读链路需要多种异构存储(ES/Redis/宽表),或不同业务方对「同一份数据」有截然不同的查询视图。
小结:CQRS 用「读写分离」换取吞吐与可维护性,用「事件投影」把写侧发生的变更同步到读侧。它把一致性从「强一致」降级为「最终一致」,换来的却是写读两套模型各自自由伸缩的空间。落地时守住命令幂等、投影可重放、读写解耦三条铁律,就能把这套架构稳稳跑在生产环境上。
如果你正在评估是否引入 CQRS,可以先做一个最小验证:把现有系统里「读多写少」且「查询形态和写入形态差异大」的那张表摘出来,单独建一份读模型并异步同步,跑两周看监控。一旦读写压力被拆开、主库写延迟回落,你就拿到了继续深入 CQRS 最硬的证据。反之,若拆分后复杂度上升却没换来可观测的性能收益,及时收手也是理性决策。




