做过几年数据相关的工作,几乎都会碰到同一个场面:产品同学截了两张图甩过来,一张是自建看板上的首页访问量 12.4 万,另一张是第三方统计后台的 8.1 万,然后问一句——到底哪个是对的。这个问题几乎无解,因为两边可能都对,只是它们统计的根本不是同一件事。流量分析、点击流分析、用户访问分析这三个词经常被混着用,但在工程实现上,它们对应着完全不同的数据粒度、不同的计算链路和不同的可信度边界。我写这篇东西,是想把这条链路上从采集到出数的每一环拆开讲清楚:埋点怎么设计、会话怎么切、指标口径怎么定、漏斗和路径在数据库里到底长什么样、以及那些只有真正跑过生产环境才会知道的坑。适合正在搭建或维护自有分析体系的后端、数据开发和产品分析同学看,也适合已经接了第三方统计但发现数据"不太对劲"、想搞清楚底层逻辑的人。
1. 一次"数据对不上"的复盘:流量分析到底在分析什么
先说结论:绝大多数"数据对不上"的问题,不是哪一方算错了,而是双方对"一次访问"的定义不一样。第三方统计默认按它自己的规则切会话,你的自建看板可能按另一套规则切;第三方把爬虫和内部测试流量过滤掉了,你可能没过滤;第三方按设备 ID 去重,你按登录账号去重。三个变量一叠加,差出百分之三四十很正常。
所以在动手写任何一行采集代码之前,得先把这三个概念的分工理清楚,否则后面所有的建模都是建在沙子上。
1.1 三个词的分工:流量、点击流、访问
流量分析回答的是"有多少",它关心的是量的统计——某个页面被打开了多少次、有多少个不同的设备或账号来过、从哪些渠道来的。这一层的数据粒度最粗,通常只保留计数器级别的信息,典型产物就是 PV、UV、来源分布、地域分布这类报表。
点击流分析回答的是"做了什么",它关心的是行为序列——用户进来之后先点了哪、再点了哪、在哪一步退出了、在某个按钮上反复点了多少次。这一层必须是完整的事件明细,每一个动作带时间戳、带页面或控件标识、带上下文参数,缺一条就断链。
用户访问分析回答的是"这一次来值不值",它把前两者打包成一个有边界的时间单元——一次会话。会话里有入口页、有出口页、有停留时长、有页面深度、有没有产生转化。留存、复购、单次访问价值这些指标全都建立在这一层上。
打个比方:流量分析是收费站统计今天过了多少辆车,点击流是每辆车的行车记录仪轨迹,访问分析是把一辆车从驶入高速到驶出的完整行程打包成一条记录。收费站不需要知道你去哪,行车记录仪不需要知道今天总共多少车,但要做路径优化就得三层都有。
1.2 一条数据从产生到进报表要过几道手
很多同学以为埋点就是"前端发个请求",实际上从用户手指落下到看板上出现一个数字,中间至少有七道工序,每一道都可能引入偏差:
| 环节 | 典型动作 | 最容易出的偏差 |
|---|---|---|
| 采集 | SDK 初始化、事件组装、公共参数注入 | 初始化时机太晚,首屏事件丢失 |
| 传输 | 批量上报、失败重试、离线缓存 | 弱网丢包、重试导致重复 |
| 落库 | 消息队列缓冲、写入明细表 | 分区写错、乱序到达 |
| 清洗 | 格式校验、脏数据过滤、环境标记剔除 | 把测试流量放过来了 |
| 建模 | 事件宽表、会话聚合、用户维度 | 会话切分规则与业务预期不符 |
| 指标 | 口径定义、去重逻辑、时间窗口 | 同名指标两套算法 |
| 展示 | 缓存、预聚合、时区换算 | 看板时区与明细时区不一致 |
我在一个项目里排查过一个"转化率突然掉了 8 个点"的问题,最后定位到是清洗环节加了一条新的参数校验规则,把某个客户端版本上报的事件整批拦掉了,而那个版本恰好是某个渠道的主推版本。数据本身没错,链路没错,是规则变更没同步给业务方。这类问题在纯技术视角下几乎发现不了,必须把链路的每一环都当成可能出问题的地方来看。
1.3 为什么大多数团队的分析结果不可信
我见过最典型的情况是同一个指标有三套算法。页面停留时长这一个数,前端上报的、后端计算的、看板展示的,三个值能差出一倍。原因很简单:前端算的是两次页面曝光的时间差,后端算的是两次接口请求的时间差,看板用的是会话总时长除以页面数。
要避免这种局面,只有一条路:把指标口径写成可执行的文档,不是写"平均停留时长",而是写清楚"会话内最后一次非退出页曝光到下一次曝光的时间差,超过 30 分钟按 30 分钟截断,退出页不计入"。写不到这个精度,后面一定会打架。
我在自己的项目里维护了一张"指标字典"表,每个指标下面挂四列:计算表达式、依赖的事件、排除条件、负责人。任何看板上的数字,点进去都能追到 SQL。这张表最初只有十几行,一年后涨到两百多行,但它是整个分析体系里最值钱的东西——比任何一套采集工具都值钱。
2. 采集端的四种埋点形态与选型逻辑
埋点是整条链路的源头,源头脏了后面全白搭。业内常见的做法有四种,每一种都有自己的甜区和盲区,几乎没有哪种能包打天下,实际项目里基本都是组合使用。
2.1 代码埋点:精度最高,成本也最贵
代码埋点就是在业务代码里显式调用上报接口,事件名、参数、触发时机全部由开发手写。它的好处非常直接:语义清晰、参数可控、时机精确。用户点了"提交订单"按钮且接口返回成功之后才上报,这种"结果导向"的埋点只有代码埋点能做到。
代价是两个:一是开发成本,一个新功能上线要同步写埋点,产品需求变更要同步改埋点,很容易漏;二是维护成本,事件一旦上线就很难改,参数加一个字段要发版,字段废弃了还得保留兼容。
我的经验是,代码埋点只应该用在真正关键的、需要精确语义的地方——支付、注册、核心功能完成这类"结论型事件"。这类事件数量少,但每一个都必须准。至于那些"大概看看"的浏览行为,用别的方式补更划算。
2.2 全埋点:技术实现与它的能力边界
全埋点(也叫无埋点、自动埋点)的原理是在不侵入业务代码的前提下,通过技术手段拦截通用的交互行为,自动生成事件并上报。不同端的技术路线差别挺大:
- Web 端:在 document 上做事件委托,监听 click、change 等冒泡事件,再通过事件目标反查元素的层级路径或唯一标识,组装成一个"某某位置的元素被点击"的事件。
- 原生客户端:安卓侧常见的是字节码插桩,在编译期往指定的控件点击回调里织入上报逻辑;或者用一个统一的点击代理类包装监听器。iOS 侧常见的是方法交换,替换掉控件的事件处理方法。
- 小程序侧:拦截页面和组件的生命周期钩子,自动在页面显示、隐藏、下拉刷新这些节点上报。
全埋点最大的价值是"先采后分析"——你不知道未来会关心哪个按钮,那就先全采下来,回头再圈。这在产品探索期非常有用,我做过的一个项目里,某个上线时完全没被关注的筛选控件,三个月后成了路径分析里的关键分流点,靠的就是当时全埋点留下的数据。
但它的边界也很清楚。第一,采不到业务语义。全埋点只知道"某个位置的某个元素被点了",不知道这次点击是"加购成功"还是"加购失败"。第二,元素标识的稳定性是个大问题。Web 端如果靠 DOM 路径定位,改一次页面结构,历史数据和未来数据就接不上了。所以工程上一般会给关键元素加一个稳定的自定义属性作为唯一键,全埋点采集时优先读它、读不到再降级到路径。第三,数据量会显著膨胀,一个页面几十个可点元素,全埋点的上报量通常是代码埋点的几倍到十几倍,存储和计算成本要提前算。
2.3 可视化埋点与服务端埋点适合补哪块短板
可视化埋点的思路是:用全埋点先把数据采上来,同时在后台提供一个"圈选"界面,运营或产品在页面上框选一个元素,系统把它的标识登记成一个正式事件。好处是业务方能自助配置,不需要开发介入;短板是只能覆盖"能被框选到的"前端交互,而且圈选配置本身也需要一套版本管理机制,否则页面改版后配置全失效。
服务端埋点是在业务逻辑内部(通常是接口层)上报,它的优势是数据最可信——接口真的成功了才上报,而且不受客户端网络、SDK 版本、用户关闭采集开关的影响。它特别适合做交易、订单、支付这类"账实必须相符"的场景,很多团队的做法是前端埋点用来看行为,后端埋点用来对账,两边差得太多就说明前端采集出了问题。
一个比较务实的组合是:核心转化链路用代码埋点加服务端埋点双保险;页面浏览、元素点击这类行为用全埋点铺底;需要业务方自助分析的维度用可视化圈选补;日志类、接口类的数据直接走服务端日志采集。这套组合我在两个项目里都用过,前期投入大概两到三周,但后面省下的扯皮时间远超这个成本。
2.4 选型落地时最容易被忽略的三个决策
第一个是采集开关的设计。采集 SDK 必须支持在运行时关闭,而且关闭要真的生效——不是不发送,而是不采集、不缓存、不上报。这个能力从第一天就要留好,后补非常麻烦,因为要改所有调用点。
第二个是公共参数的注入方式。设备标识、会话标识、应用版本、渠道、网络类型这些每个事件都要带的字段,应该由 SDK 统一注入,而不是让业务方每次手写。我见过一个项目里每个埋点都手写渠道参数,结果某次发版漏写,那批数据全成了"未知渠道"。
第三个是事件命名的规范。上线之后再改事件名,等于历史数据全废。所以命名规范必须在第一次埋点之前定死,常见做法是"对象_动作"的下划线格式,全小写,动词用过去式或原形统一,参数名也要有统一前缀避免冲突。
3. 会话重建:把散乱的事件拼成"一次访问"
原始事件流是一条按时间排好的长列表,里面混着几天的数据、几个设备的数据。会话重建要做的事情就是在这条长列表上画出边界,把同一个人的连续行为切成一段一段。这一步做错了,后面所有的访问分析都是错的,而且错得很隐蔽——数字看起来正常,只是含义变了。
3.1 用户身份识别与多端打通
切会话的前提是先确定"这是谁"。实际场景里通常有两套 ID:
- 设备级 ID:客户端首次启动时生成并持久化保存的随机串,或者使用系统提供的广告标识。它标识的是"这台设备",用户不登录也能有。
- 账号级 ID:用户登录后拿到的业务账号 ID。它标识的是"这个人",但换设备就断了。
两者需要用一张映射关系表打通。常见做法是:未登录时所有事件挂在设备 ID 上,一旦发生登录事件,就把当前设备 ID 与账号 ID 建立绑定关系,并且把该设备在此之前的匿名行为回溯归属到这个账号。这个"回溯"动作在离线数仓里很常见,实现方式一般是维护一张设备到账号的映射表(带生效时间区间),然后在计算用户维度指标时做关联。
这里有个必须注意的点:一台设备可能先后登录多个账号(共用设备),一个账号也可能出现在多台设备上。如果不做区分,直接把设备 ID 和账号 ID 混为一谈,UV 会算得乱七八糟。我的做法是两条线分开算——设备维度指标用设备 ID 去重,用户维度指标用账号 ID 去重,报表上分别标注清楚,绝不相加也绝不互相替代。
3.2 会话切分规则与超时阈值的取舍
业界最通用的规则是"静默超过 30 分钟算新会话",这个阈值的来源是早期对用户浏览行为的统计经验,但并不是什么金科玉律。不同业务的最优阈值差别很大:内容资讯类可能 30 分钟合适,工具类可能是 15 分钟,而即时通讯这种持续在线的场景,靠时间切会话根本不合理,得靠前后台切换事件来切。
工程上的切分逻辑一般是这样的:把同一用户的事件按时间排序,比较相邻两条事件的时间差,超过阈值就打一个新会话标记,然后用累计求和给每条事件分配会话序号。用窗口函数写出来大概是这样:
WITH base AS ( SELECT user_id, event_time, LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS prev_time FROM dwd_event_detail WHERE dt = '2024-06-01' ), marked AS ( SELECT user_id, event_time, CASE WHEN prev_time IS NULL THEN 1 WHEN event_time - prev_time > INTERVAL '30' MINUTE THEN 1 ELSE 0 END AS is_new_session FROM base ) SELECT user_id, event_time, SUM(is_new_session) OVER ( PARTITION BY user_id ORDER BY event_time ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS session_seq FROM marked;这段逻辑跑起来很直观,但有两个坑。一是跨天问题:如果按天分区计算,23:55 开始的会话在 00:05 的事件会被切成两段,导致会话数虚高、时长偏短。解决办法是在计算时把时间窗口前后各扩一天,算完再按会话起始时间归到对应日期。二是乱序问题:客户端离线缓存后批量补传的数据,到达时间晚于事件真实发生时间,如果不先按事件时间排序而是按入库时间排序,会话会被切错。
3.3 停留时长里的"尾部陷阱"
页面停留时长的标准算法是"下一条事件的时间减去当前页面曝光的时间",这个算法对中间页面成立,但对会话的最后一个页面不成立——因为后面没有事件了,算不出来。
很多系统图省事,直接把最后一个页面的停留时长记为 0 或者直接丢弃。前者会让平均停留时长系统性偏低,后者会让样本有偏(停留时间长的会话更可能被丢掉)。更常见的做法是用会话总时长减去前面各页停留时长之和,把这个差值补给退出页。这个补法也有问题:如果用户在退出页停留了很久才离开,这个值会明显偏大。
我的处理方式是两条腿走:报表里同时给出"平均页面停留时长(不含退出页)"和"会话总时长",两个数配合着看。前者用来比较不同页面的吸引力,后者用来衡量整体访问深度。不追求一个数说清楚所有事,比强行拼一个"准确"的数字要实在。
3.4 边界情况的处理清单
会话重建这块的边界条件特别多,我整理过一份清单,每次新建链路都会过一遍:
| 边界情况 | 现象 | 处理方式 |
|---|---|---|
| 完全重复事件 | 同一毫秒同一事件 ID 出现两次 | 按事件唯一 ID 去重,保留首条 |
| 时间戳回退 | 事件时间早于上一条 | 归入同一会话,不触发新会话 |
| 跨天会话 | 午夜前后连续行为被切断 | 计算窗口前后各扩一天 |
| 单事件会话 | 只上报了一条事件就离开 | 保留,但标记为"极短会话"单独统计 |
| 超长会话 | 后台挂机导致会话长达数小时 | 设置会话最大时长上限,超出截断 |
| 设备 ID 重置 | 用户清理缓存后 ID 变化 | 依赖账号 ID 兜底,设备维度指标会低估 |
最后一条值得多说两句。设备 ID 重置导致的 UV 虚高是所有做流量分析的团队都会遇到的,行业里普遍的虚高幅度在 5% 到 15% 之间,具体取决于产品和用户群体。这个偏差没法完全消除,只能通过账号 ID 兜底来缓解,并且在做趋势对比时保持口径稳定——只要虚高比例稳定,趋势判断就不受影响,怕的是某次发版改了 ID 生成逻辑,导致前后不可比。
4. 指标体系:被误用最多的那几个数
指标定义不清楚,是整个分析体系里最隐形也最致命的问题。因为数字看起来总是合理的,没人会怀疑一个"跳出率 62%"有什么问题,直到有一天你发现两个团队用同一个词说的是两件事。
4.1 PV 和 UV 的口径分歧
PV 看起来最没有歧义——页面被打开一次算一次。但"打开"这个词就有分歧:单页应用里路由切换算不算新页面?页面从后台切回前台算不算?下拉刷新算不算?浏览器前进后退算不算?
我在项目里定过一条规矩:PV 只统计"页面首次进入"这一种情况,由路由层的进入钩子触发,刷新和前后台切换单独记为不同事件,不计入 PV。这条规矩写进指标字典之后,前后端统计口径的差异从两位数百分比降到了一位数。
UV 的分歧更多。按设备去重、按账号去重、按浏览器指纹去重,三种算法在同一个产品上能差出两成。还有一个更隐蔽的问题:UV 是"日 UV"、"周 UV"还是"月 UV",这三个数不是简单相加的关系,月 UV 一定小于等于 30 天日 UV 之和。很多看板把不同周期的 UV 并排放,容易被误读成"用户流失了一半"。
4.2 跳出率的三种算法
跳出率是我见过定义最混乱的指标。常见的至少有三种:
- 单页会话占比:整个会话只浏览了一个页面就算跳出。这是最主流的定义。
- 短会话占比:会话时长低于某个阈值(比如 5 秒)算跳出。这个定义更关注"误触"。
- 无转化会话占比:会话内没有完成任何预设的关键动作算跳出。这个定义最贴近业务,但和"bounce"的原意已经差很远了。
三种算法算出来的数可能差一倍。所以在任何一份报表里看到跳出率,第一件事是搞清楚它用的是哪一种。我自己的习惯是给指标加后缀,比如"跳出率(单页)",虽然丑,但不会吵。
4.3 留存曲线的正确读法
留存的计算逻辑是"某天新增的用户中,第 N 天还在的比例"。听起来简单,实际做起来有三个坑。
第一个坑是**"还在"的定义**。是当天有任意事件就算留存,还是必须有关键事件才算?前者会把所有打开过应用的人都算进去,数值虚高;后者更严格但数值难看。我一般两个都出,一个叫"活跃留存",一个叫"核心行为留存",分开看。
第二个坑是新用户的定义。是首次安装、首次启动,还是首次登录?首次启动的口径最宽,会把很多误点进来的人也计入分母,导致留存率被稀释。
第三个坑是观察窗口。7 日留存需要等 7 天才能算出来,30 日留存要等 30 天。如果看板每天更新,那么最近 30 天的留存数据都是不完整的,必须在报表上标注清楚或者干脆留空,否则非常容易被误读成"留存骤降"。
4.4 漏斗的窗口期与有序性
漏斗不是一个数,是一组数加一个约束条件。同样的事件集合,加上不同的约束,结果能差很多:
- 有序还是无序:用户必须先 A 后 B 才算走完,还是只要 A 和 B 都发生过就算?路径分析一般要求有序,转化归因有时允许无序。
- 窗口期多长:从第一步到最后一步,中间允许隔多久?7 天、30 天、还是必须在同一次会话内?窗口期越长,转化率越高,但归因到具体渠道的可信度越低。
- 是否允许中间穿插其他步骤:用户走完 A 之后去逛了别的页面,再回来做 B,算不算完成 A→B?大部分业务场景应该算,但严格的行为分析会排除。
我在项目里踩过一个坑:初期做下单漏斗时没限制窗口期,结果算出来的转化率比实际高了将近一倍,因为把"用户三个月前加购、这次才下单"也算进去了。后来改成"同一次会话内且在 24 小时内",数字就合理了。这个改动需要同步给所有看这份报表的人,否则又要开始解释为什么数字变了。
5. 点击流分析:路径、漏斗与热力的数据底座
有了干净的会话数据,点击流分析才谈得上。这一层的核心难点不在业务理解,而在计算复杂度——事件明细的量级通常是百万到十亿级,路径和漏斗又天然是"多次遍历"型的计算,不做优化直接查,一个看板能卡到超时。
5.1 路径分析的组合爆炸与压缩办法
路径分析的原始形态是"从入口页出发,列出用户接下来访问的前 N 个页面"。如果不加任何约束,N 等于 10 的时候,可能的路径组合是指数级的,既算不出来也画不出来。
实际工程里有三种压缩手段:
限制深度。只展示前 5 到 8 步,再往后就归到"其他"。大部分路径分析的有效信息都在前几步,深度超过 8 的路径往往是个位数用户走出来的,没有统计意义。
按节点聚合而不是按完整路径聚合。不统计"A→B→C→D"这样的完整序列有多少人,而是统计"到达 C 页面之前一步是 B 的有多少人"。这样计算量从指数级降到线性,代价是丢了完整序列的信息。桑基图基本都是这么算的。
按会话采样。对于探索性分析,全量算没必要,采 10% 的会话算出来的分布已经足够稳定。这个手段要慎用,一定要在报表上标注采样率,否则容易被人当成真实数据引用。
还有一个容易被忽略的点:路径分析里的"页面"到底怎么定义。是页面级还是控件级?如果是控件级,那"点击了搜索框"和"点击了搜索按钮"是两个不同节点,路径会变得非常细碎。我一般把路径分析做成两级,先用页面级看宏观流向,下钻到某个页面之后再切到控件级看具体交互。
5.2 漏斗计算的两条实现路线
漏斗的实现方式直接决定了它能支持多大的数据量。
路线一:逐步关联。把每一步的事件筛出来,用用户 ID 和会话 ID 做内连接,每次连接都加上时间先后约束。写法直观,可读性好,但每一步都要全表扫描,五步漏斗就是五次全扫,在十亿级明细上基本跑不动。
-- 三步漏斗的逐步关联写法(示意) WITH step1 AS ( SELECT session_id, MIN(event_time) AS t1 FROM dwd_event_detail WHERE dt BETWEEN '2024-06-01' AND '2024-06-07' AND event_name = 'view_product' GROUP BY session_id ), step2 AS ( SELECT e.session_id, MIN(e.event_time) AS t2 FROM dwd_event_detail e JOIN step1 s ON e.session_id = s.session_id WHERE e.dt BETWEEN '2024-06-01' AND '2024-06-07' AND e.event_name = 'add_to_cart' AND e.event_time > s.t1 GROUP BY e.session_id ) SELECT COUNT(DISTINCT s1.session_id) AS step1_users, COUNT(DISTINCT s2.session_id) AS step2_users FROM step1 s1 LEFT JOIN step2 s2 ON s1.session_id = s2.session_id;路线二:位图交集。把每个步骤的用户集合编码成位图,漏斗计算就变成了位图之间的与运算。位图压缩做得好的话,一亿用户的集合只占几十兆内存,五步漏斗的计算能在秒级完成。代价是需要额外的编码步骤和一套位图运算的封装,前期开发成本高一些。
我的经验是:日活低于十万、漏斗步骤不超过五步的,直接用逐步关联完全够用;一旦数据量上去或者要做任意步骤的自定义漏斗,就得考虑位图方案。另外还有一条中间路线——预先把"某天某事件"的用户集合物化成中间表,漏斗计算只查中间表不查明细,这样既保留 SQL 的可读性又能提速,适合团队里没有人手做底层优化的情况。
5.3 归因窗口与转化路径的归属
用户从看到推广内容到最终转化,中间可能经过好几个触点。归因要解决的问题是:这个转化应该记在哪个触点头上。技术上常见的几种做法:
- 首次触点:记在第一次接触的渠道上,适合拉新导向的产品。
- 末次触点:记在转化前最后一个接触的渠道上,适合短决策周期的产品。
- 线性均分:把功劳平均分给所有触点。
- 时间衰减:越靠近转化的触点权重越高。
这几种算法算出来的渠道 ROI 能差好几倍,所以做归因报表时一定要标明用的哪种模型,而且不要随便改——模型一换,所有历史数据都不再可比。我倾向于先用末次触点跑一段时间,把基线摸清楚,再引入更复杂的模型做对比,而不是一上来就搞多触点归因。
技术上还有个小细节:归因计算需要把"曝光类事件"也接进明细表,而这类事件的量级通常比点击类大一个数量级。如果直接和转化事件一起算,性能压力会很大。常见的优化是把曝光事件按渠道加用户做预聚合,只保留"某用户在某天被某渠道触达过"这样的汇总记录,转化计算时再关联这张汇总表。
5.4 热力与滚动深度的采集限制
热力图和滚动深度分析是点击流分析里最直观的部分,但它们的采集限制比大多数人想的多。
点击热力图在 Web 端比较可靠,因为可以拿到精确的坐标。但在响应式布局下,同一个页面在不同屏幕尺寸上的坐标完全不同,需要按分辨率分桶或者改用元素相对位置,否则热力图会糊成一团。
移动端的点击热力更麻烦,不同机型、不同系统版本、不同字号设置都会导致布局差异。行业内通行的做法是按设备屏幕宽度分几档(比如窄屏、标准、大屏)分别出图,不追求一张图覆盖所有设备。
滚动深度在移动端的准确性尤其差,因为滚动事件触发频率极高,全都上报会撑爆存储。常见的采样策略是每滚动 10% 记录一次,或者只在页面卸载时上报一次最终深度。前者会在快速滑动时丢数据,后者在用户直接杀掉应用时不上报。我的做法是滚动事件做节流上报,同时在页面隐藏时补一个终值事件,两相结合覆盖率能到 90% 以上。
6. 数据链路的工程实现:从明细宽表到指标服务
前面讲的是"算得对不对",这一节讲"算得出来吗"。点击流的数据量决定了它不可能用查业务库的方式来做分析,必须有独立的数仓链路。
6.1 分层建模:事件明细、会话聚合、指标层
我习惯分四层,虽然名字土,但边界清楚:
明细层:原始事件清洗后的宽表,一行一个事件,字段包括用户 ID、设备 ID、会话 ID、事件名、事件时间、页面标识、公共参数、业务参数(通常以键值对形式存成映射或 JSON)。按天分区,保留原始粒度,不做任何聚合。这一层是唯一不可再生的数据,必须保证完整性和可回溯性。
会话层:一行一次会话,字段包括会话 ID、用户 ID、入口页、出口页、页面数、会话时长、是否跳出、是否转化、来源渠道。这一层是访问分析的主力表,数据量通常比明细层小一到两个数量级。
用户层:一行一个用户(按天或按月快照),字段包括首次访问时间、最近访问时间、累计访问天数、累计会话数、生命周期价值。这一层用拉链表或者每日全量快照都可以,看数据量决定。
指标层:预聚合好的指标结果,直接对看板提供服务。这一层可以根据查询频率做不同粒度的预聚合,比如"日粒度的渠道分布"单独一张表,"小时粒度"单独一张表。
分层的核心目的是控制重算的代价。口径变了要重算指标,只跑指标层;会话规则变了,跑会话层和指标层;采集字段变了,才需要回到明细层。如果不分层,任何一次小改动都要全链路重跑,一次就是几小时。
6.2 实时链路的会话窗口与水位线
实时看板的需求越来越多,但实时和离线的会话切分难度完全不是一个量级。离线可以用窗口函数在排好序的数据上算,实时必须处理"什么算一个窗口结束了"这个问题。
流处理引擎提供的会话窗口机制可以按"活跃间隔"自动合并窗口,但有个绕不开的问题:数据可能乱序到达。用户在弱网环境下产生的三条事件,可能间隔几分钟才陆续到达。如果窗口在第一条事件到达后 30 分钟就关闭了,后面两条就丢了。
解决办法是设置水位线——允许数据迟到一定时间,窗口在水位线推进到窗口结束时间之后才真正关闭。水位线设得太短会丢数据,设得太长会延迟出数。我一般在业务能接受的延迟基础上再多留 5 分钟,比如业务要求 1 分钟内出来,水位线设 6 分钟。同时要准备好"迟到数据修正"机制,即超过水位线的数据走离线补偿,第二天重算覆盖。
实时链路的另一个坑是状态管理。会话窗口需要为每个活跃用户维护一个状态(比如当前会话的起始时间、事件计数),日活百万的产品,同时活跃的用户状态可能占几个 G 内存。状态如果只存内存,重启就丢;如果落盘,又要考虑清理策略。这块在选型和容量规划时要提前算清楚,不能等上线了才发现内存爆了。
6.3 存储选型与查询性能
分析型存储的选型主要看三个维度:写入吞吐、聚合查询性能、存储成本。事件明细的特点是写多读少、批量写、按时间范围查、聚合为主,所以列式存储基本是默认选择。
具体的存储介质要按访问频率分。最近 7 天的数据要支持秒级查询,放高性能的列式库;7 天到 90 天的数据支持分钟级查询就行,可以用成本更低的方案;90 天以上的历史明细,如果只是偶尔回溯,压缩后放到对象存储里,需要时再加载。
这里有个容易忽略的成本项:明细层如果每个事件都存完整的公共参数(几十个字段),存储量会非常吓人。常见的优化是公共参数按天做一张维度表,明细表里只存一个指向维度组合的 ID。这个改动做好了,存储能降一半以上,查询性能也会明显提升,因为扫描的列少了。
6.4 回刷与口径变更的代价
只要业务在变,口径就一定会变。新增一个渠道、调整一次会话阈值、修改一次漏斗步骤顺序,都需要回刷历史数据。回刷的成本取决于要回溯多久以及分层设计得好不好。
我的经验是提前做两件事:一是给所有指标表打上"口径版本"标记,任何一次口径变更都递增版本号,新老数据并存一段时间方便对比;二是把回刷做成可配置的任务,指定时间范围和目标表就能跑,而不是每次临时写脚本。我做过一次涉及半年的历史回刷,因为前期已经把任务模板化了,实际执行只花了半天调参和等跑完;另一个项目没有这套东西,改一个指标口径前后折腾了三天。
7. 埋点治理与数据质量:真实项目里最耗时间的部分
如果问我整个分析体系里哪块最花时间,答案不是建模、不是计算、也不是可视化,而是埋点治理。新埋点上线三个月中,渠道参数写错导致数据全成了"未知",工具侧的漏斗和自建看板的漏斗差了 40%,因为工具默认用无序漏斗而自建用的是有序的。
7.1 埋点文档与命名规范
埋点文档不是给别人看的,是给三个月后的自己看的。一份能用的埋点文档至少要包含:事件名、事件中文名、触发时机(精确到"点击后且接口返回成功时"这种程度)、参数列表(名字、类型、是否必填、取值范围、示例值)、所属页面、负责人、上线版本。
我特别强调"触发时机"这一栏要写细。写"点击时"是没用的,因为点击和点击成功是两回事。我在项目里要求写成三段式:触发条件(用户做了什么)、判定条件(满足什么才上报)、上报内容(带哪些参数)。比如"提交订单"这个事件,触发条件是点击提交按钮,判定条件是订单接口返回成功码,上报内容是订单 ID、商品数量、订单金额、优惠金额。
命名规范我在前面提过,这里补充两点细节:一是布尔型的参数统一用 is_ 前缀加 true/false,不要用 1/0 或者 Y/N 混着来;二是枚举型参数必须先定义好取值范围并写进文档,客户端不能自己发挥,我见过一个参数里同时出现了 "ios"、"iOS"、"IOS"、"iphone" 四种写法。
7.2 上报丢失与重复的排查链路
数据对不上的时候,第一步永远是区分是采不到还是算错了。我的排查顺序是这样的:
- 拿一个测试设备手动操作一遍,看上报日志有没有对应事件。有,说明采集正常,问题在下游;没有,往下走。
- 检查 SDK 初始化时机。很多丢失发生在应用冷启动的首屏,因为 SDK 初始化是异步的,首屏事件在初始化完成前就触发了。解决办法是把事件先入本地队列,初始化完成后统一补发。
- 检查网络失败处理。弱网环境下上报失败是否有重试?重试是否有幂等保护?没有幂等保护的重试就是重复上报的源头。
- 检查清洗规则的过滤条件。新增的校验规则可能把合法的历史格式数据拦掉了。
- 检查分区和时区。事件时间是不是被错误地当成了入库时间?跨时区用户的事件有没有归到正确的日期分区?
重复上报的排查反过来:先看有没有重试,再看有没有重复初始化 SDK(页面热更新、组件重复挂载都会导致),最后看服务端有没有做幂等。事件唯一 ID 是防重复最有效的手段,从第一天就要有。
7.3 环境、爬虫与内部流量的过滤
线上分析数据里混进测试流量是最常见的数据污染,而且危害不小——测试同学反复点击能轻易把某个功能的点击量刷到榜首。
标准做法是给每个事件带一个环境标识字段(生产、预发、测试、开发),用打包配置注入,不靠运行时判断。再加上内部账号名单,在计算时统一排除。这两条看起来简单,但要求所有客户端都老老实实带上,我见过某条业务线漏了,导致那部分数据在清洗时无法判断环境,只能整批保留,最后污染了整个看板。
爬虫和自动化流量的识别要复杂一些。常见特征包括:用户代理字符串异常、请求频率远高于正常用户、没有加载静态资源只请求接口、事件序列过于规整(每次都是同样的三步)。工程上一般是维护一套规则,命中多条的打标记,报表默认排除。但规则不能太激进,误杀真实用户的代价比放进少量爬虫更大。
7.4 时区与时钟漂移
这个坑极其隐蔽,因为数据看起来完全正常,只是分布错了。
客户端上报的时间通常是设备本地时间,而设备时间可以被用户随意修改,也可能因为时区设置错误而整体偏移。所以事件时间必须以服务端接收时间为准,或者用"客户端时间 + 服务端校准差值"的方式修正。
具体做法是:SDK 在启动时请求一次服务端时间,计算与本地时间的差值并缓存,后续每个事件上报时带上修正后的事件时间。同时保留原始本地时间字段,方便排查。对于时区,统一在入库时转成业务约定的标准时区,不要在不同环节用不同时区。
时钟漂移还会导致另一个问题:事件时间出现"未来时间"。某些设备时间设错了,上报的事件时间是明年。这类数据如果不处理,会破坏所有按时间排序的计算。我的处理方式是在清洗层加一条规则:事件时间超过当前时间一定范围的直接打标记并剔除,同时计数报警,方便发现批量问题。
7.5 验收与回归
埋点验收不能靠人肉点一遍看看有没有。我的做法是三条线并行:
开发自测:提供一个调试工具,能实时看到当前设备上报的所有事件和参数,开发自己过一遍。这个工具的开发成本很低,收益极高。
自动化回归:把核心链路的埋点写成自动化用例,每次发版跑一遍,校验关键事件是否上报、参数是否完整、取值是否在枚举范围内。这套用例我一般覆盖支付、注册、核心功能这三类,不求全,但求关键路径不漏。
上线后对账:新版本上线后第一周,每天对比新旧版本的同类事件量级,波动超过阈值就告警。这个能抓到很多"代码逻辑看起来没问题但实际没触发"的情况。
8. 采集边界:脱敏、最小化与用户可退出
做了这么多年数据,我越来越觉得这块比任何算法都重要,因为它决定了一套分析体系能不能长期跑下去。
最小化采集是最基本的原则。设计埋点时问自己一个问题:这个字段真的会被用于分析吗?如果答案不确定,宁可不采。我见过参数列表里带用户手机号、带身份证后四位的埋点,问起来就说"以后可能有用",这种"以后可能有用"是数据风险的主要来源。
敏感字段脱敏要在采集端做,不能在展示端做。因为数据一旦以明文形式落到明细表,后面所有环节都要承担泄露风险。常见做法是手机号只保留前三后四、中间用固定字符替代;IP 地址只保留网段部分;用户标识用不可逆的哈希值替代原始 ID,需要关联时通过映射表反查,而映射表单独做权限管控。
用户可退出是一个必须内建的能力。前面说过采集开关从第一天就要留,具体实现上要做三件事:开关状态持久化在本地,不依赖网络;关闭后停止一切采集和缓存,包括已经在队列里未发送的数据也要清空;关闭状态要能覆盖所有采集通道,包括全埋点和可视化埋点,不能只关代码埋点。
数据生命周期也要提前定好。明细层数据保留多久、聚合层保留多久、超过保留期的数据怎么处理,这些都要有明确的规则并且自动化执行。我见过一个项目明细表存了三年没清理,存储成本占了整个数据平台预算的六成,而实际查询只用到最近三个月。
权限分级是最后一道防线。明细层的数据访问权限要严格控制,只给必要的开发和分析人员;聚合层的范围可以放宽一些;对外提供的接口一律只返回聚合结果,不返回明细。这套分级看起来是管理问题,实际实现上需要在查询层做行级和列级的权限控制,不是靠"大家自觉"。
有一点我特别想强调:脱敏和最小化不是为了合规才做的,它本身就能显著降低系统的复杂度和成本。字段少了,存储小了,计算快了,出问题的概率也低了。把"少采一点"当成一项技术优化来做,比当成一项负担来做,心态和结果都不一样。
最后分享一个我在实际项目里用了很久的做法:每次新建一条采集链路,我都会先画一张表,左边写"这个字段用来回答什么问题",右边写"不采会怎样"。右边写不出实际损失的字段,一律砍掉。这张表在第一版通常能砍掉三成以上的字段,而砍完之后,没有任何一次分析因为缺字段而做不了。