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) | 事件创建时间(微秒精度) |
4.2 序列化策略
事件序列化需要平衡可读性、存储效率和模式演进能力。主流方案对比:
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| JSON(文本) | 人类可读、调试方便、语言无关 | 存储膨胀、无类型校验 | 中小规模、快速迭代 |
| Protocol Buffers | 紧凑、强类型、跨语言 | 需要预定义 schema、调试困难 | 高性能、多语言异构系统 |
| Avro | 自带 schema、支持演进 | Java 生态偏重、序列化/反序列化较慢 | 大数据管道、Schema Registry 场景 |
| MessagePack | 紧凑、JSON 兼容 | 生态不如 JSON/Protobuf | 对存储敏感但不想引入编译步骤 |
4.3 版本控制与事件演进
事件是不可变记录,但业务需求变化会导致事件结构变更。版本控制策略分为三类:
- 向前兼容(Forward Compatible):新版本消费者可以读取旧版本事件。做法:只添加可选字段,不删除或重命名字段。
- 向后兼容(Backward Compatible):旧版本消费者可以读取新版本事件。做法:新字段提供默认值,不改变已有字段语义。
- 全兼容(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"
}
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 事件存储架构总览
下图展示了事件存储的核心组件及其交互关系:
4.6 并发冲突处理策略
当乐观锁检测到版本冲突时,应用层需要决定如何处理。常见策略:
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 重试(Retry) | 重新读取最新版本,合并业务逻辑后再次尝试写入 | 低冲突率、业务逻辑可重入 |
| 失败上报(Fail Fast) | 直接返回 409 Conflict,由客户端决定后续操作 | 高一致性要求、人工介入场景 |
| 最后写入胜利(LWW) | 忽略版本校验,以时间戳为准覆盖写入 | 非关键日志、监控事件(不推荐用于业务事件) |
| 合并(Merge) | 将冲突事件作为分支存储,后续由补偿逻辑合并 | CQRS 读模型最终一致场景 |
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 范围扫描事件主表,避免全表扫描。
下图展示了读写分离的查询路径:
4.8 本章小结
事件存储设计需要同时关注表结构、序列化、版本控制和并发处理四个维度。核心原则:
- 表结构采用双表模型,支持按聚合追加和全局顺序读取。
- 序列化策略根据团队技术栈和性能要求选择 JSON 或 Protobuf,始终记录格式版本。
- 事件版本控制遵循向前/向后兼容原则,破坏性变更应创建新事件类型。
- 并发处理使用乐观锁 + 重试策略,避免分布式锁带来的性能开销。
- 读写分离通过流索引表优化读取性能,写入路径保持顺序追加。