系列第四篇。上一篇讲包(打包与分包),这一篇讲包和点击数据进来之后怎么变成决策依据:数据体系。

依然四段:业务背景 → 方案设计 → 关键取舍 → 真实经验。


一、业务背景

发行每天要回答的问题

买量发行的日常决策,全部由数据驱动:

  • 昨天花了多少钱?来了多少人?
  • 这批人留下来了多少?赚回来多少?
  • 哪个计划值得加预算,哪个计划该停?
  • 用户进来之后做了什么,卡在哪一步?

这些问题背后是三类数据源:

数据源 内容 特点
客户端埋点 激活、登录、创角、付费 量大、实时、可能重复或缺失
广告平台回调 点击、转化回传状态 第三方发起、有重试、有时间差
广告平台成本 各计划每日消耗 需要导入、按天、口径来自平台

核心矛盾

实时性、准确性、成本,三个都要,但它们的实现方式互相冲突。

  • 要实时,最直接的做法是”报表直接查原始数据”——但原始数据量大,查询成本高
  • 要准确,最直接的做法是”离线预聚合”——但预聚合意味着”今天看不到今天”
  • 要省成本,最直接的做法是”少存、少算”——但发行分析恰恰需要长窗口(LTV 要看 180 天)

这一章的所有设计,本质上都是在回答”哪部分实时直查、哪部分预计算、哪部分干脆承认它还没完成”。


二、方案设计

2.1 存储分层:业务库管业务,分析库管分析

第一个决定是不把分析负载压在业务库上:

flowchart LR
    SDK["客户端 SDK<br/>激活 / 登录 / 创角 / 付费"] --> M["业务库 MySQL<br/>(OLTP)"]
    CB["广告平台<br/>点击回调"] --> M
    M -->|实时同步| D["分析库 Doris<br/>(OLAP)"]
    M -->|成本导入| D
    D --> R["报表层<br/>日报 / 留存 / LTV / ROI"]
    M --> A["后台管理<br/>游戏配置 / 推广包 / 权限"]
  • 业务库(MySQL):登录、支付、订单、游戏配置这些在线业务,要求低延迟单行读写
  • 分析库(Doris):事实数据和报表查询,要求大范围扫描和聚合
  • 两者之间通过云上实时同步(DTS)打通,不写双写代码

不双写这条很重要:双写意味着业务代码里有一半的失败分支要处理(写 A 成功写 B 失败怎么办?),而实时同步是基础设施层的职责,业务代码完全无感。能用同步解决的事,不要用双写解决。

2.2 事件模型:为什么是一张扁平事实表

埋点事件如果按”一种事件一张表”设计,很快会变成:激活表、登录表、创角表、付费表、点击表……然后每个漏斗分析都要 join 五张表,每个口径都要在五张表上各写一遍。

我们的选择是一张扁平事实表:

事实表(分析库)
├── 时间维度:dt、event_time、transaction_time
├── 身份维度:g_uid、gid、main_gid、device_id
├── 归因维度:adv_id、adv_id_original、attribution_applied、adv_type、adv_create_by
├── 事件标记:is_click / is_activate / is_first_bind_device
│            / is_register / is_login / is_create_role / is_paid_order
└── 业务数值:order_amount

一行事实 = 一个事件,事件类型由一组布尔标记列表达。

好处非常直接:

  • 跨事件分析变成单表查询:从激活到付费的漏斗,不需要 join,只需要按标记列筛选
  • 口径统一:所有指标都从同一张表出,不会出现”激活表里算的注册数和注册表里对不上”
  • 扩展事件是加一列,不是加一张表、加一套同步、加一套查询

代价也要认:

  • 表很宽、很稀疏:一行登录事件里,订单金额字段是空的
  • 写放大:每个事件都要写完整维度

但在 Doris 这种列存分析库里,稀疏和宽度都不是问题——列存天然适合宽表。这是”选对存储之后,数据模型可以更激进”的典型例子。

