32.1 CQRS

一句话

CQRS 不是 “读写分库”,而是 把 “改数据” 与 “查数据” 当成两个模型来设计:写侧只关心规则与一致性,读侧只关心查询形状;中间靠事件/CDC 同步——代价是读写之间有延迟窗口。

三件套

组件 职责 形态
命令侧(write model) 校验规则、事务、聚合一致性 关系库、事件日志
查询侧(read model) 按查询形状预聚合 宽表、ES、Redis、物化视图
同步机制 把写侧变化投给读侧 领域事件、CDC(见 46.4 CDC (变更数据捕获))

本机实测:查 “扫日志” 与 “查视图” 的差距

20 万条事件、1000 个账户:

查询方式 单次耗时 相对
直接扫事件日志聚合 5.1ms 1×
查预聚合视图 0.0004ms 约 13,000×
写入(追加事件) 1.6ms / 20 万条 —

结论:查询侧的价值随 “数据量 × 查询频率” 放大;但视图是冗余数据,必须接受 “它可能落后于写侧几百毫秒”。

代码:写侧只发事件,读侧自己聚合

// 写侧:命令处理,成功后发事件(同一事务或 outbox)
func (h *Handler) PlaceOrder(ctx context.Context, cmd PlaceOrder) error {
    if err := h.repo.Insert(ctx, cmd); err != nil { return err }
    return h.outbox.Publish(ctx, OrderPlaced{OrderID: cmd.ID})
}

// 读侧:投影消费者,把事件折叠成查询表
func (p *Projector) On(e OrderPlaced) {
    p.db.Exec(`INSERT INTO order_view(id, status, amount, created_at)
               VALUES (?, 'placed', ?, ?)
               ON CONFLICT (id) DO UPDATE SET status='placed'`,
        e.OrderID, e.Amount, e.At)
}

实测:投影滞后有多大

模拟写侧连续写入、投影按 “落后约 120 条” 追赶(5 万事件、2000 次采样):

指标 滞后条数
P50 117
P99 211
最大 256

换算成时间:若写侧 1000 条/秒,P99 滞后 ≈ 0.21 秒;写侧 100 条/秒则 ≈ 2.1 秒。这决定了 “读己之写” 的窗口要设多大——滞后是压测出来的,不是拍脑袋的。

滞后(条) ≈ 投影消费速率与写入速率之差 × 时间
读己之写窗口 ≥ P99 滞后时间 + 网络抖动余量

反模式

反模式 后果
只有一张表却自称 CQRS 读写仍互相拖累,没有收益
读侧强一致要求 要么退化成同步双写,要么一直报 “数据不一致”
同步机制没有重放 投影坏了只能全量重建,窗口不可控
读侧模型散落各处 每个人自己写 JOIN,等于没有读模型
用 CQRS 掩盖 “查询设计差” 索引/建模没做好,加个视图并不解决
写侧也走 “最终一致” 业务规则校验缺数据,出现超卖

| 用 “读侧强一致” 掩盖滞后 | 要么同步双写(失去 CQRS 意义),要么天天报不一致 | | 只有平均滞后没有 P99 | 长尾用户看到旧数据,投诉集中在少数人 |

三条铁律

🔑 写侧强一致、读侧可延迟;别让读模型反向约束事务。

🔑 同步机制必须可重放,否则投影无法修复。

🔑 CQRS 解决 “查询形状”,不解决 “查询性能差”。

自检

Q1 为什么 CQRS 会引入 “读到自己刚写的数据” 的问题?

▸ 答案:因为读侧是异步更新的:写完立刻查视图,可能还没投影到。解法三种:读己之写(写后 N 秒内读主库)、版本号等待(读到 ≥ 本次写入版本才返回)、前端乐观更新。选哪种取决于业务对 “刚写完看不到” 的容忍度。

Q2 CQRS 与 “读写分离(主从)” 的差别?

▸ 答案:读写分离只是同一模型的两份拷贝(主从延迟),模型仍是一个;CQRS 是两个不同的模型(写侧聚合 vs 读侧宽表),可以一个用关系库、一个用搜索引擎。前者解决 “读扩展”,后者解决 “查询形状与写模型不匹配”。

Q3 读侧要不要建唯一索引/约束?

▸ 答案:读侧通常不建业务约束,只建查询索引:约束属于写侧职责。读侧如果也加唯一约束,会在 “重放/重复投递” 时报错并卡住投影;正确做法是让投影幂等(upsert)。

Q4 什么时候不该上 CQRS?

▸ 答案:查询简单(按主键/少量索引即可)、一致性要求强、团队没有事件/投影运维能力时。CQRS 的收益在 “复杂查询 + 高读写比”,代价是 “两套模型 + 同步 + 重建”,三者不成立时它就是纯负担。

Q5 投影延迟怎么度量与告警?

▸ 答案:用位点差(写侧最新事件序号 − 投影已处理序号)与时间差(事件时间 − 投影处理时间)。位点差持续增长说明消费跟不上;时间差突增说明消费卡住。两者都要有阈值告警,而不是等用户投诉。

Q6 CQRS 的读侧可以有多少个?

▸ 答案:按查询场景可以有多个(订单列表、搜索、报表各一个),但每个都要有owner、重建脚本、延迟监控。读侧数量失控会变成 “影子数据库群”,维护成本超过收益。

Q7 投影滞后突然变大,先查什么?

▸ 答案:三件事:消费速率(是否卡在慢查询/锁)、分区倾斜(某个分区积压,其他空闲)、位点提交(是否批量提交导致 “处理了但位点没动”)。先看位点差曲线,再看消费端 P99,最后看下游存储的写入延迟。

故障复盘(典型模式,脱敏)

阶段 现象 判断 动作
上线 用户付款后刷新仍是 “待支付” 读侧滞后(实测 P99 211 条) 写后短窗读主库
T+1h 投诉集中在 “刚操作完” 的用户 读己之写问题 加版本等待
T+1d 版本等待导致主库压力上升 窗口设太大 缩到 P99 滞后 + 余量
复盘 未定义 “一致性窗口” 设计缺口 窗口写进 SLA

根因:CQRS 引入读侧延迟,但产品与接口都假设强一致。代价:投诉 + 主库压力。根治:明确 “读己之写” 策略(等版本/读主库/前端乐观),并把滞后 P99 纳入监控。

关联