3. 事件数据模型设计:用户事件的定义与分类、事件属性设计原则、事件时间与处理时间、事件ID与用户ID的关联

数据模型是实时归因的根基。模型没设计好,后面所有分析都是空中楼阁。我见过太多团队,一上来就急着写 Flink 作业,结果事件字段对不上、时间戳乱飞、用户 ID 串号——最后全得重来。

这一章,我们就来聊聊怎么把事件数据模型搭扎实。说白了,就是回答四个问题:事件是什么?属性怎么定?时间怎么管?人和事怎么关联?

核心原则:事件模型的设计,要以「分析场景」为驱动,而不是以「采集成本」为驱动。宁可多记一个字段,也不要事后补数据。

3.1 用户事件的定义与分类

先定义清楚:什么是用户事件?

我的定义很简单——用户在应用内完成的一个有业务意义的原子操作。比如「点击购买按钮」「提交订单」「播放视频」「分享给好友」。这些是事件。而「页面停留 3 秒」「滑动屏幕 2 次」这类,通常算作状态或指标,不适合作为独立事件。

事件分类,我习惯分成三大类:

事件类型 说明 典型例子
行为事件 用户主动触发的操作 点击、滑动、提交、播放、收藏
状态事件 系统或用户状态的变化 登录、登出、进入页面、离开页面
业务事件 与核心业务流程强相关 下单、支付成功、退款、注册完成

为什么要分这么细?因为不同事件在归因中的权重不一样。行为事件是归因的主体,状态事件用来做上下文,业务事件是最终转化的目标。我在项目中遇到过,有人把所有事件混在一个大表里,结果做路径分析时,状态事件和业务事件搅在一起,根本算不清转化率。

我的习惯:每个事件都带一个 event_type 字段,取值就是上面三类。这样在过滤和聚合时,可以快速区分。

3.2 事件属性设计原则

事件属性,就是描述这个事件的「细节」。比如「点击购买按钮」这个事件,我们需要知道:点击的是哪个商品?价格多少?从哪个页面跳过来的?用户用的什么设备?

属性设计,我总结了四个原则:

  1. 原子性:每个属性只表达一个含义。别把「城市+设备型号」塞到一个字段里。
  2. 可枚举性:能用枚举值就别用自由文本。比如 device_type 用 ios/android/web,而不是用户自己填。
  3. 可扩展性:预留扩展字段。我一般会加一个 properties_ext 的 JSON 字段,放那些不确定的属性。
  4. 业务相关性:只记和分析相关的属性。别把 CPU 温度、内存占用这种技术指标塞进来。

举个例子,一个标准的「商品点击」事件属性设计:

{
  "event_name": "product_click",
  "event_type": "behavior",
  "properties": {
    "product_id": "sku_123456",
    "product_name": "无线蓝牙耳机",
    "category": "数码/音频",
    "price": 299.00,
    "source_page": "homepage",
    "source_module": "recommend",
    "device_type": "ios",
    "os_version": "16.4"
  }
}

嗯,这里要注意:price 字段我建议用浮点数,但如果你做实时聚合,最好转成整数(单位分),避免精度问题。我曾经因为这个踩过坑,实时计算里浮点数累加,结果对不上账。

3.3 事件时间与处理时间

这是实时计算里最容易搞混的概念。我见过不少新人,把这两个时间当成一回事,结果数据延迟一波动,归因全乱套。

简单说:

  • 事件时间(Event Time):用户实际发生操作的时间。由客户端 SDK 生成,通常就是 timestamp。
  • 处理时间(Processing Time):服务端接收到事件的时间。由 Flink 或 Kafka 等系统打上的时间戳。

为什么必须区分?因为网络延迟、客户端离线、服务端积压,都会导致处理时间远晚于事件时间。如果你用处理时间做归因,那凌晨 2 点的用户操作,可能被归到早上 8 点的活动上。

避坑指南:我曾经接手过一个项目,他们用处理时间做 30 分钟转化窗口。结果用户晚上 10 点点击广告,第二天早上 8 点才被服务端收到,转化窗口早就关了。最后归因率不到 30%。

我的建议是:所有归因逻辑,必须基于事件时间。处理时间只用来做监控和延迟告警。

在 Flink 中,设置事件时间的标准做法:

DataStream<UserEvent> eventStream = ...
    .assignTimestampsAndWatermarks(
        WatermarkStrategy.<UserEvent>forBoundedOutOfOrderness(Duration.ofSeconds(10))
            .withTimestampAssigner((event, timestamp) -> event.getEventTime())
    );

这里 forBoundedOutOfOrderness 设了 10 秒的乱序容忍度。为什么是 10 秒?不是拍脑袋定的。我一般会根据历史数据,统计 99 分位的延迟时间,然后取整。比如 99% 的事件在 8 秒内到达,我就设 10 秒。

3.4 事件ID与用户ID的关联

事件 ID 和用户 ID 的关联,是归因分析的基石。没有这个关联,你根本不知道「谁」做了「什么事」。

先说事件 ID。每个事件必须有一个全局唯一的 ID。我习惯用 UUID 或者 雪花算法 生成。为什么?因为实时计算中,事件可能被重复消费,唯一 ID 可以用来做去重。

用户 ID 就复杂一些。一个用户可能有多个 ID:

  • 设备 ID:IDFA、OAID、IMEI(已限制)
  • 登录 ID:手机号、邮箱、用户名
  • 匿名 ID:Cookie、Session ID

我的做法是:统一用户 ID 体系。在客户端 SDK 里,优先取登录 ID,如果没有,就用设备 ID 或匿名 ID。然后在服务端维护一个 user_mapping 表,把各种 ID 映射到一个全局的 user_id 上。

事件和用户的关联,最终落地到数据模型上就是:

{
  "event_id": "uuid_xxxxx",
  "user_id": "global_user_123",
  "device_id": "idfa_yyyyy",
  "event_name": "purchase",
  "event_time": 1712345678000,
  "properties": { ... }
}

这里 user_id 是全局统一的,device_id 只是辅助字段。归因分析时,全部基于 user_id 来做。

我的习惯:在实时流中,我会用 user_id 做 keyBy,保证同一个用户的事件进入同一个算子。这样归因状态可以本地维护,不用跨节点通信。

3.5 本章知识体系总览

下面这张图,把事件数据模型的核心要素串起来了。你可以把它当作设计时的检查清单。

事件数据模型核心要素 用户事件 事件定义与分类 事件属性设计 时间管理 事件ID与用户ID关联 行为事件 状态事件 业务事件 原子性 可枚举性 可扩展性 业务相关性 事件时间 处理时间 设备ID / 登录ID / 匿名ID 统一 user_id 映射

这张图把事件数据模型的四个核心要素串在了一起。你设计模型时,可以拿它当 checklist:事件分类清楚吗?属性满足四个原则吗?时间用对了没有?ID 关联打通了吗?