Skip to content

CQRS 不是两套系统:从查询分离到读模型 ​

用聚合完成列表和报表,会加载大量用不上的数据;为了一个看板立刻引入两套数据库和事件溯源,又是过度设计。命令查询职责分离(CQRS)是一段连续的光谱,从「查询不走仓储」开始,按证据一级一级往上走。

运营看板要看「活动 1 的每个场次:确认多少人、候补多少人、收了多少钱」。200 个场次、5 万条报名。经过仓储逐个加载场次聚合、在内存里汇总,每次读取 50,200 行,平均 84.9ms;在同一个库上写一条 GROUP BY,31.4ms;查一张随报名命令一起更新的统计表,1.4ms。三种读法的结果完全相同。

本文在 MySQL 8.4.11 上比较这三种读法,再加上一个异步投影的读模型,演示投影暂停时读到的旧数据、恢复后的追赶,以及读模型损坏后怎样从事件重放。

一、先说结论 ​

  • 查询不必穿过聚合:聚合为执行命令而设计,用它做报表会加载完整的对象图。实测经聚合读取的行数是结果行数的 250 倍。
  • 最小的 CQRS 只是代码分离:同一个库、同一组表,查询写成单独的 SQL,不经过仓储。很多看板到这一步就够了。
  • 读模型用写入换读取:同步维护的统计表让看板查询从 31.4ms 降到 1.4ms;在这个规模下,报名命令多一条 UPDATE 的代价在测量波动之内。
  • 异步读模型要接受延迟窗口:投影器暂停时,200 个场次的统计全部落后于写侧;恢复后 15ms 追上积压的 500 个事件。
  • 读模型可以丢掉重建:人为改坏 10 个场次后,清空并从 84,440 个事件重放,约 0.5 秒恢复一致。
  • CQRS 不等于事件溯源:本文的命令侧仍然以状态为准,事件只用来维护读模型;两者可以组合,但各自独立成立。

二、为什么查询不该穿过聚合 ​

场次聚合是为了守护容量、重复和候补顺序而设计的。它的仓储按聚合根加载:先读场次,再读这个场次的全部报名。用它来做看板:

java
for (int id : sessionIdsOfEvent(1)) {
    Session s = sessions.load(id);                          // 1 行场次 + 250 行报名
    long confirmed = s.registrations().stream().filter(...).count();
    ...
}
text
经仓储加载聚合:返回 200 行,平均 84.9ms,每次读取 50200 行

看板只需要每个场次的三个数字,却把 5 万条报名逐条读进了内存,还发了 201 次查询。数据再多一个数量级,这个页面就会成为慢查询的主要来源。

更隐蔽的问题是仓储的膨胀:为了支持各种列表,仓储里会长出 findByEventAndStatusOrderByCreatedAt 之类的方法,每个方法都要返回完整的聚合。仓储应该只服务于命令,见 分层、应用服务与仓储。

三、分离程度的连续光谱 ​

0 共用聚合经仓储读84.9ms1 查询单独 SQL同库同表31.4ms2 同步读模型同一事务维护1.4ms3 异步投影可换存储接受延迟4 事件溯源以事件为准独立的选择强一致最终一致为决策而读永远走命令侧;只有为展示而读才能用读模型
图 1 · 同一个看板:经聚合 84.9ms,一条 GROUP BY 31.4ms,同步读模型 1.4ms;往右每走一级都多一份成本,异步投影开始接受延迟窗口,事件溯源是另一个独立的选择
级别做法一致性新增的成本
0命令和查询共用聚合与仓储强一致无,但查询慢、仓储膨胀
1查询单独写 SQL,直接读命令侧的表强一致查询和表结构耦合
2同一个库里的读模型表,与命令同一事务维护强一致每次命令多写一次;读模型结构变化要回填
3读模型由投影器异步维护,可以在另一个库甚至另一种存储里最终一致延迟窗口、投影器的运维、重放
4事件溯源:命令侧以事件为准,状态由事件重建取决于实现事件版本管理、快照、完全不同的编程模型

第 4 级与前面几级是正交的:可以只做事件溯源而不分离读模型,也可以像本文一样分离读模型而不做事件溯源。

3.1 第一级:一条 GROUP BY ​

sql
SELECT s.id, SUM(r.status='CONFIRMED'), SUM(r.status='WAITLISTED'), SUM(r.paid_cents)
FROM session s JOIN registration r ON r.session_id = s.id
WHERE s.event_id = 1 GROUP BY s.id ORDER BY s.id
text
平均 31.4ms;执行计划节选:
-> Nested loop inner join (actual rows=50000)
    -> Covering index lookup on s using idx_event (event_id=1) (rows=200)
    -> Index lookup on r using uk_session_attendee (session_id=s.id) (rows=250 loops=200)

数据库仍然要扫 5 万行报名,但只返回 200 行,省掉了对象构建和 200 次往返。这一级不需要任何新的表、新的进程或新的一致性问题,只是承认「查询和命令是两件事」。

3.2 第二级:同一事务维护的读模型 ​

sql
CREATE TABLE session_stats (
  session_id INT PRIMARY KEY, event_id INT NOT NULL,
  confirmed INT NOT NULL, waitlisted INT NOT NULL, paid_cents BIGINT NOT NULL);

报名命令在同一个事务里多执行一条 UPDATE session_stats SET confirmed = confirmed + 1 WHERE session_id = ?。看板查询变成按主键扫 200 行:

text
同步读模型:返回 200 行,平均 1.4ms
写路径,顺序执行 1000 次报名命令(3 轮):没有投影 539—638ms,有投影 579—601ms

写路径的差别落在 3 轮的波动范围内,这个规模下看不出代价。统计行和场次聚合根是同一个粒度,命令本来就要锁住场次,多更新一行统计不会制造新的热点。如果统计粒度更粗(例如按活动汇总),它就会成为所有场次共同竞争的一行,这时要么改成异步,要么拆细。

同步读模型的另一个成本在变更时:读模型的结构变了,要用一条 INSERT ... SELECT 从命令侧回填。实验里每次回填后都与 GROUP BY 的结果比对,确认一致。

四、第三级:异步投影 ​

当读模型需要放到另一个库、另一种存储(搜索引擎、列存),或者要组合多个上下文的数据时,就不能再和命令在同一个事务里维护。命令侧把事件写入事件表(或 Outbox),投影器按顺序读取、更新读模型,并把处理到哪里记在 checkpoint 里:

java
// checkpoint 与投影结果在同一个事务里提交:崩溃重启后不会重复计数,也不会跳过
long from = selectForUpdate("SELECT last_event_id FROM projection_checkpoint WHERE name='stats'");
List<Event> events = select("SELECT ... FROM domain_event WHERE id > ? ORDER BY id LIMIT ?", from, batch);
upsert("INSERT INTO session_stats_async ... ON DUPLICATE KEY UPDATE confirmed = confirmed + VALUES(confirmed), ...", events);
update("UPDATE projection_checkpoint SET last_event_id = ?", events.getLast().id());
commit();
命令投影器看板500 次报名,事件写入事件表运行暂停:积压 500 个事件追赶 15ms200/200 个场次是旧数据与写侧一致checkpoint 与投影结果同一事务提交:重启后不重复、不跳过
图 2 · 投影器暂停期间执行 500 次报名,200 个场次的异步统计全部落后,同步读模型仍然一致;恢复后 15ms 追上;读模型损坏后清空重放约 0.5 秒恢复一致

实验记录了异步读模型的完整生命周期:

text
从 0 重放 83940 个事件建立异步读模型:538ms;与 GROUP BY 一致
投影器暂停期间执行 500 次报名:积压 500 个事件,异步读模型 200/200 个场次与写侧不一致;同步读模型一致
恢复后追上 500 个事件:15ms;与 GROUP BY 一致
人为改坏 10 个场次后清空重放 84440 个事件:512ms;与 GROUP BY 一致

几点观察:

  • 延迟窗口是真实存在的:投影器暂停期间,看板上的每一个场次都是旧数据。读模型要能告诉调用方它处理到了哪里(checkpoint 对应的时间),页面上可以显示「数据截至某时」。
  • 监控积压,而不是只监控投影器是否在运行:投影器活着但处理不过来,和投影器挂了,对看板的影响是一样的。
  • 读模型是可以丢弃的:只要事件还在,读模型坏了、结构改了、换了存储,都可以清空重放。这要求事件表只追加、不修改,并且有归档策略,否则重放时间会随事件数线性增长。

五、为执行命令而读,仍然是命令 ​

报名时要知道场次的容量和已确认人数,这也是「读」,但它属于命令路径:必须读到最新的、加锁的状态,由聚合做判断。用异步读模型里的「已确认人数」来判断名额是否已满,会在延迟窗口里超卖。

CQRS 分离的是为展示而读和为决策而改。为决策而读,永远走命令侧。

六、什么时候往上走一级 ​

信号说明考虑
查询要加载完整聚合、仓储里出现大量 findBy查询在借用命令模型第 1 级
GROUP BY 或多表 JOIN 成为慢查询,且查询频率高读取代价被重复支付第 2 级
读模型需要另一种存储,或要组合多个上下文的数据同一个事务做不到第 3 级
读写负载差距大,需要分别扩缩容读侧要独立部署第 3 级
业务需要完整的变更历史、按任意时间点重建状态事件本身是业务数据评估第 4 级

每往上一级,都要能说出是哪个信号触发的。在这个实验的规模下,5 万条报名的 GROUP BY 只要 31ms,很多看板停在第 1 级就足够了。

七、常见误区 ​

  • 「CQRS 就是读写分离两个库」:主从复制的读写分离是同一个模型的两份拷贝;CQRS 分离的是模型本身,最小的形式只是代码分离。
  • 「CQRS 必须配合事件溯源」:两者独立成立,本文的命令侧完全以状态为准。
  • 「读模型要保证强一致」:异步读模型的价值就在于接受延迟,需要强一致的查询用第 1、2 级。
  • 「用读模型的数据做业务判断」:延迟窗口里会做出错误的决定;为决策而读要走命令侧。
  • 「读模型坏了要写迁移脚本修数据」:只要事件还在,清空重放通常更简单、更可靠。

小结 ​

CQRS 从承认「为展示而读」和「为决策而改」是两件事开始。先让查询不再穿过聚合,直接写 SQL;读取代价被反复支付时,加一张与命令同一事务维护的读模型;需要另一种存储或跨上下文组合数据时,才换成异步投影,同时接受并监控延迟窗口。最后一篇讨论怎样在真实项目里开始:精益切片与遗留改造。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权