4. 事件存储设计:事件表结构、序列化策略、版本控制与并发处理

事件存储是事件溯源架构的核心持久化组件,它决定了系统能否可靠地重建聚合状态、支持高并发写入以及应对模式演进。本章从表结构设计、序列化策略、版本控制机制和并发处理四个维度展开,给出可落地的设计规范。

4.1 事件表结构设计

事件存储的表结构需要同时支持按聚合追加、按全局顺序读取和按聚合ID重建三种访问模式。推荐采用双表模型:一张事件主表(event_store)存储事件载荷与元数据,一张流索引表(stream_index)维护聚合流与事件的映射关系。

字段名 类型 说明
event_id BIGINT UNSIGNED 全局自增主键,决定全局顺序
aggregate_id VARCHAR(64) 聚合根标识符
aggregate_type VARCHAR(64) 聚合类型(如 Order、Account)
stream_version INT UNSIGNED 聚合流内的版本号,从1递增
event_type VARCHAR(128) 事件类型全限定名
event_data JSON / BLOB 事件序列化后的载荷
metadata JSON 元数据:时间戳、用户ID、关联ID等
created_at DATETIME(6) 事件创建时间(微秒精度)
设计要点:全局主键 event_id 用于订阅者按序拉取;复合唯一索引 (aggregate_id, stream_version) 用于防止同一聚合流内版本冲突;aggregate_type 字段支持多聚合类型共存于同一表。

4.2 序列化策略

事件序列化需要平衡可读性、存储效率和模式演进能力。主流方案对比:

策略 优点 缺点 适用场景
JSON(文本) 人类可读、调试方便、语言无关 存储膨胀、无类型校验 中小规模、快速迭代
Protocol Buffers 紧凑、强类型、跨语言 需要预定义 schema、调试困难 高性能、多语言异构系统
Avro 自带 schema、支持演进 Java 生态偏重、序列化/反序列化较慢 大数据管道、Schema Registry 场景
MessagePack 紧凑、JSON 兼容 生态不如 JSON/Protobuf 对存储敏感但不想引入编译步骤
推荐实践:内部系统优先使用 JSON + 事件类型版本号;跨服务或需要长期归档时采用 Protobuf + 注册表。始终在 metadata 中记录序列化格式版本,避免反序列化时出现不兼容。

4.3 版本控制与事件演进

事件是不可变记录,但业务需求变化会导致事件结构变更。版本控制策略分为三类:

  1. 向前兼容(Forward Compatible):新版本消费者可以读取旧版本事件。做法:只添加可选字段,不删除或重命名字段。
  2. 向后兼容(Backward Compatible):旧版本消费者可以读取新版本事件。做法:新字段提供默认值,不改变已有字段语义。
  3. 全兼容(Full Compatible):同时满足向前和向后兼容。做法:使用 Protobuf 的 optional 字段或 Avro 的默认值机制。

事件版本号通常嵌入在 event_type 字段中,例如:OrderCreated.v1、OrderCreated.v2。当事件结构发生破坏性变更时,建议创建新事件类型而非修改已有事件。

// 事件版本控制示例(Java)
public class OrderCreatedV1 {
    private String orderId;
    private String customerId;
    private BigDecimal totalAmount;
}

public class OrderCreatedV2 {
    private String orderId;
    private String customerId;
    private BigDecimal totalAmount;
    private String currencyCode;  // 新增字段,默认 "CNY"
    private String sourceChannel; // 新增字段,默认 "WEB"
}
避坑提示:永远不要修改已持久化的事件。如果需要修正错误事件,应追加一个补偿事件(如 OrderCorrected),并在读取时由投影逻辑处理修正。直接修改事件会破坏溯源完整性。

4.4 并发处理与乐观锁

事件存储的并发写入主要发生在同一聚合流上。采用乐观锁(Optimistic Locking)机制解决冲突:

  • 每次写入事件时,客户端携带当前聚合流的 expected_version(即上次读取到的 stream_version)。
  • 存储层在插入前检查 expected_version 是否等于数据库中该聚合流的最大 stream_version。
  • 如果匹配,则插入新事件并将 stream_version 递增;如果不匹配,则拒绝写入并返回并发冲突错误。

