2537 字
约 8 分钟
13
S18-A28 · CQRS:为什么"读写分离"做到一半就卡住了

一个订单系统先在数据库层面做了读写分离:主库写、从库读。跑得挺好,直到业务要求"下单后立刻能在列表里看到这一单"——从库延迟 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. 自测

  1. 三层"读写分离"分别分离什么?哪一层是 CQRS 的分水岭?
  2. 为什么"同库视图表"在视图复杂后会拖慢写?
  3. "读己之写"为什么是产品问题而不是技术问题?两种实现各有什么代价?
  4. 判断"读模型是否真是 CQRS"的试金石是什么?
  5. 事件 schema 为什么必须从第一天就带版本?
  6. 什么信号说明不该上 CQRS?判据是什么?

9. 一句话总结

CQRS 分的不是库,是模型:写侧按不变量建模、读侧按查询形状建模,两者靠事件连起来。它成立的两个前提是事件可持久与投影可重建——读模型能删掉重算,才叫 CQRS;不能重建的视图只是一张换了名字的主表。判据:读慢先看索引与缓存,只有当读侧需要"另一种数据形状"时才轮到它;而强一致、团队无事件经验、写量低这三个条件任一成立,轻量版就够。

S18-A28 · CQRS:为什么"读写分离"做到一半就卡住了
https://www.clxhxhhr.top/posts/6742/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。