当前位置: 首页 > news >正文

【架构实战】CQRS命令查询职责分离:读写分离的进阶实践

【架构实战】CQRS命令查询职责分离:读写分离的进阶实践

一、背景:一个报表查询拖垮了整个交易系统

2020年双十一,我们交易系统出现了一次P0事故。

事情的起因很简单:运营想在活动看板上实时查看"各渠道的GMV和转化率"。后端查询需要JOIN五张表(订单表、订单明细表、渠道表、用户表、支付流水表),做了三个聚合计算(SUM、COUNT DISTINCT、GROUP BY)。

上午10点,运营打开了看板页面。30秒后,交易系统响应时间从50ms飙升到8秒。数据库CPU从30%直接打到100%。所有下单请求开始超时。

根本原因:查询和命令共享了同一套数据模型。交易系统用的主库既要处理高并发写入(下单),又要承受复杂的分析查询(报表)。这在传统架构里是无解的——你不可能对着一张既要快速插入又要复杂查询的表,同时优化写性能和读性能。

这就是CQRS要解决的问题。


二、CQRS核心原理

2.1 什么是CQRS

CQRS(Command Query Responsibility Segregation,命令查询职责分离)是Greg Young在2010年提出的一种架构模式。核心思想只有一句话:将系统的读操作和写操作分离到不同的模型

【传统架构】 ┌──────────┐ │ Controller │ └──────┬───┘ │ ┌──────▼───┐ │ Service │ ← 读写混合,一个模型同时处理 └──────┬───┘ │ ┌────────────┼────────────┐ │ │ │ ┌─────▼─────┐ ┌────▼────┐ ┌─────▼─────┐ │ 查询1 │ │ 写入 │ │ 查询2 │ │ (报表) │ │ (下单) │ │ (详情) │ └───────────┘ └─────────┘ └───────────┘ 【CQRS架构】 ┌──────────┐ │ 应用层 │ └──────┬───┘ │ ┌────────────┼────────────┐ │ │ ┌─────▼─────┐ ┌────▼────┐ │ 命令模型 │ │ 查询模型 │ │(Command) │ │(Query) │ │ 写优化 │────事件─────▶│ 读优化 │ └─────┬─────┘ └────┬────┘ │ │ ┌─────▼─────┐ ┌────▼────┐ │ 写库 │ │ 读库 │ │ MySQL(主) │ │ ES/Redis │ └───────────┘ └──────────┘

2.2 CQRS vs 传统读写分离

很多人以为CQRS就是数据库主从读写分离。这是一个常见的误解。

维度数据库读写分离CQRS
分离层级数据库层应用层(模型层)
数据模型同一个表结构不同的数据模型(可以不同表、甚至不同数据库)
一致性主从复制延迟(秒级)事件异步同步(毫秒到秒级)
优化方向主库优化写入,从库分担读压力写模型优化业务完整性,读模型优化查询性能
适用场景读多写少读写模型差异大、查询复杂

一句话总结:CQRS是模型级别的读写分离,传统方案是数据级别的读写分离。

2.3 何时该用CQRS

不是所有系统都需要CQRS。判断标准:

需要CQRS的标志: ✅ 查询需要的数据结构与写入的数据结构差异很大 ✅ 查询需要JOIN多张表,写入只需要单表 ✅ 查询量远大于写入量(100:1以上) ✅ 查询需要跨多个限界上下文聚合数据 不需要CQRS的标志: ❌ 读写模型几乎一致 ❌ 业务逻辑简单,CRUD即可 ❌ 团队规模小,引入CQRS增加维护成本 ❌ 查询和写入量都比较低

三、实战:交易系统CQRS改造

3.1 命令模型(写模型)

