本页内容受版权保护 · 已添加水印 · 禁止任何形式转载
进阶查询(窗口函数 / CTE / 递归 / 子查询) · 窗口函数(Window Functions)

NTILE(n) 的功能?如何用窗口函数把数据分成等大的 n 桶?

What is the function of NTILE(n)? How to use this window function to divide data into

答案语言

考察要点

这道题面试官想看你是否掌握了窗口函数的基本语法和核心功能,特别是对 NTILE(n) 这一特定分桶函数的深刻理解。它考察的不仅仅是函数的功能,更重要的是其背后的分配逻辑、边界条件处理以及在不同场景下的适用性。高水平的回答应能清晰阐述 NTILE 如何处理无法均分的数据,并能与其他排序或分桶方法进行对比。

核心概念

NTILE(n) 是一个窗口函数,用于将一个有序分区中的行尽可能均匀地分配到 n 个桶(group/bucket)中,并为每一行分配一个从 1 到 n 的桶编号。

核心语法:

sql
1NTILE(n) OVER ([PARTITION BY expr1, expr2...] ORDER BY expr3, expr4... [ASC|DESC])
  • n: 正整数,代表要分成的桶的数量。
  • PARTITION BY: 可选子句,用于将数据行分成多个分区(partition),NTILE 会在每个分区内独立进行分桶。如果省略,则整个结果集视为一个分区。
  • ORDER BY: 必需子句,定义了在分区内对行进行排序的依据。NTILE 根据这个顺序来分配桶号。

关键分配逻辑: 当分区内的总行数 R 不能被 n 整除时,NTILE 的行为是确定的:

  1. 计算余数:rem = R % n
  2. 计算每个桶的基础大小:size = R / n (整数除法)
  3. 前 rem 个桶会得到 size + 1 行。
  4. 后 n - rem 个桶会得到 size 行。

数据示例: 假设我们有一张 salaries 表,记录员工薪资。

employee_idsalary
150000
260000
360000
475000
580000
690000
7100000
8110000
9120000
10150000

我们要将这 10 名员工按薪资从高到低分为 4 个等级(4个桶)。 总行数 R = 10,桶数 n = 4。

  • 余数 rem = 10 % 4 = 2。
  • 基础大小 size = 10 / 4 = 2。
  • 因此,前 2 个桶各有 2 + 1 = 3 人,后 4 - 2 = 2 个桶各有 2 人。

SQL 查询:

sql
1SELECT
2 employee_id,
3 salary,
4 NTILE(4) OVER (ORDER BY salary DESC) AS salary_quartile
5FROM
6 salaries;

输出结果:

employee_idsalarysalary_quartile
101500001
91200001
81100001
71000002
6900002
5800002
4750003
3600003
2600004
1500004

版本差异:

  • NTILE 是 SQL 标准函数,在主流数据库如 PostgreSQL, Oracle, SQL Server, DB2 中广泛支持。
  • MySQL: 从 8.0 版本开始支持窗口函数,包括 NTILE。MySQL 5.7 及更早版本不支持。

多种解法

假设题目是“将员工按薪资从高到低分为 4 个等级”。

解法 1:使用 NTILE(n) (标准、高效)

这是最直接、最符合语义的解法。

  • 适用版本:✅ MySQL 8.0+ / PostgreSQL / Oracle / SQL Server
  • SQL 代码:
    sql
    1SELECT
    2 employee_id,
    3 salary,
    4 NTILE(4) OVER (ORDER BY salary DESC) AS salary_quartile
    5FROM
    6 salaries;
  • 复杂度:
    • 时间复杂度:O(Nlog⁡N)O(N \log N),主要开销在于 ORDER BY 子句对分区内数据进行排序。如果分区很小,性能会很高。
    • 空间复杂度:O(N)O(N),需要额外空间存储窗口计算的中间结果。
  • 索引依赖:为了优化排序性能,应在 ORDER BY 的列(本例中为 salary)上建立索引。如果使用了 PARTITION BY,则最好建立 (partition_by_col, order_by_col) 的复合索引。

解法 2:使用 ROW_NUMBER() 和数学运算 (兼容性/手动实现)

在不支持 NTILE 的老版本数据库,或需要更精细控制分桶逻辑时,可以手动模拟。

  • 适用版本:✅ MySQL 8.0+ / PostgreSQL (任何支持窗口函数的版本)
  • 思路:
    1. 使用 ROW_NUMBER() 为有序的行生成一个连续排名。
    2. 获取分区内的总行数。
    3. 通过公式 CEIL(row_num * n / total_rows) 计算桶号。
  • SQL 代码:
    sql
    1WITH RankedSalaries AS (
    2 SELECT
    3 employee_id,
    4 salary,
    5 ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn,
    6 COUNT(*) OVER () AS total_count
    7 FROM
    8 salaries
    9)
    10SELECT
    11 employee_id,
    12 salary,
    13 -- 公式也可以是 FLOOR((rn - 1) * 4 / total_count) + 1
    14 CEIL(rn * 4.0 / total_count) AS salary_quartile
    15FROM
    16 RankedSalaries
    17ORDER BY
    18 salary DESC;
  • 复杂度:
    • 时间复杂度:O(Nlog⁡N)O(N \log N),同样受限于 ROW_NUMBER() 内部的排序。
    • 空间复杂度:O(N)O(N)。
  • 索引依赖:与解法 1 相同,需要在 salary 列上建立索引。

何时使用:当数据库版本过低(如 MySQL 5.7)或需要实现非标准的分桶逻辑(例如,你想让最后一个桶吸收所有余数)时,此方法非常灵活。

