人物采访 / T SALON

编辑内容

张彦瑞与武蕴谈知乎埋点治理:从数据模型到 A/B 实验

回顾张彦瑞与武蕴在 2022 年 T Chat 的两段分享,从知乎数据链路、事件模型、路径染色与上报策略,讲到质量验证、实验分组稳定性、曝光缓存和公司级指标,呈现客户端与数据平台之间的工程取舍。

张彦瑞与武蕴以知乎数据链路团队身份参与 T Chat 的活动海报
张彦瑞与武蕴以知乎数据链路团队身份参与 T Chat 的活动海报

本期视频发布于 2022 年。文中的技术状态、个人经历与观点均为当时情况。

一次点击从客户端进入数据仓库,中间会发生什么?它为什么有时迟到,有时缺字段,甚至在不同团队的报表里代表不同的含义?张彦瑞与武蕴在这场 T Chat 中,把这些看起来零散的问题放回同一条数据链路里讨论。

两位讲者在当期活动海报中以知乎数据链路团队的身份参与。第一段围绕埋点体系的建设与治理,第二段讨论 A/B 实验的客户端设计。前者解决行为数据怎样可靠地产生、传递和解释,后者进一步追问:用户到底接受了哪个实验方案,记录下来的数据又能否支撑判断。

从 SDK 到数仓,数据会经过多次加工

分享先展开知乎当时的埋点链路:Web、H5、小程序和移动应用通过各端 SDK 上报,日志接收服务将数据送入 Kafka 等消息队列,经过 ETL 加工,再进入 Hive、Doris 等存储与查询环节,供下游业务使用。推荐和机器学习等实时服务,与偏离线的数据分析,对到达时间和处理方式有不同要求。

讲者把这条链路逐层拆开,是因为移动端看到的只是「调用 SDK」的那一步。接收服务要接住各端同时产生的日志,消息队列负责缓冲,ETL 抽取、转换并补充字段,数仓再按查询需求组织数据。推荐服务关心能否尽快拿到用户行为,业务复盘则还要关心字段是否统一、是否能长期回查。相同的一次点击,到达不同消费者时可能已经过了不同的加工步骤。

他们介绍,当时实时通道与其他日志通道分别承载不同业务。接收侧还需要应对下游故障:例如消息队列暂时不可用时,先用 LevelDB 缓存数据,待通道恢复后继续发送。与此同时,两条通道存在部分数据重复,因此讲者提出,未来可以考虑合并上游接收,再向下游分流。这是分享中的后续设想,不能当作已经完成的架构改造。

数仓里的字段也不一定全部由客户端直接填写。例如 App 启动很早时,还没有拿到用于时间校准的网络时间;ETL 可以结合随后上报的设备信息和时间坐标,尝试补齐早期日志。另一些字段则来自业务规则:某类点击或页面曝光是否代表活跃行为,可以在下游统一计算。对客户端开发者来说,理解这些加工过程,才能解释为什么查询表里会出现自己没有直接上报的字段。

现场问答里,一位讲者指出客户端从未填写某个表示活跃行为的字段,却能在数仓查询到。另一位解释,ETL 会解析事件类型,再根据点击、页面曝光等条件标记它是否属于相应行为。这个例子把「数据进入仓库」与「数据被解释为一个指标」区分开来:客户端保证事件与上下文可用,业务口径的判断仍可能在下游发生。

数据模型决定后续治理能走多远

接下来,两位讲者把重心转向数据模型。SDK、传输协议、ETL、数仓表和消费方都会依赖它;上线之后,修改模型往往意味着跨团队迁移。单个团队未必能获得足够直接的收益,却需要共同承担改造成本,因此实际系统很容易不断增加字段,而难以重新整理结构。

他们把模型设计称为早期最需要想清楚的工作。一个字段如果已被接收层解析、ETL 处理、数仓建表和下游推荐逻辑引用,后来即使发现命名或层级不合适,也很难只在客户端改一下。更常见的结果是继续追加兼容字段,让各环节分别解释新旧含义。重构则可能成为公司级协作项目:每个团队都要付成本,却未必都能立即从中受益。

这里有一个重要范围:分享随后展开的模型,是讲者结合不同工作经历总结的设计思路,并非每一项都对应知乎当时已经部署的内部实现。它的价值在于说明,怎样让采集、规则校验、自动生成代码和数据分析共享一套稳定的语义。

