产品同学问了一个看似简单的问题:用户从点开商品到支付成功,每一步流失了多少?回答它动用的远不止一句 SQL:数据从哪里采,按什么分层加工,做成看板还是接口,策略改了以后怎么验证有没有效,表越来越多以后又怎么治理。平时我们往往只负责其中一段,很难看清全貌。
Chromium 有一门经典的内部课程 Life of a Pixel(Steve Kobes,Chrome University):它不按模块讲浏览器,而是跟着一个像素走,从网页的 HTML 一直走到屏幕上的一个点,沿途每一站都讲清楚输入是什么、输出是什么、中间的数据结构长什么样。
数据系统也可以这样讲。这篇文章跟着一笔订单走一遍它的一生:从用户点下「支付」的那一刻,到它变成漏斗看板上的一个数字、一次 AB 实验里的一笔转化;然后看它被退款改写、被回溯修正,最后被清理掉。走完这一趟,再回答一个做久了数据都会被问到的问题:怎样算一个好的数仓。
起点:一个需求
先把这个问题拆成能算的指标:
定义漏斗的阶段:点击商品 → 创建订单 → 订单支付 → 支付成功;
确定北极星指标:各阶段之间的转化率,以及每一步花了多长时间。
要算出这些转化率,需要下单路上每一步的记录。下面挑其中一笔订单,看它是怎么变成漏斗上的一个数字的。
全程地图
每一站都按同样的顺序讲:
你在这里:流水线上当前这一站;
它长什么样:数据在这一站的具体形态;
发生了什么:这一站做的事和背后的原理;
深入阅读:知识库里讲这一站的文章。
文中的订单号、金额和时间都是示例。
主角登场
2026 年 10 月 18 日晚上 8 点 15 分,用户 42 在 App 里为一件 99 元的商品点下了「支付」,订单号 1001。几百毫秒后,支付成功的页面弹了出来。对用户来说,这件事已经结束了;对数据来说,一生才刚刚开始。
第一站:诞生
它长什么样
这一下点击,至少在两个地方留下了痕迹。一个是业务数据库,订单的状态被更新了:
UPDATE orders SET status = 'PAID', paid_at = '2026-10-18 20:15:03'
WHERE order_id = 1001;另一个是 App 上报的埋点日志,记录用户做了什么:
2026-10-18 20:15:02 pay_click {"user_id":42,"order_id":1001,"page":"checkout"}发生了什么
两份数据描述的是同一件事,性质却很不一样:
业务库 | 埋点日志 | |
|---|---|---|
记录的是 | 状态:订单现在是什么样 | 行为:用户做了什么 |
会不会变 | 会:退款时这一行还会被更新 | 不会:只追加,不修改 |
准确性 | 高,是事务的结果 | 可能丢、可能重复、时间可能不准 |
量 | 一笔订单一行 | 一次购物可能产生几十条 |
后面会看到,「会不会变」这一点,决定了下游要付出多大的代价。
第二站:采集
它长什么样
业务库不能被分析查询直接打扰,所以数据要先「抄」出来。CDC(Change Data Capture,变更数据捕获)工具读取 MySQL 的 Binlog,把刚才那次 UPDATE 变成一条变更事件。以 Debezium 的格式为例(简化后):
{
"op": "u",
"before": {"order_id": 1001, "user_id": 42, "amount": 99.00, "status": "CREATED"},
"after": {"order_id": 1001, "user_id": 42, "amount": 99.00, "status": "PAID"},
"source": {"db": "shop", "table": "orders", "file": "mysql-bin.000003", "pos": 4721},
"ts_ms": 1792325703000
}op 表示变更类型:c 是插入、u 是更新、d 是删除、r 是初始快照读出来的行。
发生了什么
漏斗要的数据不止订单表,四个阶段的记录来自不同的地方:
数据源 | 采集方式 | 在这个需求里 |
|---|---|---|
客户端 | 埋点,通过 API 上报 | 用户点击商品 |
服务端 | 日志采集 | 创建订单、发起支付的请求 |
数据库 | Binlog 增量同步(CDC) | 订单状态、支付结果 |
外部 | 爬虫或数据采购 | 本需求不涉及 |
先全量,再增量:第一次接入时,先把整张表读一遍(快照),再从当时的 Binlog 位置开始接着读增量,两段无缝衔接,下游就拿到了一份完整、并且持续更新的副本。
既有前值,也有后值:变更事件里同时带着
before和after,下游才知道「从什么变成了什么」。这一点后面处理退款时会派上大用场。日志走另一条路:埋点日志由客户端上报或采集 Agent 收集,只有「后值」,没有「前值」。
深入阅读
解构数据引擎:数据集成工具(SeaTunnel、Flink CDC)的定位和选型。
第三站:传输
它长什么样
变更事件被写进 Kafka 的一个 Topic,成为一条消息:
topic: ods_orders
partition: 7 ← 由 key(order_id = 1001)哈希决定
offset: 1849203 ← 在这个分区里的序号,只增不减
key: 1001
value: {"op":"u","before":{...},"after":{...}}
timestamp: 2026-10-18 20:15:03.412发生了什么
按 key 分区,分区内有序:同一个订单的所有变更都用订单号作 key,总会落进同一个分区,所以「先创建、再支付、再退款」的顺序不会乱。
可以重放:消费者自己记录读到了哪个 offset。下游出了问题,可以把 offset 拨回去重新消费。但消息只保留一段时间(比如 7 天),更早的数据就要另想办法。
Kafka 的局限:它是为事件流设计的,不支持按主键更新,也几乎不能查询。实时分析的很多麻烦都从这里开始,于是有了面向分析的流存储 Fluss:用主键表存数据,自动生成变更日志,还能直接点查。
深入阅读
Fluss:重塑实时开发:Kafka 在分析场景里的三个问题,以及 Fluss 怎样解决。
第四站:计算
它长什么样
Flink 读到这条消息,把它反序列化成内部的一行数据。注意每一行前面都带着一个变更类型(RowKind):
-U[1001, 42, 99.00, CREATED] ← update before:撤回旧值
+U[1001, 42, 99.00, PAID] ← update after:新值经过和用户维表的关联,这一行被补上了城市等信息;再经过按天聚合,它变成了某个城市当天 GMV 里的 99 元:
+U[2026-10-18, 北京, gmv=1288430.00, orders=10392]发生了什么
这一站是整条链路里最复杂的,几个关键概念:
概念 | 在这条订单身上 |
|---|---|
事件时间 | 用 20:15:03(订单支付的时间)计算,而不是数据到达 Flink 的时间 |
水位线 | 判断「20:15 之前的数据应该都到齐了」,窗口才能放心地输出结果 |
状态 | 「北京今天的 GMV 目前是多少」要一直记着,下一条订单来了才能接着累加 |
变更类型 | 上游的一次更新,在流里变成「撤回 + 新值」两条,聚合结果才能保持正确 |
写这些逻辑有不同的层次:底层的 DataStream API 可以精细控制状态和定时器;Flink SQL 把同样的逻辑写成一条 GROUP BY;SQL 不够用时,再用 UDF 扩展,甚至调用 Python 写的模型。
深入阅读
我所了解的 Flink:从 ProcessFunction 手动管状态,到窗口、SQL 和 PyFlink,也是 Flink 这组文章的入口。
SQL Is All Your Need:Flink SQL:事件时间、水位线、窗口聚合和复杂事件匹配。
SQL Is All Your Need:Flink SQL UDF:SQL 不够用时,用 UDF 扩展。
第五站:落盘
它长什么样
计算结果和明细都要长期保存,写进数据湖的一张表,比如 Paimon 主键表。对应用开发来说,它就是一张既能流式读、也能批量读的表;底下是一组按快照组织的列式文件,订单 1001 的订单号、金额、状态被拆进了不同的列。文件怎么摆、怎么合并,由表格式和引擎管着。
发生了什么
快照让读写互不干扰:每次提交生成一个新快照,读的人要么看到旧快照、要么看到新快照,不会读到写了一半的数据。旧快照还能被查询,这就是「时间旅行」。
流和批读同一张表:实时作业可以流式读它的变更,离线作业可以批量读它的快照。
后面第二幕的回溯修正,靠的就是这两点:旧快照还在,同一张表能换成批模式重读。
深入阅读
第六站:加工
它长什么样
技术上,数据开发可以概括为一句话:SQL Is All Your Need。把各种存储抽象成表,用 SQL 描述加工过程(见 SQL Is All Your Need: Flink SQL)。
组织上,要用数仓分层来管理数据,每一层只做一类事情:
层 | 做什么 | 订单 1001 在这一层 | 在漏斗需求里 |
|---|---|---|---|
ODS(贴源层) | 原样接入各种数据源 | 和业务库一模一样的一行: | 埋点、日志、订单表的原始数据 |
DWD(明细层) | 清洗、统一口径,形成明细 | 清洗、关联成宽表: | 把四个阶段的行为串成一个用户的完整路径 |
DWS(汇总层) | 按常用粒度汇总 | 不再单独存在,成为「北京 · 2026-10-18」这一行汇总的一部分: | 按用户、按天汇总各阶段的次数和时间 |
ADS(应用层) | 面向具体应用 | 看板要的指标:北京 GMV 128.8 万,下单转化率 3.2% | 每天各阶段的转化率和转化时间 |
注意 DWS 里的 uv 存的不是一个数字,而是一个 HyperLogLog:去重计数不能直接相加,要保存能合并的中间状态,周、月的 UV 才能从每天的结果合并出来。
发生了什么
分层:每一层只做一类事。业务一变,只需要改受影响的那一层。
增量而不是暴力扫描:算「近 30 天的 GMV」时,不必每天扫 30 个分区,用「昨天的近 30 天 + 今天 − 30 天前的那一天」只读 3 个分区。
数据倾斜:大促时某个爆款的订单可能占了一大半,按商品聚合时它们全挤在一个任务上。调大下面的「热点数据比例」,看看负载会变成什么样:
1,000 条记录 · 8 个任务 · 热点占比 60%
流批一体:实时和离线如果是两套代码,同一个指标就会算出两个数。理想的做法是同一份逻辑,按新鲜度要求选择流式还是批式运行。
深入阅读
少算一点:大数据计算优化的统一视角:增量、去重、倾斜背后的同一个问题,聚合能不能合并、能不能撤销。
消灭暴力扫描,世界属于渐进:用递推代替重复扫描。
大数据去重统计:Set、Bitmap、HyperLogLog 的原理和取舍。
大数据性能调优:数据倾斜怎样识别、怎样打散。
我理解的流批一体:从三条链路到一个参数。
第七站:服务
它长什么样
运营同学打开看板,或者某个数据产品调用接口时,一条查询到达 OLAP 引擎:
SELECT city, SUM(gmv) AS gmv, HLL_UNION_AGG(uv) AS uv
FROM ads_city_daily
WHERE dt = '2026-10-18'
GROUP BY city
ORDER BY gmv DESC
LIMIT 10;如果是通过接口访问,结果会被封装成 JSON:
{
"data": [{"city": "北京", "gmv": 1288430.00, "uv": 324817}],
"code": 0,
"message": "success"
}发生了什么
同一份数据,可以用不同的方式提供给不同的人:
形态 | 技术 | 在这个需求里 |
|---|---|---|
看板 | OLAP + BI | 转化漏斗图 |
标签 | KV 存储 | 用户的支付偏好画像 |
特征与样本 | Feature Store | 给推荐和风控模型用的转化特征 |
监控 | 消息队列 | 转化率突然下跌时告警 |
MPP 并行执行:一条查询被拆成很多片段,分发到所有节点同时执行,再汇总结果,秒级甚至毫秒级返回。
少搬数据:需要关联时,引擎会尽量选择不搬动数据的方式(Colocate、Bucket Shuffle、Broadcast),并用 Runtime Filter 提前过滤掉关联不上的数据。
接口:简单的数据查询接口可以用 SQL 模板生成(SQL2API),但带复杂业务逻辑的接口,还是交给后端更合适。
深入阅读
大数据性能调优:MPP 引擎里的 Join 方式和 Runtime Filter。
SQL Is All Your Need:SQL2API:用 SQL 生成数据接口,以及它的边界。
第八站:价值
它长什么样
终于,那 99 元成了看板上「北京 GMV 128.8 万」的一部分。它也可能出现在别的地方:
去处 | 它的样子 |
|---|---|
看板 | GMV、转化漏斗、趋势图上的一个点 |
告警 | 某个用户当天支付金额超过阈值,触发一条风控告警 |
AB 实验 | 实验组的一笔转化,用来判断新的下单流程是否更好 |
模型 | 推荐模型的一个正样本:用户 42 买了这类商品 |
发生了什么
数据从「描述发生了什么」,走到了「帮助决定接下来做什么」。从数据、信息、知识到智慧,越往上越接近决策,也越难衡量数据到底贡献了多少。
看到漏斗之后,产品会提出改进策略,比如简化下单流程。策略有没有效,要用 AB 实验来验证:把用户随机分成两组,只改变一个变量,比较两组的转化率。数据从「描述发生了什么」走到了「验证怎么做更好」。
深入阅读
Flink 规则引擎:怎样让告警规则不改代码就能实时生效。
SQL Is All Your Need:Flink Dynamic SQL:只用 SQL 实现动态规则,以及它的边界。
第二幕:当数据被改写
Life of a Pixel 的后半部分讲的是:页面内容变了,哪些步骤需要重做。数据也一样。三天后,用户 42 申请了退款。
一次更新,在流里变成两条消息
业务库执行了 UPDATE orders SET status = 'REFUNDED' WHERE order_id = 1001。这次更新沿着同一条路往下走,在 Flink 里变成了两条消息:
-U[1001, 42, 99.00, PAID]
+U[1001, 42, 99.00, REFUNDED]下游的聚合先撤回这 99 元,再按新的状态重新计算。词频统计的例子能更直观地看到这个过程:
回撤保证了最终结果正确,但也带来了副作用:两条消息之间,下游可能短暂地看到一个错误的中间结果,比如 GMV 先掉下去再涨回来;在分布式环境里,这两条消息还可能乱序。这正是「会不会变」这一点要付出的代价。
Flink SQL 回撤机制与问题:回撤的原理、乱序与中间结果,以及版本列和自定义算子两种解法。
当口径错了:回溯修正
一周后发现,GMV 的口径漏掉了一类优惠券。逻辑改好之后,过去一周的数据也要重算:
消息队列里的数据可能已经过期,但数据湖里的快照和分区还在,可以从湖里重新读取;
如果流和批用的是同一份 SQL,只要切换成批模式重跑这一周,结果和实时链路天然一致;
递推计算的指标,补数据时要按天串行重算,代价更高。
SQL Is All Your Need:Flink SQL:一行配置把同一段 SQL 切成批模式。
我理解的流批一体:回溯和修正为什么是统一的关键。
终点:治理与消亡
需求越来越多,表越来越多,数据不会自己变好,就需要治理:消除重复建设的「烟囱」,统一指标口径,清理没人用的数据。上线之后,每张表还要持续运维:
补数:口径变了或者数据出错,要能重新计算历史数据,最好全量和增量能用同一套逻辑;
质量:数据必须准时、准确地产出,满足约定的 SLA;
成本:计算和存储都要钱;
合规与安全:隐私数据脱敏,权限最小化。
数据不会永远存在,每一站都有自己的清理机制:
位置 | 怎么清理 | 例子 |
|---|---|---|
Flink 状态 | 状态过期时间(TTL) |
|
Kafka | 消息保留期 | 超过 7 天的消息被删除 |
数据湖 | 快照过期、分区过期 | 只保留最近若干个快照;超过保留期的分区被删除 |
数仓 | 表的生命周期 | 明细保留一年,汇总长期保留 |
订单 1001 的明细终有一天会被清理,但它曾经贡献过的那 99 元,会以汇总的形式留在历史的报表里。
怎样算一个好的数仓
做了很多需求之后,难免被问一个灵魂问题:怎么评价一个数仓建设得好不好? 我的思考分两部分:需求管理和底层技术。
需求管理:从一个需求到一类需求
好的数仓不是逐个满足需求,而是把一类需求沉淀成可复用的模型:
业务建模:理清业务过程(下单、支付)和业务对象(用户、商品、订单);
维度建模:业务过程沉淀为事实表(订单事实表,记录每笔订单发生了什么),业务对象沉淀为维度表(用户维表,记录用户是谁);
指标建模:把每个指标拆成标准的四个部分,口径就不会乱。
以「近 7 天各城市支付成功的订单金额」为例:
组成 | 含义 | 对应 SQL | 例子 |
|---|---|---|---|
原子指标 | 对什么做什么聚合 | 聚合函数 | 订单金额求和 |
业务限定 | 只算哪些数据 | WHERE | 支付成功 |
统计粒度 | 按什么分组 | GROUP BY | 城市 |
统计周期 | 算多长时间 | 时间窗口 | 近 7 天 |
拆开之后,「近 30 天各城市支付成功的订单金额」只是换了统计周期,「近 7 天各品类支付成功的订单金额」只是换了统计粒度,不必每次从头开发。
此外,还要思考需求背后真正的问题,以及更大的问题:团队的问题背后是部门的问题,部门的问题背后是公司的问题。思考问题不能有边界,但团队协作时要明确分工。
底层技术:交给平台,但要知道取舍
数据系统本质上是数据密集型应用:瓶颈不在计算有多复杂,而在数据量、数据的复杂度和变化速度。平台替我们屏蔽了大部分细节,但有些取舍应用开发绕不过去。拿漏斗需求里的一个问题举例:
为什么不直接在业务库上算?订单本来就存在业务数据库里,为什么还要通过 Binlog 同步出来、放进数仓再算转化率?
因为两边要回答的问题不一样。业务库面向事务(OLTP):每次读写一两个订单,要的是又快又准,所以数据按行存放,一个订单的所有字段挨在一起,再配上索引,按订单号找一行很快。漏斗面向分析(OLAP):一次要扫过几个月的全部订单,却只用到状态、时间这几列。按行存放时,为了这几列也得把整行读出来;按列存放时,同一列的值挨在一起,只读需要的列就行,同类型的值也更容易压缩。
而且这样的扫描一跑就是全表,放在业务库上,会和用户下单抢资源。所以数据要先同步出来,换成适合分析的列式存储,再交给分析引擎。同一份数据按不同的访问方式存成不同的样子,这个取舍在整个数据体系里反复出现。
这类取舍最终都落在新鲜度、成本和查询性能三者之间(第六站那张图)。更系统的原理,我主要参考 Kleppmann 的《数据密集型应用系统设计》;具体组件的定位和选型,见 解构数据引擎。
技术往哪里发展
技术成熟度曲线是一个直观的参照:每项新技术都会经历期望膨胀、泡沫破裂,再慢慢爬上实用的平台期。
我比较关注的两个方向:
湖仓一体:在数据湖上提供数仓的能力,做到分钟级的准实时,原理见 数据湖。
数据与 AI 结合:一方面 DataOps 用工程化方法重塑数据开发的工作流;另一方面,以数据为中心的 AI(Data-Centric AI)认为,提升模型效果的关键越来越在于数据质量,而不是模型结构。
支线:一帧图像的一生
订单是结构化数据,一行就能说清楚。多模态数据的一生则是另一番样子:一台机器人摄像头拍下的一帧图像,要和其他传感器按时间对齐,存进为随机读设计的 Lance 表,经过解码、打标、提取向量,被挖掘成难例,最后进入下一版训练集。数仓的哪些直觉在这里失效、新工具怎样接手,见这组文章:
回顾
站 | 数据的样子 | 关键技术 | 深入阅读 |
|---|---|---|---|
诞生 | 业务库的一行、一条埋点日志 | 事务数据库、埋点 | — |
采集 | 带 before / after 的变更事件 | CDC、日志采集 | |
传输 | 分区里的一条消息 | Kafka、Fluss | |
计算 | 带变更类型的行、状态 | Flink、Flink SQL | |
落盘 | 快照里的一组列式文件 | Paimon、Iceberg | |
加工 | 宽表的一行、汇总里的一部分 | 数仓分层、增量计算 | |
服务 | 查询结果、接口的 JSON | OLAP、SQL2API | |
价值 | 看板上的数字、告警、实验里的一笔转化 | BI、规则引擎、AB 实验 |
回到开头的两个问题。漏斗上的那个转化率,是一笔笔订单走完这八站之后汇总出来的:每一站单独看都不复杂,难的是它们连在一起时,上游的一次更新会沿着整条链路一路传下去。而一个好的数仓,是把一个个这样的需求沉淀成可复用的模型,再在新鲜度、成本和查询性能之间做出清醒的取舍。
参考资料
Steve Kobes,Life of a Pixel,Chrome University 2020;幻灯片。
Martin Kleppmann,《数据密集型应用系统设计》(Designing Data-Intensive Applications),O'Reilly,2017。
Ralph Kimball、Margy Ross,《数据仓库工具箱:维度建模权威指南》(The Data Warehouse Toolkit)。
Matt Bornstein、Jennifer Li、Martin Casado,Emerging Architectures for Modern Data Infrastructure,a16z。
Daochen Zha 等,Data-centric Artificial Intelligence: A Survey,2023。
全文评论
0正在加载评论…