在一批订单数据里找出每个客户最常购买的商品,在日志表里统计每台机器出现频率最高的错误码,这类需求本质上都是求分组内的众数。众数就是一组数据中出现次数最多的那个值。看起来简单,但很多人动手写SQL时才发现,主流数据库并没有提供现成的众数聚合函数,直接GROUP BY之后再怎么写就成了难题。本文从数据准备开始,一步步演示如何用分组统计配合窗口函数解决这个问题,并对比几种不同写法的适用场景。

准备测试数据:一张订单表
为了让后面的示例有据可依,先建一张简单的订单表,包含客户编号和购买的商品名称两列。我们的目标是找出每个客户买得最多的商品。
CREATE TABLE orders (
customer_id INT,
product VARCHAR(50)
);
INSERT INTO orders VALUES
(1, '苹果'),
(1, '苹果'),
(1, '香蕉'),
(1, '苹果'),
(2, '香蕉'),
(2, '香蕉'),
(2, '橘子'),
(2, '橘子'),
(3, '牛奶'),
(3, '面包');
观察一下数据:客户1买了3次苹果、1次香蕉,众数是苹果;客户2香蕉和橘子各2次,属于并列众数;客户3牛奶和面包各1次,也是并列。这个特意设计的并列情况,正好可以用来检验不同SQL写法的行为差异,后面会看到有的写法只返回一个结果,有的会全部返回。
另外要注意,如果真实业务表中存在NULL值,比如product列为空,统计时需要先想清楚NULL算不算一个有效取值。多数场景下建议在WHERE中过滤掉NULL,避免统计结果被无意义的空值干扰。
基础思路:GROUP BY计数后再取最大值
最直观的思路分两步走。第一步,按客户和商品分组,数出每种商品被买了多少次;第二步,从每个客户的统计结果里挑出计数最大的那一行。先看第一步的SQL。
SELECT
customer_id,
product,
COUNT(*) AS cnt
FROM orders
GROUP BY customer_id, product
ORDER BY customer_id, cnt DESC;
这条语句执行后,每个客户每种商品各占一行,cnt列就是出现次数。接下来问题转化为经典的每组最大值问题。传统做法是用相关子查询,判断当前行的计数是否等于该客户计数中的最大值。
SELECT t.customer_id, t.product, t.cnt
FROM (
SELECT customer_id, product, COUNT(*) AS cnt
FROM orders
GROUP BY customer_id, product
) t
WHERE t.cnt = (
SELECT MAX(cnt)
FROM (
SELECT customer_id, product, COUNT(*) AS cnt
FROM orders
GROUP BY customer_id, product
) m
WHERE m.customer_id = t.customer_id
);
这个写法兼容性最好,几乎所有数据库都支持,但它对同一张表做了两次分组扫描,相关子查询还可能被反复执行,数据量大时性能不理想。而且写法冗长,逻辑分了好几层,可读性较差。这就引出了更优雅的窗口函数方案。
窗口函数方案:ROW_NUMBER与RANK的选择
窗口函数的核心价值在于,它可以在保留明细行的基础上为每行打上计算标记。我们先按客户和商品统计出频次,然后按客户分区,对频次倒序排列,给每行编号,最后取编号为1的行,众数就出来了。
SELECT customer_id, product, cnt
FROM (
SELECT
customer_id,
product,
cnt,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY cnt DESC
) AS rn
FROM (
SELECT customer_id, product, COUNT(*) AS cnt
FROM orders
GROUP BY customer_id, product
) s
) r
WHERE rn = 1;
用ROW_NUMBER时,即使出现并列(比如客户2的香蕉和橘子都是2次),也只会返回其中一条,因为ROW_NUMBER保证编号唯一。这在业务上只要求一个代表值时很合适,但结果带有随机性,具体返回哪条取决于数据库内部的排序处理。
如果希望并列众数全部列出来,把ROW_NUMBER换成RANK即可。RANK对相同的排序值给出相同的名次,并列第一都会被标记为1,外层筛选rn = 1就能把所有并列众数都取到。改写后的关键片段如下。
RANK() OVER (
PARTITION BY customer_id
ORDER BY cnt DESC
) AS rn
对比一下子查询方案,窗口函数方案只做一次分组统计,ROW_NUMBER或RANK在分组结果集上计算,整体执行效率明显更高,而且逻辑集中在一层嵌套里,维护起来更轻松。这也是目前业界处理分组众数最主流的写法。需要提醒的是,窗口函数要求MySQL 8.0及以上版本,如果还在用MySQL 5.7,就只能退回子查询方案。
其他数据库的补充写法与注意事项
除了通用方案,部分数据库提供了更直接的特性。Oracle内置了STATS_MODE聚合函数,一条语句就能搞定。
SELECT customer_id, STATS_MODE(product) AS mode_product FROM orders GROUP BY customer_id;
PostgreSQL 9.4+则支持自定义聚合,官方文档中就有用C语言或PL/pgSQL实现mode函数的例子,社区里也有现成的pg_tools扩展可以用。SQL Server和MySQL没有原生支持,窗口函数方案仍是首选。
实际落地时还有几个细节值得留意。第一,并列众数的业务定义要提前和需求方确认,是要任意一个还是全部返回,这决定了用ROW_NUMBER还是RANK。第二,如果数据存在NULL,GROUP BY会把NULL单独算作一组,若不希望NULL参与众数竞争,需要在统计前过滤。第三,当分组数量特别多时,可以在频次子查询上针对分组列建立索引,减少分组扫描的开销。
总结一下,分组众数的通用解法就是频次统计加窗口排序筛选:GROUP BY负责算次数,窗口函数负责在组内排名,外层过滤排名第一的记录。掌握这个组合套路后,类似的最常见值、组内TopN问题也都能举一反三地解决。
SQL众数窗口函数GROUP BY分组统计修改时间:2026-09-07 02:16:33