传统业务系统把当前状态当作唯一事实:账户余额、订单状态、库存数量,改了旧值就消失。事件溯源(Event Sourcing)翻转了这个假设——把"发生了什么"作为唯一事实,当前状态只是对事件流的推导结果。配合 CQRS 把读写模型彻底分离,这套架构在审计、追溯、复杂查询与系统演化上展现了独特优势。本文从事件溯源模型讲到事件存储、CQRS 拆分、回放投影与一致性取舍。
一句话:传统系统存储"现在的样子",事件溯源存储"一路走来的过程",状态随时可以从过程重算。
1. 从状态建模到事件建模
1.1 传统状态建模的问题
-- 传统做法:只保存当前余额,转出 100 元后,旧余额不可见
UPDATE account SET balance = balance - 100 WHERE id = 'a001';
三个根本问题:
- 历史丢失:没有任何"为什么变成这样"的记录
- 审计困难:需要额外的操作日志表,且与状态数据难对齐
- 分析受限:只能看当前快照,无法回溯任何时刻的状态
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. 常见坑与最佳实践
- 事件与命令混用:把业务校验逻辑写进事件,事件不再"纯事实",破坏重放
- 事件设计脆弱:事件是永久的公开契约,字段名、语义一经发布难改,设计时要有版本演进规划(如 payload 版本化)
- 忘记快照:聚合事件无限增长,重放越来越慢
- 投影滞后无告警:读模型长时间落后等于"假数据",要有 lag 监控
- 读模型无限膨胀:投影要为查询而生,不需要的字段不进读模型
- 盲目上 CQRS:简单系统用 CQRS 只会放大复杂度
最佳实践总结:
| 实践 | 要点 |
|---|---|
| 事件即契约 | 事件语义稳定,payload 带版本 |
| 聚合边界要小 | 边界越小并发越高,冲突越少 |
| 快照策略 | 按事件数阈值定期快照 |
| 投影可重建 | 保留事件流,读模型随时可回放重建 |
| 监控滞后 | 投影 lag、事件吞吐全面可观测 |
总结
| 主题 | 关键内容 |
|---|---|
| 事件建模 | 命令 vs 事件、聚合根、追加日志 |
| 事件存储 | 事件表/专用库/Kafka、快照、版本并发 |
| CQRS | 命令模型与查询模型分离、按需使用 |
| 回放与投影 | 事件流重建、多投影、滞后窗口 |
| 一致性 | 聚合内强一致、聚合间最终一致 |
| 实践 | 事件契约、快照、投影重建、lag 监控 |
事件溯源 + CQRS 的适用边界很清晰:需要完整审计、复杂追溯、读写分离、系统持续演化的业务,它是把"事实"与"展示"解耦的利器;简单的 CRUD 系统则不需要这份复杂度。它的心智模型是把不可变的事件流当真相,把一切可变的状态当投影。与 https://plumephp.com/distributed-event-driven-architecture/ 的事件协作模型、https://plumephp.com/distributed-idempotency-reliability/ 的幂等保障配合,可以构建出既可追溯又高可用的业务系统。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。