命令与读取的分界
传统三层架构中,ORM 实体对象同时承担「写业务逻辑」和「查报表数据」两个角色。当查询越来越复杂(多表 join、聚合统计、全文检索)时,写路径被迫为读路径让路——索引膨胀、锁粒度收紧、批量写入效率下降。
CQRS 把这个矛盾显性化:用不同的模型处理命令(写)和查询(读)。命令模型关注领域规则、不变量和事务一致性;查询模型关注数据投影、视图优化和读取性能。
事件日志是事实来源
在事件驱动的 CQRS 中,命令模型不直接更新查询模型,只生成事件:
class LoanCommandHandler {
void createLoan(String loanId, String memberId, String bookId) {
if (memberId == null || bookId == null) {
throw new IllegalArgumentException("memberId and bookId required");
}
LoanEvent event = new LoanEvent(loanId, memberId, bookId, "CREATED", 1);
eventStore.append(event);
}
}
class EventStore {
private final List<LoanEvent> events = new ArrayList<>();
void append(LoanEvent event) { events.add(event); }
List<LoanEvent> getEvents(String loanId) {
return events.stream()
.filter(e -> e.loanId.equals(loanId))
.collect(Collectors.toList());
}
}
查询模型通过订阅事件日志来更新:
class LoanProjectionHandler {
void onEvent(LoanEvent event) {
if ("CREATED".equals(event.type)) {
projection.insert(event.loanId, event.memberId, event.bookId, "ACTIVE");
}
}
}
事件日志是唯一的事实来源。命令模型只写事件,查询模型只读投影,两者通过事件总线解耦。
H2 演示:完整的 CQRS 流程
import java.sql.*;
public class CQRSWithH2 {
public static void main(String[] args) throws SQLException {
Connection conn = DriverManager.getConnection("jdbc:h2:mem:cqrs");
Statement stmt = conn.createStatement();
stmt.execute("CREATE TABLE events (" +
"id IDENTITY, loan_id VARCHAR(64), event_type VARCHAR(32)," +
"member_id VARCHAR(64), book_id VARCHAR(64), version INT)");
stmt.execute("CREATE TABLE loans (" +
"loan_id VARCHAR(64) PRIMARY KEY, member_id VARCHAR(64)," +
"book_id VARCHAR(64), status VARCHAR(32), version INT)");
stmt.executeUpdate(
"INSERT INTO events VALUES (DEFAULT, 'L001', 'CREATED', 'M100', 'B200', 1)");
stmt.executeUpdate(
"INSERT INTO loans VALUES ('L001', 'M100', 'B200', 'ACTIVE', 1)");
ResultSet rs = stmt.executeQuery(
"SELECT * FROM loans WHERE loan_id = 'L001'");
while (rs.next()) {
System.out.printf("loan=%s member=%s book=%s status=%s%n",
rs.getString("loan_id"), rs.getString("member_id"),
rs.getString("book_id"), rs.getString("status"));
}
}
}
编译运行(单次运行,随调度波动,不作基准):
javac --release 21 CQRSWithH2.java && java -cp ".:h2-2.3.232.jar" CQRSWithH2
输出:
loan=L001 member=M100 book=B200 status=ACTIVE
三种同步策略对比
| 策略 | 延迟 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 同步双写 | 零延迟 | 强一致 | 低 | 单体应用,查询简单 |
| Kafka 异步 | 毫秒级 | 最终一致 | 中 | 微服务,读模型独立演进 |
| 事件溯源 + 重建 | 秒级重建 | 严格顺序 | 高 | 审计、回溯、复杂业务逻辑 |
同步双写必须在同一事务中,否则重启后会不一致。Kafka 异步模式通过事件总线保证最终一致。
同步双写的实现细节
Connection conn = DriverManager.getConnection("jdbc:h2:mem:sync");
conn.setAutoCommit(false);
try {
stmt.executeUpdate("INSERT INTO commands VALUES (DEFAULT, 'L001', 'CREATE')");
stmt.executeUpdate("INSERT INTO queries VALUES ('L001', 'ACTIVE')");
conn.commit();
} catch (SQLException e) {
conn.rollback();
}
同步双写的关键是必须在同一事务中提交。查询表的写入延迟会阻塞命令返回,在高并发场景下可能成为瓶颈。另一个限制是查询模型无法独立演进。
异步事件总线的实现细节
命令侧发送事件到 Kafka:
ProducerRecord<String, String> record = new ProducerRecord<>(
"loan.events", "L001", "{\"type\":\"CREATED\",\"member\":\"M100\"}");
producer.send(record);
查询侧消费事件并更新投影:
consumer.subscribe(List.of("loan.events"));
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> r : records) {
updateProjection(r.value());
consumer.commitSync();
}
}
命令侧和查询侧完全解耦。查询侧可以独立扩展消费者实例。但「最终一致性」的延迟通常在毫秒到秒级。
CQRS 的代价
引入 CQRS 的代价:
- 数据一致性复杂化:查询模型滞后于命令模型,需要处理「查不到刚写入的数据」的场景。
- 系统复杂度上升:需要维护两套数据模型、两套序列化格式、两套验证逻辑。
- 调试困难:一个问题可能涉及命令侧生成的事件、事件总线的投递、查询侧的投影更新三个环节。
如果应用只是普通的 CRUD,CQRS 是过度设计。只有当读写需求出现明显分歧时才引入。
CQRS 与微服务的配合
在微服务架构中,每个服务维护自己的命令模型和查询模型,服务之间通过事件总线通信。
例如:借阅服务处理 BorrowBook 命令生成 LoanCreated 事件;通知服务订阅后发送邮件;统计服务更新日借阅量。
命令模型的验证责任
命令模型负责所有业务规则的校验:
- 用户有权限执行这个命令
- 业务不变量不被破坏(如同一本书不能同时借给两个人)
- 前置条件已满足(如图书状态为「在架」才能借阅)
投影处理器只做一件事:把事件翻译成查询模型的状态更新。
CQRS 下的读取策略
- 按主键查:直接查投影表的主键索引
- 按字段查:在投影表上建立二级索引
- 聚合统计:用物化视图或专门的统计表
- 全文检索:投影到 Elasticsearch 等搜索引擎
每一种查询场景都可以有自己的投影处理器和存储后端,互不影响。
事件与命令的映射
一个命令可能生成多个事件。例如 RenewLoan 命令可能生成:
LoanRenewed:续借成功DueDateChanged:到期日变更NotificationScheduled:到期提醒重新调度
查询模型可以针对特定事件选择性更新。
典型应用场景与反模式
适合引入 CQRS 的场景:
| 场景 | 原因 |
|---|---|
| 读多写少 | 查询频率远高于修改,需要单独优化索引和缓存 |
| 复杂查询 | 多表 join、聚合计算、全文检索 |
| 多视图需求 | 同一数据需要以不同形式展示 |
| 独立扩展 | 读流量和写流量的增长模式不同 |
不适合 CQRS 的场景:
| 场景 | 原因 |
|---|---|
| 简单 CRUD | 数据模型简单,查询不复杂,读写比例均衡 |
| 强一致性要求 | 查询必须立即反映最新写入,无法接受延迟 |
| 小型应用 | 维护两套模型的成本高于收益 |
事件日志 vs 操作日志
| 维度 | 事件日志 | 操作日志 |
|---|---|---|
| 内容 | 业务事实 | 函数调用 |
| 消费者 | 任意下游服务 | 运维监控 |
| 可重放性 | 是,重建状态时重放事件 | 否,仅用于排查 |
| 版本化 | 需要,事件 schema 会演进 | 不需要 |
CQRS 使用事件日志作为事实来源,它携带完整的业务语义,可以被任意下游消费者解析。
本节小结
- CQRS 的核心是命令模型与查询模型分离。
- 事件日志是唯一事实来源,命令模型只写事件,查询模型通过事件订阅更新。
- 同步双写、异步事件总线、事件溯源是三种常见实现策略。
- 同步双写阻塞命令返回,查询模型无法独立演进;异步模式解耦但引入延迟。
- CQRS 增加系统复杂度,只在读写需求分歧明显时引入。
- 命令模型负责所有业务校验,投影处理器只做状态翻译。
- 事件日志记录业务事实,与操作日志有本质区别。
- 生产环境下投影延迟监控是核心运维指标,超过阈值必须立即告警并由运维人员排查根因。
H2 依赖说明
H2 内存模式在 JVM 退出后数据丢失,适合演示和测试。生产环境应使用文件模式或外部数据库。
生产环境注意事项
内存数据库不适合生产,应使用 PostgreSQL 或 MySQL
事件表应加索引:
投影表应定期清理过期数据
监控事件处理延迟和投影 lag
投影延迟监控应作为核心指标,超过阈值时触发告警通知运维人员排查根因。
事件日志作为事实来源,必须与操作日志区分:前者可重放,后者仅用于排查。
阅读导航:上一节 2.3 事件目录与发现 | 下一节 3.2 投影与视图构建
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。