CQRS 这概念最早是 Greg Young 提的,但你要是觉得它只是让你把读和写拆成两个 service,那就踩了第一个坑。咱们得撕开表象,看看它的底层机制。
布隆过滤器、投影和位图索引:读模型的脏活

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

解决方案:关键场景必须走写后读一致(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 和最终一致性的论述至今一针见血。

