3. 事件溯源核心原理:以事件为唯一真相源、事件存储、事件流与聚合重建
事件溯源(Event Sourcing, ES)颠覆了传统以“当前状态”为中心的持久化范式。在 ES 中,事件是唯一的真相源(Source of Truth),系统的当前状态仅是通过重放事件流计算得出的副产品。本节将深入剖析 ES 的三大核心支柱:事件作为唯一真相源、事件存储与事件流、以及聚合重建机制。
3.1 以事件为唯一真相源
传统 CRUD 系统直接存储聚合的当前快照,丢失了所有变更的历史上下文。事件溯源则记录每一个已发生的事实,这些事实是不可变、可追加的。
| 维度 | 传统 CRUD | 事件溯源 |
|---|---|---|
| 真相源 | 当前数据库行(状态快照) | 事件流(不可变日志) |
| 数据操作 | UPDATE / DELETE(覆盖历史) | APPEND(仅追加) |
| 审计能力 | 弱,需额外审计表 | 原生完整审计,可回溯任意时间点 |
| 时间旅行 | 不支持 | 支持,通过重放到指定版本 |
| 并发控制 | 乐观锁(版本号) | 事件流版本 + 幂等性 |
核心原则:事件是事实,不可修改。如果需要修正错误,应追加一个“补偿事件”(如
OrderCorrected),而非修改已有事件。
3.2 事件存储:不可变日志的物理实现
事件存储(Event Store)是 ES 的持久化基础设施,它本质上是一个仅追加的日志。每个事件都包含以下关键字段:
- EventId:全局唯一标识(UUID)。
- AggregateId:所属聚合的标识。
- AggregateType:聚合类型(如
Order)。 - EventType:事件类型(如
OrderPlaced)。 - Data:事件载荷(JSON 或二进制序列化)。
- Metadata:元数据(时间戳、用户ID、关联ID等)。
- Version:聚合内的版本号(单调递增)。
-- 事件存储表结构示例(PostgreSQL)
CREATE TABLE events (
event_id UUID PRIMARY KEY,
aggregate_id UUID NOT NULL,
aggregate_type VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
data JSONB NOT NULL,
metadata JSONB,
version INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (aggregate_id, version)
);
CREATE INDEX idx_events_aggregate
ON events (aggregate_type, aggregate_id, version);
关键约束:同一聚合内的事件版本必须严格递增且无空洞。这保证了事件流的有序性和完整性,是聚合重建正确性的基础。
3.3 事件流:聚合的生命线
事件流(Event Stream)是按时间顺序排列的、属于同一个聚合实例的所有事件的序列。每个聚合实例对应一个独立的事件流。
下图展示了一个订单聚合的事件流生命周期:
3.4 聚合重建:从事件流到当前状态
聚合重建(Aggregate Reconstruction)是 ES 的核心操作:通过重放聚合的完整事件流,依次调用每个事件的 apply 方法,逐步构建出聚合的当前状态。
3.4.1 重建流程
- 加载事件流:从事件存储中读取指定聚合 ID 的所有事件,按版本升序排列。
- 创建空聚合:实例化聚合对象,初始状态为默认值(如空集合、零值)。
- 重放事件:遍历事件流,对每个事件调用聚合的
apply(event)方法,该方法内部更新聚合状态。 - 返回就绪聚合:重放完成后,聚合处于最新状态,可接受新的命令。
// 聚合重建核心逻辑(TypeScript 伪代码)
class OrderAggregate {
private state: OrderState = { items: [], status: 'pending', total: 0 };
// 重放事件流
static loadFromHistory(events: DomainEvent[]): OrderAggregate {
const aggregate = new OrderAggregate();
for (const event of events) {
aggregate.apply(event);
}
return aggregate;
}
// 应用单个事件
private apply(event: DomainEvent): void {
switch (event.type) {
case 'OrderPlaced':
this.state.items = event.data.items;
this.state.status = 'placed';
break;
case 'PaymentReceived':
this.state.status = 'paid';
break;
case 'OrderShipped':
this.state.status = 'shipped';
break;
// ... 其他事件处理
}
}
// 当前状态只读访问
getState(): Readonly<OrderState> {
return this.state;
}
}
3.4.2 快照优化
当事件流非常长(数万甚至数百万个事件)时,每次重建都从头重放会导致性能问题。解决方案是引入快照(Snapshot):
- 定期(如每 100 个事件)保存聚合状态的快照。
- 重建时,先加载最近的快照,然后仅重放快照之后的事件。
| 场景 | 无快照 | 有快照(每 100 事件) |
|---|---|---|
| 事件总数 | 10,000 | 10,000 |
| 重建需重放事件数 | 10,000 | ≤ 100(最近快照后的事件) |
| 重建延迟 | 高(秒级) | 低(毫秒级) |
| 存储开销 | 仅事件 | 事件 + 快照(可接受) |
最佳实践:快照应作为缓存层,事件存储始终是权威真相源。快照可以异步生成,且允许丢失(丢失后只需从更早的快照或从头重建)。
3.5 事件溯源架构总览
下图展示了事件溯源在典型后端发布拓扑中的完整数据流:
3.6 关键设计考量
- 事件版本兼容性:事件结构会随时间演化。需设计事件版本策略(如
EventUpcaster),确保旧版本事件能被当前聚合代码正确重放。 - 并发控制:使用乐观锁(基于聚合版本号)防止并发命令导致的事件流冲突。
- 事件幂等性:事件存储应保证同一事件不会被重复追加。通常通过
event_id唯一约束实现。 - 性能与存储:事件存储是写优化的,读(重建)可通过快照和缓存优化。长期运行的系统需考虑事件归档策略。
总结:事件溯源的核心在于“事件即事实”。事件存储保证了事实的不可变性和可追溯性;事件流提供了聚合的完整生命周期视图;聚合重建则通过重放事件流,将历史事实转化为当前可用的业务状态。这三者共同构成了 ES 的坚实基石,为后端发布拓扑中的状态恢复提供了理论保障和工程实践路径。