4、数据模型设计:事件与快照,时间与版本

好,咱们进入第四章。这一章,说白了就是整个回放系统的地基。

你想想看,回放系统要回放什么?当然是数据。那数据长什么样?怎么组织?怎么保证回放的时候不出错?这些都得靠数据模型来定。

我个人习惯,在设计任何系统之前,先把数据模型想清楚。模型稳了,代码怎么写都顺。模型乱了,后面全是坑。

4.1 事件(Event)与快照(Snapshot)

回放系统里,数据有两种基本形态:事件和快照。

事件,就是「发生了什么」。比如用户下单了、商品价格改了、库存扣了。每个事件都记录一次变化。

快照,就是「某一刻的全貌」。比如今天零点整,整个订单系统的完整状态。

我在项目中遇到过一个问题:只存事件,回放时要从头算到尾,太慢了。只存快照,又丢了中间的变化细节。所以,两者得配合着用。

核心原则:

  • 事件用于精确回放每一步操作
  • 快照用于快速恢复到某个时间点
  • 两者通过时间戳关联

举个例子。假设我们要回放一个电商订单的状态变化:

// 事件模型
{
  "event_id": "evt_20240101_001",
  "event_type": "order_created",
  "timestamp": 1704067200,
  "data": {
    "order_id": "ORD123",
    "user_id": "USR456",
    "amount": 299.00,
    "status": "pending"
  }
}

// 快照模型
{
  "snapshot_id": "snap_20240101_0000",
  "snapshot_time": 1704067200,
  "data": {
    "orders": [
      {"order_id": "ORD123", "status": "pending", "amount": 299.00},
      {"order_id": "ORD124", "status": "shipped", "amount": 159.00}
    ],
    "total_orders": 2
  }
}

嗯,这里要注意:快照不是简单的事件累加。快照是经过聚合、清洗后的完整状态。事件是原始日志,快照是业务视图。

4.2 时间戳处理:业务时间 vs 事件时间

这是个大坑。我踩过,而且不止一次。

业务时间:用户实际操作的时间。比如用户下单时,手机上的时间。

事件时间:系统记录事件的时间。比如服务器收到请求的时间。

为什么会不一样?你想想看,用户手机时间可能不准,网络有延迟,消息队列有积压。这两个时间差个几秒甚至几分钟,太正常了。

我曾经犯过的错:

用事件时间代替业务时间做回放。结果回放出来的顺序跟用户实际操作的顺序完全对不上。用户先下单后付款,回放出来变成先付款后下单。这谁受得了?

所以,我的建议是:

  • 回放时用业务时间:保证回放顺序跟真实世界一致
  • 存储时两个时间都保留:方便排查问题
  • 用事件时间做监控:比如检测系统延迟

来看一个实际的数据结构设计:

{
  "event_id": "evt_20240101_001",
  "business_time": 1704067200,  // 用户操作时间
  "event_time": 1704067215,     // 系统记录时间
  "time_diff": 15,              // 时间差,单位秒
  "event_type": "order_created",
  "data": { ... }
}

加上 time_diff 字段,是我个人的小习惯。调试时一眼就能看出延迟有多大。

4.3 数据版本控制

数据模型不是一成不变的。今天设计好了,明天业务一改,字段就得加。那历史数据怎么办?

我见过最粗暴的做法:直接改数据库表结构,历史数据全报废。嗯,这显然不行。

数据版本控制,说白了就是给每个数据模型打上版本号。不同版本的数据,用不同的处理逻辑。

我的做法:

每个事件和快照都带一个 schema_version 字段。回放时根据版本号选择对应的解析器。这样新旧数据可以共存。

版本控制的核心策略:

策略 说明 适用场景
向前兼容 新版本能读旧版本数据 大多数情况,推荐
向后兼容 旧版本能读新版本数据 需要长期支持旧系统
不兼容 版本间完全隔离 重大重构,数据格式彻底改变

我个人习惯用向前兼容。新代码能处理旧数据,旧代码不用管新数据。这样升级时压力最小。

来看一个版本控制的代码示例:

// 版本1的事件
{
  "schema_version": "1.0",
  "event_type": "order_created",
  "data": {
    "order_id": "ORD123",
    "amount": 299.00
  }
}

// 版本2的事件,增加了 user_id 字段
{
  "schema_version": "2.0",
  "event_type": "order_created",
  "data": {
    "order_id": "ORD123",
    "user_id": "USR456",
    "amount": 299.00
  }
}

// 解析器
function parseEvent(event) {
  switch(event.schema_version) {
    case "1.0":
      return {
        order_id: event.data.order_id,
        user_id: "unknown",  // 旧版本没有,给默认值
        amount: event.data.amount
      };
    case "2.0":
      return event.data;
    default:
      throw new Error("Unknown version: " + event.schema_version);
  }
}

你想想看,如果没有版本控制,版本2的数据来了,旧解析器直接报错。整个回放系统就崩了。

4.4 核心逻辑图

下面这张图,把事件、快照、时间戳、版本控制的关系串起来了。我画的时候特意把「业务时间」和「事件时间」分开标,因为这是最容易搞混的地方。

数据模型核心逻辑 数据源 事件 (Event) 快照 (Snapshot) 业务时间 事件时间 版本控制 回放引擎 按业务时间排序 按版本解析 实线:主要流程 | 虚线:辅助流程 事件记录变化,快照记录全貌 时间戳决定回放顺序,版本控制保证兼容性

这张图你看懂了吗?数据源产生事件和快照。事件带着业务时间和事件时间,快照带着版本号。最后都交给回放引擎,按业务时间排序,按版本号解析。

一个小技巧:

快照可以定期生成,比如每小时一次。事件则实时记录。回放时,先加载最近的快照,再回放快照之后的事件。这样既快又准。

好了,这一章就到这里。数据模型设计好了,后面的事情就顺了。下一章咱们聊聊存储选型,看看事件和快照到底该放哪儿。

可可精品资料库