他们用 Who、When、Where、How、What 拆解一次行为。用户和设备身份、事件时间、环境信息,以及从推送或外链进入应用的方式,大多适合由 SDK 统一管理;业务开发者主要描述对什么对象做了什么。时间也不只有一个:事件产生、客户端发送和服务端接收的时刻,分别帮助解释本地积压和传输延迟。把公共上下文统一补齐,才能减少不同业务各自实现时的口径偏差。

例如客户端本地时间并非总是可靠,网络校准又可能晚于应用启动;把发生时间、发送时间和接收时间分别留下,后续才能判断某条日志是在设备里等了很久,还是传输、接收环节变慢。设备与用户标识同样不宜让每个页面单独填写。如此安排后,业务代码真正要描述的是「在这个页面、这个模块,对这项内容做了什么」,而不是每次重复拼装一份设备环境。

用资源层级把页面、模块和动作组织起来

针对业务行为,讲者提出 App、Channel、Page、Module、Action 的层级:应用之下区分业务归属,再定位页面、页面中的模块以及具体动作。页面曝光、按钮点击、滚动或编辑,都应当能落到明确的位置和含义上。

一条「首页出现」事件,可以由应用、首页标识和出现动作共同确定;一条页面内按钮点击,再加上模块和点击动作。层级的价值不仅是字段看起来整齐,而是平台能根据这组位置和动作,找到这个场景应有的业务参数。例如内容详情页除了「出现」,还应交代内容类型和内容 ID;验证工具有了定义,才能指出实际日志缺了什么。

Channel 不只是一个分类字段。它可以帮助确定成本归属、质量问题由谁处理,以及分析结果属于哪个业务。当数据规模继续扩大时,这个维度还可能参与消息处理和存储资源的划分。不过,讨论中的分部门路由属于设计思路,文章不把它写成知乎当时已经实施的方案。与此同时,模型也需要给崩溃、系统事件等难以套入页面结构的记录保留出口,不能为了整齐而丢失有价值的数据。

一位讲者提到,过去工作中没有 Channel 的概念,到了知乎才体会到它在看部门收益和定位数据质量问题时的用处。另一位补充,业务之间埋点规模不同,Channel 也让资源成本有机会被讨论清楚。至于超大规模应用按业务部门分别进入消息队列、甚至拆成较小的数据仓库,那是他借此维度提出的扩展设想,并非要求每家公司先做这样的基础设施。

有了这些元数据,埋点平台才能知道一条日志应当包含哪些字段、哪些值有效。否则,平台只能检查数据有没有送到,无法判断业务含义是否正确。讲者也讨论了行业能否形成更统一的模型,但那是一种协作愿望,并不是已经存在的通用标准。

页面实例和模块继承,让一条点击带着上下文

在这个模型里,页面名称与一次具体的页面曝光是两回事。同一个页面反复进入,需要分别识别每次访问。分享用 RequestID 表示页面的一次曝光实例:页面出现和离开使用同一个标识,模块事件也继承这个实例上下文。这里的 RequestID 不能简单理解为一次网络请求的编号。

讲者举了首页进入详情页、随后返回首页的过程。首页这个 PageID 没变,但前后是两次曝光;若只按页面名称汇总,停留时长和页面内模块动作就可能串到一起。实例标识为每次出现划出边界,模块本身则依附于这次页面曝光,不另造一套与页面脱离的生命周期。

业务侧提供页面 ID 和内容等参数,SDK 补充实例标识、时间、会话与来源信息;离开页面时,再计算这次停留的时长。下游可以把离开事件中的时长关联回曝光记录,让分析者更方便地查询完整的一次访问。另一种做法是等页面结束后只发送一条记录,传输和处理更简单,但如果离开事件丢失,这次曝光也可能无法留下。因此,拆分曝光与离开事件,是可靠性和处理复杂度之间的选择。

模块点击同样可以继承页面的内容 ID、页面 ID 和曝光实例,避免业务重复填写。关联也可以放到数仓完成,但实时推荐希望尽早拿到完整上下文,就更有理由在客户端先做合并。讨论的关键不是把所有工作固定放在某一端,而是看数据的消费者何时需要这些信息。

Session 的边界,需要先说清业务口径

用户切到聊天软件回复消息,随即回到原应用,是否应该开始一个新会话?如果只按前后台切换更新 Session,连续的行为会被切得很碎。讲者因此讨论了超时机制:用户在一段时间内没有行为,再回来才视作新会话,而具体时长应当适配应用类型。

聊天软件的例子表明,「离开 App」并不必然等于「这次使用结束」。如果用户短暂回复消息就返回,过于机械的前后台规则会让同一段浏览路径变成两个会话;如果离开很久后才回来,沿用旧会话又会掩盖新的使用意图。超时阈值需要和业务一起确定,它是一条分析约定,而不是 SDK 自己找到的自然事实。

