全文共 6,508 字 预计阅读 19 分钟
bg

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_idpositionalgo_version 这些属性。全埋点的价值在于兜底——用来回答"某个按钮到底有没有人点"这类临时问题,避免"想分析时发现没埋"。

为什么代码埋点无法被取代:因为下游要算的是"算法版本 A 在首页第 3 个坑位曝光的商品的点击率"。这句话里的"算法版本""首页""第 3 个坑位"三个信息,只存在于业务代码的上下文里,SDK 从界面控件上抓不到。这是一条硬约束,不是工程投入能绕过的。

再切一刀:埋点代码放在哪一端。

客户端埋点 服务端埋点
能看到 曝光、滑动、停留、渲染失败、未登录行为 请求、业务处理结果、金额、状态流转
看不到 服务端内部逻辑(比如订单最终风控结论) 一切不产生请求的行为
数据可靠性 :会丢、会重、时间不可信、可被伪造 :在自己机房里,时间可信,不会因为用户杀进程而丢
上线速度 慢,跟 App 版本发布,还要等用户升级 快,服务端发版即生效

通行做法是分工而非二选一

  • 和钱、和状态相关的关键事件走服务端埋点——下单、支付、退款。理由很直接:客户端上报的"支付成功"不可信(会丢、可伪造),而财务口径不允许误差。
  • 交互行为走客户端埋点——曝光、点击、停留。服务端根本看不到。

埋点数据的通行结构是三段:

{ 事件名 } + { 公共属性 } + { 事件专属属性 }
  • 事件名(event):标识"发生了什么",全局唯一。如 product_clickpage_view
  • 公共属性:所有事件都带的字段,由 SDK 自动填充。如设备型号、App 版本、网络类型、时间戳、用户标识。
  • 事件专属属性(properties):该事件独有的业务字段,由埋点代码传入。如 item_idposition

为什么要拆成"公共"和"专属"两部分? 因为公共属性由 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_iduser_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

一个自然的问题:为什么不每产生一个事件就立刻发一次请求?

因为代价扛不住,三条因果链

  1. 网络开销。一次 HTTPS 请求的握手 + 头部开销是固定的(TLS 握手在冷连接下要多个往返)。单条事件的 body 可能只有 300 字节,而握手和头部的开销远大于它。一次页面浏览产生 30 个曝光事件,就是 30 次这样的浪费。
  2. 耗电。移动网络的射频模块从休眠切到激活有固定能耗,且激活后会保持一段时间(尾部能耗)。高频小请求会让射频几乎无法休眠,直接体现为耗电量投诉。
  3. 弱网下必然失败。地铁、电梯里网络中断,实时发送的事件直接丢失,没有第二次机会。

所以通行设计是:事件先进本地队列,攒够 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。批量上报 + 重试 + 多连接,三者叠加,乱序是必然结果,不是异常。

问题一:客户端时间不可信:

成因有三条,性质完全不同

  1. 用户手动改了系统时间。为了在某些 App 里"穿越",用户会把手机时间调到未来或过去。这类偏差可以是几年
  2. 设备时钟自然漂移。手机时钟晶振有误差,若长期未与时间服务器同步(NTP,Network Time Protocol,一种通过网络校准本机时钟的协议),会累积秒到分钟级的偏差。
  3. 时区处理错误。跨时区用户,或客户端上报本地时间而下游按 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;

模拟输入:某设备当天上报的 seq100, 101, 102, 105, 106(103、104 丢失)。全库共收到 98,000,000 条,检出缺口 147,000 个。

运行结果

lost_cnt   received_cnt   lost_rate_pct
147000     98000000       0.1497

为什么是这个结果LAG 取到同设备上一条的 seq105 - 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,字段还是整数、还是非空、行数一样——但"首位曝光的点击率"这个指标从此错了,而且错得很平滑,不会触发任何波动告警。

