事件溯源与 CQRS 架构

事件溯源与 CQRS 架构:从状态建模到事件建模、事件存储与快照、CQRS 读写分离、回放与投影、最终一致性取舍与实战案例

传统业务系统把当前状态当作唯一事实:账户余额、订单状态、库存数量,改了旧值就消失。事件溯源(Event Sourcing)翻转了这个假设——把"发生了什么"作为唯一事实,当前状态只是对事件流的推导结果。配合 CQRS 把读写模型彻底分离,这套架构在审计、追溯、复杂查询与系统演化上展现了独特优势。本文从事件溯源模型讲到事件存储、CQRS 拆分、回放投影与一致性取舍。

一句话:传统系统存储"现在的样子",事件溯源存储"一路走来的过程",状态随时可以从过程重算。

1. 从状态建模到事件建模

1.1 传统状态建模的问题

-- 传统做法:只保存当前余额,转出 100 元后,旧余额不可见
UPDATE account SET balance = balance - 100 WHERE id = 'a001';

三个根本问题:

  1. 历史丢失:没有任何"为什么变成这样"的记录
  2. 审计困难:需要额外的操作日志表,且与状态数据难对齐
  3. 分析受限:只能看当前快照,无法回溯任何时刻的状态

1.2 事件建模

事件溯源把业务决策记录为一串不可变事件:

账户 a001 的事件流:
  AccountOpened { id: a001, init: 1000 }     <- 初始事件
  MoneyDeposited { id: a001, amount: 500 }   <- 存款事件
  MoneyWithdrawn { id: a001, amount: 100 }   <- 取款事件
当前余额 = 1000 + 500 - 100 = 1400           <- 对事件流的推导
维度状态存储事件溯源
事实当前状态事件流
历史丢失完整保留
更新UPDATE 覆盖INSERT 追加
溯源难天然可追溯
审计需旁路日志事件即审计日志

2. 事件溯源模型

2.1 命令与事件

命令(Command)是意图,事件(Event)是事实。命令可能被拒绝(余额不足、库存为零),但一旦接受,产生的事件就不可变更:

命令:Withdraw(账户 a001, 金额 200) ──检查余额──► 事件:MoneyWithdrawn(a001, 200)
                                                   或拒绝:InsufficientFunds

一句话:命令表达"想做什么",事件记录"确实发生了什么",两者不可混淆。

2.2 聚合根

聚合根是事件一致性的边界。对聚合根的操作必须通过"加载事件流 → 重放得到状态 → 校验 → 追加新事件"完成:

public class Account extends AggregateRoot {
    private BigDecimal balance;

    // 从事件流重放构建状态
    public void apply(Event event) {
        if (event instanceof MoneyDeposited d) balance = balance.add(d.amount());
        if (event instanceof MoneyWithdrawn w) balance = balance.subtract(w.amount());
    }

    // 命令处理:校验后产生事件,而不是直接改字段
    public void withdraw(BigDecimal amount) {
        if (balance.compareTo(amount) < 0) {
            throw new InsufficientFunds();
        }
        addEvent(new MoneyWithdrawn(amount));   // 校验通过 → 追加事件
    }
}

2.3 事件流的语义

事件流是追加日志(Append-Only Log):事件按发生顺序追加,永不修改、永不删除。每个事件带聚合 ID、版本号(乐观并发)、类型与时间戳。事件语义在分布式系统中与 https://plumephp.com/distributed-event-driven-architecture/ 的事件模型互补——一个是系统内部的事实记录,一个是跨系统的事件通信。

3. 事件存储

3.1 存储形态

事件存储可以是专用数据库(EventStoreDB)、专用表,或者直接落在 Kafka 这类追加日志上:

存储优点缺点
关系型事件表事务与聚合版本控制成熟事件量大后归档复杂
EventStoreDB面向事件溯源设计,快照内建生态相对小众
Kafka 等 MQ天然追加、跨系统分发需自行处理聚合版本与读取模型
-- 事件表核心结构
CREATE TABLE account_events (
  aggregate_id   VARCHAR(64)  NOT NULL,
  version        BIGINT       NOT NULL,
  event_type     VARCHAR(128) NOT NULL,
  payload        JSONB        NOT NULL,
  created_at     TIMESTAMPTZ  NOT NULL DEFAULT now(),
  PRIMARY KEY (aggregate_id, version)   -- 版本唯一 → 乐观并发保护
);