// ===== 命令端:领域模型,保持业务完整性 =====// 命令对象@DatapublicclassCreateOrderCommand{@NotBlankprivateStringuserId;@NotEmptyprivateList<OrderItemCommand>items;privateStringcouponId;privateStringaddressId;}// 命令处理器@ServicepublicclassCreateOrderCommandHandler{@AutowiredprivateOrderRepositoryorderRepository;@AutowiredprivateInventoryServiceinventoryService;@AutowiredprivateEventBuseventBus;@TransactionalpublicOrderIdhandle(CreateOrderCommandcommand){// 1. 创建订单聚合(充血模型,包含业务规则)Orderorder=Order.create(command);// 2. 扣减库存command.getItems().forEach(item->inventoryService.deduct(item.getProductId(),item.getQuantity()));// 3. 持久化orderRepository.save(order);// 4. 发布领域事件 → 驱动读模型更新eventBus.publish(newOrderCreatedEvent(order));returnorder.getId();}}// 订单聚合(写模型:关注业务完整性)@Entity@Table(name="t_order")publicclassOrder{@IdprivateStringorderId;privateStringuserId;privateBigDecimaltotalAmount;privateStringstatus;// CREATED → PAID → SHIPPED → COMPLETEDprivateStringaddressId;privateStringcouponId;privateLocalDateTimecreatedAt;// 订单明细(一对多)@OneToMany(cascade=CascadeType.ALL,fetch=FetchType.LAZY)@JoinColumn(name="order_id")privateList<OrderItem>items;// ===== 业务方法 =====publicstaticOrdercreate(CreateOrderCommandcommand){Orderorder=newOrder();order.orderId=OrderIdGenerator.generate();order.userId=command.getUserId();order.status="CREATED";order.createdAt=LocalDateTime.now();// 计算订单金额order.items=command.getItems().stream().map(item->OrderItem.create(order.orderId,item)).collect(Collectors.toList());order.totalAmount=order.items.stream().map(OrderItem::getSubTotal).reduce(BigDecimal.ZERO,BigDecimal::add);returnorder;}publicvoidpay(StringpayMethod){if(!"CREATED".equals(this.status)){thrownewOrderException("只能支付创建状态的订单");}this.status="PAID";}}

3.2 查询模型(读模型)

// ===== 查询端:扁平化、宽表、NoSQL,优化查询性能 =====// 读模型DTO:一张宽表,包含所有展示需要的数据@Document(indexName="order_view")@DatapublicclassOrderView{@IdprivateStringorderId;privateStringuserId;privateStringuserName;// 冗余用户名privateStringuserPhone;// 冗余用户手机privateBigDecimaltotalAmount;privateStringstatus;privateStringstatusDisplay;// 状态中文名:已创建/已支付/已发货privateStringaddressDetail;// 冗余地址详情privateStringcouponName;// 冗余优惠券名称privateList<OrderItemView>items;// 嵌套对象privateLocalDateTimecreatedAt;privateLocalDateTimepaidAt;}// 查询处理器@ServicepublicclassOrderQueryHandler{@AutowiredprivateElasticsearchTemplateesTemplate;@AutowiredprivateRedisTemplate<String,OrderView>redisTemplate;publicPageResult<OrderListDTO>listOrders(ListOrdersQueryquery){// 构建ES查询NativeSearchQuerysearchQuery=newNativeSearchQueryBuilder().withQuery(QueryBuilders.boolQuery().must(QueryBuilders.termQuery("userId",query.getUserId())).must(QueryBuilders.rangeQuery("createdAt").gte(query.getStartTime()).lte(query.getEndTime()))).withSort(SortBuilders.fieldSort("createdAt").order(SortOrder.DESC)).withPageable(PageRequest.of(query.getPage(),query.getSize())).build();// 从ES查询(毫秒级响应)returnesTemplate.search(searchQuery,OrderView.class);}publicOrderDetailDTOgetOrderDetail(StringorderId){// 先查缓存StringcacheKey="order:detail:"+orderId;OrderViewcached=redisTemplate.opsForValue().get(cacheKey);if(cached!=null){returnOrderDetailDTO.from(cached);}// 缓存未命中,查ESOrderViewview=esTemplate.get(orderId,OrderView.class);if(view!=null){redisTemplate.opsForValue().set(cacheKey,view,5,TimeUnit.MINUTES);}returnOrderDetailDTO.from(view);}// 运营看板:实时聚合查询(ClickHouse)publicDashboardDTOgetDashboard(){// 从ClickHouse查询聚合数据Stringsql=""" SELECT channel_id, COUNT(DISTINCT user_id) as uv, COUNT(*) as order_cnt, SUM(amount) as gmv, SUM(amount) / COUNT(*) as avg_order_amount FROM order_wide_table WHERE created_at >= today() GROUP BY channel_id """;returnclickhouseTemplate.query(sql,DashboardDTO.class);}}

3.3 事件处理器:同步读写模型

