产品同学问了一个看似简单的问题:用户从点开商品到支付成功,每一步流失了多少?回答它动用的远不止一句 SQL:数据从哪里采,按什么分层加工,做成看板还是接口,策略改了以后怎么验证有没有效,表越来越多以后又怎么治理。平时我们往往只负责其中一段,很难看清全貌。

Chromium 有一门经典的内部课程 Life of a Pixel(Steve Kobes,Chrome University):它不按模块讲浏览器,而是跟着一个像素走,从网页的 HTML 一直走到屏幕上的一个点,沿途每一站都讲清楚输入是什么、输出是什么、中间的数据结构长什么样。

数据系统也可以这样讲。这篇文章跟着一笔订单走一遍它的一生:从用户点下「支付」的那一刻,到它变成漏斗看板上的一个数字、一次 AB 实验里的一笔转化;然后看它被退款改写、被回溯修正,最后被清理掉。走完这一趟,再回答一个做久了数据都会被问到的问题:怎样算一个好的数仓。

起点:一个需求

先把这个问题拆成能算的指标:

  • 定义漏斗的阶段:点击商品 → 创建订单 → 订单支付 → 支付成功;

  • 确定北极星指标:各阶段之间的转化率,以及每一步花了多长时间。

要算出这些转化率,需要下单路上每一步的记录。下面挑其中一笔订单,看它是怎么变成漏斗上的一个数字的。

全程地图

每一站都按同样的顺序讲:

  • 你在这里:流水线上当前这一站;

  • 它长什么样:数据在这一站的具体形态;

  • 发生了什么:这一站做的事和背后的原理;

  • 深入阅读:知识库里讲这一站的文章。

文中的订单号、金额和时间都是示例。

主角登场

2026 年 10 月 18 日晚上 8 点 15 分,用户 42 在 App 里为一件 99 元的商品点下了「支付」,订单号 1001。几百毫秒后,支付成功的页面弹了出来。对用户来说,这件事已经结束了;对数据来说,一生才刚刚开始。

第一站:诞生

它长什么样

这一下点击,至少在两个地方留下了痕迹。一个是业务数据库,订单的状态被更新了:

sql
UPDATE orders SET status = 'PAID', paid_at = '2026-10-18 20:15:03'
WHERE order_id = 1001;

另一个是 App 上报的埋点日志,记录用户做了什么:

plaintext
2026-10-18 20:15:02 pay_click {"user_id":42,"order_id":1001,"page":"checkout"}

发生了什么

两份数据描述的是同一件事,性质却很不一样:


业务库

埋点日志

记录的是

状态:订单现在是什么样

行为:用户做了什么

会不会变

会:退款时这一行还会被更新

不会:只追加,不修改

准确性

高,是事务的结果

可能丢、可能重复、时间可能不准

量

一笔订单一行

一次购物可能产生几十条

后面会看到,「会不会变」这一点,决定了下游要付出多大的代价。

第二站:采集

它长什么样

业务库不能被分析查询直接打扰,所以数据要先「抄」出来。CDC(Change Data Capture,变更数据捕获)工具读取 MySQL 的 Binlog,把刚才那次 UPDATE 变成一条变更事件。以 Debezium 的格式为例(简化后):

