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 核心逻辑图
下面这张图,把事件、快照、时间戳、版本控制的关系串起来了。我画的时候特意把「业务时间」和「事件时间」分开标,因为这是最容易搞混的地方。
这张图你看懂了吗?数据源产生事件和快照。事件带着业务时间和事件时间,快照带着版本号。最后都交给回放引擎,按业务时间排序,按版本号解析。
一个小技巧:
快照可以定期生成,比如每小时一次。事件则实时记录。回放时,先加载最近的快照,再回放快照之后的事件。这样既快又准。
好了,这一章就到这里。数据模型设计好了,后面的事情就顺了。下一章咱们聊聊存储选型,看看事件和快照到底该放哪儿。