---
title: '用 QUALIFY 写出更清晰的窗口函数 SQL'
date: 2026-07-21
slug: category-engineering/qualify-window-functions
cover_url: 'https://static.databend.cn/img/202607/QUALIFYbanner.png'
description: '`QUALIFY` 的价值不只是"少写一层子查询"，更重要的是把窗口函数的计算与过滤放回同一个语义层里表达。本文通过事件去重、Top N 排名、CTE 拆分和子句分工等常见场景，说明如何用 `QUALIFY` 写出更清晰、更稳定、更容易维护的 SQL，适合数据工程师、Analytics Engineer 和经常编写复杂分析查询的开发者阅读。'
tags: [databend]
category: Engineering
authors:
  - coldWater
---

## 为什么 QUALIFY 不只是少一层子查询

提到 `QUALIFY`，很多讨论会先注意到"少写一层子查询"这件事。
{/* truncate */}
但在真实查询里，更实际的问题往往不是能不能少套一层，而是：当一条 SQL 已经同时出现窗口函数、alias、CTE、聚合、排序时，`QUALIFY` 能不能把查询意图写得更清楚。

`QUALIFY` 的核心价值，是把下面两件事放在同一层 SQL 里表达：

- 计算窗口值；

- 按窗口值筛选结果。

如果这两件事被拆到子查询和外层 `WHERE`，阅读时就需要在两层之间来回跳。如果它们留在同一层，通常更容易一眼看清：

- 分区规则是什么；

- 排序规则是什么；

- 最后保留哪些行。

换句话说，`QUALIFY` 不只是语法糖。它改变的是复杂分析 SQL 的阅读路径。

## 场景一：每组取最新一条，把窗口计算和过滤放在一起

这是 `QUALIFY` 最直接的可读性收益。

比如从事件表里取每个用户最近一次登录记录，传统写法通常需要先在子查询里计算 `row_number()`，再在外层过滤。

### 不推荐的写法

```SQL
SELECT *
FROM (
    SELECT
        user_id,
        event_time,
        row_number() OVER (
            PARTITION BY user_id
            ORDER BY event_time DESC
        ) AS rn
    FROM events
    WHERE event_type = 'login'
) t
WHERE rn = 1;
```

这段 SQL 当然能看懂，但需要跨两层才能把意图拼完整：

- 内层负责计算排名；

- 外层负责保留第一条；

- 读者需要把 `WHERE rn = 1` 映射回内层的窗口函数定义。

### 更推荐的写法

```SQL
SELECT
    user_id,
    event_time,
    row_number() OVER (
        PARTITION BY user_id
        ORDER BY event_time DESC
    ) AS rn
FROM events
WHERE event_type = 'login'
QUALIFY rn = 1;
```

这里的阅读顺序更自然：

1. 先保留登录事件；

2. 再在每个用户内按时间倒序排名；

3. 最后保留排在最前面的一条。

"怎么算排名"和"保留哪一名"被放在同一个视野里，SQL 的意图会更直接。

## 场景二：窗口表达式起 alias，让业务意图更明显

如果窗口表达式稍微复杂一点，直接在 `QUALIFY` 里重复写一遍，SQL 很快就会变重。

### 不推荐的写法

```SQL
SELECT
    user_id,
    session_id,
    amount
FROM payments
QUALIFY row_number() OVER (
    PARTITION BY user_id
    ORDER BY amount DESC, session_id
) = 1;
```

这段 SQL 的问题不是不能执行，而是窗口规则和过滤条件被揉在一起，读者需要先解析完整表达式，才能理解最终筛选逻辑。

### 更推荐的写法

```SQL
SELECT
    user_id,
    session_id,
    amount,
    row_number() OVER (
        PARTITION BY user_id
        ORDER BY amount DESC, session_id
    ) AS top_payment_rank
FROM payments
QUALIFY top_payment_rank = 1;
```

