命令查询职责分离:从痛点到爽点的一跃

我至今还记得那个凌晨三点的线上事故——订单列表刷不出来,写入倒是正常。查了半小时,就因为我们把查询和写操作的代码塞进同一个Service,一个慢查询拖垮了整个数据库连接池。当时我拍桌子骂:“谁他妈说读写必须搁一块儿的?” 事后冷静下来,这其实就是命令查询职责分离(CQRS)想要解决的核心矛盾。

CQRS 这概念最早是 Greg Young 提的,但你要是觉得它只是让你把读和写拆成两个 service,那就踩了第一个坑。咱们得撕开表象,看看它的底层机制。

布隆过滤器、投影和位图索引:读模型的脏活

布隆过滤器、投影和位图索引:读模型的脏活
布隆过滤器、投影和位图索引:读模型的脏活
命令模型关心的是“发生了什么”。比如一个订单,有创建、支付、发货这些状态变迁。在传统 CRUD 里,我们直接改那条订单记录,查询时再关联七八张表出报表。但 CQRS 说:别,命令侧只管写,然后发出领域事件;查询侧收到事件后,把数据揉成各种视图——这个过程叫投影(Projection)。

投影可以简单到只是一条 SQL 插入,但棘手的是怎么保证查询侧的数据能高效检索。比如要支持组合条件筛选:“最近一周、金额大于100、已支付的订单”。你可以直接在读库里建一堆索引,但索引是昂贵的——我说的不止是磁盘,还有写放大。我们团队的方案是:读模型用 Elasticsearch,但命令侧事件发到消息队列后,由一个轻量级的投影引擎用位图索引(RoaringBitmap)做批量聚合写入。RoaringBitmap 这东西,原理是把 int 集合分成块,高16位做索引,低16位用容器存,容器可以是位图或数组,自适应。结果就是,比起简单往 ES 灌数据,我们的投影写入 TPS 从 1500 提到 4300,还不算压缩省下的内存。

压测数据不说谎:单独读库 + 缓存 > 你优化的 SQL

压测数据不说谎:单独读库 + 缓存 > 你优化的 SQL” style=”max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);” /><figcaption style=压测数据不说谎:单独读库 + 缓存 > 你优化的 SQL
光嘴上说 CQRS 好没用,得上数据。我们电商系统的一个交易台,改造前是一套 MySQL 扛所有——写入订单、库存扣减、外加后台的30种报表查询。全链路压测时,500并发下单,报表查询一大,写入 TPS 直接腰斩到 120,P99 延迟 4 秒。拆分后,命令侧只保留必要的联表(比如扣库存),剥离所有复杂查询;查询侧用独立的 PostgreSQL 从库,配上 Redis 缓存热点报表。同样是 500 并发,写入 TPS 飙到 680,P99 降到 80ms;查询 QPS 从 2000 拉到 12000,缓存命中率 92%。你敢说这不香?——但别急着照搬,看看下面这三个血泪坑。

坑一:异步投影带来的“假一致”

坑一:异步投影带来的“假一致”
坑一:异步投影带来的“假一致”
你刚下完单,立马去“我的订单”页面,结果发现订单没出来?这是因为命令侧写完事务,发出事件到查询侧更新存在延迟。产品经理可不管什么最终一致性,拍桌子就问:“为什么老子付了钱还显示‘待支付’?”

解决方案:关键场景必须走写后读一致(read-your-writes)。我们做法是:命令侧写完订单后,把订单ID写入一个布隆过滤器(用 Redis 实现,容量预估好,误判率控制在1%以下),查询侧在故障时回退到读命令库。或者更粗暴的:支付成功页直接展示命令侧的数据,不依赖读模型。至于非关键场景,监控投影延迟就行了——我们设置了 100ms 的告警阈值,一旦积压就自动扩容消费者。

坑二:事件溯源 + CQRS 的幂等性噩梦

如果你给命令侧上了事件溯源(Event Sourcing),那就有得折腾了。因为每次状态变化是一条不可变的事件,读模型重放事件来构建当前状态。可万一消息队列重复投递?或者投影进程崩溃重启,又重放了一部分事件?你的读库可能就会出现奇怪的数据重复或状态回滚。我们试过 Kafka 的 at-least-once 语义,投影引擎没做幂等检查,结果财务报表里同一条退款居然出现两次——审计那边的同事差点没把我吃了。

解决方案:投影必须基于事件全局序号做幂等。我们在每条事件里附加一个单调递增的序号(由命令侧的一个全局发号器生成,比如用数据库自增ID或 Snowflake 变种),投影侧本地持久化“已处理的最新序号”,重启时从此序号+1开始消费。同时,所有投影写入用 INSERT … ON CONFLICT DO NOTHING(或 upsert)来防重。这套机制在 3000 TPS 事件流下运行稳定,偶尔重复投递也没翻车。

坑三:把 CQRS 当成微服务的嫁衣

很多团队一上来就拍板:“既然读写分离,那命令和查询微服务化吧!”真别。CQRS 是架构模式,不是微服务的前提。我们见过一个项目,一个单体库拆成 4 个命令服务、8 个查询服务,跨服务调用链比蜘蛛网还密,最后定位个 bug 要开三个追踪面板。更可笑的是,查询侧因为要支持复杂过滤,又引入了 GraphQL,研发成本翻了 3 倍,性能却没比原来好多少——因为网络开销把优化吃掉了。

解决方案先用模块边界代替网络边界。你可以把命令和查询搞成同一个应用里的不同包,中间用事件总线(比如 Spring 的 ApplicationEvent 或 Guava EventBus)解耦。等到真正需要独立伸缩时再拆分服务。我们现在的策略是:查询侧如果 QPS 增长到需要独立扩缩容了(比如超过 5000 QPS 且 CPU 持续 > 70%),才考虑抽离为微服务。否则,一个 WAR 包搞定,简单得让人想哭。

说到底,CQRS 这东西不是让你炫技的,它是为了解决读写负载不对称、模型不匹配的实在问题。就像一个精巧的齿轮组,用对了,你感觉不到它的存在;用错了,到处都是嘎吱嘎吱的响声。最后提一句,如果你准备落地这套模式,建议读一下 Martin Fowler 那一篇经典的《CQRS》,虽然有点年头,但里面对 CAP 和最终一致性的论述至今一针见血。
CQRS架构详细组件交互图
CQRS架构详细组件交互图
电商高并发场景下CQRS与传统CRUD性能对比柱状图
电商高并发场景下CQRS与传统CRUD性能对比柱状图
免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:命令查询职责分离:从痛点到爽点的一跃
文章链接:https://lfdjt.com/info_23_7564.html