2.3 归因落库:把”他是从哪来的”钉在事实上

数据体系里最容易出问题的不是聚合,是归因——一个用户到底算不算广告带来的。

链路是这样的:

flowchart TB
    CL["广告平台回调点击<br/>(设备标识 + 计划 ID)"] --> CT["点击记录<br/>(按设备标识存下来)"]
    AC["客户端上报激活"] --> Q{"这个设备<br/>已有游戏用户?"}
    Q -->|有| OLD["老用户回流<br/>不重复归因"]
    Q -->|没有| C["按设备标识匹配点击记录<br/>(缓存优先)"]
    C -->|命中| HIT["写入 adv_id + 点击事件 ID<br/>标记 attribution_applied"]
    C -->|未命中| MISS["归因到自然量包<br/>(兜底)"]

三个关键设计:

① 匹配键的选择是分平台的

Android 的点击匹配用 oaid 优先、ip 兜底。有些设备标识(比如某些平台特有的匿名 ID)虽然能存、能分析,但不能用来做点击匹配——因为和广告平台回传的字段对不齐,用它匹配会造出一堆假命中。

选匹配键的标准不是”这个 ID 有多稳定”,而是”平台回调里有没有同一个值”。 两边对不上的标识,再稳定也不能用。

② 匹配结果要缓存,而且缓存里要能存”没匹配上”

每个激活都要查点击记录,量大且重复(同一设备可能多次上报)。所以做了缓存优先:匹配结果写缓存,下次直接读。

容易漏的一点:“未命中”也要缓存。否则每次上报都要扫一遍历史点击记录,然后得到同样的”没匹配上”。

③ 归因是”当时的事实”,不是”最终的事实”

事实表里同时存了三个字段:最终 adv_id、原始上报的 adv_id_original、以及是否发生了归因修正(attribution_applied)。

为什么要留原始值?因为归因结果会变:点击数据可能晚到,实时归因可能先给了个初值后面又被修正。留下原始值和修正标记,才能回答”这个数字为什么和昨天看到的不一样”。

这直接引出一个必须写进产品说明的结论:报表不是最终快照。今天的数据还在变,最近几天的历史数据也可能因为延迟同步或回填补齐而变化。页面必须诚实地说明这一点,而不是让运营自己发现数字在动。

2.4 口径治理:指标字典是唯一真相

数据体系最大的隐性成本是口径分裂:同一个词,不同报表、不同人理解不一样。

所以做了一件事:把所有指标定义收敛到一份指标字典(metric registry),代码里的报表口径以字典为准,文档冲突时以字典为准。

这不是形式主义,看看真实踩到的口径问题:

① “付费用户”包不包括 0 元订单

事实表里有一个付费标记,它把真实付费和 0 元奖励订单(比如广告激励发放)都标成了付费事件。

后果:如果统计”付费人数/流水”时直接用它,0 元订单会混进来,付费率被抬高、ARPU 被拉低。

规则因此定死:所有真金白银指标必须加”订单金额 > 0”。奖励订单只参与活动分析,不参与收入分析。

② “当日付费用户”不是”首次付费用户”

有一个指标字面意思是”激活付费用户数”,实际语义是“当天注册且当天付费”。这和”首次付费”完全是两回事。

指标名会骗人,所以字典里必须写清语义,而不是只写公式。

③ 命名欠债要认

还有一批指标名字里带”激活”,但实际是注册口径(历史遗留命名)。真正的激活事件口径只有少数几个字段在用。

处理方式不是”假装没这回事”,而是在字典里明确标注”这个名字是历史遗留,语义是注册”,并把它列进待重构清单。数据体系里,诚实地承认命名债,比偷偷改口径安全得多。

2.5 分层报表:一部分直查事实,一部分预计算

报表不是一种东西,按计算特性分两类:

flowchart TB
    F["事实表<br/>(明细)"] --> R1["日报类报表<br/>(游戏日报 / 投放日报)"]
    F --> C["预计算任务<br/>(留存 / LTV / 角色 LTV)"]
    C --> T["Cohort 表<br/>(按注册日期分群)"]
    T --> R2["留存 / LTV 报表"]

日报类 → 直接查事实表

因为日报的维度固定、时间范围短(页面限制最多查 90 天),而且今天的数据必须能看——只有直查事实表才能做到”今天的数据持续更新”。

留存 / LTV → 预计算 Cohort 表

因为它们的计算窗口长(LTV 要看到 180 天),而且每天的窗口都在变:今天算 day7,明天同一个用户群要算 day8。

Cohort 表的做法是按注册日期分群:每一行是一个”注册日 + 游戏”的用户群,列是 day1、day2……day180 的留存或 LTV 值。

几个细节值得说:

  • 重算窗口是滚动 180 天:每天定时任务重算最近 180 天的 cohort(因为新数据会补进来)
  • 重算不是覆盖,是修订:每张 cohort 表有一个 version 字段,它是重算修订号,不是 schema 版本。任务每重算一次就递增——这样”这个数字变过几次”是可查的
  • 当天数据只给能知道的窗口:当天注册的用户群,day7 留存还没发生,就必须返回空值,而不是 0。把”还不知道”和”是 0”混为一谈,是所有留存报表里最常见的错误。

2.6 聚合语义:不要平均百分比

这一条是报表正确性的底线,值得单独说:

  • 留存率汇总:不能把各分群的留存率取平均,要用”留存人数之和 ÷ 分群人数之和”
  • LTV 汇总:不能简单平均,要按各分群的用户数加权

原因很简单:一个 100 人的群和一个 10 万人的群,权重完全不同。直接平均百分比,在样本量差异大的时候会得出完全错误的结论,而且看起来很”正常”,不会报错。

2.7 首次绑定设备:一个需要三层实现的指标

“激活设备数”这个指标看似简单,实际很难做对,因为同一个设备可能多次上报(重装、多包、异常重试),而”激活设备数”要的是去重后的首次。

实现拆成三层职责:

  1. 分析库事实表:存行级事实 + 一个”是否首次绑定设备”的标记
  2. 业务库状态表:存每个(游戏 + 设备)当前的”首次”赢家——谁先来谁是赢家
  3. 业务库修复队列:写入时先记录”这对(游戏 + 设备)被碰过”,再做事实写入

第三层的顺序很关键:队列先写、事实后写。这样即使后续同步失败,也有一份持久化的待修复清单,可以精确重算。如果反过来,失败之后就永远不知道哪些数据需要修。

“先记待办、再做事”,是跨系统写入里最实用的可靠性模式。

2.8 成本与 ROI:宁可标记”未完成”,不假装算完了

ROI 需要两边数据:收入(我们有)和成本(在广告平台手里)。

成本侧的做法是导入:从广告平台导出消耗数据(CSV),按天、按计划导入成本表,导入时用包名匹配到具体游戏——因为广告平台是按包名投放的,包名是唯一的连接点。

这里有一个我认为很关键的设计态度:

依赖成本的指标(消耗金额、ROI N 日)在指标字典里被明确标记为”规划中”,而不是”已上线”。

为什么?因为成本数据来自外部导入,口径和时效都不如自有数据可靠。把”我们还没验证过”的指标混进已上线指标里,会污染所有人对整套数据的信任。 一个诚实的”规划中”标签,比一个没人敢用的 ROI 数字有价值。

这里也回到上一章的分包设计:包名(包标识)在投放时就已经确定了,所以”广告平台的包名 → 我们的游戏”这条映射从出包那一刻就存在——数据体系的连接键,是分包模块埋下的。


三、关键取舍

