求每个商品近 30 天的累计 GMV(rolling window)。
Calculate the 30-day rolling cumulative GMV for each product.
考察要点
这道题面试官想看你是否精通 SQL 窗口函数(Window Functions),特别是用于时间序列分析的 滑动窗口(Sliding Window) 计算。考察的核心点在于:
- 窗口函数的语义理解:能否准确使用
OVER()子句,包括PARTITION BY进行分组和ORDER BY进行排序。 - 窗口框架(Window Frame)的定义:是否知道
ROWS和RANGE的区别,并能为“近30天”这个业务需求选择最合适的框架定义。 - SQL 性能意识:是否能写出在大数据量下依然高效的 SQL,并理解其背后的索引原理。
核心概念
本题的核心是使用窗口函数计算移动聚合值。
-
窗口函数 (Window Function) 窗口函数对与当前行相关的行集(即“窗口”)进行计算。与
GROUP BY聚合函数不同,它在计算后不合并行,而是为结果集中的每一行都返回一个计算值。其基本语法是:sql1<function_name>() OVER (2 [PARTITION BY <...>]3 [ORDER BY <...>]4 [<frame_clause>]5) -
分区
PARTITION BYvs 分组GROUP BYGROUP BY product_id: 会将每个product_id的所有行聚合成一行。PARTITION BY product_id: 会按product_id将数据分成多个逻辑分区,但保留所有原始行。窗口函数在每个分区内独立计算。
-
窗口框架 (Window Frame) 这是本题的关键。它定义了在当前行的分区内,用于计算的行的具体范围。对于“近30天”的需求,我们关注
PRECEDING(之前的行)。ROWS BETWEEN <N> PRECEDING AND CURRENT ROW: 基于物理行数定义窗口。ROWS BETWEEN 29 PRECEDING AND CURRENT ROW指的是当前行及按ORDER BY排序的前29行,共30行。RANGE BETWEEN <value> PRECEDING AND CURRENT ROW: 基于逻辑值定义窗口。RANGE BETWEEN INTERVAL '29' DAY PRECEDING AND CURRENT ROW指的是当前行的order_date以及从该日期往前推29天范围内的所有行。
ROWSvsRANGE的关键区别: 假设某商品在某几天没有销售记录。product_id order_date daily_gmv ROWS(2 preceding)RANGE(2 days preceding)A 2023-01-01 100 100 100 A 2023-01-02 150 250 (100+150) 250 (100+150) A 2023-01-05 200 350 (150+200) 200 (只有01-05当天) - 对于
2023-01-05这一行:ROWS BETWEEN 2 PRECEDING: 会找到前2行记录(01-02和01-01),计算100+150+200。这不符合“近3天”的业务语义,因为它跨越了5天。RANGE BETWEEN INTERVAL '2' DAY PRECEDING: 会找到日期在[2023-01-03, 2023-01-05]范围内的所有行。在这个例子中,只有2023-01-05这一天的记录,所以结果是200。这才是业务上正确的“时间窗口”。
因此,对于“近 N 天”这类需求,
RANGE模式是更准确的选择。
多种解法
假设我们有订单表 sales:
1CREATE TABLE sales (2 order_id INT,3 product_id INT,4 order_date DATE,5 gmv DECIMAL(10, 2)6);7-- 假设 gmv = price * quantity 已计算好
解法 1:使用窗口函数(推荐,最优解)
这是最现代、最高效、最优雅的解法。
- 标注版本:✅ MySQL 8.0+ / PostgreSQL / Oracle / SQL Server。❌ MySQL 5.7 及更早版本不支持。
- SQL 代码:
sql1-- 首先,按天聚合每个商品的GMV,防止一天内有多笔订单导致窗口函数计算错误2WITH daily_gmv AS (3 SELECT4 product_id,5 order_date,6 SUM(gmv) AS total_daily_gmv7 FROM sales8 GROUP BY product_id, order_date9)10SELECT11 product_id,12 order_date,13 total_daily_gmv,14 SUM(total_daily_gmv) OVER (15 PARTITION BY product_id16 ORDER BY order_date17 RANGE BETWEEN INTERVAL '29' DAY PRECEDING AND CURRENT ROW18 ) AS rolling_gmv_30d19FROM daily_gmv20ORDER BY product_id, order_date;
- 时间/空间复杂度:时间复杂度主要由排序决定,为 ,其中 N 是
daily_gmv的行数。如果利用索引,可以优化到近 。空间复杂度为 ,P 为分区数,用于维护窗口状态。 - 索引依赖:在
sales表上建立(product_id, order_date)的复合索引至关重要。这能极大地加速GROUP BY和后续窗口函数的PARTITION BY/ORDER BY操作,避免全表扫描和昂贵的filesort。
解法 2:使用自连接(Self-Join)或相关子查询
这是一种兼容老版本数据库的经典解法,但性能通常较差。
- 标注版本:✅ 兼容所有主流 SQL 数据库,包括 MySQL 5.7。
- SQL 代码 (Self-Join):
sql1-- 同样先按天聚合2WITH daily_gmv AS (3 SELECT4 product_id,5 order_date,6 SUM(gmv) AS total_daily_gmv7 FROM sales8 GROUP BY product_id, order_date9)10SELECT11 t1.product_id,12 t1.order_date,13 SUM(t2.total_daily_gmv) AS rolling_gmv_30d14FROM15 daily_gmv t116JOIN17 daily_gmv t2 ON t1.product_id = t2.product_id18 AND t2.order_date BETWEEN t1.order_date - INTERVAL '29' DAY AND t1.order_date19GROUP BY20 t1.product_id, t1.order_date21ORDER BY22 t1.product_id, t1.order_date;
- 时间/空间复杂度:在没有索引的情况下,复杂度接近 。即使有
(product_id, order_date)索引,对于daily_gmv中的每一行t1,都需要在t2中进行一次范围查找,性能远不如窗口函数。复杂度可近似为 ,其中 K 是窗口内的平均天数(这里是30)。 - 索引依赖:
daily_gmv作为中间结果,无法直接使用sales表的索引。但在支持物化CTE的数据库中,或如果将daily_gmv存为临时表并加索引,(product_id, order_date)索引将是性能的关键。
何时使用:当必须兼容不支持窗口函数的老旧数据库系统时,这是唯一的选择。在现代数据库中,应始终首选窗口函数。
常见低分回答
- 误用
GROUP BY:直接写GROUP BY product_id, DATE_TRUNC('month', order_date),这只能计算月度总GMV,而不是滑动的30天累计值。 - 混淆
ROWS和RANGE:使用ROWS BETWEEN 29 PRECEDING AND CURRENT ROW。如核心概念中所述,这在日期不连续时会产生错误结果,因为它计算的是最近30条销售记录,而不是最近30天的销售记录。 - 忘记
PARTITION BY:SUM(gmv) OVER (ORDER BY order_date ...)会计算全站的总累计GMV,而不是按商品分别计算。 - 相关子查询性能灾难:写出类似下面的相关子查询,并且没有在
(product_id, order_date)上建立索引,导致对主表的每一行都进行一次全表扫描。sql1-- 性能极差的写法2SELECT3 s1.product_id,4 s1.order_date,5 (SELECT SUM(s2.gmv)6 FROM sales s27 WHERE s2.product_id = s1.product_id8 AND s2.order_date BETWEEN s1.order_date - INTERVAL '29' DAY AND s1.order_date9 ) AS rolling_gmv_30d10FROM sales s1; - 在
WHERE子句中使用函数:在子查询或JOIN条件中对日期列使用DATE_FORMAT(order_date, ...)等函数,这会导致索引失效,退化为全表扫描。应始终保持列的“裸露”,将函数应用在常量或变量上。
工程实践与优化
- 数据预聚合:在真实生产环境中,订单表(
sales)通常是亿级别的。直接在原始表上运行此查询会非常慢。最佳实践是创建一个每日聚合表daily_product_gmv,通过离线任务(如每日凌晨的 Spark/Hive 任务)计算好每个商品每天的总GMV。我们的查询就直接跑在这个小得多的聚合表上。 - 执行计划分析 (
EXPLAIN):- 对于窗口函数解法,通过
EXPLAIN查看执行计划。关键是看Extra列。理想情况下,不应出现Using filesort。如果(product_id, order_date)上有索引,数据库可以直接利用索引的有序性来完成PARTITION BY和ORDER BY,效率极高。 - 对于自连接解法,
EXPLAIN会显示两个表的连接。要确保t2的访问类型(type)是range或ref,而不是ALL(全表扫描),这依赖于(product_id, order_date)上的索引。
- 对于窗口函数解法,通过
- 物化视图/汇总表:对于需要频繁查询的BI报表,可以将最终的滚动GMV结果存储在一个专门的汇总表或物化视图中。前端应用直接查询此结果表,实现秒级响应,避免了每次查询都重复计算。
- 避免大事务:如果这个计算是某个更新流程的一部分,确保它在一个合理的事务大小内完成,避免长时间持有锁。
常见追问与延伸题目
-
追问:如果数据量增长100倍,达到百亿级别,这条SQL还能跑吗?怎么优化?
- 回答要点1 (架构层面):在线查询(Ad-hoc Query)绝对不可行。必须转向离线计算 + 在线服务的架构。使用大数据处理框架(如 Spark、Flink、Hive)在数据仓库中进行批量计算。每天凌晨定时任务启动,计算所有商品的最新滚动GMV,并将结果写入一个轻量的KV存储(如 Redis)或分析型数据库(如 ClickHouse, Doris)中。线上应用直接从这些服务中读取预计算好的结果。
- 回答要点2 (算法层面):在离线计算中,可以进一步优化。由于是滑动窗口,今天的计算结果和昨天的结果有大量重叠。可以采用增量计算:
今日30天GMV = 昨日30天GMV - 31天前的GMV + 今日GMV。这种方式避免了对整个30天窗口的重复求和,计算复杂度从 (W为窗口大小)降为 ,极大地提升了大规模数据处理的效率。这在流处理(Stream Processing)场景中是标准做法。
-
追问:如果索引建错了,比如建成了
(order_date, product_id),会有什么影响?- 回答要点:索引的顺序非常重要,这被称为最左前缀原则。
- 对于
PARTITION BY product_id ORDER BY order_date,理想的索引是(product_id, order_date)。数据库可以先按product_id定位到分区,然后在分区内部,数据已经按order_date有序,无需再次排序。 - 如果索引是
(order_date, product_id),数据库可以利用它来满足ORDER BY order_date,但无法高效地按product_id分区。它需要读取所有按日期排序的数据,然后在内存中进行 "Hash Partition" 或再次排序来形成分区。这会导致额外的CPU和内存开销,性能远不如前者。在EXPLAIN中可能会看到Using temporary; Using filesort。
- 对于
- 回答要点:索引的顺序非常重要,这被称为最左前缀原则。
-
追问:如果把数据库换成 ClickHouse 这种
OLAP数据库,实现上有什么不同?- 回答要点1 (SQL语法):ClickHouse 也支持标准的窗口函数语法,所以解法1的SQL基本可以通用。但ClickHouse有更丰富的函数集,可能会有更高效的特定函数。
- 回答要点2 (性能与原理):根本区别在于底层引擎。ClickHouse是列式存储数据库,对于聚合查询性能极高。它会只读取
product_id,order_date,gmv这三列,而不是像InnoDB那样读取整行,I/O效率大幅提升。其向量化执行引擎也能并行处理数据块,使得聚合和排序操作非常快。即使没有索引,其暴力扫描的性能也远超行存数据库。 - 回答要点3 (特定函数):ClickHouse 提供
runningAccumulate、windowFunnel等专为用户行为和时间序列分析设计的函数,可能提供更简洁或高效的实现方式。例如,sumXOver系列函数可以实现类似的功能。在面试中,能提到列存、向量化执行这些OLAP数据库的核心特性,会非常加分。