json
{
  "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 收集,只有「后值」,没有「前值」。

深入阅读

第三站:传输

它长什么样

变更事件被写进 Kafka 的一个 Topic,成为一条消息:

plaintext
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:用主键表存数据,自动生成变更日志,还能直接点查。

深入阅读

第四站:计算

它长什么样

Flink 读到这条消息,把它反序列化成内部的一行数据。注意每一行前面都带着一个变更类型(RowKind):

plaintext
-U[1001, 42, 99.00, CREATED]    ← update before:撤回旧值
+U[1001, 42, 99.00, PAID]       ← update after:新值

经过和用户维表的关联,这一行被补上了城市等信息;再经过按天聚合,它变成了某个城市当天 GMV 里的 99 元:

plaintext
+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 写的模型。

深入阅读

第五站:落盘

它长什么样

计算结果和明细都要长期保存,写进数据湖的一张表,比如 Paimon 主键表。对应用开发来说,它就是一张既能流式读、也能批量读的表;底下是一组按快照组织的列式文件,订单 1001 的订单号、金额、状态被拆进了不同的列。文件怎么摆、怎么合并,由表格式和引擎管着。

发生了什么

  • 快照让读写互不干扰:每次提交生成一个新快照,读的人要么看到旧快照、要么看到新快照,不会读到写了一半的数据。旧快照还能被查询,这就是「时间旅行」。

  • 流和批读同一张表:实时作业可以流式读它的变更,离线作业可以批量读它的快照。

后面第二幕的回溯修正,靠的就是这两点:旧快照还在,同一张表能换成批模式重读。

深入阅读

  • 数据湖:表格式怎样给对象存储装上数据库的能力。

  • 解构数据引擎:块、文件、对象存储,行存与列存,以及各种表格式的定位。

第六站:加工

它长什么样

技术上,数据开发可以概括为一句话:SQL Is All Your Need。把各种存储抽象成表,用 SQL 描述加工过程(见 SQL Is All Your Need: Flink SQL)。

把存储抽象成表,用 SQL 统一计算

组织上,要用数仓分层来管理数据,每一层只做一类事情:

层

做什么

订单 1001 在这一层

在漏斗需求里

ODS(贴源层)

原样接入各种数据源

和业务库一模一样的一行:1001, 42, 99.00, PAID, 2026-10-18 20:15:03

埋点、日志、订单表的原始数据

DWD(明细层)

清洗、统一口径,形成明细

清洗、关联成宽表:1001, 42, 北京, 女, 25-30 岁, 数码, 99.00, PAID, ...

把四个阶段的行为串成一个用户的完整路径

DWS(汇总层)

按常用粒度汇总

不再单独存在,成为「北京 · 2026-10-18」这一行汇总的一部分:gmv=1288430.00, orders=10392, uv=<HLL>

按用户、按天汇总各阶段的次数和时间

ADS(应用层)

面向具体应用

看板要的指标:北京 GMV 128.8 万,下单转化率 3.2%

每天各阶段的转化率和转化时间

注意 DWS 里的 uv 存的不是一个数字,而是一个 HyperLogLog:去重计数不能直接相加,要保存能合并的中间状态,周、月的 UV 才能从每天的结果合并出来。

发生了什么

  • 分层:每一层只做一类事。业务一变,只需要改受影响的那一层。

  • 增量而不是暴力扫描:算「近 30 天的 GMV」时,不必每天扫 30 个分区,用「昨天的近 30 天 + 今天 − 30 天前的那一天」只读 3 个分区。

  • 数据倾斜:大促时某个爆款的订单可能占了一大半,按商品聚合时它们全挤在一个任务上。调大下面的「热点数据比例」,看看负载会变成什么样:

任务分布模拟器热点数据全部落到一个任务上时,每个任务分到多少条记录。

1,000 条记录 · 8 个任务 · 热点占比 60%

  • 流批一体:实时和离线如果是两套代码,同一个指标就会算出两个数。理想的做法是同一份逻辑,按新鲜度要求选择流式还是批式运行。

深入阅读

第七站:服务

它长什么样

运营同学打开看板,或者某个数据产品调用接口时,一条查询到达 OLAP 引擎:

sql
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:

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),但带复杂业务逻辑的接口,还是交给后端更合适。

深入阅读

第八站:价值

它长什么样

终于,那 99 元成了看板上「北京 GMV 128.8 万」的一部分。它也可能出现在别的地方:

去处

它的样子

看板

GMV、转化漏斗、趋势图上的一个点

告警

某个用户当天支付金额超过阈值,触发一条风控告警

AB 实验

实验组的一笔转化,用来判断新的下单流程是否更好

模型

推荐模型的一个正样本:用户 42 买了这类商品

发生了什么

