数仓里大部分表都是按天分区的增量表。要算「过去 30 天的成交额」这类指标,最自然的写法是每天把最近 30 个分区扫一遍。问题在于:今天扫的 30 个分区里,有 29 个昨天已经扫过了。这篇文章介绍几种增量计算的办法,避免重复扫描,提高计算效率。
现象:每天重复扫描同样的分区
先区分两种常见的时间窗口:
滑动窗口:近 N 天,起点和终点每天一起往后挪,比如「近 30 天成交额」;
累计窗口:起点固定,终点每天往后挪,比如「本月至今成交额」。
最直接的实现是把需要的分区都扫一遍,再按条件聚合(变量 ${bizdate} 表示业务日期,${bizdate-nd} 表示 N 天前):
SELECT pk
-- 滑动窗口:开始和结束时间每天变化
,SUM(IF(dt > '${bizdate-nd}' AND dt <= '${bizdate}', m, 0)) AS m_sum_nd
,MAX(IF(dt > '${bizdate-nd}' AND dt <= '${bizdate}', m, 0)) AS m_max_nd
-- 累计窗口:开始时间固定,结束时间变化
,SUM(IF(dt > '${startdate}' AND dt <= '${bizdate}', m, 0)) AS m_sum_td
,MAX(IF(dt > '${startdate}' AND dt <= '${bizdate}', m, 0)) AS m_max_td
FROM a_1d
-- 假定 startdate 晚于 bizdate-nd
WHERE dt > '${bizdate-nd}'
AND dt <= '${bizdate}'
GROUP BY pk;这样做有两个明显的浪费:
每天反复扫描大量相同的分区,产出慢、成本高;
同一个作业里混着不同周期的指标时,为了保证长周期指标的产出,短周期指标也被跟着重复计算。
下面结合阿里云 MaxCompute 讨论几种解法。
递推:今天的 N 天 = 昨天的 N 天 + 今天 − N 天前
能递推的指标
如果指标存在递推公式,就可以用昨天的结果加上今天的变化得到今天的结果,不必回头扫描。
累计窗口最简单,求和与最大值都能直接递推:
滑动窗口的求和也可以:新的一天进入窗口,最老的一天离开窗口:
这样每天只需要读 3 个分区,而不是 30 个;窗口越长,省得越多。
天下没有免费的午餐,递推也有代价:
作业要依赖自己前一天的结果(自依赖),补数据时只能按天串行执行;
遇到紧急需求,往往还要先用暴力扫描初始化一遍历史数据,开发成本更高。
写法一:JOIN
把今天的增量、昨天的结果和 N 天前的增量按主键关联起来:
SELECT COALESCE(today.pk, yst.pk) AS pk
-- 近 N 天:昨天的 N 天 + 今天 - N 天前的那一天
,COALESCE(yst.m_sum_nd, 0) + COALESCE(today.m_1d, 0) - COALESCE(nd.m_1d, 0) AS m_sum_nd
-- 累计至今:昨天的累计 + 今天
,COALESCE(yst.m_sum_td, 0) + COALESCE(today.m_1d, 0) AS m_sum_td
,GREATEST(COALESCE(yst.m_max_td, today.m_1d), COALESCE(today.m_1d, yst.m_max_td)) AS m_max_td
FROM (
SELECT pk, m_1d
FROM a_1d
WHERE dt = '${bizdate}'
) today
FULL OUTER JOIN (
SELECT pk, m_sum_nd, m_sum_td, m_max_td
FROM a_nd
WHERE dt = '${bizdate-1d}'
) yst
ON today.pk = yst.pk
LEFT JOIN (
SELECT pk, m_1d
FROM a_1d
WHERE dt = '${bizdate-nd}'
) nd
ON COALESCE(today.pk, yst.pk) = nd.pk;写法直观,但 Join 本身代价不小,而且多张表要依次关联,维度和周期一多,提升就不明显了。
写法二:UNION ALL
换个思路:三份数据各自补齐成相同的列,UNION ALL 拼在一起,再按主键汇总一次。三个子查询可以并行,效率更高:
SELECT pk
,SUM(m_sum_nd + m_1d - m_np1d) AS m_sum_nd
FROM (
-- 昨天的近 N 天
SELECT pk, m_sum_nd, 0 AS m_1d, 0 AS m_np1d
FROM a_nd
WHERE dt = '${bizdate-1d}'
UNION ALL
-- 今天新增
SELECT pk, 0 AS m_sum_nd, m_1d, 0 AS m_np1d
FROM a_1d
WHERE dt = '${bizdate}'
UNION ALL
-- N 天前离开窗口的那一天
SELECT pk, 0 AS m_sum_nd, 0 AS m_1d, m_1d AS m_np1d
FROM a_1d
WHERE dt = '${bizdate-nd}'
) a
GROUP BY pk;缺点是字段要逐个对齐,指标一多就很繁琐。这种机械的活很适合交给大模型,下面是我用过的提示词:
prompt
You are a SQL expert that knows use progressive method to calculate past periods metircs. For example, when you need calculate past 30 days metric each day, you can use recurrence formula: use yesterday 30 days metric plus today 1 day metric substract 30 days ago 1 day metric Pay attention to use variable like ${bizdate-30}, not use date functions. Use the following format: Question: "Question here" Answer: "SQL Query to run" Here an example: Question: "Suppose we have two tables: CREATE TABLE a_1d ( pk STRING ,m_1d BIGINT ,n_1d BIGINT ,dt STRING ); CREATE TABLE a_nd ( pk STRING ,m_7d BIGINT ,n_7d BIGINT ,m_30d BIGINT ,n_30d BIGINT ,dt STRING ); How to calculate passed 7, 30 days metrics using SUM aggregation progressively by a_1d and a_nd? Answer: SELECT pk ,SUM(m_7d + m_1d - m_8d) AS m_7d ,SUM(n_7d + n_1d - n_8d) AS n_7d ,SUM(m_30d + m_1d - m_31d) AS m_30d ,SUM(n_30d + n_1d - n_31d) AS n_30d FROM ( SELECT pk ,m_nd ,n_nd ,0 AS m_1d ,0 AS n_1d ,0 AS m_8d ,0 AS n_8d ,0 AS m_31d ,0 AS n_31d FROM a_nd WHERE dt = '${bizdate-1}' UNION ALL SELECT pk ,0 AS m_nd ,0 AS n_nd ,m_1d ,n_1d ,0 AS m_8d ,0 AS n_8d ,0 AS m_31d ,0 AS n_31d FROM a_1d WHERE dt = '${bizdate}' UNION ALL SELECT pk ,0 AS m_nd ,0 AS n_nd ,0 AS m_1d ,0 AS n_1d ,m_1d AS m_8d ,n_1d AS n_8d ,0 AS m_31d ,0 AS n_31d FROM a_1d WHERE dt = '${bizdate-7}' UNION ALL SELECT pk ,0 AS m_nd ,0 AS n_nd ,0 AS m_1d ,0 AS n_1d ,0 AS m_8d ,0 AS n_8d ,m_1d AS m_31d ,n_1d AS n_31d FROM a_1d WHERE dt = '${bizdate-30}' ) a GROUP BY pk;" Question: "Suppose we have two tables: {1d table DDL} and {nd table DDL} How to calculate passed {periods} days metrics using SUM aggregation progressively by {1d table name} and {nd table name} ? " Answer: "
不能递推的指标:把窗口里的明细带上
滑动窗口的最大值没法递推,那就把窗口里每一天的值都存下来。可以用 MAP 之类的复杂类型保存「日期 → 当天的值」,每天做两件事:
增量维护:去掉已经离开窗口的那一天,加上今天;
在复杂类型上聚合:用复杂类型函数对窗口内的值求最大值。
SELECT pk
,ARRAY_MAX(MAP_VALUES(MAP_FILTER(m_map, (k, v) -> k > '${bizdate-nd}'))) AS m_max_nd
,m_map
FROM (
SELECT COALESCE(today.pk, yst.pk) AS pk
-- 维护近 N 天的 map:去掉过期的日期,加上今天
,MAP_CONCAT(
MAP_FILTER(COALESCE(yst.m_map, MAP()), (k, v) -> k > '${bizdate-nd}'),
MAP('${bizdate}', COALESCE(today.m_1d, 0))
) AS m_map
FROM (
SELECT pk, m_map
FROM a_nd
WHERE dt = '${bizdate-1d}'
) yst
FULL JOIN (
SELECT pk, m_1d
FROM a_1d
WHERE dt = '${bizdate}'
) today
ON yst.pk = today.pk
) mid;缺点是复杂类型的写法门槛较高;指标多了,每个都要带一份 N 天的明细,存储和计算也会变重。
把三种做法放在一起比一比:拖动「今天是第几天」,看每种做法要读哪些分区,算出来的近 N 天求和与最大值对不对。第 9 天有一个峰值,留意它离开窗口的那一天。
- 暴力扫描
- 递推
- 带上明细
近 7 天 · 第 20 天 · 从「递推」看起
渐进计算:让平台替你做
改代码的投入产出比还是太低。有没有不改代码的办法?MaxCompute 的渐进计算带来了曙光:在原来暴力扫描的 SQL 前加一行参数就行。
-- 开启渐进计算
SET odps.progressive.enable=true;
-- 下面是和开头完全相同的暴力扫描 SQL
SELECT pk
,SUM(IF(dt > '${bizdate-nd}' AND dt <= '${bizdate}', m, 0)) AS m_sum_nd
,MAX(IF(dt > '${bizdate-nd}' AND dt <= '${bizdate}', m, 0)) AS m_max_nd
,SUM(IF(dt > '${startdate}' AND dt <= '${bizdate}', m, 0)) AS m_sum_td
,MAX(IF(dt > '${startdate}' AND dt <= '${bizdate}', m, 0)) AS m_max_td
FROM a_1d
WHERE dt > '${bizdate-nd}'
AND dt <= '${bizdate}'
GROUP BY pk;原理大致是:
第一次运行时,产出可以复用的中间结果,比如按天、按月预先聚合好的表;
之后每次运行,只计算当天的增量,再和中间结果合并出最终结果。
优点 | 缺点 | |
|---|---|---|
渐进计算 | 不用改业务逻辑;中间表的合并、拆分和生命周期由平台管理 | 首次运行要生成中间表,耗时较长,最好在周期开始前先跑一次;通用优化的效果可能不如手写递推;用的人少,遇到问题较难排查 |
更进一步:定义即研发
再往前一步,是在指标定义阶段就把计算方式交给平台。比如 Dataphin 的规范建模:开发者只声明原子指标、统计周期和统计粒度,平台自动生成计算代码,递推、渐进这类优化也由平台统一完成。
总结
方法 | 每天扫描 | 改动 | 适用 |
|---|---|---|---|
暴力扫描 | N 个分区 | 无 | 窗口短、数据量小 |
递推(JOIN / UNION ALL) | 3 个分区 | 改写 SQL,作业变成自依赖 | 求和、计数等可递推的指标 |
复杂类型保存明细 | 2 个分区 | 改写 SQL,存储变大 | 最大值等不可递推的指标 |
渐进计算 | 平台决定 | 加一行参数 | 不想改逻辑,平台支持 |
这些技巧本质上都是同一个思想:只处理变化的部分。我更希望它们最终都不用开发者操心,而是集成在开发平台里,开发者只需要关心业务逻辑。这个思想在流计算里走得更远,相关的讨论见 我理解的流批一体。
全文评论
0正在加载评论…