直接处理SQL分组和排序这两个操作,看起来入门书里都有,但实际上手时你会发现坑一个接一个。这篇文章不打算重复教科书里的概念定义,而是围绕真实业务里最常见的一批需求场景,把 GROUP BY 和 ORDER BY 从原理到实战拆开揉碎,连带着讲讲 WHERE、HAVING、聚合函数、索引优化这些绕不开的邻居。不管你是刚学会写 SELECT 的新人,还是写了大半年 SQL 但总在统计报表上翻车的开发,这篇文章应该都能帮你把思路理顺。
1. 为什么单独把"分组与排序"拎出来讲
1.1 一个统计报表引发的思考
差不多半年前,我在一个数据报表模块里接到一个需求:要统计每个品类的订单总量、订单总金额,然后按销售额从高到低排列,只取前 15 个品类。当时写 SQL 的人刚接手,他给出的第一版查询长这样:
SELECT category, COUNT(order_id) AS order_count, SUM(amount) AS total_amount FROM orders ORDER BY total_amount DESC LIMIT 15;这条 SQL 在 MySQL 里运行直接报错,因为 SELECT 里的 category 没有出现在 GROUP BY 子句中,而且聚合函数 SUM(amount) 和普通列混在同一个查询里,这在大多数数据库的严格模式下是不被允许的。把 ORDER BY total_amount 换成 ORDER BY SUM(amount) 也一样解决不了分组缺失的问题。
这个例子特别典型——单看 ORDER BY 和 LIMIT 都能理解,但一旦和分组需求叠加,就暴露了一个核心问题:很多人对"行"和"组"这两种数据处理模式没有建立清晰的概念。分组的本质是把多行数据折叠成更少的行,折叠之后每个组只剩一行,普通列和聚合函数必须遵守完全不同的规则。这篇文章要讲的不只是语法,而是让这套规则真正内化。
1.2 SQL 执行顺序:搞不清这个,分组排序必错
我在带新人时最喜欢问一个问题:"SQL 语句各子句的执行顺序是什么?"多数人答不上来,或者只能说出 SELECT、FROM、WHERE 这几个关键字,顺序还说反。然而 GROUP BY 和 ORDER BY 能否正确使用,几乎完全取决于对这个顺序的理解。
一条标准查询语句的语义执行顺序是:
- FROM:确定数据源,可能涉及多表 JOIN,先生成虚拟表
- WHERE:对虚拟表的每一行做过滤,淘汰不满足条件的行
- GROUP BY:将剩余的行按指定列分组,每个组折叠成一行
- HAVING:对分组后的结果做过滤,淘汰不满足条件的组
- SELECT:计算选择列表中的表达式,决定最终输出哪些列
- ORDER BY:对 SELECT 产出的结果行进行排序
- LIMIT / OFFSET:截取结果集中的特定区间
这个顺序解释了为什么 WHERE 里不能写聚合函数——因为 WHERE 执行时分组还没发生,每个组还是一个一个的行,SUM、COUNT 这些函数根本没有意义;ORDER BY 在 SELECT 之后执行,所以可以直接引用 SELECT 里定义的别名。
我在指导同事改那条报表 SQL 时,会让他先把"每个品类分组统计"这个目标在脑中过一遍,再往执行顺序里套。他最后的修正版本是:
SELECT category, COUNT(order_id) AS order_count, SUM(amount) AS total_amount FROM orders WHERE order_status = 'completed' GROUP BY category HAVING SUM(amount) > 1000 ORDER BY total_amount DESC LIMIT 15;这条 SQL 在业务上已经基本通顺,但从执行顺序来看,WHERE 在分组前把非完成状态的订单剔除,GROUP BY 把每个品类折叠成一行,HAVING 判断该组累计金额门槛,ORDER BY 按 SELECT 里算出的别名排序,LIMIT 最后截取。每一步都很自然,也不容易写出违规的语句。
2. GROUP BY 的分组逻辑:从行思维切换成组思维
2.1 分组最小单元:单列、多列与表达式
GROUP BY 最基础的使用就是"按某列分组",例如按省份统计用户数。但真正复杂的需求往往落在多列分组上,比如按"省份 + 城市"两级分组,或者按某个时间维度分组,比如按年-月分组。
SELECT province, city, COUNT(user_id) AS user_count FROM users GROUP BY province, city;这里的语义是:把 province 和 city 两个值完全相同的行归入同一个组。请注意,SELECT 中出现的非聚合列必须全部包含在 GROUP BY 中,这是 SQL 标准的要求,也是新手最容易踩的报错点。
分组键不限于裸列名,也可以是表达式。按月份分组时,可以直接对日期列做函数运算:
SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, COUNT(*) AS signup_count FROM users WHERE created_at >= '2025-01-01' GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY month;不同数据库对 GROUP BY 表达式的要求不同。MySQL 相对宽松,允许 GROUP BY 后面使用列的别名,也允许 SELECT 里出现不参与聚合、也不在 GROUP BY 中的非聚合列(在关闭 ONLY_FULL_GROUP_BY 时),但这种写法非常危险——SQL Server、PostgreSQL、Oracle 等都会直接报错或在逻辑上给出不可预期的结果。我个人的原则是:分组键一律用原始表达式书写,SELECT 中的非聚合列必须与 GROUP BY 完整一一对应,绝不依赖数据库的宽松模式。
2.2 COUNT(*) 与 COUNT(列名) 的微妙差异
这个点看似简单,却是分组统计里最容易得出"看似对实则错"结果的地方。两者的差别在于是否忽略 NULL 值。
COUNT(*):统计组内所有行的数量,不管某列是不是 NULLCOUNT(column_name):统计组内该列非 NULL的数量
举个例子,假设有一张订单表,其中 cancel_reason 列只对取消订单填写了原因,正常订单该列为 NULL。要统计每个品类的订单数和"有取消原因的订单数":
SELECT category, COUNT(*) AS total_orders, COUNT(cancel_reason) AS canceled_with_reason FROM orders GROUP BY category;如果在该品类 100 个订单中,有 10 个是取消订单且都填写了取消原因,那么 COUNT(*) 是 100,COUNT(cancel_reason) 是 10。很多人会拿 COUNT(cancel_reason) 当作取消订单数,但一旦存在"取消订单却没填原因"的记录,这个数字就偏小了。做统计数据校验时,这类口径问题经常导致报表对不上。
另一个容易混淆的是 COUNT(DISTINCT column_name),它统计组内该列去重后的个数。例如统计每个品类下有多少个不同的用户下了单:
SELECT category, COUNT(DISTINCT user_id) AS unique_users FROM orders GROUP BY category;2.3 空值在分组中的行为:NULL 也会自成一派
我在某个项目里遇到过一张客户表,region 列存在不少 NULL 值,写分组统计时原本以为这些记录会被忽略,结果发现 NULL 值单独形成了一组,而且这一组往往排在结果最前面或者最后面。
这是 SQL 标准行为:NULL 被视为一个特殊的分组值。如果你想把这组排除,必须在 GROUP BY 之前用 WHERE 过滤,或者在 HAVING 里用 IS NOT NULL 条件排除组。比如:
SELECT region, COUNT(customer_id) AS customer_count FROM customers WHERE region IS NOT NULL GROUP BY region;注意:不要试图用 WHERE region != NULL 或 WHERE region <> NULL 来过滤,NULL 的比较结果永远不是 TRUE,而是 UNKNOWN,这个条件会把所有行都过滤掉。必须用 IS NULL / IS NOT NULL。
这个坑在新手里出现频率极高,而且不报错,只是结果默默变空。排查方式很简单:对比过滤前后的总数,如果差异明显,多半就是 NULL 参与分组导致的。
3. ORDER BY 的排序细节:远比想象中复杂
3.1 多列排序的优先级与升序降序混用
ORDER BY 后面可以跟多个列名,先从第一列排,第一列值相同的情况下再按第二列排,以此类推。关键点是:每一列可以单独指定 ASC 或 DESC,它们不影响其他列的方向。
举个例子,按"销售额从高到低,销售额相同则按订单量从低到高"排序:
SELECT category, SUM(amount) AS total_amount, COUNT(order_id) AS order_count FROM orders GROUP BY category ORDER BY total_amount DESC, order_count ASC;这里 DESC 只作用于 total_amount,ASC 只作用于 order_count。不要误以为一个 DESC 会作用于后面所有列。我见过有人写成 ORDER BY total_amount, order_count DESC,然后抱怨为什么 order_count 没有按预期升序排列——实际上它被指定成了降序。写多列排序时,把 ASC 和 DESC 写全,即使 ASC 是默认值,也能避免阅读上的歧义。
需要注意 NULL 参与排序时的位置。多数数据库默认认为 NULL 是最大值,所以 ASC 时 NULL 排最后,DESC 时 NULL 排最前。SQL Server 恰好相反,它默认认为 NULL 是最小值。如果你对排序位置有明确要求,用 NULLS FIRST 或 NULLS LAST 显式指定(PostgreSQL、Oracle 支持),在 MySQL 中则没有这个语法,只能通过 CASE WHEN 表达式来强制位置。
3.2 排序的底层:不是"按拼音"也不是"按字典序"那么简单
有同事曾经问我:"为什么按 name 排序时,'阿' 排在 '百' 后面?为什么数字 10 排在 9 前面?"这些问题都指向同一个根源:排序基于列的排序规则(collation),即字符集层面的比较规则。
- 数字字符串按字典序排序,'10' < '9' 成立,因为先比较第一位字符,'1' < '9'
- 中文字符串排序依赖字符集编码和排序规则。在 utf8mb4 下,中文默认可能按 Unicode 码点排序,而不是我们直觉上的拼音顺序
- 如果你希望中文按拼音排序,需要指定 collation,例如
ORDER BY name COLLATE utf8mb4_zh_0900_as_cs(MySQL 8.0+),或者NLSSORT(name, 'NLS_SORT=SCHINESE_PINYIN_M')(Oracle),不同数据库的方案差异很大
实际业务中,中文排序需求并不常见,真正常见的是日期时间的排序问题。日期列存成字符串时,如果格式不是 YYYY-MM-DD,比如用了 MM/DD/YYYY 或者 DD-MM-YYYY,排序结果会非常诡异,本质上还是字符串字典序在作怪。
3.3 用 CASE WHEN 定制特殊排序优先级
业务里经常有"不是按数值大小,而是按业务优先级排列"的场景。最典型的是订单状态,比如想要"待付款"排最前,"已完成"排最后,其余状态居中。这时候直接按 status 列排序不能满足需求,可以在 ORDER BY 中使用 CASE WHEN 表达式:
SELECT order_id, status, created_at FROM orders ORDER BY CASE WHEN status = 'pending' THEN 1 WHEN status = 'processing' THEN 2 WHEN status = 'completed' THEN 3 ELSE 4 END, created_at DESC;这个技巧在管理后台的待办列表、工单列表里非常实用。它本质上是在分组键的位置上构造一个数值映射,再用映射后的数值排序。同理,这个表达式也可以用在 GROUP BY 里,实现自定义分组规则。
4. WHERE、HAVING 与 GROUP BY 的三角关系
4.1 两张"过滤网"的边界
很多教程用一句话区分 WHERE 和 HAVING:"WHERE 在分组前过滤行,HAVING 在分组后过滤组。"这句话简洁但不透彻。真正要理解的是:两条过滤发生的时机不同,导致它们能使用的操作对象完全不同。
WHERE 执行时,数据还是行级结构,所以只能使用非聚合条件,比如WHERE status = 'completed'、WHERE amount > 100。它不能写WHERE COUNT(order_id) > 5,因为此时 COUNT(order_id) 根本还不存在。
HAVING 执行时,分组已经完成,每组已经折叠成一行,聚合函数的结果已经算出,所以 HAVING 可以写HAVING COUNT(order_id) > 5。但它也可以写非聚合条件,比如HAVING category = 'books',前提是该列出现在 SELECT 或 GROUP BY 中。从语法上讲这是允许的,但从性能和语义上讲,这种写法应该放进 WHERE。
一个非常有价值的优化习惯:尽量在 WHERE 中过滤掉尽量多的行,再用 HAVING 处理组级条件。因为 WHERE 过滤发生在分组前,减少了参与分组的行数,对性能是实打实的提升。HAVING 的过滤作用发生在分组后,如果前面没过滤,分组阶段就要处理更多数据。
4.2 一次典型的"分组前过滤 vs 分组后过滤"案例
假设要找出"下单用户数超过 50 人"的品类,同时只统计 2025 年第一季度创建的订单:
SELECT category, COUNT(DISTINCT user_id) AS user_count FROM orders WHERE created_at >= '2025-01-01' AND created_at < '2025-04-01' GROUP BY category HAVING COUNT(DISTINCT user_id) > 50 ORDER BY user_count DESC;WHERE 在分组之前先把时间范围外的订单剔除,仅让符合时间条件的订单参与分组统计。随后 HAVING 再筛选满足"用户数 > 50"的组。如果把这个时间条件误放到 HAVING 里,也能得到正确结果吗?在多数数据库里能,但性能上差很多,尤其订单表数据量很大时,HAVING 的时间过滤发生在所有分组计算完之后,白白浪费了资源。
4.3 分组键与 SELECT 列的关系:一个容易违法的细节
SELECT 中出现的非聚合列必须与 GROUP BY 中的分组键保持一致。这个规则背后的逻辑是:分组之后,组内每个非聚合列理论上可能有多个值,只挑一个输出没有任何确定性依据,会造成结果不可预期。
MySQL 在关闭 ONLY_FULL_GROUP_BY 的情况下不会阻止这种写法,比如SELECT category_id, category_name, SUM(amount) FROM orders GROUP BY category_id,此时 category_name 可能来自组内任意一行。在单表分组且 category_id 唯一确定 category_name 的语义下,结果往往是对的,这也是很多人放任这种写法的原因。但一旦数据出现 category_id 对应多个 category_name 的情况,结果就会混乱。在 PostgreSQL、SQL Server、Oracle 中,这直接就是语法错误。
我的建议是:即使数据库允许,也不要这样做。如果确实需要在分组后带上与分组键绑定的附加字段,有以下几种方式:
- 将附加字段直接加入 GROUP BY(缺点是分组粒度变细)
- 在子查询中先聚合得到分组键,再 JOIN 回原始表取得附加字段
- 使用窗口函数,按分组键只取第一个值
5. 分组排序实战:从需求到 SQL 的完整拆解
5.1 场景一:按分类汇总销售金额并降序排列
这是最基础也最常见的需求。数据库是一张销售明细表,字段包括品类、金额、用户ID、下单时间,目标是得到每个品类的销售总额,从高到低排列,并展示订单量和下单人数:
SELECT category, SUM(amount) AS total_sales, COUNT(order_id) AS order_count, COUNT(DISTINCT user_id) AS buyer_count FROM order_detail WHERE order_status = 'paid' GROUP BY category ORDER BY total_sales DESC;这个语句里有个值得注意的地方:ORDER BY total_sales 引用的是 SELECT 中定义的别名。因为 ORDER BY 在 SELECT 之后执行,所以可以使用别名。但如果这个查询子句变得更复杂,比如前面加了 DISTINCT,某些数据库可能限制 ORDER BY 中使用别名。跨数据库经验是:在不必要的场景下尽量使用表达式本身,而不是别名。
5.2 场景二:每个用户的最新一条订单
这是一个典型的分组-排序-取值需求,但直接用 GROUP BY 很难优雅解决。因为需求是"每个用户最新一条订单的完整记录",而 GROUP BY 会把组内多行折叠成一行,如果 SELECT 需要整个订单的多个字段,把非分组列硬塞进 SELECT 就会碰壁。
推荐用窗口函数解决:
SELECT user_id, order_id, amount, created_at FROM ( SELECT user_id, order_id, amount, created_at, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM orders ) t WHERE rn = 1;这里 PARTITION BY user_id 相当于"按用户分组",ORDER BY created_at DESC 在每个用户组内按时间倒序排序,ROW_NUMBER() 给每组打上 1、2、3 的序号。外层再筛出每个组序号为 1 的行,就是每个人的最新订单。
窗口函数的灵活性远超 GROUP BY,它是"分组后保留行明细"的最优解。关于 GROUP BY 能实现同样效果吗?可以,但往往需要自连接或者相关子查询,复杂度和性能表现都不如窗口函数。对于 MySQL 8.0、PostgreSQL、SQL Server、Oracle 这类支持窗口函数的数据库,强烈建议优先用窗口函数。
5.3 场景三:筛选订单量超过 N 的品类,并按最新订单时间排序
这个需求把分组、HAVING、排序都揉在一起:
SELECT category, COUNT(order_id) AS total_orders, MAX(created_at) AS latest_order_time FROM orders GROUP BY category HAVING COUNT(order_id) >= 20 ORDER BY latest_order_time DESC;这里 HAVING 筛掉订单量不足 20 的品类,ORDER BY 按最新订单时间排序。如果还要限制只输出前 10 名,就加 LIMIT 10。需要注意,MAX(created_at) 本身是聚合函数,所以它出现在 SELECT 中是合法的。
这类需求的关键点是理清"哪个条件放在哪一层":下单时间、状态、金额维度是行级条件,放 WHERE;订单量、累计金额是组级条件,放 HAVING;输出顺序用 ORDER BY;截断条数用 LIMIT。理顺了这张顺序图,大多数统计报表的 SQL 都可以像搭积木一样拼出来。
6. 进阶用法与性能优化
6.1 DISTINCT 与 GROUP BY 的选择
DISTINCT 本质上也是一种去重,有人会用它来模拟分组。比如:
SELECT DISTINCT category FROM orders;与
SELECT category FROM orders GROUP BY category;结果一样,但语义不同。DISTINCT 是行级去重,GROUP BY 是分组。当 SELECT 中只有分组键列时,两者效果相同;但一旦涉及聚合函数,DISTINCT 就没法处理了。
在性能上,两者在大多数数据库中的执行计划相差不大,都会走排序或哈希去重。但若 DISTINCT 后面跟了多个列,它的语义是"多列值组合去重",这与多列分组也相通。实际项目中,我习惯用 GROUP BY 表达统计类需求,用 DISTINCT 表达纯粹的枚举去重需求,两者各司其职。
6.2 配合 CASE WHEN 做条件分组
有些需求不是按某一列的直接值分组,而是按某个逻辑条件分组。比如把用户按消费金额分为"高、中、低"三档,然后统计各档人数:
SELECT CASE WHEN total_spent >= 10000 THEN 'high' WHEN total_spent >= 5000 THEN 'medium' ELSE 'low' END AS spending_level, COUNT(user_id) AS user_count FROM user_profile GROUP BY CASE WHEN total_spent >= 10000 THEN 'high' WHEN total_spent >= 5000 THEN 'medium' ELSE 'low' END;注意 GROUP BY 需要重复完整的 CASE WHEN 表达式。有些数据库允许 GROUP BY 写别名,但为了跨库兼容和可读性,建议全部写完整表达式。如果嫌重复太啰嗦,可以先在子查询里计算好分档字段,再在外面分组:
SELECT spending_level, COUNT(user_id) AS user_count FROM ( SELECT user_id, CASE WHEN total_spent >= 10000 THEN 'high' WHEN total_spent >= 5000 THEN 'medium' ELSE 'low' END AS spending_level FROM user_profile ) t GROUP BY spending_level;子查询方案的可读性更好,而且便于在分档逻辑上做进一步修改。
6.3 窗口函数与 GROUP BY 的协作
窗口函数和 GROUP BY 不是互斥的,它们可以嵌套使用。一个常见需求是:先按品类分组计算销售额,再对所有品类按销售额做排名。
SELECT category, total_sales, RANK() OVER (ORDER BY total_sales DESC) AS sales_rank FROM ( SELECT category, SUM(amount) AS total_sales FROM orders GROUP BY category ) t;内层子查询先用 GROUP BY 求出每个品类的销售额,外层再用窗口函数对整个结果集做排名统计。这样既享受了 GROUP BY 的聚合能力,又保留了逐行输出的特点。
同等需求也可以直接在外层写:
SELECT category, SUM(amount) AS total_sales, RANK() OVER (ORDER BY SUM(amount) DESC) AS sales_rank FROM orders GROUP BY category;两种写法都可以,我个人偏好子查询方案,因为逻辑分层更清晰:内层负责"得到每个品类的销售额",外层负责"排名"。
6.4 索引如何加速分组排序
分组和排序的底层实现通常有两种方式:排序(Sort)和哈希(Hash)。如果表上有与 GROUP BY 键匹配的索引,数据库可以直接按索引顺序读取数据,天然形成分组,省去排序步骤,性能提升非常明显。如果 GROUP BY 键还和 WHERE 的过滤条件共同构成一个复合索引,效果更好。
举个例子,一个高频 SQL 是SELECT region, COUNT(*) FROM users WHERE status = 'active' GROUP BY region,那么在 (status, region) 上建立复合索引,让 WHERE 先通过 status 快速定位,同时索引顺序天然按 region 聚合,避免二次排序。
关于 ORDER BY 的优化,要点是让 ORDER BY 使用的列与索引的列顺序一致,且方向一致。索引本身按升序存储,如果 ORDER BY 是降序,某些数据库可以倒序扫描索引(比如 MySQL 8.0 之后对降序索引支持更好),但不是所有数据库都高效。
实际调优时,我一般会先用 EXPLAIN 查看执行计划,重点看 Extra 列是否出现 Using filesort 或 Using temporary。如果出现,说明排序或分组没有走上索引,需要评估是否可以调整索引设计或改写 SQL。
6.5 使用临时表/子查询处理复杂排序需求
有些排序需求涉及"先排序后分组"或"先分组后取组内特定行",直接一条 SQL 写出来可读性很差。这时可以把中间结果拆到子查询或 CTE(Common Table Expression)里。
比如 ORDER BY 在 GROUP BY 之前执行其实是违反直觉的,因为执行顺序中 ORDER BY 永远在 GROUP BY 之后。但业务上确实存在"按最新订单时间确定每个用户所属分组"这类需求,此时可以先在子查询里按时间排序并分组,再在外层完成统计。
一个常见例子:要统计每个用户最近一次购买时间所在的月份,然后按月份统计用户数。
WITH latest_purchase AS ( SELECT user_id, DATE_FORMAT(MAX(purchase_time), '%Y-%m') AS latest_purchase_month FROM orders GROUP BY user_id ) SELECT latest_purchase_month, COUNT(user_id) AS user_count FROM latest_purchase GROUP BY latest_purchase_month ORDER BY latest_purchase_month;CTE 把"每个用户最近购买月份"这种中间结果固化下来,再基于它做第二层分组统计。对比嵌套子查询,CTE 的可读性要好很多。在 MySQL 8.0、PostgreSQL、SQL Server、SQLite 中都可以放心使用。
7. 一些我用真金白银趟出来的经验
7.1 警惕 GROUP BY 后的"伪总数"问题
做报表时常常要在分组统计结果下面加一行总计。新手容易直接在 SQL 后面加WITH ROLLUP,这个语法在 MySQL 和 SQL Server 中都有,但它在分组结果中插入聚合行时,如果业务还要求同时展示均值和比率,逻辑会变得很复杂,而且绝大多数 BI 工具对这种额外聚合行的解析并不友好。
更稳妥的做法是分两条 SQL:一条负责分组明细,一条负责总数统计。然后在代码层拼接展示。数据量不大时,两条 SQL 的代价可以忽略。不要为了省一条 SQL 硬把 ROLLUP 塞进来,后面维护的人大概率会骂人。
7.2 小心 ORDER BY 子查询中的字段名冲突
当 ORDER BY 使用别名时,如果别名和外层列名重名,可能引起歧义。举个例子:
SELECT category, SUM(amount) AS total_sales FROM orders GROUP BY category ORDER BY total_sales;这没问题。但如果在子查询中 SELECT 了 total_sales,外层又有同名列,数据库可能无法判断 ORDER BY 引用的是哪一层。遇到这类情况,优先给表或子查询起别名,用别名.列名的方式限定字段来源。
7.3 聚合函数与 NULL 的连环坑
SUM 函数对全 NULL 组返回 NULL,而不是 0,这会导致排序时该组被排到最后。很多报表希望全 NULL 组显示 0,这样就需要 COALESCE 包裹:
SELECT category, COALESCE(SUM(amount), 0) AS total_sales FROM orders GROUP BY category;同样,AVG 函数在计算时会忽略 NULL 值,即它拿非 NULL 值的数量做分母,而不是总行数。如果需要把 NULL 视为 0 参与平均,就需要先做空值处理:
SELECT category, AVG(COALESCE(amount, 0)) AS avg_sales FROM orders GROUP BY category;这些细节在数据量小时看不出来,一旦进入对账环节或指标口径核对,就会变成焦头烂额的问题。
7.4 写 SQL 前的三分钟检查清单
我每写一条带分组和排序的 SQL,几乎都会在心里过一遍这个小清单:
- SELECT 中的非聚合列是否都出现在 GROUP BY 中?
- 条件里有没有聚合函数?有就进 HAVING,普通条件进 WHERE
- ORDER BY 用的列在 SELECT 中是否可访问?别名会不会有歧义?
- 有没有用 COUNT(*) 还是 COUNT(列名) 的口径问题?
- 排序字段有没有 NULL 值风险?NULL 排位是否符合业务预期?
- 数据量大的情况下,分组键是否有索引支撑?
这套清单帮我挡下了大量肉眼很难发现的逻辑错误,也让我写出的 SQL 在交到别人手里做 code review 时,减少了很多不必要的往返沟通。
分组和排序在 SQL 里就像做菜时的火候和调味:单看都不难,难的是把它们放在同一个锅里协调好。掌握执行顺序、理解行与组的差异、熟悉聚合函数的口径,再加上实际业务里反复验证,基本上就能把这块地基打得很稳。下次再遇到"每个XX的最新一条记录"或"按分类汇总后取Top N"这类需求时,你应该能直接说出至少两种写法,并且清楚每一种在什么场景下更优。