6.1 CQRS:读写两条路


6.1 CQRS:读写两条路

本节摘要:CQRS(命令查询职责分离)把模型的写入与读取拆成两套结构:命令侧用聚合守护不变量,查询侧按页面形态搭投影,两者以领域事件同步。本节从客服列表那个"做不了"的筛选需求讲起,给出投影的完整代码,并立下三条适用判据——CQRS 的价值不在分离本身,而在拒绝让一种结构伺候两种诉求。

一个做不了的筛选需求

季度回顾上的第一块硬骨头:客服负责人要按"近三个月有退款记录的订单"筛选列表,开发答复做不了。为什么做不了?看订单聚合的结构就懂:TradeOrder 聚合围绕"一笔交易的自洽"组织——订单行、金额、状态、券使用。退款记录不属于这个聚合(3.5 判过:退款单是独立聚合),于是"订单列表页按退款筛选"这个需求,要求一次跨聚合、甚至跨上下文(退款在交易,赔付审批在客服)的关联查询——聚合结构对它天然失明。

三条路摆在面前。改聚合,把退款记录塞进订单——3.5 的判据当场否决,退款不是订单的不变量。跨库联表查询——回到 2.1 的老路,联的还不是同一个库。第三条路就是本节主题:给查询一个自己的结构。查询关心的是"客服视角的订单列表"这个视图,不是订单聚合;那就为视图专门建一张读模型,命令侧的事件发生时,投影更新它。

拆开:命令侧与查询侧各自的道理

CQRS 的全称是 Command Query Responsibility Segregation。它先陈述一个常被忽略的事实:命令与查询的诉求在结构上相反。命令侧在乎不变量:状态迁移必须合法、金额必须守恒、并发必须受控——聚合为此设计,代价是结构服从一致性而非查询便利。查询侧在乎形态:筛选、聚合、排序、跨域关联——视图为此设计,代价是没有业务规则可言、只管被填充。两边各拿各的结构,中间用事件把"发生了什么"传给读侧。

分离后的读侧不装业务规则——这是它合法性的来源。读模型里的每个字段都是对已发生事实的陈述("退款状态:有"),不含任何"应该怎样"的判断;所有判断仍归命令侧。如果发现自己开始在投影里写业务条件,说明这条规则被放错了边,拉回聚合。

// 查询侧:客服视角的订单列表视图(就是一张为页面而生的表) public class CustomerServiceOrderView { public TradeOrderId orderId; public String buyerNickname; public Money payable; public String orderStatus; public RefundFlag refundFlag; // 近三个月有无退款:页面要什么就存什么 public Instant lastRefundedAt; } // 投影器:订阅领域事件,维护视图——纯机械,无业务规则 public class CustomerServiceOrderProjection { private final ViewDao dao; @EventHandler public void on(OrderPlaced e) { dao.insert(new CustomerServiceOrderView(e.orderId(), e.buyerNickname(), e.payable(), "WAIT_PAYMENT", RefundFlag.none(), null)); } @EventHandler public void on(RefundCompleted e) { // 退款上下文的事实广播 dao.updateRefundFlag(e.orderId(), RefundFlag.recent(e.completedAt())); } @EventHandler public void on(DayPassed e) { // 时间推进让"近三个月"过期 dao.expireStaleRefundFlags(e.at().minus(90, DAYS)); } } // 查询入口:直查视图,不碰聚合 public List<CustomerServiceOrderView> search(RefundFilter filter) { return dao.query(filter); // 一条 SQL 的事 }

三个值得注意的细节。投影订阅的是退款上下文的 RefundCompleted——跨上下文的数据经事件合法流入,不需要联表。视图冗余了付款人昵称——读侧的冗余是美德,页面要什么存什么,聚合不变就不算撒谎。"近三个月"的过期由时间事件驱动——视图连时间维度的规则都提前物化好了,查询时零计算。

CQRS 全景:两条路与一座桥

CQRS 全景:两条路与一座桥

什么时候值得拆

CQRS 不是默认选项,它的成本是双结构加事件同步的最终一致。三条判据同时满足才值得上。判据一:读形态与写结构实质冲突——像退款筛选这种聚合结构答不出的查询,且不是一两个(一两个用异构查询救急就行,成片冲突才值得立读侧)。判据二:读的规模与写的规模悬殊——订单列表读是写的一百倍,用一个为守恒设计的聚合去伺候高并发读,两头受气。判据三:读侧有独立的演化诉求——报表、客服、推荐各自要不同的视图形态,读库可以按需重建、按场景立多份,聚合一份不动。

三条只中一条的,用轻量方案:单库内建查询视图、定时刷新,别立消息总线。最常见的过度工程是把 CQRS 等于"上消息中间件加读写分离数据库"——青柚商城的第一个读视图就是应用内事件直接驱动投影更新的,零新增组件,两周上线。中间件是规模问题,CQRS 是结构问题,两者不需要同时解决。

⚠️ 读侧的纪律红线只有一条:投影不许回写命令侧。视图发现"库存不足"要改订单,是命令越权——它该发起一条命令走聚合,而不是顺手改数。读侧一旦可写,两套结构的分工当场瓦解,最终一致会演变成没人说得清的不一致。

带走的判断

  • 聚合为一致性而生、对查询形态失明;读形态成片冲突时,另立读侧结构比扭曲聚合便宜。
  • 读侧只陈述事实、不装业务规则;投影里出现业务判断,说明规则放错了边。
  • 三判据齐上才值得拆;单条满足用轻量方案,消息中间件是规模问题不是 CQRS 的必要件。
  • 红线一条:读侧永不回写命令侧,缺的接口走命令补。

读侧存的是"现在的样子",可财务要的是"每一步的来路"——把镜头转向事实本身:6.2 事件溯源。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U