图 1:本文封面,聚合、排序、取头部,十根柱子排开就是答案。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料(原题给出订单记录表结构,要求「写一个语句,求卖得最好的 top 10 product_id」),与任何公司的当前招聘无关。这题写出 SQL 只是及格线,真正的考点藏在两个追问里:第 10 名并列了怎么办?表大了性能怎么办?
想象面试现场,订单表就三个关键列:product_id、quantity、created_at。
核心结论一句话
及格线写法是三步:GROUP BY 商品聚合销量、ORDER BY 销量降序、LIMIT 10 取头部。但「卖得最好的前 10」在并列时有三种语义:要严格名次用 ROW_NUMBER,并列也分出先后;要并列同名次用 RANK,并列后跳号;或 DENSE_RANK,并列后不跳号。三者在第 10 名并列时结果不同。主动讲清这个分叉,才是这题的得分点。
先把及格线写出来,再看它在哪里翻车。
SELECT product_id, SUM(quantity) AS total
FROM orders
GROUP BY product_id
ORDER BY total DESC
LIMIT 10;
这条语句人人会写,但它有个隐蔽行为:如果第 10 名和第 11 名销量相同,LIMIT 会随机截断其中一个——两个商品销量一样,谁上榜看运气。这在业务上常常不可接受:奖品名额、榜单公示都容不下「看运气」。
用可运行的实测看清三种语义。
下面这份脚本用 Node.js 22.15.0 内置的 node:sqlite 建内存库实测(保存为 top10.mjs 后运行 node top10.mjs;窗口函数语义与 MySQL 一致,node:sqlite 目前带实验性提示,不影响结果)。造数时刻意让第 10 名出现两个并列,销量都是 40:
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL,
created_at TEXT NOT NULL
)`);
// 12 个商品,第 10 名故意并列(销量都是 40)
const sales = [300, 260, 220, 180, 150, 120, 100, 90, 70, 40, 40, 25];
const insert = db.prepare(
'INSERT INTO orders (product_id, quantity, created_at) VALUES (?, ?, ?)');
sales.forEach((total, idx) => {
let rest = total, day = 1;
while (rest > 0) { // 拆成多笔订单,模拟真实流水
const q = Math.min(rest, 15);
insert.run(101 + idx, q, `2026-09-0${day}`);
rest -= q; day++;
}
});
const show = (title, rows) => {
console.log(`\n-- ${title}`);
for (const r of rows) console.log(JSON.stringify(r));
};
show('GROUP BY + ORDER BY + LIMIT 10', db.prepare(`
SELECT product_id, SUM(quantity) AS total
FROM orders GROUP BY product_id ORDER BY total DESC LIMIT 10`).all());
show('ROW_NUMBER / RANK / DENSE_RANK 对比', db.prepare(`
SELECT product_id, total,
ROW_NUMBER() OVER w AS rn,
RANK() OVER w AS rk,
DENSE_RANK() OVER w AS dr
FROM (SELECT product_id, SUM(quantity) AS total FROM orders GROUP BY product_id)
WINDOW w AS (ORDER BY total DESC)`).all());
show('DENSE_RANK <= 10(并列的第 10 名都保留)', db.prepare(`
SELECT product_id, total FROM (
SELECT product_id, total,
DENSE_RANK() OVER (ORDER BY total DESC) AS dr
FROM (SELECT product_id, SUM(quantity) AS total FROM orders GROUP BY product_id)
) t WHERE dr <= 10`).all());
db.close();
实测输出的关键部分:
-- GROUP BY + ORDER BY + LIMIT 10(第 10 名只进了 111,110 被随机截掉)
{"product_id":109,"total":70}
{"product_id":111,"total":40}
-- ROW_NUMBER / RANK / DENSE_RANK 对比(节选并列处)
{"product_id":110,"total":40,"rn":10,"rk":10,"dr":10}
{"product_id":111,"total":40,"rn":11,"rk":10,"dr":10}
{"product_id":112,"total":25,"rn":12,"rk":12,"dr":11}
-- DENSE_RANK <= 10(第 10 名并列都保留,共 11 行)
{"product_id":110,"total":40}
{"product_id":111,"total":40}
三个窗口函数的分歧在输出里一目了然:ROW_NUMBER 给并列的两人编号 10 和 11,强行分出先后;RANK 给两人并列第 10,下一名跳成 12;DENSE_RANK 同样并列第 10,但下一名是 11,不跳号。要「前 10 名、并列都保留」就用 DENSE_RANK 过滤,实测返回 11 行——这正是多数榜单业务真正想要的语义。
图 2:三步主流程、并列语义分叉与优化提示。
性能这一半考点。 三步写法的执行代价是全表聚合加排序,订单表一大就吃不消。三个手段按序使用:先加过滤条件减量,比如只看本月订单,让聚合前先甩掉大部分行;再给聚合键和过滤列建索引,让分组与排序有帮手;表大到一定程度就考虑预聚合,按天先算好每个商品的销量,查 Top 10 时聚合的是日汇总而不是原始流水。「分组聚合取 Top N」是数据分析与特征统计的第一高频操作,这套分层写法在工作里每天都会用到。
可能的追问与易错点。「并列的三语义里该选哪个?」问业务:奖金名额有限选 ROW_NUMBER 并按规则加决胜排序键;荣誉榜单并列常见,用 RANK 或 DENSE_RANK。「窗口函数版本的数据库不可用怎么办?」老版本可以用变量模拟行号,但那是历史包袱,知道有这回事即可。易错点有二:一是把 LIMIT 的随机截断当成正确处理并列;二是只谈写法不谈代价,大表上跑全表聚合的语句上线就是事故。
回到面试现场的建议节奏:先写三步及格线,立刻主动补一句「第 10 名并列时 LIMIT 会随机截断」,然后给出 DENSE_RANK 版本,最后谈一句性能分层。这道题你遇到过并列的坑吗?当时业务上要的是哪种语义?欢迎分享。