数据从「描述发生了什么」,走到了「帮助决定接下来做什么」。从数据、信息、知识到智慧,越往上越接近决策,也越难衡量数据到底贡献了多少。

看到漏斗之后,产品会提出改进策略,比如简化下单流程。策略有没有效,要用 AB 实验来验证:把用户随机分成两组,只改变一个变量,比较两组的转化率。数据从「描述发生了什么」走到了「验证怎么做更好」。

深入阅读

第二幕:当数据被改写

Life of a Pixel 的后半部分讲的是:页面内容变了,哪些步骤需要重做。数据也一样。三天后,用户 42 申请了退款。

一次更新,在流里变成两条消息

业务库执行了 UPDATE orders SET status = 'REFUNDED' WHERE order_id = 1001。这次更新沿着同一条路往下走,在 Flink 里变成了两条消息:

plaintext
-U[1001, 42, 99.00, PAID]
+U[1001, 42, 99.00, REFUNDED]

下游的聚合先撤回这 99 元,再按新的状态重新计算。词频统计的例子能更直观地看到这个过程:

回撤保证了最终结果正确,但也带来了副作用:两条消息之间,下游可能短暂地看到一个错误的中间结果,比如 GMV 先掉下去再涨回来;在分布式环境里,这两条消息还可能乱序。这正是「会不会变」这一点要付出的代价。

当口径错了:回溯修正

一周后发现,GMV 的口径漏掉了一类优惠券。逻辑改好之后,过去一周的数据也要重算:

  • 消息队列里的数据可能已经过期,但数据湖里的快照和分区还在,可以从湖里重新读取;

  • 如果流和批用的是同一份 SQL,只要切换成批模式重跑这一周,结果和实时链路天然一致;

  • 递推计算的指标,补数据时要按天串行重算,代价更高。

终点:治理与消亡

需求越来越多,表越来越多,数据不会自己变好,就需要治理:消除重复建设的「烟囱」,统一指标口径,清理没人用的数据。上线之后,每张表还要持续运维:

  • 补数:口径变了或者数据出错,要能重新计算历史数据,最好全量和增量能用同一套逻辑;

  • 质量:数据必须准时、准确地产出,满足约定的 SLA;

  • 成本:计算和存储都要钱;

  • 合规与安全:隐私数据脱敏,权限最小化。

数据不会永远存在,每一站都有自己的清理机制:

位置

怎么清理

例子

Flink 状态

状态过期时间(TTL)

table.exec.state.ttl = 2 d:两天没被访问的分组状态被清掉

Kafka

消息保留期

超过 7 天的消息被删除

数据湖

快照过期、分区过期

只保留最近若干个快照;超过保留期的分区被删除

数仓

表的生命周期

明细保留一年,汇总长期保留

订单 1001 的明细终有一天会被清理,但它曾经贡献过的那 99 元,会以汇总的形式留在历史的报表里。

怎样算一个好的数仓

做了很多需求之后,难免被问一个灵魂问题:怎么评价一个数仓建设得好不好? 我的思考分两部分:需求管理和底层技术。

需求管理:从一个需求到一类需求

好的数仓不是逐个满足需求,而是把一类需求沉淀成可复用的模型:

  • 业务建模:理清业务过程(下单、支付)和业务对象(用户、商品、订单);

  • 维度建模:业务过程沉淀为事实表(订单事实表,记录每笔订单发生了什么),业务对象沉淀为维度表(用户维表,记录用户是谁);

  • 指标建模:把每个指标拆成标准的四个部分,口径就不会乱。

以「近 7 天各城市支付成功的订单金额」为例:

组成

含义

对应 SQL

例子

原子指标

对什么做什么聚合

聚合函数

订单金额求和

业务限定

只算哪些数据

WHERE

支付成功

统计粒度

按什么分组

GROUP BY

城市

统计周期

算多长时间

时间窗口

近 7 天

拆开之后,「近 30 天各城市支付成功的订单金额」只是换了统计周期,「近 7 天各品类支付成功的订单金额」只是换了统计粒度,不必每次从头开发。