alias 不只是为了复用表达式，也是在解释业务含义。

当 `OVER (...)` 本身比较长、后面还会继续引用这个窗口值，或者希望让读者直接看出"这个值代表什么"时，建议为窗口函数起一个明确的名字，例如：

- `latest_row`

- `dept_rank`

- `dedup_rank`

- `top_payment_rank`

好的 alias 能减少注释需求，让 SQL 本身更接近业务表达。

## 场景三：区分 Top 1 和并列 Top N

很多可读性问题，实际上来自函数选型不清楚。

如果目标是"每组只保留一条"，通常使用 `row_number()`：

```SQL
SELECT
    user_id,
    event_time,
    row_number() OVER (
        PARTITION BY user_id
        ORDER BY event_time DESC, event_id DESC
    ) AS latest_row
FROM events
QUALIFY latest_row = 1;
```

这里需要注意：`row_number() ... QUALIFY latest_row = 1` 表达的是"按当前排序规则取一条"。

如果 `ORDER BY` 中的列存在并列，而数据里又没有额外排序字段把这些并列情况区分开，那么最终保留的只是并列记录中的一条，而不是业务上可稳定复现的"唯一代表行"。因此，如果有可用字段能把排序规则写得更完整，应尽量补进 `ORDER BY`。

如果目标是"保留并列前几名"，通常使用 `rank()` 或 `dense_rank()`：

```SQL
SELECT
    department,
    employee_id,
    salary,
    rank() OVER (
        PARTITION BY department
        ORDER BY salary DESC
    ) AS salary_rank
FROM employees
QUALIFY salary_rank <= 3;
```

这时 `QUALIFY` 的可读性价值在于，它把"排名规则"和"保留前 3 名"直接放在了一起。而 `rank()`、`dense_rank()`、`row_number()` 的函数选型，也直接决定了结果预期。

## 场景四：让 WHERE、HAVING、QUALIFY 各自只做自己的事

复杂查询最怕的是每个子句里都掺一点别的阶段语义。

更清楚的分工通常是：

- `WHERE`：过滤原始行；

- `HAVING`：过滤聚合结果；

- `QUALIFY`：过滤窗口结果。

例如：

```SQL
SELECT
    user_id,
    event_time,
    row_number() OVER (
        PARTITION BY user_id
        ORDER BY event_time DESC
    ) AS rn
FROM events
WHERE event_type = 'login'
QUALIFY rn = 1;
```

这段 SQL 的语义分层很明确：

- `WHERE event_type = 'login'` 说明窗口计算基于登录事件；

- `row_number()` 说明在每个用户内排序；

- `QUALIFY rn = 1` 说明最终只保留每个用户的最新一条。

如果把本该在 `WHERE` 里完成的基础过滤拖到窗口之后，读者就很难判断：窗口是基于全量数据算的，还是基于过滤后的数据算的。

## 场景五：CTE 负责切分语义阶段，不要只为了过滤窗口值而包一层

CTE 在复杂查询里当然仍然有价值，但前提是每一层都要有明确职责。

更推荐的切法通常是：

- 前一层 CTE：准备基础数据、做预聚合、清洗字段；

- 当前层：做窗口计算；

- 当前层直接 `QUALIFY`。

例如，先聚合出每个店铺每天的销售额，再找出每个店铺销售额最高的一天：

```SQL
WITH daily_sales AS (
    SELECT
        shop_id,
        sale_date,
        sum(amount) AS daily_amount
    FROM sales
    GROUP BY shop_id, sale_date
)
SELECT
    shop_id,
    sale_date,
    daily_amount,
    row_number() OVER (
        PARTITION BY shop_id
        ORDER BY daily_amount DESC, sale_date DESC
    ) AS sales_rank
FROM daily_sales
QUALIFY sales_rank = 1;
```

这里 CTE 的职责很清楚：它是为了先得到日粒度结果，而不是为了给外层 `WHERE rn = 1` 腾一个位置。

