《Java 消息中间件·事件驱动架构》3.1 CQRS 基础

CQRS 通过命令与读取分离化解读写冲突。本节用 H2 内存数据库演示命令写入、事件生成与投影更新的完整分离流程,给出可运行的 Java 代码示例,深入对比同步双写与异步事件总线两种同步策略,并提供典型应用场景、反模式警示与事件日志操作日志的区别分析。

命令与读取的分界

传统三层架构中,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 命令可能生成:

  1. LoanRenewed:续借成功
  2. DueDateChanged:到期日变更
  3. NotificationScheduled:到期提醒重新调度

查询模型可以针对特定事件选择性更新。

典型应用场景与反模式

适合引入 CQRS 的场景:

场景原因
读多写少查询频率远高于修改,需要单独优化索引和缓存
复杂查询多表 join、聚合计算、全文检索
多视图需求同一数据需要以不同形式展示
独立扩展读流量和写流量的增长模式不同

不适合 CQRS 的场景:

场景原因
简单 CRUD数据模型简单,查询不复杂,读写比例均衡
强一致性要求查询必须立即反映最新写入,无法接受延迟
小型应用维护两套模型的成本高于收益

事件日志 vs 操作日志

维度事件日志操作日志
内容业务事实函数调用
消费者任意下游服务运维监控
可重放性是,重建状态时重放事件否,仅用于排查
版本化需要,事件 schema 会演进不需要

CQRS 使用事件日志作为事实来源,它携带完整的业务语义,可以被任意下游消费者解析。

本节小结

  • CQRS 的核心是命令模型与查询模型分离。
  • 事件日志是唯一事实来源,命令模型只写事件,查询模型通过事件订阅更新。
  • 同步双写、异步事件总线、事件溯源是三种常见实现策略。
  • 同步双写阻塞命令返回,查询模型无法独立演进;异步模式解耦但引入延迟。
  • CQRS 增加系统复杂度,只在读写需求分歧明显时引入。
  • 命令模型负责所有业务校验,投影处理器只做状态翻译。
  • 事件日志记录业务事实,与操作日志有本质区别。
  • 生产环境下投影延迟监控是核心运维指标,超过阈值必须立即告警并由运维人员排查根因。

H2 依赖说明

H2 内存模式在 JVM 退出后数据丢失,适合演示和测试。生产环境应使用文件模式或外部数据库。

生产环境注意事项

  • 内存数据库不适合生产,应使用 PostgreSQL 或 MySQL

  • 事件表应加索引:

  • 投影表应定期清理过期数据

  • 监控事件处理延迟和投影 lag

  • 投影延迟监控应作为核心指标,超过阈值时触发告警通知运维人员排查根因。

  • 事件日志作为事实来源,必须与操作日志区分:前者可重放,后者仅用于排查。

阅读导航:上一节 2.3 事件目录与发现 | 下一节 3.2 投影与视图构建

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收