此外,还要思考需求背后真正的问题,以及更大的问题:团队的问题背后是部门的问题,部门的问题背后是公司的问题。思考问题不能有边界,但团队协作时要明确分工。

底层技术:交给平台,但要知道取舍

数据系统本质上是数据密集型应用:瓶颈不在计算有多复杂,而在数据量、数据的复杂度和变化速度。平台替我们屏蔽了大部分细节,但有些取舍应用开发绕不过去。拿漏斗需求里的一个问题举例:

为什么不直接在业务库上算?订单本来就存在业务数据库里,为什么还要通过 Binlog 同步出来、放进数仓再算转化率?

因为两边要回答的问题不一样。业务库面向事务(OLTP):每次读写一两个订单,要的是又快又准,所以数据按行存放,一个订单的所有字段挨在一起,再配上索引,按订单号找一行很快。漏斗面向分析(OLAP):一次要扫过几个月的全部订单,却只用到状态、时间这几列。按行存放时,为了这几列也得把整行读出来;按列存放时,同一列的值挨在一起,只读需要的列就行,同类型的值也更容易压缩。

而且这样的扫描一跑就是全表,放在业务库上,会和用户下单抢资源。所以数据要先同步出来,换成适合分析的列式存储,再交给分析引擎。同一份数据按不同的访问方式存成不同的样子,这个取舍在整个数据体系里反复出现。

这类取舍最终都落在新鲜度、成本和查询性能三者之间(第六站那张图)。更系统的原理,我主要参考 Kleppmann 的《数据密集型应用系统设计》;具体组件的定位和选型,见 解构数据引擎。

技术往哪里发展

技术成熟度曲线是一个直观的参照:每项新技术都会经历期望膨胀、泡沫破裂,再慢慢爬上实用的平台期。

Gartner 2023 年数据管理技术成熟度曲线

我比较关注的两个方向:

  • 湖仓一体:在数据湖上提供数仓的能力,做到分钟级的准实时,原理见 数据湖。

统一数据基础设施 2.0,来源:a16z《Emerging Architectures for Modern Data Infrastructure》
  • 数据与 AI 结合:一方面 DataOps 用工程化方法重塑数据开发的工作流;另一方面,以数据为中心的 AI(Data-Centric AI)认为,提升模型效果的关键越来越在于数据质量,而不是模型结构。

机器学习基础设施 2.0,来源:a16z《Emerging Architectures for Modern Data Infrastructure》

支线:一帧图像的一生

订单是结构化数据,一行就能说清楚。多模态数据的一生则是另一番样子:一台机器人摄像头拍下的一帧图像,要和其他传感器按时间对齐,存进为随机读设计的 Lance 表,经过解码、打标、提取向量,被挖掘成难例,最后进入下一版训练集。数仓的哪些直觉在这里失效、新工具怎样接手,见这组文章:

回顾

站

数据的样子

关键技术

深入阅读

诞生

业务库的一行、一条埋点日志

事务数据库、埋点

—

采集

带 before / after 的变更事件

CDC、日志采集

解构数据引擎

传输

分区里的一条消息

Kafka、Fluss

Fluss:重塑实时开发

计算

带变更类型的行、状态

Flink、Flink SQL

我所了解的 Flink

落盘

快照里的一组列式文件

Paimon、Iceberg

数据湖

加工

宽表的一行、汇总里的一部分

数仓分层、增量计算

少算一点

服务

查询结果、接口的 JSON

OLAP、SQL2API

大数据性能调优

价值

看板上的数字、告警、实验里的一笔转化

BI、规则引擎、AB 实验

Flink 规则引擎

回到开头的两个问题。漏斗上的那个转化率,是一笔笔订单走完这八站之后汇总出来的:每一站单独看都不复杂,难的是它们连在一起时,上游的一次更新会沿着整条链路一路传下去。而一个好的数仓,是把一个个这样的需求沉淀成可复用的模型,再在新鲜度、成本和查询性能之间做出清醒的取舍。

参考资料


全文评论

0

正在加载评论…