一、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的完整数据流。注意看命令和查询两条路径是完全独立的。

客户端 命令处理器 写模型(3NF) 写数据库 查询处理器 读模型(宽表) 读数据库 数据同步(MQ/Binlog) Command Query

六、什么时候该用CQRS?

不是所有项目都需要CQRS。我个人的判断标准:

  • 读写负载差异大:读多写少,或者写多读少,总之不平衡
  • 查询复杂度高:需要多种维度的聚合、统计、报表
  • 团队规模大:读写可以分给不同团队开发
  • 需要事件溯源:审计、回放、时间旅行是硬需求

一句话总结:CQRS不是银弹。如果你的项目就几张表、几十个用户,别折腾。但如果你在做高并发、复杂查询的系统,CQRS能帮你把架构理得清清楚楚。

嗯,CQRS的核心就这些。记住:命令和查询,各管各的,别混在一起。数据同步用异步,别追求强一致。事件溯源是好东西,但别滥用。


公众号:蓝海资料掘金营,微信deep3321