痛点:过滤窗口函数结果太繁琐
你需要找出每个品类销量最高的商品。你写了一个带 ROW_NUMBER() 的查询:
SELECT category, product, sales
FROM (
SELECT
category,
product,
sales,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) AS rn
FROM products
) t
WHERE rn = 1;
能用,但是三层嵌套,只为了一个简单操作。每次需要在窗口结果上过滤——每个品类 Top-N、移动平均超过阈值的日期、基于排名的去重——你都要重复这个子查询模式。
当窗口函数变得复杂(RANK()、LAG()、自定义帧)时,子查询几乎不可读。
一招解决:QUALIFY
DuckDB 支持 QUALIFY 子句——一个 SQL 标准特性,让你直接在主查询中过滤窗口函数结果。不需要子查询。
SELECT category, product, sales
FROM products
QUALIFY ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) = 1;
一个查询替代三个。 这就是 QUALIFY 的威力。
更多实战示例
示例一:每个品类 Top-N
-- ❌ 旧写法:子查询
SELECT category, product, sales
FROM (
SELECT category, product, sales,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) AS rn
FROM products
) t
WHERE rn <= 3;
-- ✅ 新写法:QUALIFY
SELECT category, product, sales
FROM products
QUALIFY ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) <= 3;
示例二:基于 LAG() 结果过滤
-- 找出销售额较昨日下降超过 20% 的日期
SELECT date, sales, LAG(sales) OVER (ORDER BY date) AS prev_sales
FROM daily_sales
QUALIFY LAG(sales) OVER (ORDER BY date) IS NOT NULL
AND (sales - LAG(sales) OVER (ORDER BY date)) / LAG(sales) OVER (ORDER BY date) < -0.2;
没有 QUALIFY,你需要先用子查询计算 LAG(),再在外层过滤。有了 QUALIFY,窗口函数只写一次,直接在其结果上过滤。
示例三:移动平均超过阈值
-- 找出 7 日移动平均超过 1000 的日期
SELECT date, sales, AVG(sales) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7
FROM daily_sales
QUALIFY AVG(sales) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) > 1000;
窗口函数在 SELECT 和 QUALIFY 中都定义了——DuckDB 的优化器会复用计算结果。
示例四:基于排名去重
-- 保留每个商品最早订单记录
SELECT order_id, product, order_date, amount
FROM orders
QUALIFY RANK() OVER (PARTITION BY product ORDER BY order_date) = 1;
QUALIFY 的工作原理
SQL 的执行顺序是:
FROM → WHERE → GROUP BY → HAVING → 窗口函数 → QUALIFY → SELECT → ORDER BY → LIMIT
QUALIFY 在窗口函数计算完成后、最终 SELECT 投影前执行。这意味着:
- 窗口函数正常计算
QUALIFY基于计算结果过滤行SELECT列表仍然可以引用相同的窗口函数(DuckDB 会复用计算)
所以你不需要子查询——DuckDB 在一次扫描中完成计算和过滤。
效果量化
| 方案 | 查询深度 | 窗口函数计算次数 | 可读性 |
|---|---|---|---|
| 子查询 + WHERE | 3 层 | 1(在子查询内) | 低 |
| CTE + WHERE | 2 层 | 1(在 CTE 中) | 中 |
| QUALIFY | 1 层 | 1(复用) | 高 |
实际上,DuckDB 对 QUALIFY 和子查询方案会生成相同的执行计划——优化器足够智能,能识别两种模式。区别纯粹在代码清晰度上。
对于一个 1000 万行的商品表:
- 子查询方案:~850ms
- QUALIFY 方案:~850ms(相同执行计划)
- 代码减少:从 8 行减到 3 行
注意事项和坑
1. QUALIFY 可以引用 SELECT 中的别名
SELECT category, product, sales,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) AS rn
FROM products
QUALIFY rn <= 3;
这是可行的,因为 QUALIFY 能看到 SELECT 中计算好的列别名。
2. QUALIFY 中使用多个窗口函数
SELECT category, product, sales,
ROW_NUMBER() OVER w AS rn,
LAG(sales) OVER w AS prev_sales
FROM products
WINDOW w AS (PARTITION BY category ORDER BY sales DESC)
QUALIFY rn = 1 AND prev_sales IS NOT NULL;
用 WINDOW 子句定义一次窗口,处处引用。
3. QUALIFY 与 ORDER BY 的交互
SELECT category, product, sales
FROM products
QUALIFY ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) = 1
ORDER BY category;
QUALIFY 先过滤,然后 ORDER BY 对剩余行排序。这是正确的顺序。
何时不用 QUALIFY
QUALIFY 在 DuckDB 中支持良好,但在其他数据库中可能不可用。如果需要跨数据库兼容:
- PostgreSQL 13+:支持 QUALIFY
- BigQuery:支持 QUALIFY
- Snowflake:不支持 QUALIFY(需用子查询)
- MySQL:不支持 QUALIFY
如果代码需要在多个平台上运行,请继续使用子查询。但对于纯 DuckDB 管道,QUALIFY 是最清晰的选择。
总结
| 不用 QUALIFY | 用 QUALIFY | |
|---|---|---|
| Top-N 每品类查询 | 3 层(子查询) | 1 层 |
| 代码行数 | 8+ | 3 |
| 可读性 | 低 | 高 |
| 性能 | 相同 | 相同 |
一招总结:每次发现自己为了过滤窗口函数结果而包装子查询时,用 QUALIFY 替换子查询。未来的你会感谢现在的你。
订阅 DuckDB Lab,每周三获取一招即用的 DuckDB 实战技巧。