CQRS 实战——命令查询分离,真正解决了什么问题
CQRS 实战——命令查询分离,真正解决了什么问题
适读人群:关注系统架构、读写性能优化的工程师 | 阅读时长:约16分钟 | 核心价值:CQRS 不只是分库,它是一种让读写模型各自最优化的思维方式
从一个读写冲突说起
我们曾经有一个商品详情页,需要展示的信息包括:商品基本信息、库存数量、价格(含促销价格)、评价摘要(总评分、最新3条评价)、商家信息,以及"同类商品推荐"列表。
这些信息来自 5 张不同的表,有的甚至来自不同的微服务。
最初的实现方式是在 Service 里聚合查询:
public ProductDetailVO getProductDetail(Long productId) {
Product product = productMapper.findById(productId);
Stock stock = stockMapper.findByProductId(productId);
Price price = priceService.getCurrentPrice(productId); // RPC
ReviewSummary reviews = reviewService.getSummary(productId); // RPC
Merchant merchant = merchantMapper.findById(product.getMerchantId());
List<Product> similar = recommendService.getSimilar(productId); // RPC
return buildVO(product, stock, price, reviews, merchant, similar);
}这个接口有几个问题:
- 响应时间 = 多个 RPC 调用串行叠加,通常 300-500ms
- 任何一个数据源慢了,整个接口就慢
- 商品信息每次修改,都会影响这个读接口的展示
- 为了支撑读接口的各种查询需求,域模型(Product、Stock 等)开始被迫增加各种冗余字段
这就是读写模型耦合在一起带来的问题。CQRS 的出现,就是为了解决这个矛盾。
CQRS 是什么
CQRS(Command Query Responsibility Segregation,命令查询职责分离)的核心思想:
命令(Command):改变系统状态的操作(创建、修改、删除)。命令不返回数据(或只返回操作结果)。
查询(Query):读取系统状态,不改变状态。
这两类操作使用不同的模型、不同的数据存储、不同的处理路径来处理。
CQRS 最基础的形态:分离处理逻辑
不搞分库,只是把读和写的处理逻辑分离:
// 命令侧:走领域模型,保证业务规则
@Service
public class ProductCommandService {
public void updateProductPrice(UpdatePriceCommand cmd) {
Product product = productRepository.findById(cmd.getProductId()).orElseThrow();
product.updatePrice(cmd.getNewPrice(), cmd.getOperatorId()); // 领域逻辑
productRepository.save(product);
}
}
// 查询侧:直接查数据,返回 DTO,不走领域对象
@Service
public class ProductQueryService {
public ProductDetailVO getProductDetail(Long productId) {
// 直接用 SQL JOIN,返回 DTO,不经过领域对象
return productDetailMapper.selectDetailById(productId);
}
}这是 CQRS 最轻量的实现,不需要引入任何新技术,只是代码层面的职责分离。
很多情况下,这个级别的 CQRS 就够用了。
CQRS 进阶:读写分库
当读写量差距很大时(比如读是写的 100 倍),进一步把读写分到不同的数据库:
写入走主库,保证事务和一致性。
读取走从库或专门的查询数据库(比如 ES),可以根据查询需求建不同的索引和视图。
CQRS 高阶:读模型物化视图
这是 CQRS 最有价值的应用形态。
问题背景:上面商品详情页的问题,本质上是:用一个规范化(标准化的关系型设计)的数据模型来服务一个反规范化的展示需求。
商品详情页需要的数据,跨越了 5 张表、3 个服务,每次展示都要做大量 JOIN 和 RPC 聚合,非常低效。
CQRS 的解法:预先为读操作构建一个物化视图(Materialized View)——一个专门为这个查询场景设计的、预计算好的、可以直接读取的数据结构。
// 商品详情的物化视图(专门为这个查询场景设计的文档结构)
public class ProductDetailView {
private Long productId;
private String productName;
private String description;
private BigDecimal originalPrice;
private BigDecimal currentPrice; // 促销价格已经计算好了
private Integer stockQuantity;
private MerchantInfo merchantInfo; // 商家信息内嵌
private ReviewSummary reviewSummary; // 评价摘要内嵌
private List<SimilarProduct> similarProducts; // 推荐商品内嵌
private LocalDateTime lastUpdated;
}这个视图存在 Elasticsearch 或 Redis 里,每次写操作触发异步更新:
查询时,直接从 ES 读取预计算好的视图,一次查询返回所有需要的数据,响应时间从 300ms 降到 10ms。
真实代码实现
视图更新服务
@KafkaListener(topics = {"product-events", "price-events", "stock-events", "review-events"})
public class ProductViewUpdater {
@EventHandler
public void onProductUpdated(ProductUpdatedEvent event) {
updateProductView(event.getProductId());
}
@EventHandler
public void onPriceChanged(PriceChangedEvent event) {
// 价格变动只需要更新视图里的价格字段
ProductDetailView view = esRepository.findById(event.getProductId());
view.setCurrentPrice(event.getNewPrice());
esRepository.save(view);
}
private void updateProductView(Long productId) {
// 从各个数据源聚合数据,构建完整视图
ProductDetailView view = viewBuilder.build(productId);
esRepository.save(view);
}
}查询服务
@Service
public class ProductQueryService {
private final ProductDetailViewRepository esRepository;
public ProductDetailVO getProductDetail(Long productId) {
ProductDetailView view = esRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
return converter.toVO(view);
}
// 复杂查询:利用 ES 的全文搜索、过滤、排序
public Page<ProductListVO> searchProducts(ProductSearchQuery query) {
return esRepository.search(query);
}
}踩坑记录
踩坑一:读写不一致导致用户困惑
用户刚刚修改了商品描述,刷新页面还是显示旧内容(视图还没异步更新)。
这是 CQRS 最本质的代价:读写最终一致性。
解决方案有两种:
方案 A:接受最终一致,在 UI 上做处理(比如修改成功后在本地显示新数据,不依赖服务端刷新)。
方案 B:对于刚写入的数据,在短时间内(比如 5 秒)走写侧数据库读取,绕过视图:
public ProductDetailVO getProductDetail(Long productId) {
// 检查是否是刚更新的数据(最近 5 秒内有写操作)
if (recentWriteCache.contains(productId)) {
// 走写库,保证强一致性
return buildFromWriteDB(productId);
}
// 走读视图,高性能
return esRepository.findById(productId).map(converter::toVO).orElseThrow();
}踩坑二:视图更新失败,数据长期不一致
MQ 消费失败(比如 ES 不可用),视图没有更新,但代码没有告警,几天后才发现数据不一致。
修复:
- 消费失败要有告警,不能静默失败
- 定期做全量对账,对比写库和视图的关键数据
- 提供手动触发视图重建的运维工具
踩坑三:把 CQRS 用到了不需要的地方
有个简单的用户管理模块(增删改查为主),也被搞成了 CQRS,写侧走 MySQL,读侧走 Redis 视图,视图更新逻辑比业务逻辑复杂多了。
用户管理的数据量小,查询也简单,完全没必要 CQRS。复杂度比收益高了 10 倍。
CQRS 的适用条件:读写模型差异大、读写量差距大、或者读的性能要求远高于写。不满足这些条件,普通三层架构就够了。
CQRS 是否需要和 DDD 一起用
不是必须的,但它们非常互补:
- DDD 的聚合根帮助你建立清晰的命令模型(写侧)
- CQRS 的查询模型让你可以把视图的需求和领域模型彻底解耦
- 领域事件是连接两侧的桥梁
什么时候该考虑 CQRS
- 读接口需要聚合多个服务/表的数据,联合查询复杂度高
- 读取量远大于写入量,希望对读操作单独优化
- 不同的读场景需要不同的数据结构(详情页、列表页、搜索)
- 历史数据分析和实时查询需求并存
不需要 CQRS 的场景:简单 CRUD、数据量小、读写量相当、一致性要求强。