常见低分回答

  1. 混淆 NTILE(n) 和 NTH_VALUE():回答成 NTILE 是用来取第 N 个值的。NTH_VALUE(expr, n) 是从窗口框架中获取第 n 行的 expr 值,而 NTILE 是分配桶号。
  2. 对余数处理含糊其辞:只说“平均分成 n 份”,但当面试官追问“10 行数据分 4 份,每份几行?”时,无法准确说出 (3, 3, 2, 2) 的分配结果。这表明理解不够深入。
  3. 忽略 ORDER BY 的必要性:认为 NTILE(n) OVER () 可以工作。NTILE 的分桶逻辑完全依赖于行的顺序,因此 OVER() 子句中必须包含 ORDER BY,否则数据库会报错。
  4. 将 PARTITION BY 与 GROUP BY 混用:写出类似 ... NTILE(4) OVER () ... GROUP BY department 的错误代码。PARTITION BY 是在窗口函数内部对数据进行逻辑分组,保留所有原始行;而 GROUP BY 会对行进行聚合,每个分组只返回一行。
  5. 对 NULL 和排序关系的处理不清晰:没有意识到 ORDER BY 中 NULLS FIRST / NULLS LAST 会影响 NTILE 的结果,因为 NULL 值的行会被排在最前或最后,从而影响分桶。

工程实践与优化

  • 索引是性能的关键:NTILE 的性能瓶颈几乎总是在 ORDER BY 排序上。确保 (PARTITION BY cols..., ORDER BY cols...) 上有合适的B+树索引。如果查询的列都被索引覆盖,可以触发覆盖索引(Covering Index),避免回表,极大提升性能。
  • 查看执行计划:使用 EXPLAIN (或 EXPLAIN ANALYZE) 分析查询。
    • 关键字段 Extra:如果看到 Using filesort,说明无法利用索引进行排序,数据库正在使用外部文件排序,这是严重的性能问题,需要优化索引。理想情况是 Using index。
    • 关键字段 type:应为 index 或 range,避免 ALL (全表扫描)。
  • 大数据量下的考量:在数亿行的表上直接使用 NTILE 进行全表分桶,可能会导致数据库内存和临时磁盘空间耗尽。在这种情况下,可以考虑:
    • 业务上缩小范围:通过 WHERE 子句先过滤掉大量无关数据。
    • 近似计算:如果不需要精确的分桶,可以先对数据进行采样(TABLESAMPLE 或 WHERE rand() < 0.01),在样本上计算分桶的边界值,再用 CASE 语句应用到全量数据,这是一种估算分桶(Approximate Equi-depth Histograms)的思路。
  • 结果确定性:当 ORDER BY 的列存在大量重复值时,NTILE 对这些并列行的分配可能是不确定的(取决于物理存储或并发等因素)。为了保证每次运行结果一致,应在 ORDER BY 子句中加入一个唯一列(如主键 id)作为次要排序条件:ORDER BY salary DESC, employee_id ASC。

常见追问与延伸题目

  1. 追问:如果让你在 MySQL 5.7 中实现 NTILE(4) 的功能,你会怎么做?

    • 回答要点:MySQL 5.7 不支持窗口函数,需要用用户变量(User Variables)来模拟。思路如下:
      1. 先用一个查询 SELECT COUNT(*) INTO @total_rows FROM salaries; 获取总行数。
      2. 再在主查询中初始化行号变量 @rn := 0。
      3. 在 SELECT 列表中,通过 @rn := @rn + 1 来模拟 ROW_NUMBER()。
      4. 使用公式 CEIL(@rn * 4 / @total_rows) 计算桶号。
      5. 必须强调风险:在 MySQL 8.0 之前,用户变量在复杂查询中的求值顺序是不保证的,这可能导致结果不稳定。因此,这是一种 hacky 的方法,在生产环境中需谨慎使用。
      sql
      1-- 示例代码(有风险)
      2SELECT
      3 s.employee_id,
      4 s.salary,
      5 CEIL((@rn := @rn + 1) * 4 / @total) AS salary_quartile
      6FROM
      7 salaries s, (SELECT @rn := 0) r, (SELECT @total := COUNT(*) FROM salaries) c
      8ORDER BY
      9 s.salary DESC;
  2. 追问:NTILE 是按行数均分,如果我想按值的范围分桶(比如薪资 0-5万, 5-8万, 8万以上),该用什么方法?

    • 回答要点:这是典型的“等宽分桶”(Equi-width Binning),而 NTILE 是“等深/等高分桶”(Equi-depth/Equi-height Binning)。对于等宽分桶,应该使用 CASE 表达式,这是最直接、最灵活的方法。
      sql
      1SELECT
      2 employee_id,
      3 salary,
      4 CASE
      5 WHEN salary <= 50000 THEN 'Level C'
      6 WHEN salary > 50000 AND salary <= 80000 THEN 'Level B'
      7 WHEN salary > 80000 THEN 'Level A'
      8 ELSE 'Unknown'
      9 END AS salary_level
      10FROM
      11 salaries;
  3. 追问:NTILE、RANK、DENSE_RANK、ROW_NUMBER 有什么区别?在什么场景下分别使用它们?

    • 回答要点:
      • ROW_NUMBER():无论值是否相同,都分配一个连续的、唯一的排名(1, 2, 3, 4)。用于需要唯一序号的场景,如分页查询。
      • RANK():对于值相同的行,分配相同的排名,但后续排名会跳跃(1, 2, 2, 4)。用于计算带有并列情况的排名。
      • DENSE_RANK():对于值相同的行,分配相同的排名,且后续排名不跳跃(1, 2, 2, 3)。用于计算连续的排名。
      • NTILE(n):不关心值的具体内容,只关心行的位置,将有序的行分配到 n 个桶中。用于按百分位、四分位等将数据分组的分析场景。
      • 总结:前三者是基于值的比较来排名,NTILE 是基于行的位置来分桶。