一、CQRS模式:为什么我要把读写拆开?
说实话,我第一次接触CQRS时,第一反应是:这不就是读写分离吗?搞这么复杂干嘛?
后来在一个高并发订单系统里,我被狠狠教育了一回。查询接口越来越慢,但业务方又要求查询必须支持各种维度的聚合统计。改一次查询,写逻辑就得跟着改,改完还得担心会不会影响数据一致性。嗯,那时候我才真正理解CQRS的价值。
CQRS全称是Command Query Responsibility Segregation,翻译过来就是「命令查询职责分离」。它的核心思想很简单:写操作和读操作,用不同的模型来处理。
核心原则:
- Command(命令):改变状态的操作,返回void或结果标识
- Query(查询):读取状态的操作,不改变数据
- 两者使用完全不同的数据模型和实现方式
二、传统CRUD的痛点
你想想看,传统的三层架构里,一个UserService既要处理saveUser(),又要处理findUserWithOrders()。这两个方法看似都在操作User,但背后的关注点完全不同。
我在一个电商项目中遇到过这样的场景:订单查询需要关联商品、用户、物流、优惠券等七八张表,而订单创建只需要校验库存、扣减金额、生成记录。用同一个模型,查询时不得不做大量JOIN,写操作又担心锁表影响查询性能。
传统模型的几个硬伤:
- 模型膨胀:一个实体类承载了太多职责
- 性能冲突:写优化的表结构不适合读,反之亦然
- 团队协作困难:读写逻辑耦合,改一处可能影响另一处
三、CQRS的核心组件
3.1 命令模型(Command Model)
命令模型只处理写操作。它接收一个Command对象,执行校验、业务逻辑,然后持久化。注意,它不返回数据,只返回成功或失败。
// Java示例:命令模型
public class CreateOrderCommand {
private String userId;
private List<OrderItem> items;
private Address shippingAddress;
// getter/setter省略
}
public class OrderCommandHandler {
@Autowired
private OrderRepository orderRepository;
public void handle(CreateOrderCommand command) {
// 校验库存
// 计算金额
// 生成订单
Order order = Order.create(command);
orderRepository.save(order);
// 不返回数据,只做操作
}
}
3.2 查询模型(Query Model)
查询模型完全独立。它可能使用不同的数据库、不同的表结构,甚至不同的存储引擎。我习惯用专门的读库,表结构按查询需求设计,比如宽表、冗余字段、预聚合数据。
// Java示例:查询模型
public class OrderQueryService {
@Autowired
private OrderReadRepository readRepo;
public OrderDetailDTO getOrderDetail(String orderId) {
// 直接从读库查询,可能已经预JOIN好了
return readRepo.findOrderDetail(orderId);
}
public PageResult<OrderSummaryDTO> listUserOrders(
String userId, int page, int size) {
// 使用专门的查询优化
return readRepo.findByUserId(userId, page, size);
}
}
3.3 事件溯源(Event Sourcing)
事件溯源是CQRS的黄金搭档。说白了,就是不存当前状态,只存事件流。想知道当前状态?把事件从头到尾重放一遍。
我记得有个金融项目,要求记录每一笔账户变动的完整历史。用事件溯源就特别自然:每次存款、取款、转账都是一个事件,账户余额就是这些事件的累加结果。
// Java示例:事件溯源
public class Account {
private String accountId;
private List<Event> changes = new ArrayList<>();
public void deposit(Money amount) {
// 不是直接修改余额,而是记录事件
apply(new MoneyDeposited(accountId, amount));
}
private void apply(MoneyDeposited event) {
changes.add(event);
// 更新状态
this.balance = this.balance.add(event.getAmount());
}
// 从事件流重建状态
public static Account replay(List<Event> events) {
Account account = new Account();
events.forEach(account::apply);
return account;
}
}
我的经验:事件溯源不是必须的。如果你的业务不需要审计、回放、时间旅行这些能力,用普通的CQRS就够了。别为了用而用。
四、读写分离架构
读写分离是CQRS的落地实现。写库用3NF范式,保证数据一致性;读库用宽表、冗余、甚至NoSQL,保证查询性能。
数据同步是关键环节。我常用的几种方式:
| 同步方式 | 延迟 | 一致性 | 适用场景 |
|---|---|---|---|
| 同步双写 | 低 | 强一致 | 小规模、低并发 |
| 消息队列异步 | 秒级 | 最终一致 | 大多数业务场景 |
| 数据库binlog同步 | 毫秒级 | 最终一致 | 高吞吐、解耦 |
| 事件溯源重放 | 取决于事件量 | 强一致 | 需要审计/回放 |
避坑指南:我曾经在一个项目里用了同步双写,结果写库压力一大,读库也跟着卡死。后来改成消息队列异步同步,虽然有一两秒的延迟,但系统稳定多了。记住:CQRS的核心是解耦,别为了「实时」牺牲了「可用」。
五、CQRS架构图
下面这张图展示了CQRS的完整数据流。注意看命令和查询两条路径是完全独立的。
六、什么时候该用CQRS?
不是所有项目都需要CQRS。我个人的判断标准:
- 读写负载差异大:读多写少,或者写多读少,总之不平衡
- 查询复杂度高:需要多种维度的聚合、统计、报表
- 团队规模大:读写可以分给不同团队开发
- 需要事件溯源:审计、回放、时间旅行是硬需求
一句话总结:CQRS不是银弹。如果你的项目就几张表、几十个用户,别折腾。但如果你在做高并发、复杂查询的系统,CQRS能帮你把架构理得清清楚楚。
嗯,CQRS的核心就这些。记住:命令和查询,各管各的,别混在一起。数据同步用异步,别追求强一致。事件溯源是好东西,但别滥用。
公众号:蓝海资料掘金营,微信deep3321