运营要一张看板:每个城市近 30 天的成交额、最大一笔订单、平均订单金额和下单人数。四个指标写成 SQL 只是一行 GROUP BY,上线后却各慢各的:成交额每天把 30 个分区重扫一遍,其中 29 个昨天刚扫过;下单人数的 COUNT(DISTINCT user_id) 把一个任务的内存撑爆;北京的订单占了一半,整个作业都在等北京那一个任务。

站里有三篇文章分别讲了这三件事:消灭暴力扫描的递推、大数据去重统计的 HyperLogLog、大数据性能调优的两阶段聚合。分开看是三套技巧。这篇回答一个问题:它们是不是同一件事?

我的答案是:是。几乎所有聚合上的优化,都在利用聚合函数的两个性质:能不能合并,能不能撤销。一个指标有哪个性质,就能用哪一类优化;没有,就换一种数据结构让它有。

一张订单表,四个指标

全文只用一张按天分区的订单表 orders(dt, city, user_id, amount),算每个城市近 30 天的四个指标:

指标

写法

慢在哪

成交额(GMV)

SUM(amount)

每天重扫 30 个分区

最大单笔

MAX(amount)

同上,而且不能像成交额那样递推

平均订单金额

AVG(amount)

同上

下单人数

COUNT(DISTINCT user_id)

重扫之外,还要把所有人放进内存去重;北京一个城市就压垮一个任务

下面先用两个问题给这四个指标分类,后面每一种优化,都只是在用这两个问题的答案。

两个问题:能不能合并,能不能撤销

能合并:拆开各算,再合起来

把 30 天的订单按天切开,每天算一个结果,再把 30 个结果合起来,等不等于一次算全部?

  • 成交额:30 个日成交额相加。能。

  • 最大单笔:30 个日最大值再取最大。能。

  • 平均订单金额:30 个日平均值再平均,不对。第一天 1 单 100 元,第二天 3 单各 20 元:两个日平均是 100 和 20,再平均得 60;真实的平均是 (100 + 60) / 4 = 40。订单多的那天被少算了。但如果每天存的不是平均值,而是金额之和与订单数两个数,合并时各自相加再相除,就对了。

  • 下单人数:30 个日人数相加,会把连续几天都下单的人算好几遍。要合并,每天必须保存「到底是哪些人」,中间状态和明细几乎一样大。

Jim Gray 等人在 1997 年的 Data Cube 论文里把聚合函数分成三类,正好对应这里的情况:中间状态就是结果本身的叫分布型(SUM、COUNT、MAX、MIN);中间状态是固定几个值的叫代数型(AVG 存和与个数);中间状态的大小没有上限、必须看到全部输入的叫整体型(中位数、众数)。精确去重的中间状态是整个用户集合,大小随人数增长,实践中和整体型一样难办。

所以第一个问题真正问的是:有没有一个大小固定、又能合并的中间状态。

能撤销:合进去的,还能减出来

第二个问题:近 30 天的结果已经算好,现在要把 30 天前那一天「拿出来」,做得到吗?

  • 成交额:减掉那天的成交额。能。

  • 平均订单金额:金额之和、订单数各减掉那天的值。能。

  • 最大单笔:不能。离开窗口的那天如果恰好是最大值,剩下 29 天的最大值是多少,光凭当前的最大值不知道。

  • 下单人数:不能。那天下单的人,有没有在另外 29 天也下过单,光凭人数不知道。

指标

中间状态

能合并

能撤销

成交额

一个数

能

能

平均订单金额

和、个数两个数

能

能

最大单笔

一个数

能

不能

下单人数(精确)

用户集合,随人数增长

能,但状态很大

不能

下单人数(HyperLogLog)

几 KB 的寄存器

能

不能

用数学的话说

「能合并」是说中间状态上有一个满足结合律和交换律的合并运算,所以可以任意拆分、按任意顺序合并,代数里叫交换幺半群。「能撤销」是说每个状态还有逆元,合并进去的可以再减出来,这就是交换群。加法有逆(减法),取最大值和集合求并没有。

这不只是一个比喻。DBSP(Budiu 等,VLDB 2023)给出了一个能把任意 SQL 查询自动改写成增量计算的方法,它对数据的唯一要求,就是变化量之间的「差」构成交换群。表里每一行带一个整数权重(插入 +1,删除 −1),整张表就满足这个要求;MAX 这类没有逆元的聚合,DBSP 也要额外保存状态才能增量维护。