这和"先套一个子查询，只是为了外层写窗口过滤条件"是两种完全不同的层次。

## 场景六：窗口规则复用时，用 named window 降低噪音

如果同一条查询里有多个窗口函数共享相同的 `PARTITION BY` / `ORDER BY`，named window 往往比重复写多遍 `OVER (...)` 更清楚。

```SQL
SELECT
    user_id,
    event_time,
    row_number() OVER w AS rn,
    lag(event_time) OVER w AS prev_event_time
FROM events
WINDOW w AS (
    PARTITION BY user_id
    ORDER BY event_time DESC
)
QUALIFY rn = 1;
```

这里的收益不是"少写几行"，而是：

- 一眼就能看出这些窗口函数共用同一套窗口定义；

- 窗口定义只需要审一遍；

- `QUALIFY rn = 1` 更像是在直接消费这套窗口规则。

当窗口逻辑开始复用时，named window 能显著减少重复噪音。

## 什么时候不要把逻辑塞进 QUALIFY

`QUALIFY` 能让 SQL 更平，但不代表所有逻辑都应该往里面堆。

下面几类内容，通常仍然不适合塞进 `QUALIFY`：

- 复杂业务过滤条件；

- 多段聚合逻辑；

- 大段重复窗口表达式；

- 和窗口无关的普通条件判断。

一个常见坏味道是：`QUALIFY` 又长又重，里面既有窗口条件，也有普通布尔逻辑，还有重复表达式。

更好的做法是：

- 普通过滤前移到 `WHERE`；

- 聚合过滤放在 `HAVING`；

- 让 `QUALIFY` 尽量只承担"按窗口结果筛选"这一件事。

判断一条查询是否适合 `QUALIFY`，一个很直接的方法是看它在语义上是不是"先计算窗口值，再按窗口值筛选"。

如果在脑子里描述这条查询时，会自然说出：

- "先在每组里排一下，再保留第一条"；

- "先求一个排名，再保留前 3 名"；

- "先算窗口值，再按窗口值筛"。

那这通常就是 `QUALIFY` 的高适配场景。

如果更接近：

- "先过滤原始数据"；

- "先聚合，再过滤聚合结果"。

那更可能属于 `WHERE` 或 `HAVING`。

## 在 Databend 中使用 QUALIFY 的意义

在 Databend 中，`QUALIFY` 可以直接用于窗口函数结果过滤，适合事件分析、用户行为去重、Top N 排名、最新状态提取等常见分析场景。

对于熟悉 Snowflake / BigQuery 查询风格的数据工程师来说，`QUALIFY` 能降低迁移成本；对于日常编写复杂 SQL 的 Analytics Engineer 来说，它能减少不必要的嵌套，让查询更接近业务表达。

这也符合 Databend 作为现代云原生数仓的设计方向：支持熟悉、开放、可组合的 SQL 能力，让复杂分析逻辑既能高效执行，也能被团队长期维护。

尤其是在事件日志、用户行为、半结构化数据和 AI / agent trace 等分析场景中，经常会遇到"先排序、再去重""先排名、再取 Top N""先计算窗口值、再保留关键行"的查询模式。`QUALIFY` 能把这些逻辑表达得更直接，减少只是为了过滤窗口值而存在的子查询层。

## 总结

`QUALIFY` 是否让 SQL 更清楚，很多时候不取决于能不能少写一层子查询，而取决于这条查询是不是天然就在表达：

> 先计算窗口值，再按窗口值筛选。

当查询的核心语义是每组取最新一条、保留 Top N、去重、排名过滤或状态提取时，`QUALIFY` 往往能让 SQL 更平、更直接、更容易 review。

但它也不应该变成所有复杂逻辑的容器。保持 `WHERE`、`HAVING`、`QUALIFY` 各自语义清晰，才是让复杂 SQL 长期可维护的关键。
