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 纳入监控。