上周搭销售报表时,被同事问了一个很有意思的问题:同一份数据,某销售员业绩的百分位,我用PERCENT_RANK()算出来是 0.4286,他用CUME_DIST()算出来却是 0.625,两个函数名都带“分布”或“排名”,看着很像,到底哪个才是对的?
两个都没错,只是回答的问题不一样。CUME_DIST()全称累积分布函数,PERCENT_RANK()全称百分比排名函数,这两个都属于 SQL 标准里的窗口函数,专门用来分析一条记录在整组数据里的相对位置。做过数据分析、报表开发、绩效统计的人基本都会碰到,区别搞不清楚,很容易被业务方追问一句“你这个百分比到底是怎么算出来的”,到时候再去翻文档就尴尬了。
这一篇是窗口函数实践笔记的第七篇。前几篇里已经陆陆续续聊过ROW_NUMBER()、RANK()、DENSE_RANK()、NTILE()这些常见函数,这次把最后两个分布类函数放在一起讲。重点放在三件事:它们各自的数学口径、什么时候用哪一个、以及实操中那些容易翻车的边界条件。
1. 先搞清楚:两个函数到底在算什么
1.1 各自的数学定义
CUME_DIST()计算的是:把窗口内的所有行按指定字段排序后,小于等于当前行排序值的行数,除以窗口内的总行数。说人话就是:当前这个值,落在整组数据的什么累计位置。
公式这样写:
CUME_DIST = (小于等于当前值的总行数) / (窗口内总行数)PERCENT_RANK()计算的是:当前行的排名减 1,除以窗口内总行数减 1。这里的“排名”在 SQL 标准里明确指向RANK()的语义,也就是说,遇到并列值时排名会跳号。
PERCENT_RANK = (当前行的RANK - 1) / (窗口内总行数 - 1)光看公式已经能感受到差异了:一个用“值的累计覆盖范围”做分母思想,一个用“排名位置的相对偏移”做归一化。但真正让人犯晕的,是它们在实际数据里算出来的结果,经常看起来非常接近,一旦出现并列值或数据倾斜,差距立刻就显现出来了。
1.2 用生活例子建立直觉
想象一个班级考试出分之后,你会关心两类问题:
第一类问题是“我考了 80 分,全班不超过这个分数的人占多少比例?”比如全班 50 人,有 40 个人分数小于等于 80 分,那这个比例就是 80%,对应CUME_DIST(),回答的是累计覆盖率。
第二类问题是“按分数从低到高排,我排第 10 名,我的相对位置在哪个区间?”第 10 名在 50 人中的位置,用公式算一下是 (10 - 1) / (50 - 1) ≈ 0.184,也就是从低到高大约 18.4% 的位置,对应PERCENT_RANK(),回答的是相对排名位置。
注意这里的细节:CUME_DIST()说的是“不超过当前值的人数比例”,PERCENT_RANK()说的是“当前排名在整个有序队列中的相对偏移”。前者关注值本身覆盖了多少数据,后者关注位置本身在整个序列中的相对路径。
1.3 先给一张对比表记住差异
| 对比项 | CUME_DIST() | PERCENT_RANK() |
|---|---|---|
| 中文全称 | 累积分布函数 | 百分比排名函数 |
| 计算公式 | (小于等于当前值的行数) / 总行数 | (当前RANK - 1) / (总行数 - 1) |
| 取值范围 | (0, 1],第一行永远大于 0 | [0, 1],第一行永远是 0 |
| 并列值处理 | 同值同行结果完全相同,不跳值 | 按 RANK 语义,同排名结果相同 |
| 回答的问题 | 有多少比例的数据不超过这个值 | 这条记录在整体中的相对排序位置 |
| 生活类比 | 成绩单上的“超过百分之多少的同学” | 排行榜上的“位置刻度” |
这张表请先存着,后面所有细节都是围绕这几行展开的。
2. 同一份数据,两个函数算出的结果为什么差这么多
2.1 准备一份可复现的示例数据
为了把计算过程摊开看,我用一个销售员业绩表来演示。数据很简单,8 个销售员,每人一个销售额,其中特意安排了两个人销售额相同,这样并列值的问题才能暴露出来。
CREATE TABLE sales_performance ( employee_name VARCHAR(50), sales_amount DECIMAL(10, 2), region VARCHAR(20) ); INSERT INTO sales_performance VALUES ('孙八', 15000.00, '华东'), ('张三', 12000.00, '华北'), ('周九', 10000.00, '华南'), ('李四', 8000.00, '华北'), ('王五', 8000.00, '华东'), ('吴十', 7000.00, '华南'), ('赵六', 5000.00, '华北'), ('钱七', 3000.00, '华东');接下来用一条查询把ROW_NUMBER()、RANK()、CUME_DIST()、PERCENT_RANK()四个函数同时算出来,方便对照。注意我这里排序用的是ASC(升序),也就是销售额从低到高排列。
SELECT employee_name, sales_amount, ROW_NUMBER() OVER (ORDER BY sales_amount ASC) AS row_no, RANK() OVER (ORDER BY sales_amount ASC) AS ranking, CUME_DIST() OVER (ORDER BY sales_amount ASC) AS cume, PERCENT_RANK() OVER (ORDER BY sales_amount ASC) AS pct_rank FROM sales_performance ORDER BY sales_amount ASC;2.2 手工推算一遍,弄清楚每个数字的来源
查询结果如下,我按行解释:
| employee_name | sales_amount | row_no | ranking | cume | pct_rank |
|---|---|---|---|---|---|
| 钱七 | 3000 | 1 | 1 | 0.125 | 0 |
| 赵六 | 5000 | 2 | 2 | 0.25 | 0.1429 |
| 吴十 | 7000 | 3 | 3 | 0.375 | 0.2857 |
| 李四 | 8000 | 4 | 4 | 0.625 | 0.4286 |
| 王五 | 8000 | 5 | 4 | 0.625 | 0.4286 |
| 周九 | 10000 | 6 | 6 | 0.75 | 0.7143 |
| 张三 | 12000 | 7 | 7 | 0.875 | 0.8571 |
| 孙八 | 15000 | 8 | 8 | 1.0 | 1.0 |
先看第一行,钱七销售额 3000,在升序排列下它是第一个。CUME_DIST()计算的是“小于等于 3000 的有多少人”,只有他自己,所以 1/8 = 0.125。PERCENT_RANK()计算的是 (1 - 1) / (8 - 1) = 0。这就是最明显的差异:同一个第一名,CUME_DIST 不是 0,PERCENT_RANK 永远是 0。
再看李四和王五这对并列值,两个人的销售额都是 8000。小于等于 8000 的行有 5 行:钱七、赵六、吴十、李四、王五,所以两个人的CUME_DIST()都是 5/8 = 0.625。但注意RANK()对他们的排名是并列第 4,不是第 4 和第 5,所以PERCENT_RANK()都是 (4 - 1) / (8 - 1) = 0.4286。
这里有个特别容易踩的误区:如果你直接看row_no,李四是 4,王五是 5,很容易下意识用(5 - 1) / 7 = 0.5714去算王五的PERCENT_RANK(),但标准定义用的是排名,不是物理行号。王五的ranking是 4,和 李四 一样,所以两个人结果相同。并列值越多,这种“用 row_no 代入公式”的错觉就越危险。
2.3 排序方向对 CUME_DIST 的影响,比想象中大
很多人在写CUME_DIST()时习惯把销售数据按降序排,觉得“业绩高的排前面更直观”。但这里有个隐藏的坑:CUME_DIST()的“小于等于”是严格依赖当前排序方向的。
我上面用的是ORDER BY sales_amount ASC,所以语义是“小于等于当前销售额的行数占比”。如果换成ORDER BY sales_amount DESC,函数内部统计的就变成了“大于等于当前销售额的行数占比”。
也就是说,同样一个人,同样的销售额,升序和降序得到的CUME_DIST()值是不同的,而且业务含义正好相反。升序时 0.625 表示“62.5% 的人业绩不超过 8000”,降序时 0.625 表示“62.5% 的人业绩不低于 8000”。
PERCENT_RANK()没有这个歧义,因为它用的是排名位置,只要排序方向定了,排名就定了,公式里的加减乘除不依赖“大于还是小于”的语义。
实践建议是:在写 SQL 之前先明确业务口径。你要的是“前 20% 的高业绩客户”,那应该用ORDER BY sales_amount DESC配合CUME_DIST()找cume <= 0.2的行;你要的是“价格低于某个水平的商品占比”,那应该用ORDER BY price ASC。方向搞反,结果完全不对。
3. 业务场景里怎么选:我的四个判断标准
3.1 需求一:判断某个值覆盖了多少数据,用 CUME_DIST
电商场景里经常要看“价格低于某个水平的商品占多少比例”。这是一个典型的累积分布问题,边界条件非常清晰,“低于”这个动作天然对应“小于等于当前值”。
SELECT product_name, price, CUME_DIST() OVER (ORDER BY price ASC) AS price_cume FROM products;假设某件商品算出来price_cume = 0.82,可以直接解读为:该商品价格不高于全站 82% 的商品。如果你想反着看,也就是“高于这个价格的商品占多少比例”,直接用1 - 0.82 = 0.18就行,不需要再写一层查询。
这是我用CUME_DIST()用得最频繁的场景。它的输出天生就是“覆盖率”,不需要再做任何换算。
3.2 需求二:判断某条记录在团队中的相对位置,用 PERCENT_RANK
再举个例子,HR 想看每个销售员在所属大区里的业绩处于什么位置。注意这里是“位置”,不是“覆盖率”,所以PERCENT_RANK()更贴合,因为它本质上就是把排名映射到 0 到 1 的区间。
SELECT employee_name, sales_amount, region, PERCENT_RANK() OVER ( PARTITION BY region ORDER BY sales_amount DESC ) AS region_pct_rank FROM sales_performance ORDER BY region, sales_amount DESC;这里我用DESC排序,PERCENT_RANK()算出的 0.85 表示:该销售员在大区内部的业绩排名,处于从高到低大约 85% 的位置。注意这个表达是“位置”,不是说“他超过了 85% 的人”。在没有并列值的情况下,“超过 85% 的人”用CUME_DIST()会更准确。这两个表达在业务上经常被混用,但代码层面必须分清楚。
3.3 需求三:给数据划分档位,两个函数都能做但结果不同
“分 A/B/C/D 档”这种需求,我见过有人用PERCENT_RANK()做,也有人用CUME_DIST()做,都能实现,但分档逻辑完全不同。
用PERCENT_RANK()分档,本质是按排名位置切成四段:
SELECT employee_name, sales_amount, CASE WHEN PERCENT_RANK() OVER (ORDER BY sales_amount DESC) < 0.25 THEN 'A' WHEN PERCENT_RANK() OVER (ORDER BY sales_amount DESC) < 0.5 THEN 'B' WHEN PERCENT_RANK() OVER (ORDER BY sales_amount DESC) < 0.75 THEN 'C' ELSE 'D' END AS grade FROM sales_performance;用CUME_DIST()分档,本质是按值覆盖范围去切:
SELECT employee_name, sales_amount, CASE WHEN CUME_DIST() OVER (ORDER BY sales_amount DESC) <= 0.25 THEN 'A' WHEN CUME_DIST() OVER (ORDER BY sales_amount DESC) <= 0.5 THEN 'B' WHEN CUME_DIST() OVER (ORDER BY sales_amount DESC) <= 0.75 THEN 'C' ELSE 'D' END AS grade FROM sales_performance;注意两段代码里PERCENT_RANK()第一个区间用的是< 0.25,CUME_DIST()用的是<= 0.25。原因在于,PERCENT_RANK()第一名的值是 0,CUME_DIST()第一名的值是 1/n,如果都用<,第一行可能被排除到第二个区间之外,边界逻辑就乱了。分档判断的边界条件必须结合函数取值范围去设计,这是我踩过坑才记住的。
3.4 需求四:头部效应和二八分析,CUME_DIST 更合适
做精细化管理时经常要回答:排名靠前的那部分数据,到底覆盖了多少业务量?比如“前 20% 的头部客户贡献了多少销售额”,或者“价格最低的那 20% 商品覆盖了多少 SKU”。
这类问题的本质是“累计覆盖”,而CUME_DIST()本身就是累积分布,非常适合用来筛选分位点边界。比如我想找到“销售额累计覆盖前 20% 的销售员”:
WITH sales_with_cume AS ( SELECT employee_name, sales_amount, CUME_DIST() OVER (ORDER BY sales_amount DESC) AS cume FROM sales_performance ) SELECT employee_name, sales_amount, cume FROM sales_with_cume WHERE cume <= 0.2 ORDER BY sales_amount DESC;这里用DESC排序配合cume <= 0.2,找出来的是“业绩不低于某个水平的前 20% 人群”。如果用ASC排序配合cume <= 0.2,找出来的就是“业绩低于 80% 人的末尾 20% 人群”。同一个函数配合不同排序方向,提取的完全是两类样本。
3.5 一个选择口诀
我自己的判断口诀很简短:
问覆盖,找 CUME;问位置,找 PERCENT;要分档,先定边界再选函数。
一旦看清楚业务在问“累计覆盖率”还是“相对位置”,选型就不会纠结。
4. 边界情况与常见坑:并列、NULL、小样本
4.1 并列值:PERCENT_RANK 的区分度会打折扣
前面示例里已经看到,李四和王五销售业绩相同,PERCENT_RANK()返回了完全相同的结果。这本身符合标准,但它带来一个问题:当某个值重复出现多次时,PERCENT_RANK 的中间区间会出现明显的“断层”和聚集。
举个例子,100 条数据里如果有一个值出现了 80 次,那这 80 行的RANK()可能完全相同,它们的PERCENT_RANK()就会集中在某个很小的区间里,无法区分这 80 个人内部的差异。而CUME_DIST()对这些行也会给出相同的值,但它的语义本来就不区分“内部差异”,只是告诉你这个值覆盖了 80% 的数据,所以不算缺陷。
遇到大量并列数据时,建议先想清楚到底要不要区分并列行。如果要区分,通常应该回到RANK()或DENSE_RANK(),或者干脆给并列行加二级排序键,而不是指望PERCENT_RANK()来区分。
4.2 NULL 排序规则不一致,直接影响计算结果
窗口函数在计算前先要排序,有 NULL 值时,不同数据库对 NULL 的默认处理完全不同:
| 数据库 | ORDER BY ASC 时 NULL 的位置 | ORDER BY DESC 时 NULL 的位置 | 是否支持 NULLS FIRST/LAST |
|---|---|---|---|
| MySQL 8.0 | NULL 排最前 | NULL 排最后 | 不支持 |
| PostgreSQL | NULL 排最后 | NULL 排最前 | 支持 |
| SQL Server | NULL 视为最小值,排最前 | NULL 视为最小值,排最前 | 不支持 |
| Oracle | NULL 排最后 | NULL 排最前 | 支持 |
这意味着同样的查询语句,在 MySQL 和 PostgreSQL 上跑出来的CUME_DIST()第一行可能完全不同。如果你在计算销售业绩时没有对 NULL 销售额做处理,CUME_DIST()的起点就会被 NULL 占据,后面所有非 NULL 数据的累计分布位置都会整体偏移。
我的处理习惯是:计算之前先用COALESCE()或者WHERE sales_amount IS NOT NULL把数据清洗干净。如果是 PostgreSQL,就直接在排序里加NULLS LAST显式声明,避免依赖默认行为。
4.3 样本量太小时,算出来的分布没有统计意义
窗口函数本身不关心样本量大小,它只是按公式机械计算。但作为分析者,你必须自己警惕小样本场景。
比如一个PARTITION BY region分区里只有 3 个人,那么PERCENT_RANK()的输出只会是 0、0.5、1 三个值;如果只有 2 个人,那就只有 0 和 1。这种数据拿去做绩效档位划分,结果非常粗糙,“0.5 的位置”到底比另一个人好多少,完全说不清楚。
CUME_DIST()在小样本下的表现同样有限,3 条数据只能给出 0.333、0.667、1 这种颗粒度。遇到分区内行数很少的情况,我的建议是:要么把分区粒度放宽,要么干脆放弃分布函数,改用绝对值或者直接展示原始排名。
4.4 单行窗口和跨分区比较的隐蔽问题
一个分区里只有 1 行数据时,PERCENT_RANK()的公式是 (1 - 1) / (1 - 1),分母为 0。标准规定这种情况返回 0,主流的 MySQL、PostgreSQL、SQL Server 也是这么实现的,但如果你用一些比较冷门的分析引擎,最好先拿一条数据自测,别想当然。
跨分区比较更隐蔽。假设 A 分区有 100 人,B 分区有 10 人,A 区第 90 名和 B 区倒数第一名,PERCENT_RANK()都可能落在 0.9 左右。但这两者的业务含义完全不同,一个是“在 100 人里排第 90”,一个是“在 10 人里基本垫底”。如果把这两个分区的PERCENT_RANK()放在一起横向比较,很容易得出错误结论。跨分区比较百分位排名时,必须同时展示分区内样本量和绝对排名。
4.5 上线前自测:用一行并列数据验证数据库实现
前面说的都是标准定义,但不同数据库、不同版本的实现可能藏着“惊喜”。我自己的做法是,第一次在新环境里使用这两个函数时,会故意造一条带并列值的数据去跑一下,确认结果是否符合预期。
WITH test_data AS ( SELECT 10 AS val UNION ALL SELECT 20 AS val UNION ALL SELECT 20 AS val UNION ALL SELECT 30 AS val ) SELECT val, CUME_DIST() OVER (ORDER BY val ASC) AS cume, PERCENT_RANK() OVER (ORDER BY val ASC) AS pct_rank FROM test_data;如果20对应的cume是 0.75,pct_rank是 0.333,那就说明实现符合标准;如果pct_rank返回了 0.333 和 0.667 两个不同值,说明这个引擎把PERCENT_RANK()按物理行号实现了,后续所有涉及并列的分析都要重新校准。这条自测 SQL 值得存进自己的工具库,定期在新的数据源上跑一遍。
5. 进阶组合:从“单点位置”到“整体分布”的分析
5.1 六个排名窗口函数放在一张表里看
CUME_DIST()和PERCENT_RANK()不孤立存在,实际分析中经常和ROW_NUMBER()、RANK()、DENSE_RANK()、NTILE()一起用。我整理了一张横评表,便于对比:
| 函数 | 输出形态 | 并列值处理 | 典型用途 |
|---|---|---|---|
| ROW_NUMBER() | 1, 2, 3, ... | 不并列,物理顺序 | 分页、取前 N 条 |
| RANK() | 1, 1, 3, ... | 并列跳号 | 竞赛排名 |
| DENSE_RANK() | 1, 1, 2, ... | 并列不跳号 | 密度排名 |
| PERCENT_RANK() | 0 ~ 1 | 同 RANK 语义 | 相对位置归一化 |
| CUME_DIST() | (0, 1] | 同值同结果 | 累积分布覆盖率 |
| NTILE(n) | 1 ~ n | 尽量均分行数 | 分桶、分位分层 |
这几个函数在分析中的分工通常是:ROW_NUMBER()负责精确行号,RANK()和DENSE_RANK()负责具名排名,NTILE()负责等量分桶,CUME_DIST()负责累计覆盖,PERCENT_RANK()负责位置归一化。一个复杂的分析需求,往往需要两三个函数配合。
5.2 用 PERCENT_RANK 配合 LAG/LEAD 观察排名漂移
排名位置的变化趋势,比某一期的绝对排名更有业务价值。比如连续几个月的销售排名若持续下滑,说明团队的业绩或市场环境出现了问题。这时窗口函数嵌套子查询就非常有用了。
WITH ranked_sales AS ( SELECT employee_name, sales_amount, sales_month, PERCENT_RANK() OVER ( PARTITION BY sales_month ORDER BY sales_amount DESC ) AS month_pct_rank FROM monthly_sales ) SELECT employee_name, sales_month, month_pct_rank, LAG(month_pct_rank) OVER ( PARTITION BY employee_name ORDER BY sales_month ) AS prev_month_pct, month_pct_rank - LAG(month_pct_rank) OVER ( PARTITION BY employee_name ORDER BY sales_month ) AS pct_rank_change FROM ranked_sales ORDER BY employee_name, sales_month;我一直强调,窗口函数不能直接嵌套,所以这里的做法是先用 CTE 算出每个月的PERCENT_RANK(),第二步再用LAG()对比上个月的百分位。pct_rank_change如果是正数,说明在当前月的相对位置比上个月靠后(因为你用的是 DESC 排序);如果是负数,说明位置前移了。这个分析不需要关心具体销售额的波动幅度,只看位置变化,对业务决策来说非常直观。
5.3 NTILE 分桶和 PERCENT_RANK 的差异:分布不均匀时会给出不同结论
很多人误以为NTILE(4)分出来的四等份就等同于PERCENT_RANK()小于 0.25、0.5、0.75。实际上它们只有在数据均匀分布时才近似等价。NTILE()的核心逻辑是把行数尽量均分,PERCENT_RANK()和CUME_DIST()则是根据值的分布位置计算。数据倾斜时,两张图的切割点完全不同。
SELECT employee_name, sales_amount, NTILE(4) OVER (ORDER BY sales_amount DESC) AS quartile, PERCENT_RANK() OVER (ORDER BY sales_amount DESC) AS pct_rank, CUME_DIST() OVER (ORDER BY sales_amount DESC) AS cume FROM sales_performance ORDER BY sales_amount DESC;比如销售额呈幂律分布时,头部几个大客户可能在NTILE()中被分到第 1 桶,但在PERCENT_RANK()中占据很小的区间,而在CUME_DIST()中覆盖了很大的累计比例。分析时要先想清楚你到底需要“把人群分成人数相同的几组”,还是需要“按值的大小看分布位置”,前者选NTILE(),后者选PERCENT_RANK()或CUME_DIST()。
5.4 一个综合示例:找出连续两期排名下滑的销售员
把前面几招组合起来,做一个稍微完整的分析。假设monthly_sales表里存了多个销售员每月的业绩,我们要找出“连续两个月相对排名下滑”的人。
WITH monthly_rank AS ( SELECT employee_name, sales_month, sales_amount, CUME_DIST() OVER ( PARTITION BY sales_month ORDER BY sales_amount DESC ) AS month_cume FROM monthly_sales ), rank_lag AS ( SELECT employee_name, sales_month, month_cume, LAG(month_cume) OVER ( PARTITION BY employee_name ORDER BY sales_month ) AS prev_month_cume FROM monthly_rank ) SELECT employee_name, sales_month, month_cume, prev_month_cume FROM rank_lag WHERE prev_month_cume IS NOT NULL AND month_cume > prev_month_cume ORDER BY employee_name, sales_month;这里用CUME_DIST()而不是PERCENT_RANK(),是因为我想看到“业绩覆盖位置”,月与月之间的累计分布变化更贴近“超过多少人”这一业务直觉。当month_cume大于prev_month_cume时,说明这个月的相对覆盖率变大了,也就是排名下滑了。再结合业绩环比去看,基本就能定位出问题的销售员。
这种组合分析方式,才是窗口函数真正发挥价值的地方。不要停留在单独演示某个函数的语法,试着把它们放进一个真实的分析链路里,会发现很多以前要写好几段程序才能算出来的指标,现在一段 SQL 就能搞定。
最后分享一个自己的判断习惯。我每次写这类分布函数前,都会先在纸上把业务问题翻译成一句话:“我要找的是覆盖边界,还是排名位置?”如果要划一条线,线的两侧分别是有没有达到某个水平,那用CUME_DIST();如果要描述“这个人当前排到哪儿了”,那用PERCENT_RANK()。这两个函数的差异就藏在公式里,一个用总行数做分母,一个用总行数减一,但业务思维模型完全不同。想明白这个问题,比背十个公式都有用。