SQL 实现示例:

-- 原子化写入事件并校验版本
INSERT INTO event_store (aggregate_id, aggregate_type, stream_version, event_type, event_data, metadata, created_at)
SELECT ?, ?, ?, ?, ?, ?, NOW()
WHERE (
    SELECT COALESCE(MAX(stream_version), 0) FROM event_store WHERE aggregate_id = ?
) = ?;

上述 SQL 通过子查询保证版本校验与插入在同一事务中完成,避免竞态条件。如果影响行数为 0,则说明版本冲突,应用层应重试或报错。

4.5 事件存储架构总览

下图展示了事件存储的核心组件及其交互关系:

事件存储架构总览 应用服务 (命令处理器) 事件存储 (写入/读取接口) 序列化器 (JSON/Protobuf) 事件表 (event_store) 乐观锁 (版本校验) 事件订阅者 (投影/读模型) 说明:写入路径经过序列化器和乐观锁校验;读取路径直接从事件表按聚合ID或全局顺序拉取,不经过锁机制。

4.6 并发冲突处理策略

当乐观锁检测到版本冲突时,应用层需要决定如何处理。常见策略:

策略 做法 适用场景
重试(Retry) 重新读取最新版本,合并业务逻辑后再次尝试写入 低冲突率、业务逻辑可重入
失败上报(Fail Fast) 直接返回 409 Conflict,由客户端决定后续操作 高一致性要求、人工介入场景
最后写入胜利(LWW) 忽略版本校验,以时间戳为准覆盖写入 非关键日志、监控事件(不推荐用于业务事件)
合并(Merge) 将冲突事件作为分支存储,后续由补偿逻辑合并 CQRS 读模型最终一致场景
生产建议:对于大多数业务聚合,采用重试策略配合指数退避(Exponential Backoff)即可满足需求。如果冲突率超过 5%,应考虑拆分聚合或引入命令队列。

4.7 事件存储的读写分离

事件存储的写入和读取具有不同的性能特征:写入是顺序追加,读取是按聚合ID或全局顺序扫描。为优化读取性能,可以引入读索引表:

-- 读索引表:按聚合ID快速定位事件范围
CREATE TABLE stream_index (
    aggregate_id   VARCHAR(64) NOT NULL,
    aggregate_type VARCHAR(64) NOT NULL,
    min_event_id   BIGINT UNSIGNED NOT NULL,
    max_event_id   BIGINT UNSIGNED NOT NULL,
    event_count    INT UNSIGNED NOT NULL,
    PRIMARY KEY (aggregate_id, aggregate_type)
);

写入事件时,同时更新 stream_index 中的 max_event_id 和 event_count;读取时先查索引表获取事件范围,再按 event_id 范围扫描事件主表,避免全表扫描。

下图展示了读写分离的查询路径:

事件存储读写分离查询路径 写入请求 (追加事件) 事件主表 (event_store) 流索引表 (stream_index) 读取请求 (按聚合ID) 流索引表 (定位范围) 事件主表 (范围扫描) 写入路径:先写事件主表,再更新流索引表(同一事务)。读取路径:先查流索引表获取事件范围,再按 event_id 范围扫描主表。

4.8 本章小结

事件存储设计需要同时关注表结构、序列化、版本控制和并发处理四个维度。核心原则:

  • 表结构采用双表模型,支持按聚合追加和全局顺序读取。
  • 序列化策略根据团队技术栈和性能要求选择 JSON 或 Protobuf,始终记录格式版本。
  • 事件版本控制遵循向前/向后兼容原则,破坏性变更应创建新事件类型。
  • 并发处理使用乐观锁 + 重试策略,避免分布式锁带来的性能开销。
  • 读写分离通过流索引表优化读取性能,写入路径保持顺序追加。