在数据分析场景中,经常需要按某个维度分组后求解每组的中位数。和平均值不同,中位数更能抵抗极端值干扰。标准SQL并没有像avg、sum那样直接的group_median函数,但我们可以借助窗口函数完成逻辑上的辅助计算,让语句既清晰又高效。

为什么中位数需要窗口函数辅助
中位数是指把一组数值按大小排序后,位于最中间位置的数。如果组内行数是奇数,中位数就是正中间那一行;如果是偶数,则是中间两行的平均值。普通的group by聚合只能算出单行结果,无法保留排序后的行级位置信息,因此单靠聚合函数很难直接表达“取中间”这个动作。
窗口函数的特点是在不折叠行的前提下,为每一行计算一个基于分组和排序的附加信息,比如行号、组内总数等。有了行号和总数,我们就能用条件判断圈出中间位置,再在外层做平均。这种写法比多层自连接更易读,也方便数据库优化器选择更优的执行路径。
核心实现逻辑拆解
基本思路分为三步。第一步,用row_number() over(partition by 组字段 order by 数值字段) 为每组内的数据按顺序编号;第二步,用count(*) over(partition by 组字段) 算出每组的总行数;第三步,在外部查询中筛选出行号满足中位数条件的记录,并对这些记录求平均。
对于奇数行,唯一的中间行号等于 (总数+1)/2;对于偶数行,中间两行号分别是 总数/2 和 总数/2 + 1。我们可以让行号满足 2倍行号减总数 的绝对值小于等于1,这样奇数和偶数情况都能被统一覆盖。下面用员工薪资按部门分组举例。
-- 建表与示例数据
create table emp_salary (
dept_id int,
emp_name varchar(20),
salary int
);
insert into emp_salary values
(1, '张三', 3000),
(1, '李四', 5000),
(1, '王五', 4000),
(2, '赵六', 2000),
(2, '钱七', 8000),
(2, '孙八', 6000),
(2, '周九', 4000);
-- 使用窗口函数辅助计算各部门薪资中位数
with ranked as (
select
dept_id,
salary,
row_number() over (partition by dept_id order by salary) as rn,
count(*) over (partition by dept_id) as cnt
from emp_salary
)
select
dept_id,
avg(salary) as median_salary
from ranked
where abs(rn - (cnt + 1.0) / 2) <= 0.5
group by dept_id
order by dept_id;
上面的语句中,子查询ranked给每一行打上了组内序号rn和组内总数cnt。外层where条件 abs(rn - (cnt + 1.0) / 2) <= 0.5 会选中正中间的一行或两行。最后用avg自然完成奇数取本身、偶数取平均的效果。
不同数据库中的写法差异
在PostgreSQL、MySQL 8.0+、SQL Server等支持窗口函数的库中,上述写法通用。如果使用的是较早的MySQL版本,不支持窗口函数,则需要用子查询和变量模拟行号,逻辑会繁琐很多。在Oracle里还可以配合median函数直接做分组中位数,但在跨库兼容场景下,窗口函数方案更稳妥。
另外要注意,当数值字段存在相等情况时,row_number会任意分配顺序,不影响中位数结果;若业务要求相同值有确定次序,可在order by后增加第二排序键,例如 order by salary, emp_name,保证结果可复现。
性能与适用建议
窗口函数只需对数据做一次分区排序,相比自连接逐组比较,通常扫描次数更少。在百万级数据上,若dept_id上有索引,排序代价也能明显降低。对于报表类定期统计,建议将计算结果落表或建物化视图,避免每次查询重复计算。
如果分组基数特别高且每组行数很小,也可以考虑用percentile_cont等有序聚合函数,但那属于各库私有语法。综合来看,用row_number加count的窗口函数方案在可读性和移植性上达到了较好平衡,是日常SQL求解分组中位数的实用选择。