但时间不是唯一条件。如果用户是被新的推送或外链重新召回,即使间隔不长,业务也可能希望开启一个新会话,以分析这次召回是否有效。这意味着 Session 同时承载行为连续性与来源归因的取舍。不同公司、甚至同一公司的不同部门,可能需要不同口径;先定义清楚,再让采集和分析保持一致,比把某个超时值当成标准更重要。

路径染色:记录一次转化经过了哪里

站内归因部分讨论的是,怎样把关键路径带进后续事件。一笔下单可能经过首页、内容页和某个按钮,也可能从另一条入口直接到达。若只保存上一个页面,分析者很难还原更早的点击。路径染色让后续记录携带此前的关键节点,从而支持路径比较和漏斗分析:用户在哪一步减少,哪些入口出现在最终转化路径中。

路径也不能无限增长。讲者举例,用户从第二个页面进入第三个页面,随后返回第二个页面再继续下单,可以按既定规则把这次绕行从关键路径中去掉。SDK 负责页面与点击等基础路径,业务还可以加入自定义标签,标记某个按钮或特定活动入口。最终,路径与标签一起进入数仓,通过查询或 UDF 等方式提取分析信息。

这段讨论实际上有两层:自动记录用户经过哪些页面,以及由业务决定哪些入口值得被「染色」。前者避免分析人员事后把海量事件一条条拼接,后者让活动位、关键按钮等具有业务意义的节点进入路径。回退、重复进入和跨入口跳转都需要清楚的剪枝规则;否则记录看似更完整,分析时却可能被无关绕行淹没。

这让业务能够检查某种入口与成交路径的关系,也减少反复拼接日志的工作。这里的归因依赖预先定义的路径规则;某个按钮出现在转化路径中,并不等于已经通过实验证明它带来了额外转化。第二段对 A/B 实验的讨论,正好补上了“看见路径”之后如何判断改动效果的问题。

四类埋点方案,没有省掉所有成本的选项

讲者比较了全埋点、可视化圈选、声明式和手动埋点。全埋点尝试通过拦截系统事件自动采集,业务接入轻,但采集范围容易超过真正的需求。系统控件名不一定等于可分析的业务名称,流量和后续加工成本也会增加;输入框等控件还要求额外处理,避免收集不应采集的信息。

可视化圈选把范围收窄到关心的控件,却往往依赖视图层级。一次没有改变产品功能的界面重构,也可能使既有规则失效。声明式方案则为控件扩展属性,让业务填写页面或模块等信息,由 SDK 管理触发时机;代价是基础设施需要持续覆盖新控件和新系统能力。手动埋点最灵活,可以精确表达业务动作,但会增加业务代码与维护工作。

全埋点尤其容易把技术事件误当业务事件:系统知道某个控件被点击,却未必知道它是「关注作者」还是「打开详情」。可视化圈选解决了采集范围,却把规则绑在界面结构上。声明式把业务语义写在控件旁边,手动埋点则让复杂场景由开发者明确触发。两位讲者比较的是信息由谁填写、何时触发和谁维护,而不只是埋点代码多寡。

两位讲者介绍,知乎当时结合了声明式与手动方式,并让 SDK 承担页面停留等公共行为的处理。手动方式的成本也可以通过工具降低:当模型和规则已经明确,管理平台便能生成示例代码,验证字段,而不必让每个开发者从头理解整套协议。方案选择因此离不开配套平台,不能只比较第一次接入需要写几行代码。

上报策略既要保护数据,也要保护服务

不同日志对业务指标的重要程度不同。分享中的方案按高、普通和低优先级管理事件:应用级事件关系到整体活跃指标,页面曝光和点击关系到具体业务,模块曝光等数据则可以有不同的调度优先级。客户端使用本地缓存,并通过分表等方式降低读取相应日志的成本。公共环境字段还可以合并,减少同一信息在每条记录里反复传输。

优先级意味着设备离线、缓存积压或恢复发送时,日志不一定按产生顺序到达。应用打开、页面行为、模块曝光对业务的影响不同,安排发送次序可以保护更关键的数据;但这种乱序也改变了后面估计丢失率的方法。公共环境信息如果对一批事件都相同,可以只发送一次再通过标识关联;究竟在端上拼好还是在数仓重组,需要同时考虑实时消费者和传输成本。

稳定性方面,两位讲者强调要感知服务端压力。当接收端持续报错时,客户端通过指数退避降低请求频率,避免重试继续扩大压力。埋点是大量主动上报的场景,不能沿用只考虑正常业务请求的思路。