// ===== 写模型变更 → 发布事件 → 读模型更新 =====@ComponentpublicclassOrderEventProjector{@AutowiredprivateElasticsearchTemplateesTemplate;@AutowiredprivateRedisTemplate<String,OrderView>redisTemplate;// 监听订单创建事件@EventListener@Async@TransactionalEventListener(phase=TransactionPhase.AFTER_COMMIT)publicvoidonOrderCreated(OrderCreatedEventevent){Orderorder=event.getOrder();// 构建读模型(聚合多源数据)OrderViewview=newOrderView();view.setOrderId(order.getOrderId());view.setUserId(order.getUserId());view.setUserName(userService.getName(order.getUserId()));// 冗余用户名view.setTotalAmount(order.getTotalAmount());view.setStatus("CREATED");view.setStatusDisplay("已创建");view.setAddressDetail(addressService.getDetail(order.getAddressId()));if(order.getCouponId()!=null){view.setCouponName(couponService.getName(order.getCouponId()));}view.setItems(order.getItems().stream().map(item->{OrderItemViewitemView=newOrderItemView();itemView.setProductId(item.getProductId());itemView.setProductName(productService.getName(item.getProductId()));itemView.setQuantity(item.getQuantity());itemView.setPrice(item.getPrice());returnitemView;}).collect(Collectors.toList()));// 写入ES(查询模型)esTemplate.save(view);// 清除列表缓存StringlistCacheKey="order:list:"+order.getUserId();redisTemplate.delete(listCacheKey);log.info("读模型已更新: orderId={}",order.getOrderId());}// 监听订单支付完成事件@EventListener@Async@TransactionalEventListener(phase=TransactionPhase.AFTER_COMMIT)publicvoidonOrderPaid(OrderPaidEventevent){// 只更新变化的状态字段(增量更新)UpdateRequestupdateRequest=newUpdateRequest().set("status","PAID").set("statusDisplay","已支付").set("paidAt",event.getPaidAt());esTemplate.update(updateRequest,OrderView.class,event.getOrderId());// 清除详情缓存StringdetailCacheKey="order:detail:"+event.getOrderId();redisTemplate.delete(detailCacheKey);}}

四、CQRS的核心挑战与应对

4.1 最终一致性

问题:写模型更新了,但读模型还没更新,用户刷新后看到旧数据。

应对策略

策略说明适用场景
命令端返回最新状态写入成功后,直接在响应中返回最新数据简单场景,下单后展示订单详情
前端轮询+loading前端显示"处理中",定时刷新直到数据一致秒杀、支付结果查询
事件驱动+WebSocket推送读模型更新后,主动推送给前端实时要求高的场景
乐观UI更新提交后立即在前端显示新状态,后台异步纠正社交互动(点赞、评论)

我们的方案:命令执行成功后,对于关键操作(支付、退款),使用WebSocket主动推送状态变更;对于非关键操作(浏览记录),接受秒级延迟。

4.2 读写模型差异大如何保证数据正确

问题:读模型从多个数据源聚合数据,如何保证聚合逻辑的正确性?

应对策略投影(Projection)模式——每个事件驱动的读模型更新,都应该是一个可重放的、幂等的函数。

// 读模型重建:当发现数据不一致时,可以全量重建@ComponentpublicclassOrderViewRebuilder{@AutowiredprivateOrderRepositoryorderRepository;@AutowiredprivateElasticsearchTemplateesTemplate;/** 全量重建指定时间范围的读模型 */@Scheduled(cron="0 0 3 * * ?")// 每天凌晨3点publicvoidrebuildDailyViews(){LocalDateTimeyesterday=LocalDateTime.now().minusDays(1);LocalDateTimetoday=LocalDateTime.now();List<Order>orders=orderRepository.findByCreatedAtBetween(yesterday,today);for(Orderorder:orders){OrderViewview=buildOrderView(order);esTemplate.save(view);}log.info("读模型重建完成: 共{}条订单",orders.size());}}

4.3 事件顺序问题

如果订单先发生"创建"事件,再发生"支付"事件,但如果"支付"事件先到达投影器,就会导致状态错误。

解决方案

// 利用数据库的乐观锁保证顺序@Document(indexName="order_view")publicclassOrderView{@VersionprivateLongversion;// ES版本号,乐观锁// 只有高版本才能覆盖低版本}// 投影器:只接受更高版本的事件publicvoidonOrderPaid(OrderPaidEventevent){// 检查当前读模型的版本OrderViewcurrent=esTemplate.get(event.getOrderId(),OrderView.class);if(current!=null&&current.getVersion()>=event.getVersion()){log.warn("忽略过期事件: orderId={}, currentVersion={}, eventVersion={}",event.getOrderId(),current.getVersion(),event.getVersion());return;}// 更新读模型...}

五、CQRS配合Event Sourcing的进阶用法

当CQRS和Event Sourcing结合,可以构建出可审计、可回溯的终极架构:

// 事件存储(Event Store)CREATETABLEevent_store(event_idVARCHAR(64)PRIMARYKEY,aggregate_idVARCHAR(64)NOTNULL,aggregate_typeVARCHAR(50)NOTNULL,event_typeVARCHAR(100)NOTNULL,event_dataJSONNOTNULL,versionINTNOTNULL,occurred_atTIMESTAMPNOTNULL,INDEXidx_aggregate(aggregate_id,version));// 命令处理器:不修改状态,只追加事件@ServicepublicclassOrderCommandHandler{@TransactionalpublicvoidpayOrder(PayOrderCommandcommand){// 1. 加载事件流List<Event>events=eventStore.load(command.getOrderId());// 2. 重放事件,重建聚合状态Orderorder=Order.replay(events);// 3. 执行业务操作,产生新事件OrderPaidEventpaidEvent=order.pay(command.getPayMethod());// 4. 追加事件eventStore.append(command.getOrderId(),paidEvent,events.size()+1);// 5. 发布事件(异步更新读模型)eventBus.publish(paidEvent);}}