拿到一个指标先问两个问题,答案决定它能用哪一类优化

上图就是全文的骨架。下面分成两条路讲:计算模式怎样利用这两个性质,数据结构怎样创造这两个性质。

计算模式一:只算变化的部分

每天重扫 30 个分区,是把「今天的近 30 天」当成一个全新的问题。增量计算换个问法:昨天的答案已经有了,今天变了什么?

累计窗口:只要能合并

「本月至今」的起点不动,每天只多一天:今天的结果 = 昨天的结果 ⊕ 今天的数据(⊕ 表示合并)。四个指标都能这样算,最大单笔取两者较大的那个,下单人数把两个集合或两个 HyperLogLog 合并。

滑动窗口:还要能撤销

「近 30 天」每天进来一天、出去一天:

今天的近 30 天=昨天的近 30 天⊕今天⊖30 天前那天\text{今天的近 30 天} = \text{昨天的近 30 天} \oplus \text{今天} \ominus \text{30 天前那天}

⊖ 就是撤销。成交额和平均订单金额能撤销,每天只读 3 个分区,这就是《消灭暴力扫描》里的递推公式。最大单笔和下单人数不能撤销,公式到这里就断了。

不能撤销:切成小块,只合并

不能撤销,就不要走「减出来」这条路:每天存一份中间状态,算近 30 天时把窗口里的 30 份合并起来,离开窗口的那天直接丢掉,不需要减。

  • 最大单笔:每天存一个日最大值,30 个取最大。《消灭暴力扫描》里用 MAP 保存「日期 → 当天的值」,就是这个做法。

  • 下单人数:每天存一份 Bitmap 或 HyperLogLog,30 份合并后再数。

读的仍然是 30 份,但每份是一个城市一天一个值或几 KB,而不是 30 个分区的订单明细。这里有一个容易踩的坑:HyperLogLog 能合并、不能撤销,所以近 30 天的下单人数不能套用「减掉 30 天前那天」的递推公式,只能把 30 份合并。《大数据去重统计》说 HyperLogLog「每天存一份,按需合成周、月」,原因就在这里。

MaxCompute 的渐进计算替你做的也是这件事:第一次运行时产出按天、按月预聚合的中间结果,之后只算当天的增量,再和中间结果合并。

流计算里,撤销叫回撤

流上没有「昨天」和「今天」,只有一条条变化。订单从「已支付」改成「已退款」,Flink SQL 会把它拆成两条消息:撤回旧值(-U)、写入新值(+U)。下游的聚合收到 -U,要做的正是撤销。

成交额收到 -U 直接减。最大单笔呢?Flink 的 MaxWithRetractAggFunction 在状态里为每个出现过的值记一个次数(MapView<值, 次数>),当前最大值被撤回时,从里面找下一个。这和离线「把每天的值都存下来」是同一个办法:不能撤销的聚合,只能多存状态来换。回撤的原理和它带来的副作用,见《Flink SQL 回撤机制与问题》。

计算模式二:把活拆开

分布式计算的前提也是能合并:每个任务算一块,最后合起来。北京的订单占了一半,问题是这一块太大,那就把它再拆开。

两阶段聚合:先局部合并,再全局合并

先按「城市 + 一个桶号」局部聚合,把北京这个大组拆成很多小组;再按城市把局部结果合并:

sql
SELECT city, SUM(part_gmv) AS gmv, MAX(part_max) AS max_amount
FROM (
    SELECT city, SUM(amount) AS part_gmv, MAX(amount) AS part_max
    FROM orders
    WHERE dt > '${bizdate-30d}' AND dt <= '${bizdate}'
    -- 局部:北京被拆成 64 个小组,分给 64 个任务
    GROUP BY city, MOD(HASH_CODE(order_id), 64)
) t
-- 全局:每个城市只剩 64 行要合并
GROUP BY city;

成交额、最大单笔、平均订单金额(存和与个数)都能这样拆,因为它们能合并。Flink SQL 的 Local-Global 聚合(table.optimizer.agg-phase-strategy)是同一个思路,只是局部合并发生在数据发往下游之前。