当本地已经积压多批日志,仅靠固定轮询又会恢复得很慢。因此可以连续发送已有批次,同时保持同一时刻只处理一个批次,兼顾追赶进度与并发控制。这里没有一个适用于所有公司的固定批大小;重要的是把平时上报、故障退避和恢复追赶放在同一套策略中设计。

质量监控需要知道自己的估算边界

怎样证明 SDK 没有漏掉日志?讲者指出,业务数据本身也依赖埋点,不能轻易充当另一套完全可靠的对照。他们介绍了利用日志序列号估计应有数量,再与实际去重上报量比较的办法。由于不同优先级事件可能以不同顺序到达,可以借助已经到达的序列范围观察缺口。

这仍然不是精确的总体成功率。如果更后面的日志也没有到达,观察到的最大序号就会偏小,估计可能过于乐观;如果日志严格按序到达,这种思路也难以暴露尚未出现的缺口。因此它更适合看变化趋势、发现异常,而不是证明数据百分之百完整。

讲者解释了为什么不简单装第二套 SDK 对照:高活跃应用再完整采一遍,传输与存储成本会很高,而且对照系统本身也有质量问题。现有方案利用高、中、低优先级事件可能乱序到达这一特点,拿已见序号范围估计应到数量,再与去重后实际到达量比较。这个估算有已知偏差,价值在于同口径持续观察,一旦趋势突然变化,就能继续排查链路。

延迟监控则利用事件产生、发送和接收时间,区分本地积压与到达服务端的时间差。跨设备时间需要考虑校准条件,不能把两端时间戳简单相减就称作精确往返时延。除这些指标外,分享还涉及设备标识发放、关键字段空值、日志量的周期变化,以及配套的质量报警。

讲者用几个 SQL 场景说明,移动端开发也可以直接参与数据检查:按积压时长分组计数,分析版本或信息来源的分布,以及检查同一个参考设备标识是否对应多个内部标识。案例的共同点,是先明确观察口径,再用条件聚合、分组和筛选定位问题。无需一开始就成为数仓专家,也能用这些查询检验自己的 SDK 行为。

管理平台把需求、研发与验证连成闭环

第一段最后回到团队流程。需求方提出行为分析诉求,数据产品同学设计埋点,研发判断能否实现;不能实现的设计需要退回调整,而不是在上线前临时用一个相似字段代替。开发完成后,依据模型与录入规则进行测试,再经过发布、灰度和回归。

管理系统保存页面、动作语义和自定义参数,生成示例代码;验证工具则抓取实际日志,按选定语义检查字段和值,并生成报告。前面反复强调的统一模型,到这里才体现出实际收益:需求、实现和验收可以围绕同一个定义交流,减少数据产品、业务研发和测试之间的重复解释。

这也解释了为什么讲者反复把 SDK 与平台放在一起谈:手动打点本来会增加研发负担,但如果平台能根据模型生成示例,测试能直接选择一个语义并抓包校验,开发者就不用每次重新猜测字段和取值。若设计阶段发现无法实现,研发应将需求退回修改,避免把未达成共识的点上线后再让数仓猜它的含义。

A/B 的客户端底线:同一实验不要串组

第二段从实验值的稳定性讲起。对同一个实验单位,实验进行与放量过程中应当保持分组稳定;否则,用户先后接受不同方案,后续就很难解释观察到的行为属于哪一组。

讲者把「不串组」作为客户端最先要保证的约束。假设一名用户开始看到 A 版,几分钟后因为请求结果更新又看到 B 版,体验本身已经混合,后续行为也不能干净地归属于任意一组。设备维度的实验要跟随设备标识,用户维度的实验要跟随登录身份;两种稳定性不能混为一谈。

讲者区分设备维度和用户维度。前者依赖设备标识,后者依赖用户身份;退出登录、切换账号后,不应沿用前一位用户的实验结果。与此同时,分流请求是异步的,业务可能在结果到达之前就读取配置。这套方案会给出默认值,并将这类尚未进入有效实验分组的读取与正式实验曝光区分开。

更细的一点是,某个实验第一次读取已经得到默认值,后续结果到达,也不立即改变这个生命周期内已经用过的值;尚未读取过的其他实验,则可以使用新拿到的结果。这种处理优先保证已经发生的体验不在中途改变。默认组不按正式实验曝光上报,是这套实现的规则,不意味着仅凭这一条规则就能证明整个实验不存在偏差。

