CQRS
数据库读写分离、本地缓存和分布式缓存本质上都是 CQRS,即将写操作和读操作拆分到不同路径;写数据存储负责可靠更新,读数据存储负责高效查询,二者通过消息队列、主从复制、binlog 监听或定时任务同步数据,从而提升高并发读能力,但代价是架构复杂度增加和读数据存在最终一致性延迟。
一、核心主线
本节是对前面高并发读方案的抽象总结。
无论是:
- 数据库读/写分离;
- 本地缓存;
- 分布式缓存;
它们本质上都在做一件事:
将数据读取路径和数据写入路径分离。
这就是 CQRS。
CQRS 全称是 Command Query Responsibility Segregation,即命令查询职责分离。
1 | Command:会改变数据的操作,如新增、删除、修改 |
二、CQRS 的基本思想
1. 读写职责分离
CQRS 将系统中的数据操作分为两类:
| 类型 | 含义 | 示例 |
|---|---|---|
| Command | 写操作,会引起数据变化 | 新增、删除、修改 |
| Query | 读操作,只查询数据 | 查询详情、搜索、列表展示 |
普通架构中,读和写可能都访问同一份存储:
1 | 读请求 ─┐ |
CQRS 中,读和写使用不同路径:
1 | 写请求 → 写数据存储 |
2. 读写存储可以不同
CQRS 不要求读存储和写存储使用同一种数据库。
更常见的做法是:
- 写存储选择适合高并发写入、事务和一致性的系统;
- 读存储选择适合高并发读取、搜索、聚合或缓存的系统。
例如:
1 | 写存储:MySQL |
三、CQRS 的简要架构
1. 基本链路
CQRS 的典型架构如下:
1 | 客户端 |
2. 写请求流程
当业务服务收到 command 请求:
- 将写请求交给写数据存储;
- 写数据存储完成数据变更;
- 将数据变更消息发送到数据传输通道;
- 读数据存储监听变更消息;
- 读数据存储更新自己的数据。
3. 读请求流程
当业务服务收到 query 请求:
- 将读请求交给读数据存储;
- 读数据存储返回查询结果;
- 业务服务将结果返回客户端。
读请求不会访问写存储,从而减轻写存储压力。
四、CQRS 与前面方案的关系
1. 数据库读写分离
数据库读写分离是 CQRS 的一个简单特例。
1 | 写数据存储:Master |
写请求进入 Master,读请求进入 Slave。
2. 分布式缓存
分布式缓存也符合 CQRS 思想。
1 | 写数据存储:数据库 |
数据库负责保存权威数据,Redis 负责高并发读取。
3. 本地缓存
本地缓存也可以理解为读模型的一种。
1 | 写数据存储:数据库或下游服务 |
本地缓存把部分高频读取数据放到进程内存中,提升读取效率。
五、使用场景一:搜索场景
1. 问题背景
很多应用支持根据关键词搜索用户昵称。
例如搜索“北京”,返回:
- 北京日报;
- 北京大学;
- 这里是北京。
账号信息通常存储在数据库中,但数据库并不擅长高效全文搜索或关键词搜索。
2. CQRS 方案
可以将数据库作为写数据存储,将 Elasticsearch 作为读数据存储。
1 | 写请求:修改用户昵称 |
3. 架构分工
| 角色 | 作用 |
|---|---|
| 数据库 | 管理账号权威数据 |
| Elasticsearch | 提供高效搜索能力 |
| 消息中间件 | 传递数据库变更事件 |
| 消费者服务 | 监听变更并同步搜索索引 |
这种设计让数据库继续承担写入和事务职责,让 Elasticsearch 承担搜索查询职责。
六、使用场景二:多表关联查询
1. 问题背景
运营后台经常需要查询复杂业务数据,这些数据可能分散在多张表中。
如果直接在在线数据库执行多表 JOIN:
- 底层通常依赖嵌套循环,效率较低;
- 会影响线上数据库性能;
- 分库分表后,跨库 JOIN 更难执行;
- 查询延迟不可控。
2. CQRS 方案
提前将多表关联数据聚合计算好,写入一张宽表。
1 | 数据库变更 |
查询时直接读取宽表,不再执行复杂 JOIN。
3. 架构分工
| 角色 | 作用 |
|---|---|
| 数据库 | 写数据存储,保存原始业务数据 |
| worker | 执行数据聚合计算 |
| 消息队列 / 定时任务 | 触发聚合计算 |
| 宽表 | 读数据存储,保存聚合后的查询结果 |
| 运营后台 | 直接读取宽表 |
4. 数据同步方式
worker 可以通过两种方式触发:
- 监听数据库 binlog,每次数据变化后实时计算;
- 定时执行聚合计算,例如每分钟执行一次。
实时性要求高时,适合 binlog + 消息队列;实时性要求低时,可以使用定时任务。
七、CQRS 架构特点
1. 读写存储模型不同
CQRS 中,读数据存储和写数据存储往往有不同的模型和选型。
1 | 写存储:面向事务、写入、一致性 |
这使得系统可以针对不同访问模式选择最合适的存储系统。
2. 读数据存在延迟
写数据存储发生变化后,读数据存储不是立刻同步完成。
同步过程依赖:
- 消息队列;
- binlog 监听;
- 定时任务;
- 主从复制;
- 其他数据传输通道。
因此,CQRS 通常只能保证:
写数据存储和读数据存储之间的最终一致性。
3. 数据传输通道必须可靠
数据传输通道负责把写存储的数据变化同步到读存储。
它必须足够健壮,尽量保证:
- 数据不丢失;
- 消息可重试;
- 消费失败可恢复;
- 延迟可监控;
- 积压可处理。
否则,读存储可能长期落后于写存储。
八、CQRS 的优势与代价
1. 优势
- 写存储和读存储可以独立优化;
- 读请求不再直接压垮写库;
- 可以为搜索、聚合、缓存等场景选择专门存储;
- 能提升高并发读场景的系统吞吐;
- 支持复杂查询结果预计算;
- 便于系统按访问模式拆分职责。
2. 代价
- 架构复杂度上升;
- 需要维护数据同步链路;
- 读数据存在延迟;
- 只能保证最终一致性;
- 需要处理消息丢失、重复消费、同步失败等问题;
- 需要监控读写数据之间的同步延迟。
九、核心架构思想
- CQRS 的本质是读写职责分离。
- Command 表示会改变数据的操作,Query 表示只读取数据的操作。
- 数据库读写分离、本地缓存、分布式缓存都可以看作 CQRS 思想的体现。
- 写数据存储应选择适合高并发写入和一致性保障的系统。
- 读数据存储应选择适合高并发读取、搜索或聚合查询的系统。
- 数据传输通道负责将写存储的数据变化同步到读存储。
- 搜索场景可以用数据库作为写存储,用 Elasticsearch 作为读存储。
- 多表关联查询场景可以用宽表作为读存储,提前保存聚合结果。
- CQRS 提升读性能的代价是架构复杂度和读数据延迟。
- CQRS 通常追求最终一致性,而不是读写存储之间的强一致。