六、技术选型建议

组件推荐方案备选方案
写数据库MySQL/PostgreSQLMongoDB
读数据库(搜索)ElasticsearchSolr
读数据库(聚合)ClickHouseDruid、Doris
缓存Redis ClusterCaffeine(本地)
事件总线RocketMQ/KafkaRabbitMQ
事件存储PostgreSQL/EventStoreDBAxon Framework

七、总结

CQRS不是银弹,它有明显的代价:系统复杂度翻倍,运维成本增加,最终一致性需要额外处理

但如果你的系统面临以下情况,CQRS是值得投入的:

  1. 读写模型差异大:查询需要JOIN多张表,写入需要保持事务完整性
  2. 读写比例悬殊:大量复杂查询拖慢写入性能
  3. 团队足够成熟:能驾驭事件驱动、最终一致性、投影重建等概念
  4. 业务价值足够高:性能提升带来的业务收益 > 架构复杂度带来的成本

核心经验

  • 从最简单的CQRS开始:先分离读写Service,再分离数据模型,最后分离数据库
  • 读模型可以有多个:搜索用ES、聚合用ClickHouse、列表用Redis,各司其职
  • 不要追求即时一致性:接受秒级延迟,用"好体验"覆盖"小延迟"
  • 建立读模型重建机制:比"保证永远正确"更重要的是"出错后能快速修复"

CQRS的本质是用空间换时间、用最终一致性换性能。当你接受这个权衡时,你离高可用的系统架构就不远了。


个人观点,仅供参考

http://www.jsqmd.com/news/1100854/

相关文章:

  • Resemble Enhance终极指南:3分钟掌握AI语音降噪增强技术
  • PHP应用防火墙AWD Watchbird部署指南:从原理到实战
  • Seedance 2.0鉴权插件离线部署:安全验证与KMS绑定全流程
  • 保姆级教程:用华为/锐捷设备手把手配置LDP动态LSP(含PHP优化与常见排错)
  • 信号处理入门:用Python手把手实现傅里叶级数可视化(附周期延拓代码)
  • 别再死记硬背了!用Python(NumPy)和MATLAB动手验证矩阵可逆的5个等价条件
  • 手把手教你用MS7024芯片搞定车载视频数字信号转AV/SV(附完整配置代码)
  • 告别丑图表!用C# Winform Chart控件打造高颜值柱状图(附完整配色与样式代码)
  • Blender资产浏览器保姆级教程:从零搭建你的3D素材库(附PoseLibrary插件配置)
  • GPT-5.4 API 中转站怎么选?使用 kingflow 快速接入高阶 AI 大模型 API
  • 从协议栈到空口验证:YunSDR打造4G/5G软件定义综合测试平台
  • 随身WiFi信号太差?手把手教你低成本改装双天线(附FPC天线焊接与短接避坑指南)
  • 如何用ShaderGlass为Windows桌面添加实时GPU着色器效果:终极视觉增强指南
  • 思路及解答排序列表法
  • 用VirtualLab Fusion搞定光栅建模:从单光栅分析到复杂系统集成的保姆级教程
  • VisualCppRedist AIO:Windows运行库终极解决方案完整指南
  • Hi7003替代H5118:60V输入与模拟/PWM双模调光的国产升级方案
  • DC-DC电源中,什么是功率地?
  • Pandas 数据分析库常用操作大全
  • 别再手动画图了!用SuperMap iDesktop的‘获取投影面’功能,5分钟搞定三维模型二维化
  • VisualCppRedist AIO:告别DLL缺失烦恼的终极解决方案
  • 从YOLO到3D点云目标检测:原理、环境搭建与实战复现
  • 众包平台任务分发与防骗机制设计——以帮帮星球为例
  • 计算机毕业设计之基于教育数字化的可视化系统的设计与实现
  • 别再手动写XML了!用Flowable UI拖拽式设计请假审批流程(附BPMN文件)
  • ANSYS APDL命令流实战:从截面特性到节点耦合,我的工程笔记大公开
  • 【Sora vs 可灵AI决策指南】:企业级视频生产选型必查的6个隐藏参数(含API吞吐量、长时序一致性、中文语义理解得分)
  • GPT Image 2 提示词教程:解决图片脏、模糊、有噪点的终极方法
  • 2026年6月国内外商城小程序开发公司测评:按价格区间、开发方式和交付能力选择,含零代码SAAS、AI编程、源码定制
  • 告别字符串处理噩梦:用MySQL的regexp_replace、regexp_substr、regexp_instr函数搞定数据清洗