工程上只能缓解,无法根除:

  1. 事件名与字段名一经发布不可复用。要改语义就发一个新事件名(product_click_v2),让老的自然消亡。这是最有效的一条——它把"悄悄变了"变成了"多了一个新事件",后者可见。
  2. schema 注册中心。上报的数据必须匹配注册过的 schema,不匹配的进错误队列。这能拦住形状变化,拦不住语义变化。
  3. 埋点变更走评审。客户端改埋点前,数据团队必须会签。这是组织手段,效果取决于流程执行力度。
  4. 灰度对比。新版本 App 灰度期间,对比新老版本同一指标的差异,异常则拦截发布。这是唯一能发现语义变更的自动化手段,代价是需要一套灰度分析能力。

客户端为了复用代码,把 A 页面的曝光事件 card_impression 也用在了 B 页面上,没通知数据团队。

后果链条

  1. card_impression 的日均量从 8 亿涨到 11 亿——涨幅 37%,可能刚好落在"大促期间量涨很正常"的容忍区间里,没触发告警
  2. 下游按"这个事件只来自 A 页面"计算的 A 页面 CTR = 点击 / 曝光,分母虚增,CTR 从 4.2% 掉到 3.1%
  3. 算法团队看到 CTR 下降,以为是模型效果退化,开始回滚模型——排查方向从一开始就是错的
  4. 真正的原因要到有人去比对 properties.page 字段的分布时才会发现

防线在哪properties 里有 page 字段的话,一个"按 page 维度的事件量分布监控"就能立刻发现 B 页面凭空出现。这也是为什么 §3.2 的 schema 里坚持要带 page——冗余字段的价值经常不在分析,而在归因排查

数据进入数仓的第一步是分区。这里有一个非常容易踩的坑:按 client_time 分区。

看起来很自然——业务分析用的是 client_time,那就按它分区,查询时分区裁剪最有效。

但是会出事,因果链如下

  1. client_time 不可信(§5.1)。有设备把时间设成了 2030 年,有设备是 2015 年
  2. 于是分区目录里会出现 dt=2030-01-01dt=2015-06-12 这种垃圾分区
  3. 更糟的是:弱网设备昨天的事件今天才上报。今天的任务在写入时,会往昨天甚至更早的分区里追加数据
  4. 结果:昨天已经跑完的下游任务,其上游分区在事后被改变了。你昨天算出的报表数是 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_timeserver_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_idseq 缺了 event_id 就无法去重,缺了 seq 就无法估算丢失率。这两个字段的成本极低(各十几个字节),但一旦上线时没加,事后补要等 App 版本覆盖率上来,周期以月计。

隐私与合规(不是可选项):埋点采集的设备标识和行为数据属于个人信息。中国的《个人信息保护法》(2021 年 11 月 1 日施行)要求告知与同意;欧盟的 GDPR(2018 年 5 月 25 日施行)另有独立要求。工程上的常见约束包括:用户未同意隐私政策前不得上报、提供关闭个性化推荐的开关、敏感字段脱敏、数据保留期限管理、支持用户请求删除。

补充说明两个移动端标识:IDFA(Identifier for Advertisers,iOS 的广告标识符)在 iOS 14.5 及以后需要用户显式授权才能获取;OAID(开放匿名设备标识符)是中国安卓生态推行的替代标识。这两个标识主要用于广告归因(第 17 篇),本篇的 device_id 通常是 App 自己生成并存储的标识,不等同于它们。

⚠️ 合规要求随法规和平台政策变化,上面的时间点和规则请以最新法规原文及平台官方文档为准,不要以本文为准。涉及具体业务时应咨询法务。

什么时候不该做重埋点:产品还在快速验证阶段、功能每周都在改的时候,重度埋点的投入会被反复推翻。此阶段用全埋点兜底 + 少量关键代码埋点更划算。埋点体系的建设应该跟随产品形态的稳定。

Back to Blog