这一细节容易被忽略:异步请求完成时,SDK 可能已经给页面返回过一个值。若只更新全局分流缓存,页面下一次读取就可能突然变样。分享中的曝光缓存因此更像一份「本生命周期实际用过什么」的记录。请求尚未完成就读取的实验,固定使用默认值;请求完成后尚未被读取的其他实验,则可以正常命中正式结果。两个实验的状态可以不同,取决于它们是否已经真正影响了界面。

分流缓存与曝光缓存,解决的是两件事

实现流程先等待设备或用户标识具备,再请求分流结果。请求失败可以重试,也可以像日志上报一样加入退避,避免服务端承压时持续高频请求。成功后,把结果放入内存,并保留磁盘缓存。

业务真正读取某个实验时,系统先查“已经曝光过什么”的缓存。如果已有记录,就继续返回这个值;没有时,才从分流结果中取值,记录曝光并固化到曝光缓存。于是,服务端返回了一组配置,与用户已经实际使用了其中某个实验,是两个不同状态。

这也解释了为什么服务端一次返回该身份下的多项分流结果,客户端不能把所有结果都算作曝光。只有业务读取到其中某个实验,并按这个值展示或运行,才有相应的实际使用记录。把「分配」和「使用」混为一谈,会让分析侧把并未受实验影响的人也纳入比较。

用户维度的实验还需要在退出登录时清理对应的曝光状态,让新身份拥有独立的记录。这个结构的意义在于,把首次使用和之后重复读取分开处理:配置可以更新,已经生效的体验则按约定保持稳定。

实验曝光也需要可靠上报

分流结果稳定且没有变化时,讲者介绍了利用 ETag 一类缓存校验机制减少重复传输的思路。多个实验在相近时间内被命中,也不必分别产生请求,可以合并成批次上报,降低请求频率。

更重要的是,用户已经接受实验方案,但曝光日志没有到达,分析侧就会少算这次经历。方案因此在发送前先落本地存储,成功后删除;失败时保留,利用后续生命周期机会继续补报。前半场的缓存、重试和丢失率问题,在实验系统中再次出现,只是此时数据质量直接影响我们如何解释实验结果。

讲者描述了两种补报时机:应用转入后台时尝试继续发送,或下次启动后再发。与普通批量日志一样,曝光数据需要降低请求次数;但与普通埋点相比,缺失一条曝光还会改变实验组的人数与归因。先写盘、成功后删除的顺序,正是为了避免「用户已经看到了方案,统计却完全不知道」的情况。

从空转检查到公司级指标,技术实现之外还有判断

最后,两位讲者补充了实验分析的两个议题。第一是正式施加不同方案之前的“空转期”:先完成分组,在各组接受相同体验时观察指标,检查是否存在明显的不均衡。若分组本身就带有差异,后来的结果不能轻易全部归因于产品改动。空转是检查环节,并不能单独替代完整的实验设计和统计分析。

现场借电商商品举例:如果某个实验组的用户构成恰好更多是关注该品类的人,销量差异可能在方案上线之前就存在。先让各组接受相同体验,同时观察预定指标,可以提前发现明显的分组偏斜。它不能消除一切统计问题,却能阻止团队把一开始就不均衡的数据误读为产品改动的功劳。

第二是局部收益与整体收益的冲突。某个首页实验可能提高 A 业务的指标,却损害 B 业务。分享用“北极星指标”讨论公司共同认可的核心衡量标准:是否推广实验,需要回到整体目标,而不能只由发起实验的团队宣布胜利。这是他们对决策口径的介绍,并非给出一套适用于所有组织的指标清单。

讲者说,发起团队通常最熟悉自己的目标,也容易先看到局部增长;首页等公共空间却同时服务多个业务。若 A 团队收益上升而 B 团队损失更大,仅凭 A 的报表无法判断是否应该全量。公司级指标提供共同的讨论依据。反过来,一个局部收益并不显眼的改动,若显著改善整体目标,也值得认真评估。这仍需结合实验设计、口径和组织目标,而不是拿一个数字代替全部判断。

两位讲者也明确,分流算法、指标计算、实验准备和报告分析还有专门的工作,短时间内无法展开。客户端这一侧能做的,是先把身份、分组、实际曝光与上报质量管理好,让后续分析有可信的输入。

这也把两段分享连在了一起:事件模型决定数据如何被解释,采集与上报决定观察是否可靠,实验设计再决定能从这些观察中得出什么结论。治理工作贯穿整条链路,需要各环节对同一件事情有一致的定义。

查看本期原视频与分段信息:知乎埋点治理与 A/B 测试 →