SQL窗口函数实战:如何精准计算分组内的百分比占比

说到用SQL计算百分比,尤其是分组内的占比,窗口函数OVER()子句绝对是绕不开的利器。不过,工具虽好,细节决定成败。一个PARTITION BY字段的微小偏差,或者对COUNT行为的误解,都可能导致最终结果“失之毫厘,谬以千里”。
怎么用 OVER() 算分组内占比,而不是全表占比
核心逻辑其实很直接:先用SUM(amount) OVER(PARTITION BY group_col)拿到每个分组的总和作为分母,再用当前行的值作为分子相除。真正的关键,往往不在于“怎么写”,而在于“怎么想”——你定义的“分组”和PARTITION BY的范围必须严丝合缝。
举个例子,如果业务需求是按产品类别(category)计算销售额占比,那么PARTITION BY category就是唯一正确的选择。一旦手滑写成了PARTITION BY category, status,分母就变成了每个“类别-状态”组合的总和,结果自然会失真,分母变小,占比虚高。
另一个常见的“坑”是整数除法。当你发现amount / SUM(amount) OVER(...)返回了一堆0或极小的数字时,别急着怀疑人生。这很可能是因为amount是整型(如INT),导致SQL执行了整数除法。解决方法很简单:在计算前显式地将分子或分母转为浮点数,比如amount * 1.0 / SUM(amount) OVER(...),或者使用CAST(amount AS DECIMAL)。
- 防除零错误:如果某分组的总和可能为0(例如该组所有记录都为NULL),记得用
NULLIF(SUM(...), 0)包裹分母,避免运行时错误。 - 注意
ORDER BY的影响:在OVER()子句中加入ORDER BY会将其变为累积计算(Running Total)。除非你明确需要“截至当前行的累积占比”,否则算静态分组占比时通常不需要它。 - 数据库支持度:MySQL 8.0+、PostgreSQL、SQL Server、Oracle等主流数据库都已支持窗口函数。但请注意,SQLite目前不支持,此路不通。
COUNT(*) 和 COUNT(col) 在分组占比里差在哪
计算行数占比时,选择COUNT(*)还是COUNT(col),结果可能天差地别。根本区别在于:COUNT(*) OVER(PARTITION BY x)会统计该分组内的所有行,包括指定列值为NULL的行;而COUNT(col) OVER(PARTITION BY x)只会统计该列值非NULL的行。
这直接关系到业务逻辑的准确性。假设你要统计每个部门中“有绩效评分的人数”占“部门总人数”的比例。这里的分子(有评分的人数)应该用COUNT(score),因为它只计算score非NULL的记录。而分母(部门总人数)必须用COUNT(*),否则就会漏掉那些尚未被打分的员工,导致计算出的占比虚高。
- 理解默认行为:
COUNT(column)忽略NULL是它的设计行为,并非缺陷。依赖这个特性时,心里一定要清楚。 - 如何统计NULL:如果业务上需要专门统计某个字段为NULL的行数,可以使用条件聚合:
SUM(CASE WHEN col IS NULL THEN 1 ELSE 0 END) OVER(...)。
为什么 ROUND(ratio, 4) 后加起来不等于 1.0000
有没有遇到过这种情况:分组内各行的占比都算出来了,分别四舍五入到小数点后四位,但一加总,发现结果是0.9999或者1.0001,怎么也凑不齐一个完美的1?
这背后其实是计算机浮点数运算的精度限制与四舍五入规则共同作用的结果。例如,一个总和为10的数据组,三行值都是3.333...。它们各自除以10后的真实比值是无限循环小数0.333333...。当你用ROUND(0.333333..., 4)时,得到的是0.3333。三个0.3333相加只有0.9999,自然不等于1。
这对报表展示和前端数据校验是个小挑战。解决方案不是去强行调整数值,而是在业务层面提前达成共识:约定展示精度,并接受一个极小的合理误差范围(比如合计与100%的误差 ≤ 0.01%)。
- 验证逻辑时用原始值:检查分组内占比逻辑是否正确时,应该使用未经
ROUND处理的原始比值进行加总验证。 - 强一致性场景的变通:在财务等要求绝对一致的场景,可以考虑使用整数百分比:先计算
ROUND(ratio * 100)得到整数百分比,确保前N-1行的整数百分比之和不超过100,最后一行用100 - SUM(其他行整数占比)来补齐,保证总和为100。
替代方案:没有窗口函数时怎么硬算分组占比
如果你的数据库版本较旧(例如MySQL 5.7或更早),不支持窗口函数,也并非无计可施。最经典的替代方法是使用自连接(Self-JOIN)配合分组子查询。
来看一个兼容性最强的写法:
SELECT t1.category, t1.amount,
t1.amount * 1.0 / t2.group_sum AS ratio
FROM sales t1
JOIN (SELECT category, SUM(amount) AS group_sum
FROM sales GROUP BY category) t2
ON t1.category = t2.category;
这种方法的思路很清晰:通过一个子查询先计算出每个分组的总和,然后再将原表与这个总和表连接起来进行除法运算。
不过,它的性能代价是显而易见的。当分组键的基数很大(比如按百万级的用户ID分组)时,子查询和连接操作的开销会显著增加。相比之下,窗口函数只需对表进行一次扫描,效率优势巨大。因此,只要数据库环境支持,OVER()永远是首选。
- 动态过滤的陷阱:使用子查询方式时,如果主查询有WHERE过滤条件,必须同步地在子查询的WHERE子句中添加相同的条件,否则分母就会算错。这一点很容易遗漏,导致结果不一致。
- CTE并非性能银弹:用CTE(公用表表达式)来写会让逻辑更清晰,但它本质上仍然是子查询,并没有解决性能层面的根本问题。
说到底,无论是用窗口函数还是传统子查询,最考验人的往往不是语法本身,而是对数据细节的把握。数据类型隐式转换带来的精度损失、NULL值在聚合函数中的微妙行为……这些地方稍不留神,得到的结果就会“看起来差不多,细算对不上”。很多时候,多花一分钟检查执行计划里的数据类型,比事后调试半小时逻辑要划算得多。
