CQRS 数据读写分离
电商平台的商品列表页需要展示:商品名称、价格、销量、评价数量、折扣信息、库存状态——这些数据来自商品表、订单表、评价表、促销表、库存表。一次列表查询可能涉及五六张表的 JOIN,查询耗时 500ms。用户说我只是想看个商品列表,怎么比下单还慢?问题根源在于:读写混合在同一套数据库模型上,互相争夺资源。写操作需要事务锁,读操作需要扫描大量数据,两者放在同一个数据库里,苍蝇老虎一把抓,谁都跑不快。CQRS(Command Query Responsibility Segregation,命令查询职责分离)正是来解决这个问题的:让写操作和读操作使用不同的数据模型,甚至不同的存储。
CQRS 核心架构
CQRS 的核心思想是"命令"和"查询"分离。命令(Command)执行创建、更新、删除操作,返回执行结果;查询(Query)只负责读取数据,返回视图化的结果。两者使用不同的数据模型和存储,可以独立优化。
flowchart TB
subgraph 命令端["命令端 Command Side"]
CMD["用户请求"]
CHandler["命令处理器\nCommand Handler"]
CRepo["命令仓储\n写入模型"]
DB_W["写库\nOLTP"]
end
subgraph 同步机制["数据同步"]
Sync["同步机制\n消息队列/CDC"]
end
subgraph 查询端["查询端 Query Side"]
QRepo["查询仓储\n读取模型"]
DB_R["读库\nOLAP/缓存"]
QHandler["查询处理器"]
end
CMD --> CHandler
CHandler --> CRepo
CRepo --> DB_W
DB_W --> Sync
Sync --> QRepo
QRepo --> DB_R
DB_R --> QHandler
subgraph 同步机制["数据同步"]
MSG["消息队列\nKafka/RocketMQ"]
CDC["CDC 变更捕获"]
end
命令端的写入模型通常采用面向业务的领域模型(如 Order、Product 实体),追求事务能力和业务表达力;查询端的读取模型通常采用面向查询的扁平化模型(如 OrderView、ProductListItem),追求查询性能和字段冗余。
同步复制 vs 异步复制
命令端的数据同步到查询端有两种模式:同步复制和异步复制。
同步复制的优势是数据延迟低,命令执行完成后查询端立即可见。缺点是命令执行时间会增加(需要等待同步完成),而且如果查询端存储故障,命令端也会受影响。这种模式适合对数据一致性要求较高的场景,但实际应用中较少。
public class SynchronousCQRSService {
private final CommandRepository commandRepo;
private final QueryRepository queryRepo;
@Transactional
public void createOrder(OrderCommand command) {
// 1. 写入命令模型
Order order = commandRepo.save(command.toOrder());
// 2. 同步构建查询模型
OrderView orderView = buildOrderView(order);
queryRepo.save(orderView);
// 3. 两者都成功才算成功
}
}
异步复制的优势是命令执行速度快,不受查询端影响;缺点是存在数据延迟,命令执行后查询端需要一段时间才能看到最新数据。这是 CQRS 最常用的同步模式。
public class AsynchronousCQRSService {
private final CommandRepository commandRepo;
private final EventPublisher eventPublisher;
public void createOrder(OrderCommand command) {
Order order = commandRepo.save(command.toOrder());
// 发布领域事件,异步同步到查询端
eventPublisher.publish(new OrderCreatedEvent(order));
}
}
// 查询端监听事件并更新读取模型
@KafkaListener(topics = "order-events")
public void handleOrderCreated(OrderCreatedEvent event) {
OrderView orderView = buildOrderView(event.getOrder());
queryRepo.save(orderView);
}
延迟多久取决于消息队列的吞吐量和查询端的消费能力。通常控制在毫秒到秒级,对于大多数业务场景是可接受的。但如果业务要求命令执行后立即查询到最新状态,就不能使用纯异步模式,需要结合其他策略(如同步写入查询模型)。
Event Sourcing 组合
CQRS 常常与 Event Sourcing(事件溯源)一起使用,形成一套完整的数据架构。在这种组合下,命令端写入的不是聚合根的当前状态,而是一系列领域事件。这些事件被持久化到 Event Store,作为系统的单一真相来源;查询端通过"投影"(Projection)机制从事件流中构建出各种查询视图。
flowchart LR
CMD["命令\nCreateOrder"] --> CH["命令处理器"]
CH --> ES["Event Store\nAppend-Only"]
ES --> PD["投影处理器\nProjection"]
PD --> QM["查询模型\nRead Model"]
subgraph ES["Event Store"]
E1["OrderCreatedEvent"]
E2["PaymentReceivedEvent"]
E3["InventoryReservedEvent"]
end
subgraph QM["查询模型"]
Q1["OrderSummaryView"]
Q2["CustomerDashboardView"]
Q3["SalesReportView"]
end
这种组合的优势是完整保留了系统的所有变更历史,支持任意时间点的状态回放和重投影;缺点是架构复杂度高,事件 Schema 变更(Upcasting)需要谨慎处理。关于 Event Sourcing 的详细内容,可参考事件溯源(Event Sourcing)详解。
读写模型差异设计
命令端的写入模型和查询端的读取模型通常存在显著差异。写入模型追求业务语义的完整表达,支持复杂的业务规则验证;读取模型追求查询效率,字段可能经过预计算、反范式化、甚至物化。
例如,一个订单写入模型可能是这样的:
// 写入模型:完整业务语义
public class Order {
private OrderId id;
private CustomerId customerId;
private List<OrderLineItem> lineItems;
private OrderStatus status;
private PaymentInfo paymentInfo;
private ShippingAddress shippingAddress;
public void addItem(Product product, int quantity) {
// 业务规则:检查库存、计算价格、应用促销
}
public void pay(Payment payment) {
// 业务规则:验证支付金额、更新状态、触发事件
}
}
对应的查询模型可能是这样的:
// 查询模型:扁平化、预计算
public class OrderSummaryView {
private OrderId id;
private String customerName;
private String customerAvatar;
private long itemCount;
private BigDecimal totalAmount;
private String totalAmountDisplay; // 预格式化:"¥1,234.56"
private String statusText; // 预翻译:"待发货"
private long createdAtTimestamp; // 时间戳,秒级
private long updatedAtTimestamp;
}
查询模型的字段往往是冗余的、预处理的。例如 totalAmountDisplay 直接存储带货币符号和千分位的字符串,避免每次查询时格式化;statusText 直接存储中文文案,避免前端做状态翻译。代价是写入时需要额外计算和转换这些字段。
数据同步延迟问题
异步复制模式下,查询端的数据存在延迟。这个延迟可能来自:消息队列的投递延迟、查询端处理能力的限制、网络抖动、甚至故障导致的消息积压。延迟可能从毫秒级到分钟级不等。
对于大多数场景,秒级延迟是可以接受的。但有几类问题需要特别处理:
重复读取问题:用户刚创建了订单,立即刷新订单列表,订单还没同步过来,用户看不到自己的订单。解决方案是在命令端同步写入一个缓存或查询模型,读取时先查缓存再查查询模型;或者在 UI 层做乐观处理——创建成功后直接在前端添加新订单的临时显示。
状态不一致问题:订单状态从"已支付"变更为"已发货",但用户查询到的状态还是"已支付"。这需要评估业务影响:如果状态变更是用户主动触发的(如点击"确认收货"),命令端可以同步更新查询模型;如果是后端异步触发的,需要在事件处理完成后做一次确认或轮询。
并发冲突问题:两个并发请求同时修改同一订单,事件顺序可能因为消息队列的异步投递而颠倒。例如"取消订单"事件先于"完成支付"事件到达查询端,导致订单状态显示为已取消但支付状态显示为已完成。解决方案是事件本身携带版本号或时间戳,查询端按时间戳排序处理,或者在命令端引入乐观锁版本号。
适用场景与权衡
CQRS 适合的场景包括:读写比例严重不均(如内容平台,读请求是写请求的 100 倍)、查询复杂度高(多表 JOIN、聚合计算)、需要独立优化读写性能、团队有足够能力维护多套模型。
不适用的场景包括:业务简单、读写模型差异不大、团队经验不足导致过度设计。一句话总结:CQRS 是解决特定问题的工具,不是银弹。如果读写混合的数据库能跑得动,就不要强行拆开。