决策 选择 放弃的方案 理由
分析存储 独立分析库(列存) 业务库直接跑分析 分析负载会拖垮在线业务;列存天然适合宽表聚合
数据同步 基础设施层实时同步 业务代码双写 双写把失败分支带进业务代码,同步对业务无感
事件模型 一张扁平事实表 一种事件一张表 跨事件漏斗单表可查,口径天然统一,扩展是加列
日报 直接查事实表 预聚合日表 只有直查才能做到”今天的数据持续更新”
留存 / LTV 预计算 Cohort 表 每次实时算 180 天窗口实时算成本不可接受
口径 收敛到指标字典 散落在各查询里 口径分裂是数据体系最大的隐性成本
未归因流量 只进运营口径 进广告口径 广告口径必须”只含可用归因的行”,否则投放决策被污染
未完成指标 标记”规划中” 先混进去上线 未验证指标会污染整份数据的可信度
去重权威 业务库权威表 + 分析库明细 只在分析库去重 分析库不适合做在线唯一性判定,明细与权威分层
当天窗口 未知返回空值 未知返回 0 “还不知道”和”是 0”必须区分

四、真实经验

1. “激活”这个词有三个不同的时间点

一个用户从点击广告到变成付费用户,至少有三个身份时间点:设备首次激活、账号注册、设备首次绑定。

早期”激活设备数”的口径模糊,导致同一个数字在不同页面对不上。后来把口径钉死:广告口径的”激活设备数”用首次绑定设备(去重后的首次),而不是”上报过激活事件的设备数”。

这类问题的根源不是技术,是词。 数据体系里最贵的工作量,往往是给每个业务词定一个精确的技术定义。

2. 0 元订单混进付费口径

前面讲过,这里补一个”怎么发现的”:付费率和 ARPU 一直和运营的直觉对不上,但差得不多,说不清哪里不对。查下来是激励发放的 0 元订单被算成了付费订单。

修正方式不是改数据,是改口径并写进字典——因为”0 元订单算不算付费”是一个业务定义问题,不同公司答案不同。定义定了,代码照着实现,字典记着,这类问题才不会再回来。

3. 平均百分比:一个不会报错的错误

留存汇总最初是”把各群留存率平均”,在样本量接近时看不出问题,直到某天一个小渠道群(几十人)和一个大群(几万人)并列展示,汇总数字明显失真。

改成”留存人数之和 ÷ 分群人数之和”后,数字立刻符合直觉。

这类 bug 的特征是:没有异常、没有报错、没有告警,只有”数字看着怪”。 唯一能防住它的是在写聚合逻辑时先问一句”这个比率的分母是什么”。

4. 重复创角事件:需要一张权威表

玩家重登、换设备、异常重试都会重复上报”创角”事件,导致同一角色有多条创角事实。

解决方案是双层:

  • 分析库保留行级事实(可追溯)
  • 业务库建一张角色权威表,存每个(游戏 + 服 + 角色)最早那次创角事件的赢家
  • 报表读事实行,展示字段(角色名、等级等)用权威表覆盖

“明细用于分析、权威用于判定”——这是处理重复事件的通用模式。

5. 页面要主动说明数据的新鲜度

因为日报是直查事实表的,页面上的”今天”一直在涨,最近几天也可能被补数据改动。

这不是缺陷,是设计选择。但必须在页面上说明(哪些数据在变、为什么变),否则运营会得出”数据不可信”的结论。

数据的可信度不只来自准确,还来自”用户知道它什么时候会变、为什么变”。

6. 口径冲突时,要有一个人说了算

指标字典的价值在冲突时才体现:当报表代码和旧文档不一致时,规则是以字典为准,而不是”谁改的谁负责”。

数据体系里必须有一个单一真相源,否则口径问题会以”两个页面数字不一样”的形式无限复现。


下一篇

(五)发行后台:功能模块、权限模型、对账、报表体系——数据出来之后,运营每天实际在哪个界面上干活 → 继续阅读


本文基于实际项目整理,已脱敏。文中不含任何公司业务数据、配置或凭据。