3.2 快照

事件无限增长会拖慢重放。**快照(Snapshot)**定期保存"到某版本为止的状态":

加载聚合 = 加载最近快照 + 重放快照之后的事件
快照频率:每 N 个事件(如 200)或按时间生成一次

3.3 幂等与并发

命令处理失败重试会产生重复事件。聚合版本控制保证并发安全,命令侧要配合幂等设计(请求 ID 去重),与 https://plumephp.com/distributed-idempotency-reliability/ 的幂等方法论一致。

// 乐观并发:版本不匹配则拒绝,防止并发命令覆盖
INSERT INTO account_events (aggregate_id, version, ...)
VALUES (?, ?, ...) WHERE version = expectedVersion

3.4 事件存储的分区与扩容

事件流是天然的分区友好结构:按聚合 ID 哈希分区,保证同一聚合的事件落在同一分区、顺序保持一致:

分区键 = hash(aggregate_id) % N
同一聚合 → 同一分区 → 顺序保真
不同聚合 → 可并行分区分发,投影可多实例并行消费

事件量持续增长后的扩容策略:

手段说明
增加分区提高并行消费能力
冷热分层近期事件存热存储,历史事件归档对象存储
归档保留超过保留期的历史可迁移,保留事件流可重放性
分区再平衡扩容时按聚合迁移动态分布,保证顺序不破

4. CQRS 读写分离

4.1 一个模型 vs 两个模型

传统 CRUD 用同一模型读写,复杂度高时读写诉求互相打架。CQRS(Command Query Responsibility Segregation)把命令模型与查询模型彻底分开:

客户端 ──► 命令侧(Command)──► 事件溯源聚合 → 追加事件 ──► 事件存储
                             事件异步投影(Projection)──► 查询模型 ──► 查询(Query)
维度命令模型(写)查询模型(读)
语义业务规则、校验、状态变更展示、报表、检索
数据形态事件流投影出的专门读模型
一致性强一致(聚合内)最终一致(异步更新)
优化方向写吞吐、事务查询性能、索引结构

4.2 什么时候需要 CQRS

  • 读多写少且读模型复杂多样(订单中心既要列表又要多维统计)
  • 读写吞吐需求差异巨大,需要独立扩缩容
  • 命令侧强一致,查询侧允许最终一致
  • 简单 CRUD 不要用 CQRS,徒增复杂度

5. 回放与投影

5.1 回放(Rebuild)

回放指从零重建读模型或状态:重新消费全部(或从某版本起)事件,重跑投影逻辑。回放能力让"读模型定义错了也能改"成为现实——这是传统数据库难以做到的。

5.2 投影(Projection)

投影把事件流转换为查询模型。同一事件流可以投影出多个读模型:

# 订单投影:从订单事件构建"订单汇总读模型"
def project_order_summary(event, store):
    if event.type == "OrderPlaced":
        store.set(f"order:{event.order_id}", {
            "customer": event.customer_id,
            "items": event.items,
            "total": event.total,
            "status": "placed",
        })
    elif event.type == "OrderPaid":
        row = store.get(f"order:{event.order_id}")
        row["status"] = "paid"               # 增量更新读模型
        store.set(f"order:{event.order_id}", row)

5.3 投影的一致性

投影从事件流异步消费,读模型存在滞后窗口。对"写后立即可见"的读路径,要么走命令侧强一致读,要么接受最终一致。事件顺序与精确一次消费是投影正确性的基础,可结合 https://plumephp.com/message-queue-deep-dive/ 理解消费语义。

5.4 投影的幂等与重试

投影消费事件流时可能重复消费(故障重放、消息重投)。投影操作必须幂等:同一事件应用两次,读模型结果不变。常用做法是事件 ID 去重,或在写读模型时以"聚合 ID + 事件版本"为幂等键做条件更新:

-- 幂等投影:重复事件不再覆盖更新
INSERT INTO order_read_model (order_id, status, event_version)
VALUES (?, ?, ?)
ON CONFLICT (order_id)
DO UPDATE SET status = EXCLUDED.status, event_version = EXCLUDED.event_version
WHERE EXCLUDED.event_version > order_read_model.event_version;

投影失败后的重试要考虑"重放边界":记录每个投影的消费位置(offset),从断点续传,而不是全量重来。事件处理的幂等与可靠性方法论可参考 https://plumephp.com/distributed-idempotency-reliability/。

6. 一致性取舍

6.1 最终一致性全景

事件溯源 + CQRS 的典型一致性分布:

聚合内部:强一致(版本控制 + 事务)
聚合之间:最终一致(事件异步跨聚合传播)
查询模型:最终一致(投影异步更新)

6.2 与传统事务的对比

传统分布式事务追求跨资源的强一致,成本高、可用性差;事件溯源把"一致性边界"缩小到聚合,聚合间用事件与补偿协调。这种取舍与 https://plumephp.com/distributed-transactions/ 中提到的"柔性事务/可靠事件"路线一脉相承。

一句话:事件溯源不是消灭一致性,而是把一致性边界画在聚合根上,边界之外用最终一致。

6.3 与缓存策略的结合

读模型本质是"事件流投影出的缓存"。读模型的更新时机、失效策略可与 https://plumephp.com/distributed-cache-strategies/ 以及缓存故障治理(https://plumephp.com/distributed-cache-failure-governance/)结合,保障读路径的稳定性。

7. 实战案例

7.1 银行与账务系统

账户事件流天然就是审计台账:每笔收支都可追溯,余额可重放验证,监管审计零改造。

7.2 电商订单

订单状态由事件驱动流转(下单→支付→发货),前端订单列表由投影生成,后台统计由独立投影(按天/按商品维度)构建,读写互不干扰。

7.3 库存与预约

库存扣减是典型的高竞争写场景,聚合根串行化扣减 + 事件追加,配合补偿事件(回补)实现可靠库存。

8. 常见坑与最佳实践

  1. 事件与命令混用:把业务校验逻辑写进事件,事件不再"纯事实",破坏重放
  2. 事件设计脆弱:事件是永久的公开契约,字段名、语义一经发布难改,设计时要有版本演进规划(如 payload 版本化)
  3. 忘记快照:聚合事件无限增长,重放越来越慢
  4. 投影滞后无告警:读模型长时间落后等于"假数据",要有 lag 监控
  5. 读模型无限膨胀:投影要为查询而生,不需要的字段不进读模型
  6. 盲目上 CQRS:简单系统用 CQRS 只会放大复杂度

最佳实践总结:

实践要点
事件即契约事件语义稳定,payload 带版本
聚合边界要小边界越小并发越高,冲突越少
快照策略按事件数阈值定期快照
投影可重建保留事件流,读模型随时可回放重建
监控滞后投影 lag、事件吞吐全面可观测

总结

主题关键内容
事件建模命令 vs 事件、聚合根、追加日志
事件存储事件表/专用库/Kafka、快照、版本并发
CQRS命令模型与查询模型分离、按需使用
回放与投影事件流重建、多投影、滞后窗口
一致性聚合内强一致、聚合间最终一致
实践事件契约、快照、投影重建、lag 监控

事件溯源 + CQRS 的适用边界很清晰:需要完整审计、复杂追溯、读写分离、系统持续演化的业务,它是把"事实"与"展示"解耦的利器;简单的 CRUD 系统则不需要这份复杂度。它的心智模型是把不可变的事件流当真相,把一切可变的状态当投影。与 https://plumephp.com/distributed-event-driven-architecture/ 的事件协作模型、https://plumephp.com/distributed-idempotency-reliability/ 的幂等保障配合,可以构建出既可追溯又高可用的业务系统。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「distributed-systems」更多文章

  1. Serverless 架构实践
  2. 流批一体架构实践
  3. 分布式时钟与逻辑时钟