下单人数不能按订单号随便分桶,否则同一个人落进几个桶,各桶人数一加就重复了。办法是按 user_id 分桶:同一个人只会落进一个桶,桶与桶之间没有交集,这时各桶的人数可以直接相加。注意这并没有让去重变得能合并,而是把数据切成互不相交的几块,集合不相交时大小才能相加。Flink SQL 的 table.optimizer.distinct-agg.split.enabled 自动做的就是这一步,细节见《大数据性能调优》。

切片窗口:每条数据只算一次

风控规则常要「近 1 天,每分钟更新一次」。如果每个窗口单独累加,一条数据属于 1440 个窗口,就要被累加 1440 次。切片把时间切成互不重叠的 1 分钟小段,每条数据只进一个切片,窗口的结果由它包含的切片合并而成。

切片同样只需要能合并:最大单笔没法撤销,照样能用切片。《Flink 规则引擎》里能随时改的窗口就是这样实现的,Flink SQL 的窗口表值函数也是。

数据结构:让中间状态又小又能合并

前两部分都有个前提:要有一个好的中间状态。没有现成的,就设计一个。

  • 预聚合表:数仓的汇总层(DWS)本质上就是把中间状态落盘:按城市、按天存好,上层任意周期都从它合并。所以汇总层该存的是金额之和与订单数,而不是平均值;是 HyperLogLog,而不是一个人数。《Life of a Data》里汇总层的 uv=<HLL> 就是这个道理。

  • 压缩和概率结构:Bitmap 让精确去重的状态小几十倍,仍然随人数增长;HyperLogLog 用 1%~2% 的误差,换来固定几 KB、能合并的状态。代价是它不能撤销,也算不准交集。原理见《大数据去重统计》。

  • 存储层聚合:Fluss 的聚合表在写入时按主键和已有的值合并,把聚合从 Flink 的状态里挪到存储。能挪下去的,也只有能合并的聚合;而它不接受回撤,原因之一就是在写入即可见的模型里,没法安全地撤销。见《Fluss:重塑实时开发》。

还有一类优化和聚合的性质无关,目标却相同,都是少读、少搬:按分区和文件统计跳过数据,按关联键分桶让 Join 不搬数据(Colocate),用 Runtime Filter 先过滤再搬。这些都取决于数据在存储里怎么摆,放在《大数据性能调优》的第二部分讲。

代价:没有白来的优化

做法

换来了什么

付出了什么

什么时候不该用

递推

每天只读 3 个分区

作业依赖自己前一天的结果,补数只能按天串行

窗口短、数据量小,或者经常要补数

切片 / 每天存中间状态

不能撤销的指标也能增量算

多一份状态要存、要清理;切片要求窗口长度是步长的整数倍

窗口和步长对不齐

回撤时多存状态

流上的 MAX 也能撤销

状态里存着每个出现过的值

值的种类非常多、又用不上回撤

两阶段聚合

热点被拆开

多一轮聚合和数据传输

没有倾斜的时候

HyperLogLog

固定几 KB、能合并

1%~2% 的误差,不能撤销,交集不准

对账、计费这类要求精确的场景

总结:拿到一个指标,先问两个问题

回到开头:递推、HyperLogLog、两阶段聚合、切片,是同一件事的不同侧面。拿到一个新指标,可以按这个顺序判断:

  1. 它能撤销吗?能,滑动窗口用递推,流上的回撤直接减。

  2. 不能撤销,但有小的、能合并的中间状态吗?有,就按时间切片,存中间状态只合并不撤销;倾斜时做两阶段聚合。

  3. 中间状态太大,或者根本不能合并?换数据结构:切成不相交的桶让它能相加,换近似结构把状态压小,或者接受多存明细。

  4. 都做了还慢?看数据怎么分、怎么搬:倾斜和 Shuffle。

文章

回答的问题

用到的性质

消灭暴力扫描,世界属于渐进

近 N 天的指标每天重扫

能撤销就递推,不能就把每天的值存下来

大数据去重统计

去重计数不可加

设计一个又小又能合并的中间状态

大数据性能调优

一个任务拖住整个作业

能合并就能拆成两阶段;再加上少搬数据

我的判断是,这些优化最终都该由平台根据聚合的性质自动选择,而不是开发者手写。MaxCompute 的渐进计算、Flink SQL 的 Local-Global 和拆分去重开关、基于 DBSP 的 Feldera,都在往这个方向走。即便如此,知道这两个问题仍然有用:平台帮不上的时候,你知道卡在了哪里。

参考资料


全文评论

0

正在加载评论…