前面几篇把数据筛出来、压成统计表、拼回一起、分层复用。还有一个几乎每次取数都要做的动作没讲:按业务关心的次序把结果排好,只看最前面的那几行。
这篇要回答的问题很具体:一句“给我金额最高的前 3 笔订单”,怎么写;以及为什么同一句 SQL,换个写法或换个数,你拿到的那几行可能并不是你以为的那几行。材料还是那 9 行 orders(环境是 MySQL 8.0,PostgreSQL 上语法基本一样,有差别的地方我会标出):
| order_id | city | amount |
|---|---|---|
| 1001 | 杭州 | 129.00 |
| 1002 | 上海 | 58.50 |
| 1003 | 杭州 | 320.00 |
| 1004 | NULL | 76.00 |
| 1005 | 杭州 | 45.00 |
| 1006 | 上海 | 210.00 |
| 1007 | 上海 | 91.50 |
| 1008 | NULL | 19.90 |
| 1009 | 北京 | 199.00 |
第一步:给结果排个序
1SELECT order_id, city, amount
2FROM orders
3ORDER BY amount DESC;跑完第一行应该是 1003(320.00),最后一行是 1008(19.90)。ORDER BY 里的列叫 排序键;不写 ASC / DESC 时默认升序。
要按两列排就写两个键,比如 ORDER BY city, amount DESC:先按城市,城市相同再按金额从大到小。注意 DESC 只作用于紧挨着它的那一列,它管不到前面的 city。
语句的位置不是随便放的。整条查询的顺序是 WHERE → GROUP BY → HAVING → ORDER BY → LIMIT,排序发生在筛选和分组之后,ORDER BY 排的是“已经过滤、已经汇总好”的那些行。所以在汇总查询里可以直接按别名排:
1SELECT city, SUM(amount) AS city_total
2FROM orders
3GROUP BY city
4ORDER BY city_total DESC;结果是杭州 494.00、上海 360.00、北京 199.00、city 为空那一组 95.90。
第二步:只留前 3 行
在刚排好的结果后面加一行:
1SELECT order_id, city, amount
2FROM orders
3ORDER BY amount DESC
4LIMIT 3;看到 1003、1006、1009 三行就对了。LIMIT 3 的含义是 在排好的结果上砍下前 3 行,它自己不带任何顺序。
顺序写反会立刻报错:把 LIMIT 放到 ORDER BY 前面,MySQL 给 ERROR 1064 (42000): You have an error in your SQL syntax。语句各部分的先后是语法规定死的。
要跳过开头几行就用 OFFSET:LIMIT 3 OFFSET 2 是跳过前 2 行、再取 3 行,得到 199.00、129.00、91.50;偏移从 0 数起。MySQL 的简写 LIMIT 2, 3 含义相同,但两个参数是“先偏移、后条数”,语序与 OFFSET 写法相反,容易写反。
最要紧的一件事:不写 ORDER BY 直接 LIMIT 3,那 3 行是任取的。数据库只保证“给你 3 行”,不保证是最小的、最早的或最新的。今天可能是这 3 行,明天加了索引、数据变多,就换成另外 3 行。LIMIT 只做裁剪,顺序得你给。
第三步:排序键出现并列时,“前 3”是谁并不确定
换个业务问题:哪个城市订单最多?
1SELECT city, COUNT(*) AS cnt
2FROM orders
3GROUP BY city
4ORDER BY cnt DESC;杭州 3 笔、上海 3 笔、city 为空那一组 2 笔、北京 1 笔。现在在末尾加 LIMIT 1:你会拿到“3 笔”,但 不能保证是杭州还是上海。两行在排序键 cnt 上完全相等,这种情况叫 并列,数据库对并列的行没有任何先后约定,它按执行计划给出的顺序截断。换版本、加索引、数据量变化,都可能让你这次看到杭州、下次看到上海。
修法取决于业务上要什么。并列必须拆开,就再加一个排序键,比如 ORDER BY cnt DESC, city——但中文列的先后由数据库的排序规则(collation)决定,既不是拼音序也不一定合你的预期,换个字符集还可能变;要稳定,用一个你能解释清楚的键(比如城市编码)更可靠。并列就是并列,那就别只要一行,把两家都交出去。
第四步:NULL 排在哪一头
执行 ORDER BY city(升序),那两条 city 为 NULL 的订单排在最前面;改成 ORDER BY city DESC,它们排到最后。这是 MySQL 的默认行为——手册写得很直白:升序时 NULL 排在其他值之前,降序时排在其后。
换库这个默认就变了:PostgreSQL 默认升序时 NULL 排在最后,并且支持 NULLS FIRST / NULLS LAST 显式指定;MySQL 8.0 的 ORDER BY 没有这个写法。所以只要“空值算不算最前”会影响结论,就不要依赖默认。MySQL 里可以自己造一个排序键:
1SELECT order_id, city, amount
2FROM orders
3ORDER BY city IS NULL, city;city IS NULL 的结果是 0 或 1,升序排时 0(非空的行)在前,空值那两行沉到最后;想放最前就给它加 DESC。如果空值压根不该参与(“城市未知”不是一个城市),更干脆的做法是在 WHERE 里筛掉。
第五步:LIMIT 取不出“每组的前几名”
真正的需求常常是:每个城市金额最高的那一笔订单,要 order_id、city、amount 三列。
先用 GROUP BY 试:
1SELECT order_id, city, MAX(amount)
2FROM orders
3GROUP BY city;MySQL 8.0 会直接拦下来:ERROR 1055 (42000): Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column ... which is not functionally dependent on columns in GROUP BY clause。这不是数据库刁难。对一个城市那一行来说,MAX(amount) 是明确的 320.00,但“320.00 来自哪一笔订单”它无从知道——聚合把 3 行压成 1 行时,其他列的信息就丢了。
LIMIT 同样帮不上。ORDER BY amount DESC LIMIT 1 得到的是 整张表 裁出来的一行(1003);LIMIT 作用在整条查询的最终结果上,不是每个分组各裁一次。所以“每组各取一行”这件事,LIMIT 在结构上做不到。
能凑出正确结果的写法是相关子查询(子查询里引用了外层的列 o.city):
1SELECT order_id, city, amount
2FROM orders o
3WHERE amount = (SELECT MAX(amount) FROM orders WHERE city = o.city);跑出来是 1003、1006、1009 三行,每个城市一笔。但它有两个必须知道的坑:city 为空的两笔 一笔都出不来——city = o.city 在两边都是 NULL 时结果是 NULL 而不是 true,跟前面讲过的“NULL 不能用等号判断”是同一件事;一组里有两笔同额最高时,两行都会返回,“每组一笔”悄悄变成“每组 N 笔”。
要做到“每组按某个次序取第 1 行”,需要一种既保留明细行、又能在组内排名的工具,那是窗口函数(ROW_NUMBER)要解决的问题,留到下一篇。
交稿前的自查
- 有
LIMIT就必须有ORDER BY,否则那几行是任取的。 - 排序键能不能把每一行区分开?有并列,就别指望
LIMIT固定给你某几行。 DESC写在哪一列上,它只管紧挨着的那一列。- NULL 默认排在哪一头,换个数据库还成不成立。
- 你要的是“整张表的前 N”,还是“每组的前 N”——后者不是
LIMIT的活。