一个订单系统先在数据库层面做了读写分离:主库写、从库读。跑得挺好,直到业务要求"下单后立刻能在列表里看到这一单"——从库延迟 200 ms,用户刷新三次都没出现。于是加了"当前用户读主库"的白名单,白名单越来越长,最后几乎等于没做主从分离。
这就是 CQRS 最常见的半途状态:读侧已经和写侧分开了,但两者仍共享同一个数据模型。真正的 CQRS 分的不是"库",是模型——写侧按命令与不变量建模,读侧按查询视图建模。而这个分法一旦落地,代价也随之而来:两份模型的一致性、查询侧的 schema 演进、以及"重放"这条唯一的修复路径。
1. 三层"读写分离",成本差一个量级
| 层 | 做法 | 分离的是什么 | 代价 |
|---|---|---|---|
| 物理分离 | 主从、读写路由 | 负载 | 复制延迟 |
| 模型分离(CQRS) | 写模型 + 读模型(视图表/搜索索引) | 数据形状 | 双写一致性 |
| 通道分离 | 命令总线 + 查询总线 | 调用路径 | 复杂度 |
第二行是分水岭:第一层只是"把同样的表复制一份",第二层是"为查询单独设计一份结构"。绝大多数"读写分离踩坑"其实卡在第二层的门槛上——它要求的不是加个从库,而是接受"同一份业务事实存在两种表示"。
2. 读模型该怎么建:三条路线
| 路线 | 机制 | 新鲜度 | 适用 |
|---|---|---|---|
| 同库视图表 | 写事务里同步更新 | 强(同事务) | 视图简单、写量不高 |
| 事件投影 | 订阅变更事件异步更新 | 最终一致(毫秒–秒) | 读侧复杂、写量大 |
| 定期重建 | 批量重算视图 | 分钟级 | 报表类、可容忍滞后 |
// 三条读路线的选择只看两件事:写量、查询形状
查询复杂 + 写量大 → 事件投影(异步,可重建)
查询简单 + 写量小 → 同库视图表(一个事务搞定)
只要报表/看板 → 定期重建(分钟级延迟可接受)
// 判据:写事务里能不能容忍"多一次视图写入"这笔开销
第一行最省事,也最容易埋坑:它在同一个事务里更新两张表,一旦视图更新逻辑变复杂(涉及聚合、外部调用),写事务就会被拖长,写吞吐直接下降。
第二行是标准答案,它的核心资产是"事件流"——读模型可以被删掉重建,因为事实都在事件里。这条性质带来一个很强的运维能力:视图改结构时,不用停机迁移,重建即可。
// 一次读模型重建的时间线(不停机)
1 新建 order_view_v2,订阅同一事件流,从 offset=0 开始回放
2 回放期间双写:事件同时进入 v1 与 v2
3 追平后(lag ≈ 0)切读流量:50% → 100%
4 观察一天,删除 v1
// 关键:v2 是"算出来的",不是"迁出来的"——这就是事件源的价值
3. 一致性:三个必须回答的问题
CQRS 把它从"数据库事务"变成了"业务问题",必须显式回答:
| 问题 | 常见答案 | 后果 |
|---|---|---|
| 用户写完之后立刻读,读不到怎么办 | 读己之写(粘主 / 本地回填) | 复杂度上升,需要会话级路由 |
| 两个视图之间不一致怎么办 | 以事件为准,重建 | 需要事件流完整 |
| 事件丢了怎么办 | 事件必须持久化 + 可重放 | 事件存储成为关键路径 |
// 验证"读己之写"是否真的生效——最容易漏的一步
$ id=$(curl -s -XPOST localhost:8080/orders -d '{"sku":"A1"}' | jq -r .id)
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/orders/$id
200 // 有承诺;若是 404,说明写完之后读不到,承诺没兑现
// 注意:多数集成测试只断言"创建成功",不断言"立刻可读"
第一行是实践里最常被低估的:它不是一个技术问题,是一个产品承诺。"提交成功后立刻能在列表看到"这句话,决定了你要不要做"读己之写",以及要做多强。
第三行是硬约束:如果事件只在内存队列里传,那 CQRS 就不是架构,是一次赌博——事件丢了,读模型永远追不上。
// 一个最小可用的 CQRS 约束清单
cqrs:
event_store: durable // 事件必须落盘,不能只在 MQ
projection: idempotent // 投影必须能重复消费
read_your_writes: session_sticky // 或本地回填,二选一
rebuild_path: supported // 必须演练过"从 0 重建"
lag_alert: p99_lag > 5s // 读模型滞后要能报警
4. 什么时候该上,什么时候不该
| 信号 | 该上 CQRS | 理由 |
|---|---|---|
| 读写比例 > 10:1 且查询形态复杂 | ✅ | 读侧值得单独建模 |
| 同一份数据要服务多个查询视图 | ✅ | 视图各自演进 |
| 需要审计/回放能力 | ✅ | 事件流顺带给你 |
| 写量低、模型简单 | ❌ | 双写成本 > 收益 |
| 强一致是硬要求 | ❌ | 最终一致不可接受 |
| 团队没有事件驱动经验 | ❌ | 故障面会超出维护能力 |
第六行最容易被忽略:CQRS 的运维面(lag 监控、重放、幂等投影、事件版本兼容)是一整套新能力。没有这套能力,CQRS 会变成"一个经常不一致且不知道为什么的系统"。
5. 半途状态的三种典型症状
| 症状 | 根因 | 处置 |
|---|---|---|
| 读主库白名单越来越长 | 用白名单打补丁代替读己之写 | 做会话粘主或本地回填 |
| 视图表与主表对不上 | 双写无事务保护 | 改为事件投影 + 定期对账 |
| 改视图要停机迁移 | 视图被当成"数据"而不是"计算结果" | 支持从 0 重建 |
第三行是判定"你到底有没有做成 CQRS"的试金石:如果你的读模型不能删掉重建,那它其实是一张主表,只是换了个名字。
// 自检:读模型是否真的可重建(一行验证)
$ # 建一个空视图,回放全部事件,比对行数与校验和
$ SELECT COUNT(*), SUM(amount) FROM order_view_v2; -- 回放后
-- 与 v1 比对:不一致就说明投影逻辑不确定(依赖了当前时间/随机/外部状态)
6. 排错:五种"CQRS 之后更乱"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 用户刚下单看不到 | 未做读己之写 | 会话粘主 / 本地回填 |
| 视图偶尔少一条 | 投影不幂等(重复消费覆盖) | 加版本号或幂等键 |
| 重建后数据不对 | 投影依赖了当前时间/随机 | 投影只依赖事件内容 |
| 事件版本升级后老投影报错 | 事件 schema 无版本 | 事件加 version + 兼容反序列化 |
| 排查问题要翻三个地方 | 缺统一 traceId | 事件带 traceId 贯穿 |
第四行是长期债:事件一旦写进存储就改不了(改了历史就不可重放)。所以事件 schema 必须从一开始就带版本,且新增字段只能可选。
7. 边界:什么情况用"轻量版"就够
| 场景 | 不必上 CQRS | 替代 |
|---|---|---|
| 单表查询为主 | 视图=表,无收益 | 索引优化 |
| 读延迟可接受 1 秒 | 简单缓存/从库足够 | 缓存 + TTL |
| 查询形态稳定 | 一份模型够用 | 只做读写分离 |
| 团队规模小 | 运维面撑不住 | 单体 + 缓存 |
判断口径一句话:CQRS 解决的是"一份模型无法同时服务写的不变量与读的查询形状",不是"读得慢"——读慢先看索引与缓存;只有当读侧需要"另一种数据形状"时,才轮到 CQRS。而它成立的前提是事件可持久、投影可重建,这两条缺一条,半途状态就是必然结局。
8. 自测
- 三层"读写分离"分别分离什么?哪一层是 CQRS 的分水岭?
- 为什么"同库视图表"在视图复杂后会拖慢写?
- "读己之写"为什么是产品问题而不是技术问题?两种实现各有什么代价?
- 判断"读模型是否真是 CQRS"的试金石是什么?
- 事件 schema 为什么必须从第一天就带版本?
- 什么信号说明不该上 CQRS?判据是什么?
9. 一句话总结
CQRS 分的不是库,是模型:写侧按不变量建模、读侧按查询形状建模,两者靠事件连起来。它成立的两个前提是事件可持久与投影可重建——读模型能删掉重算,才叫 CQRS;不能重建的视图只是一张换了名字的主表。判据:读慢先看索引与缓存,只有当读侧需要"另一种数据形状"时才轮到它;而强一致、团队无事件经验、写量低这三个条件任一成立,轻量版就够。