bg12.埋点
看看你都给我埋了些啥没用的字段!!!
最早分析用户行为,用的是服务端访问日志——Nginx 的 access log、应用的业务日志。一条记录长这样:
115.204.x.x - - [29/Jul/2026:14:23:11 +0800] "GET /api/item/detail?id=60012345 HTTP/1.1" 200 1832 "..." "okhttp/4.9.0"
从这条日志能推出:某个 IP 在某时刻请求了商品 60012345 的详情接口。
服务端日志只能看见"产生了网络请求"的行为。 而下面这些行为,服务端完全看不见:
| 行为 | 服务端能看见吗 | 为什么看不见 |
|---|---|---|
| 商品卡片在信息流里被展示(曝光) | ❌ | 首页数据是一次性下发 20 条的,用户滑到第 15 条时不会再发请求 |
| 用户在详情页停留了 40 秒 | ❌ | 页面已经渲染完,停留期间没有任何请求 |
| 用户滑动到了页面底部 | ❌ | 同上 |
| 用户点了"加入购物车"但弹窗报错了 | ⚠️ 部分 | 能看到失败的请求,但看不到用户随后是重试还是放弃 |
| 用户打开 App 但什么都没干就退出 | ❌ | 冷启动可能只有一个接口调用,看不出用户看了什么 |
其中曝光是致命的。没有曝光数据,就算不出点击率(CTR = 点击 / 曝光),而点击率是推荐、搜索、广告三个系统的核心指标。服务端日志时代,算不出 CTR。
埋点一共有三种方案:
| 维度 | 代码埋点(手动埋点) | 全埋点(无埋点 / 自动采集) | 可视化埋点 |
|---|---|---|---|
| 怎么做 | 工程师在业务代码里手写一行上报调用 | SDK 自动 hook 界面框架,采集所有点击/页面切换 | 在一个可视化后台上圈选界面元素,配置下发给 SDK |
| 采什么 | 只采你写了的,可带任意业务属性 | 采一切可见交互,但只有控件层面的信息 | 采你圈选的元素,属性有限 |
| 业务属性 | ✅ 想带什么带什么(商品 ID、坑位、算法版本) | ❌ 基本没有,只知道"某个按钮被点了" | ⚠️ 有限,靠配置映射 |
| 上线成本 | 高,要改代码、跟版本发布 | 低,接一次 SDK 就有 | 中,需要一套可视化配置平台 |
| 数据量 | 可控 | 极大,且大部分没人用 | 可控 |
| 改需求 | 要发新版本,等用户升级 | 不用改 | 改配置即可 |
术语别名:全埋点在不同厂商文档里也叫"无埋点""自动埋点""无痕埋点",指的是同一件事。本系列统一使用全埋点。
选型结论:几乎所有需要业务语义的分析(推荐、广告、交易转化)都必须用代码埋点,因为只有它能带上 item_id、position、algo_version 这些属性。全埋点的价值在于兜底——用来回答"某个按钮到底有没有人点"这类临时问题,避免"想分析时发现没埋"。
为什么代码埋点无法被取代:因为下游要算的是"算法版本 A 在首页第 3 个坑位曝光的商品的点击率"。这句话里的"算法版本""首页""第 3 个坑位"三个信息,只存在于业务代码的上下文里,SDK 从界面控件上抓不到。这是一条硬约束,不是工程投入能绕过的。
再切一刀:埋点代码放在哪一端。
| 客户端埋点 | 服务端埋点 | |
|---|---|---|
| 能看到 | 曝光、滑动、停留、渲染失败、未登录行为 | 请求、业务处理结果、金额、状态流转 |
| 看不到 | 服务端内部逻辑(比如订单最终风控结论) | 一切不产生请求的行为 |
| 数据可靠性 | 低:会丢、会重、时间不可信、可被伪造 | 高:在自己机房里,时间可信,不会因为用户杀进程而丢 |
| 上线速度 | 慢,跟 App 版本发布,还要等用户升级 | 快,服务端发版即生效 |
通行做法是分工而非二选一:
- 和钱、和状态相关的关键事件走服务端埋点——下单、支付、退款。理由很直接:客户端上报的"支付成功"不可信(会丢、可伪造),而财务口径不允许误差。
- 交互行为走客户端埋点——曝光、点击、停留。服务端根本看不到。
埋点数据的通行结构是三段:
{ 事件名 } + { 公共属性 } + { 事件专属属性 }
- 事件名(event):标识"发生了什么",全局唯一。如
product_click、page_view。 - 公共属性:所有事件都带的字段,由 SDK 自动填充。如设备型号、App 版本、网络类型、时间戳、用户标识。
- 事件专属属性(properties):该事件独有的业务字段,由埋点代码传入。如
item_id、position。
为什么要拆成"公共"和"专属"两部分? 因为公共属性由 SDK 统一注入,业务方不需要(也不应该)每次手填——手填就会出现"有的事件带了 app_version,有的没带",下游一做版本对比分析就发现数据缺口。这个设计的代价是 SDK 变重、公共属性的变更要跟 SDK 升级。
{
// ===== 事件标识 =====
"event": "product_click", // 事件名。全局唯一,必须在埋点字典里注册过
"event_id": "9f3c1a7e-4b2d-11f0", // 本次事件唯一 ID(UUID)。用于下游去重,见 §5.3
// ===== 时间(三个,缺一不可,原因见 §5.1) =====
"client_time": 1753846231120, // 客户端本地时间戳(ms)。语义最准,但不可信
"seq": 40127, // 本设备本次安装以来的事件序号,单调递增。用于估算丢失率,见 §5.2
// ===== 身份标识(三层,见 §3.3) =====
"device_id": "d-8823ef01", // 设备标识,App 安装即生成,不随登录变化
"user_id": 100238841, // 登录用户 ID。未登录时为 null
"session_id": "s-8823ef01-1753846", // 会话 ID,见 §3.3 定义
// ===== 公共属性(SDK 自动注入) =====
"app_version": "8.12.0",
"os": "iOS",
"os_version": "18.2",
"device_model": "iPhone15,3",
"network": "wifi", // wifi / 4g / 5g / offline
"channel": "appstore", // 安装渠道,做拉新归因用
// ===== 事件专属属性(业务代码传入) =====
"properties": {
"item_id": 60012345,
"position": 3, // 列表中的坑位序号,算分位点 CTR 必须有
"page": "home_feed",
"algo_version": "rank_v3.2", // 算法版本,做 A/B 实验对比必须有
"trace_id": "t-77aa12" // 追踪 ID,把同一次曝光和点击关联起来,见下
}
}
关于 trace_id(这个字段值得单独说):算点击率需要把"这次点击"和"它对应的那次曝光"关联起来。如果只按 user_id + item_id 关联,同一个商品在一天里曝光 5 次、点击 1 次,你无法知道点的是第几次曝光。做法是:服务端在下发推荐结果时生成一个 trace_id,客户端把它同时带在曝光事件和点击事件上,下游按 trace_id 关联即可精确配对。
| 标识 | 生成时机 | 生命周期 | 用来回答什么 |
|---|---|---|---|
device_id |
App 首次安装启动 | 到卸载重装为止 | "这台设备"做了什么 |
user_id |
用户登录后 | 账号级别,跨设备 | "这个人"做了什么 |
session_id |
一次会话开始 | 一次会话 | "这一次使用过程中"做了什么 |
session_id 需要精确定义,因为它是靠规则切出来的,不是天然存在的。 通行规则:App 进入前台时开启新会话;App 退到后台超过 N 分钟(常见取 30 分钟)后再回到前台,算新会话。这个 N 是人为选的,不同公司取值不同——所以"人均会话数""平均会话时长"这两个指标跨公司不可比,这是 11 篇讲的口径问题的一个具体实例。
真实难题:登录前后的身份打通。
用户下载 App 后先逛了 20 分钟(未登录,只有 device_id),然后登录(有了 user_id),再下单。问题来了:登录前那 20 分钟的行为,要不要算到这个 user_id 头上?
时间轴 ──────────────────────────────────────────────────▶
t0 t1 t2 t3
│ │ │ │
安装启动 浏览 20 分钟 登录 下单
device_id ✓ device_id ✓ device_id ✓ device_id ✓
user_id ✗ user_id ✗ user_id ✓ user_id ✓
↑ ↑
这段行为归属谁? ─────────── 登录时才知道"原来是他"
处理方式与各自的代价:
- 不回溯:登录前的行为只挂在
device_id上。简单、无副作用,但新用户的转化漏斗算不全——你会看到"注册后立刻下单",看不到他注册前逛了 20 分钟。 - 回溯打通:登录时把该设备近期的匿名行为重新归属到
user_id。漏斗完整了,代价是要重写历史数据(已经落盘的 DWD 明细要更新user_id),且遇到"一台设备多个账号登录"(家人共用手机)时会误归属。 - 双 ID 并存,分析时按需选:数仓同时保留
device_id和user_id,分析师自己决定用哪个口径。这是代价最小的做法,但把复杂度转移给了使用方,前提是数据字典里必须写清楚。
采集链路:
flowchart LR
subgraph CLIENT["客户端(用户手机,不可控环境)"]
BIZ["业务代码<br/>track('product_click', {...})"]
SDK["埋点 SDK"]
BUF["本地缓存队列<br/>内存 + 磁盘持久化"]
BIZ --> SDK --> BUF
end
subgraph SERVER["服务端(可控环境)"]
GW["上报网关<br/>鉴权/限流/补 server_time"]
MQ["Kafka"]
DUMP["落 ODS 任务"]
RT["Flink 实时链"]
end
BUF -->|"批量 HTTP POST<br/>失败重试"| GW
GW --> MQ
MQ --> DUMP
MQ --> RT
一个自然的问题:为什么不每产生一个事件就立刻发一次请求?
因为代价扛不住,三条因果链:
- 网络开销。一次 HTTPS 请求的握手 + 头部开销是固定的(TLS 握手在冷连接下要多个往返)。单条事件的 body 可能只有 300 字节,而握手和头部的开销远大于它。一次页面浏览产生 30 个曝光事件,就是 30 次这样的浪费。
- 耗电。移动网络的射频模块从休眠切到激活有固定能耗,且激活后会保持一段时间(尾部能耗)。高频小请求会让射频几乎无法休眠,直接体现为耗电量投诉。
- 弱网下必然失败。地铁、电梯里网络中断,实时发送的事件直接丢失,没有第二次机会。
所以通行设计是:事件先进本地队列,攒够 N 条或过了 T 秒再批量发一次;发送失败则保留在队列里,下次重试;队列要落磁盘,防止进程被杀后丢失。
public class TrackerUploader {
// 内存队列。真实 SDK 还会把队列内容同步落到磁盘(SQLite/文件),防止进程被杀丢数据
private final BlockingQueue<Event> queue = new LinkedBlockingQueue<>(10000);
private static final int BATCH_SIZE = 50; // 攒够 50 条就发
private static final long FLUSH_MILLIS = 10_000; // 或者最多等 10 秒也发
/** 业务代码调用这里。注意:这里只入队,不发网络请求,所以调用方感觉不到耗时 */
public void track(Event e) {
queue.offer(e); // 队列满时 offer 返回 false —— 这就是"丢失"的来源之一
}
/** 后台线程:攒批 + 上报 + 失败重试 */
private void uploadLoop() throws InterruptedException {
while (true) {
List<Event> batch = new ArrayList<>();
long deadline = System.currentTimeMillis() + FLUSH_MILLIS;
// 攒批:要么攒够 50 条,要么等到 10 秒超时
while (batch.size() < BATCH_SIZE && System.currentTimeMillis() < deadline) {
Event e = queue.poll(deadline - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
if (e != null) batch.add(e);
}
if (batch.isEmpty()) continue;
// 上报,失败最多重试 3 次
for (int attempt = 1; attempt <= 3; attempt++) {
try {
httpPost("/collect", batch);
break; // ← 成功,跳出重试
} catch (IOException ex) {
if (attempt == 3) {
queue.addAll(batch); // ← 三次都失败,放回队列下次再发
}
Thread.sleep(1000L * attempt);
}
}
}
}
}
模拟输入:用户在弱网环境下浏览,10 秒内产生 3 个曝光事件(seq 分别是 101、102、103),第一次上报时服务端已经收到并写入 Kafka,但返回的响应包在网络中丢了,客户端认为失败。
运行结果:
| 时刻 | 客户端动作 | 服务端实际收到 |
|---|---|---|
| T+10s | 发送 batch[101,102,103],未收到响应 | ✅ 已收到并写入 Kafka |
| T+11s | 重试第 1 次,成功 | ✅ 再次收到,再次写入 Kafka |
| 结果 | 客户端认为发了 1 次 | 服务端有 2 份 [101,102,103] |
为什么会是这个结果:HTTP 请求失败分两种——"没送到"和"送到了但响应丢了",客户端无法区分这两者。为了不丢,它只能选择重试;一重试,"送到了但响应丢了"的那种情况就变成了重复。
这就是至少一次投递(at-least-once)语义的本质:在不可靠网络上,你只能在"可能丢"和"可能重"之间选一个,没有第三个选项。埋点 SDK 一律选"可能重"——因为丢了的数据永远找不回来,重复的数据下游可以去掉。
乱序同理:seq=101..103 这一批在重试,与此同时新产生的 seq=104 可能通过另一个连接先发成功了。服务端收到的顺序是 104 → 101,102,103。批量上报 + 重试 + 多连接,三者叠加,乱序是必然结果,不是异常。
问题一:客户端时间不可信:
成因有三条,性质完全不同:
- 用户手动改了系统时间。为了在某些 App 里"穿越",用户会把手机时间调到未来或过去。这类偏差可以是几年。
- 设备时钟自然漂移。手机时钟晶振有误差,若长期未与时间服务器同步(NTP,Network Time Protocol,一种通过网络校准本机时钟的协议),会累积秒到分钟级的偏差。
- 时区处理错误。跨时区用户,或客户端上报本地时间而下游按 UTC 解析。这类偏差是整小时的。
为什么不能干脆只用服务端时间? 因为服务端时间语义是错的。回看 §4.2:事件在本地队列里可能躺 10 秒,弱网下可能躺几小时甚至跨天。服务端收到的时刻和用户实际操作的时刻可以差很远。用 server_time 算"用户在几点最活跃",得到的是"网络在几点最通畅"。
对策:三个时间戳全都保留,各司其职。
| 字段 | 谁写的 | 语义 | 用在哪 |
|---|---|---|---|
client_time |
客户端 | 用户实际操作的时刻 | 业务分析的时间归属(event time) |
server_time |
上报网关 | 服务端收到的时刻 | 兜底、时钟校正基准、监控上报延迟 |
ingest_time |
落库任务 | 数据写入数仓的时刻 | 排查数据延迟、做分区(见 §7) |
时钟偏移校正:网关收到请求时,可以算出 offset = server_time - client_time(含网络传输时间,所以只是近似)。对同一设备取一段时间内 offset 的中位数,若明显偏离 0,说明该设备时钟有系统性偏差,可以对它的 client_time 做整体平移修正。
【推断】 这个校正方法的有效性取决于该设备事件量是否足够、网络延迟是否稳定。我没有找到公开的准确率数据,把它当作"能治系统性漂移,治不了用户手动乱改"的手段更稳妥。
这就是 watermark 的业务源头。 你在 07 篇学到 Flink 要用 event time 而不是 processing time、要用 watermark 来判断"某个时间窗口是不是可以关闭了"——现在你知道为什么了:
- 用 event time,是因为业务问题问的是"用户几点操作的"(
client_time),不是"系统几点处理的" - 需要 watermark,是因为
client_time不保证按顺序到达(§4.3 已证明乱序是必然的),系统必须有一个机制来估计"client_time早于 X 的事件应该都到齐了"
换句话说:watermark 不是 Flink 发明的概念,它是这条采集链路的物理性质逼出来的。
问题二:丢失
丢失点位(沿链路从前到后):
| 丢失点 | 成因 | 大致可控性 |
|---|---|---|
| 队列满 | §4.3 代码里 queue.offer() 返回 false |
可控:加大队列、提高上报频率 |
| 进程被杀 | 用户划掉 App / 系统内存不足回收 | 部分可控:队列落磁盘 |
| 卸载重装 | 磁盘队列被清 | 不可控 |
| 重试耗尽 | 长时间无网络 | 部分可控:延长保留期 |
| 网关限流 | 上报量突增被拒 | 可控:容量规划 |
| Kafka 写入失败 | 见 06 篇的 acks 配置 |
可控 |
关键问题:怎么知道丢了多少? 数据丢了是无声的——你看到的行数就是你收到的行数,没有任何东西告诉你"本来应该更多"。
对策:端到端序列号。 SDK 为每台设备维护一个单调递增的 seq(见 §3.2)。下游按设备检查序号是否连续,缺口就是丢失。
-- 按设备统计序号缺口,估算丢失率
-- 思路:同一设备的相邻两条事件,seq 应该正好差 1;差值 >1 说明中间丢了
WITH t AS (
SELECT
device_id,
seq,
LAG(seq) OVER (PARTITION BY device_id ORDER BY seq) AS prev_seq
FROM ods_app_event
WHERE dt = '2026-07-29'
)
SELECT
SUM(CASE WHEN prev_seq IS NOT NULL THEN seq - prev_seq - 1 ELSE 0 END) AS lost_cnt,
COUNT(*) AS received_cnt,
ROUND(
SUM(CASE WHEN prev_seq IS NOT NULL THEN seq - prev_seq - 1 ELSE 0 END)
/ (COUNT(*) + SUM(CASE WHEN prev_seq IS NOT NULL THEN seq - prev_seq - 1 ELSE 0 END))
* 100, 4
) AS lost_rate_pct
FROM t;
模拟输入:某设备当天上报的 seq 为 100, 101, 102, 105, 106(103、104 丢失)。全库共收到 98,000,000 条,检出缺口 147,000 个。
运行结果:
lost_cnt received_cnt lost_rate_pct
147000 98000000 0.1497
为什么是这个结果:LAG 取到同设备上一条的 seq,105 - 102 - 1 = 2 即为该处丢失 2 条;把所有缺口加总得 147,000。丢失率的分母必须是"应收总量"(收到的 + 丢失的),不是"收到的",否则会低估。
这个方法的边界(必须知道):
- 只能检出中间的丢失。设备最后一批事件全丢了(比如卸载前那一批),序号在末尾断掉,检不出来。
- 跨天会误判。设备昨天最后一条
seq=500,今天第一条seq=501,如果只按当天分区算,今天这条的prev_seq是 NULL,不计入——这是上面 SQL 里prev_seq IS NOT NULL的作用,代价是每设备每天少统计一个可能的缺口。 - 重装后
seq归零,会产生一个负的差值,需要额外过滤(上面的 SQL 未处理这种情况,生产环境需要加AND seq > prev_seq条件)。
所以这个数字是丢失率的下界,用来做趋势监控(今天 0.15%,昨天 0.14%,正常;突然变成 3%,一定出事了)比用来做绝对值汇报更合适。
问题三:重复
三个可选位置,代价完全不同:
| 去重位置 | 怎么做 | 优点 | 代价 |
|---|---|---|---|
| 网关 | 用 Redis 存最近 N 分钟的 event_id 集合 |
最早拦截,下游全部干净 | 网关要扛全量写入的 Redis 压力;只能覆盖短时间窗口,跨天重传(弱网设备第二天才发出来)拦不住 |
| Flink 实时链 | 按 event_id 做 keyed state 去重,配 TTL |
实时链干净 | 状态大小 = 去重窗口内的事件数 × 每条 key 的开销,窗口设 24 小时的话状态可能到 TB 级(见 07 篇 state backend) |
| 离线批(DWD 层) | ROW_NUMBER() OVER (PARTITION BY event_id ORDER BY server_time) = 1 |
实现最简单,窗口可以开到很大,成本可控 | 只有 T+1 的 DWD 是干净的,ODS 和实时链带重复 |
-- DWD 层去重:同一 event_id 只保留最早收到的那条
INSERT OVERWRITE TABLE dwd_item_click_di PARTITION (dt = '2026-07-29')
SELECT event_time, user_id, device_id, item_id, cate_id, shop_id, position, page, trace_id, is_valid
FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY event_id ORDER BY server_time ASC) AS rn
FROM ods_app_event_parsed
WHERE dt = '2026-07-29' AND event = 'product_click'
) t
WHERE rn = 1;
模拟输入:ODS 当天 product_click 共 12,400,000 条,其中 event_id 重复的有 86,000 条(对应 43,000 个被重复上报的事件)。
运行结果:DWD 写入 12,357,000 条(12,400,000 - 43,000)。
为什么是这个结果:ROW_NUMBER 在每个 event_id 分组内按 server_time 升序编号,rn = 1 只留最早到达的那条。86,000 条重复记录里包含 43,000 条"第一次"和 43,000 条"第二次",被过滤掉的是后者。
通行折中做法:网关做短窗口去重(拦掉绝大多数几秒内的重试),离线 DWD 做兜底全量去重。实时链一般不做严格去重,接受少量重复——因为实时指标本来就是近似值,为了 0.3% 的重复付 TB 级状态的代价不划算。这是一个明确的成本-精度取舍。
问题四:乱序
乱序的处理不在采集层,而在计算层。Flink 的做法是用 watermark 声明"我认为 client_time 小于 X 的数据都到齐了",然后允许配置一个允许迟到时长(allowed lateness)来兜住一部分晚到的数据,再晚的进侧输出流(side output)单独处理。
采集层唯一能做的事是:把乱序的程度暴露出来,供下游选 watermark 的延迟参数。
-- 统计事件到达延迟的分布,用来决定 watermark 该设多大
SELECT
PERCENTILE_APPROX((server_time - client_time) / 1000.0, 0.50) AS p50_delay_sec,
PERCENTILE_APPROX((server_time - client_time) / 1000.0, 0.95) AS p95_delay_sec,
PERCENTILE_APPROX((server_time - client_time) / 1000.0, 0.99) AS p99_delay_sec,
MAX((server_time - client_time) / 1000.0) AS max_delay_sec
FROM ods_app_event
WHERE dt = '2026-07-29' AND server_time > client_time; -- 排除时钟快于服务端的设备
模拟输入:某 App 一天的全量埋点事件。
运行结果:
p50_delay_sec p95_delay_sec p99_delay_sec max_delay_sec
2.1 18.4 147.0 86400.0
为什么是这个结果:p50 约 2 秒,对应 §4.2 的攒批等待;p95 到 18 秒,是攒批超时加一次重试;p99 到 147 秒,是弱网重试多轮;最大值 86400 秒(一整天)来自那些离线很久后才重新联网上报的设备。
这组数字直接决定 watermark 怎么设:设成 20 秒,能覆盖 95% 的数据,代价是 5% 迟到;设成 150 秒,覆盖 99%,代价是所有窗口的结果都要晚 150 秒才出。没有能同时兼顾完整性和时效性的取值——这是 07 篇里 watermark 那个"延迟 vs 完整性"取舍在真实数据上的样子。
一个事件从"想要"到"能用",要经过:
数据需求 ──▶ 埋点方案(文档) ──▶ 埋点字典(注册) ──▶ 客户端实现 ──▶ 校验 ──▶ 数仓元数据
│ │ │ │ │ │
分析师提出 数据团队定义 事件名与字段 客户端工程师 对比实际 进入资产目录
事件名/字段/时机 唯一性登记 写代码 上报与定义 可被检索
埋点字典(tracking dictionary / event dictionary):登记所有事件名、字段名、字段含义、取值范围、触发时机的权威清单。
| 变更类型 | 例子 | 对下游的破坏力 | 为什么 |
|---|---|---|---|
| 加字段 | properties 里新增 is_ad: true |
🟢 低 | 老任务不读这个字段,照常运行。JSON 结构天然兼容 |
| 废弃事件 | 不再上报 old_banner_click |
🟡 中 | 依赖它的任务会查到空数据——不报错,但报表变成 0。靠血缘(11 篇 §2.4)能提前找出受影响方 |
| 改语义 | position 从"0 开始"改成"1 开始";或某事件从只在 A 页面触发变成 A/B 页面都触发 |
🔴 极高 | 字段名没变、类型没变、数据也没变少,所有校验都通过,只有数值悄悄变了。这是最难发现的一类 |
改语义为什么最危险,讲清因果链:所有自动化校验(schema 校验、非空校验、行数波动告警)检查的都是数据的形状,而语义变更不改变形状。position 从 0-based 变 1-based,字段还是整数、还是非空、行数一样——但"首位曝光的点击率"这个指标从此错了,而且错得很平滑,不会触发任何波动告警。
工程上只能缓解,无法根除:
- 事件名与字段名一经发布不可复用。要改语义就发一个新事件名(
product_click_v2),让老的自然消亡。这是最有效的一条——它把"悄悄变了"变成了"多了一个新事件",后者可见。 - schema 注册中心。上报的数据必须匹配注册过的 schema,不匹配的进错误队列。这能拦住形状变化,拦不住语义变化。
- 埋点变更走评审。客户端改埋点前,数据团队必须会签。这是组织手段,效果取决于流程执行力度。
- 灰度对比。新版本 App 灰度期间,对比新老版本同一指标的差异,异常则拦截发布。这是唯一能发现语义变更的自动化手段,代价是需要一套灰度分析能力。
客户端为了复用代码,把 A 页面的曝光事件
card_impression也用在了 B 页面上,没通知数据团队。
后果链条:
card_impression的日均量从 8 亿涨到 11 亿——涨幅 37%,可能刚好落在"大促期间量涨很正常"的容忍区间里,没触发告警- 下游按"这个事件只来自 A 页面"计算的 A 页面 CTR = 点击 / 曝光,分母虚增,CTR 从 4.2% 掉到 3.1%
- 算法团队看到 CTR 下降,以为是模型效果退化,开始回滚模型——排查方向从一开始就是错的
- 真正的原因要到有人去比对
properties.page字段的分布时才会发现
防线在哪:properties 里有 page 字段的话,一个"按 page 维度的事件量分布监控"就能立刻发现 B 页面凭空出现。这也是为什么 §3.2 的 schema 里坚持要带 page——冗余字段的价值经常不在分析,而在归因排查。
数据进入数仓的第一步是分区。这里有一个非常容易踩的坑:按 client_time 分区。
看起来很自然——业务分析用的是 client_time,那就按它分区,查询时分区裁剪最有效。
但是会出事,因果链如下:
client_time不可信(§5.1)。有设备把时间设成了 2030 年,有设备是 2015 年- 于是分区目录里会出现
dt=2030-01-01、dt=2015-06-12这种垃圾分区 - 更糟的是:弱网设备昨天的事件今天才上报。今天的任务在写入时,会往昨天甚至更早的分区里追加数据
- 结果:昨天已经跑完的下游任务,其上游分区在事后被改变了。你昨天算出的报表数是 100 万,今天再跑同一段 SQL 得到 100.3 万——数据不可重现
数据不可重现是数仓的重大缺陷,因为它意味着"同一个查询在不同时间跑出不同结果",对账、审计、故障复盘全部失去基础。
正确做法:
-- ODS 分区键用 server_time(或 ingest_time)派生,不用 client_time
CREATE TABLE ods_app_event (
...,
client_time BIGINT,
server_time BIGINT,
ingest_time BIGINT
) PARTITIONED BY (
dt STRING, -- 由 server_time 派生: FROM_UNIXTIME(server_time/1000, 'yyyy-MM-dd')
hour STRING
);
模拟输入:三条事件——
①正常事件(client_time 与 server_time 都是 07-29 10:00);
②设备时钟错乱的事件(client_time = 2030-01-01,server_time = 07-29 11:00);
③弱网补传的事件(client_time = 07-28 22:00,server_time = 07-29 09:00)。
运行结果(对比两种分区键,同样这三条数据):
| 事件 | 按 client_time 分区会落到 |
按 server_time 分区会落到 |
|---|---|---|
| ① 正常 | dt=2026-07-29 |
dt=2026-07-29 |
| ② 时钟错乱 | dt=2030-01-01 ← 垃圾分区 |
dt=2026-07-29 |
| ③ 补传 | dt=2026-07-28 ← 改写了昨天已完成的分区 |
dt=2026-07-29 |
为什么是这个结果:server_time 由网关在收到请求时打上,它单调向前且不受客户端控制,所以"今天写入的数据只会落进今天的分区"这个不变量成立——dt=2026-07-28 一旦过完就永远冻结,昨天跑出的报表今天重跑还是同一个数。而 client_time 由客户端提供,上面两条不变量都不成立。
- ODS 按
server_time分区:保证"一个分区一旦写完就不再变化",数据可重现。代价是 ODS 的分区不等于业务日期。 - DWD 按
client_time分区:业务分析要的是业务时间。加工 DWD 时,从 ODS 的 T 和 T-1 两个分区读取(覆盖跨天延迟到达的数据),按client_time重新分发。 - 迟到太久的数据单独处理:超过 N 天才到的事件(比如 N=2),写入一个专门的迟到分区,由人工或定期任务决定是否回补,而不是直接冲掉历史分区。
这个设计的代价:DWD 的加工要多读一个分区(成本增加),且 T 日的 DWD 严格来说要等到 T+1 日才"基本完整"。完整性和时效性在这里又一次对撞——和 §5.4 的 watermark 是同一个矛盾,只是换到了批处理场景。
有关埋点的几个坑:
坑一:埋点膨胀,"先埋了再说"。 埋点数据量比业务数据大两三个数量级(§1.3),每个事件都是持续的存储 + 计算成本。曾经"以防万一"埋的事件,几年后没人敢删——因为不知道谁在用(这时血缘就有价值了,见 19 篇)。对策是给埋点设准入:新增事件必须说明分析用途和预估量级。
坑二:把埋点当日志用。 埋点是给分析用的结构化事件,不是排障日志。把异常堆栈、调试信息塞进 properties,会让数据量失控且没有分析价值。排障用专门的日志系统。
坑三:客户端埋点用于结算。 §2.2 已说明,再强调一次:任何和钱有关的口径都不能只依赖客户端上报。
坑四:没有 event_id 和 seq。 缺了 event_id 就无法去重,缺了 seq 就无法估算丢失率。这两个字段的成本极低(各十几个字节),但一旦上线时没加,事后补要等 App 版本覆盖率上来,周期以月计。
隐私与合规(不是可选项):埋点采集的设备标识和行为数据属于个人信息。中国的《个人信息保护法》(2021 年 11 月 1 日施行)要求告知与同意;欧盟的 GDPR(2018 年 5 月 25 日施行)另有独立要求。工程上的常见约束包括:用户未同意隐私政策前不得上报、提供关闭个性化推荐的开关、敏感字段脱敏、数据保留期限管理、支持用户请求删除。
补充说明两个移动端标识:IDFA(Identifier for Advertisers,iOS 的广告标识符)在 iOS 14.5 及以后需要用户显式授权才能获取;OAID(开放匿名设备标识符)是中国安卓生态推行的替代标识。这两个标识主要用于广告归因(第 17 篇),本篇的
device_id通常是 App 自己生成并存储的标识,不等同于它们。⚠️ 合规要求随法规和平台政策变化,上面的时间点和规则请以最新法规原文及平台官方文档为准,不要以本文为准。涉及具体业务时应咨询法务。
什么时候不该做重埋点:产品还在快速验证阶段、功能每周都在改的时候,重度埋点的投入会被反复推翻。此阶段用全埋点兜底 + 少量关键代码埋点更划算。埋点体系的建设应该跟随产品形态的稳定。