做后端开发和数据分析这几年,我反复看到同一种现象:不少人写过不少SQL,但只要业务条件一复杂,就开始一层套一层子查询、随手LEFT JOIN、到处丢DISTINCT,最后结果对不对全看运气。问题多数时候不是语法忘了,而是没弄清楚SQL的本质是集合操作,不是逐行计算。SQL查询这门手艺,最核心的就是两件事——筛选和联结:先用一套干净的基础筛选规则把数据范围逐步收紧,再通过多表联结把分散的业务信息重新组合成一张能支撑决策的分析宽表。这篇文章就不绕弯子,从WHERE基本功讲到JOIN、聚合,再把窗口函数这个真正的进阶利器打开看一遍,希望能帮你建立一条完整的学习主线。
如果你是刚接触SQL的人,这篇文章能让你少走弯路;如果你已经会写不少查询但总被慢SQL、结果对不上折磨,里面讲的优化思路和常见误区应该也能对得上号。
1. 为什么很多人的SQL是“拼”出来的:集合思维才是查询的内功
老话说“思路决定出路”,在SQL上体现得特别明显。我见过太多人把SQL当Java、Python写:遇到需求,第一反应是“先查出这个,再循环那个,再根据条件判断”,于是在一条查询里塞子查询、塞CASE WHEN、塞各种关联,把简单问题硬做成了“俄罗斯套娃”。这种写法不能说完全错,但往往既慢又难维护,而且一旦业务逻辑稍微变一下,整段SQL就废了。
1.1 从过程式到声明式:SQL想问题的姿势完全不同
很多人没意识到,SQL和主流编程语言是两种完全不同的思维模式。
过程式语言的思路是“我一步一步做”:先打开文件、循环每一行、判断条件、累加变量、输出结果。SQL的思路是“我告诉你最终想要的集合长什么样”:给我2024年注册且有下单的用户,系统自己决定怎么去扫描、关联、排序。
用一句话概括:SQL是声明式的,不是过程式的。你声明“要哪些数据”,而不是“怎么拿这些数据”。
举个最典型的例子,需求是“找出2024年注册并且下过订单的用户名”,新手可能会写出这种:
sql复制SELECT DISTINCT u.name
FROM users u
WHERE u.id IN (
SELECT o.user_id
FROM orders o
WHERE o.user_id IN (
SELECT id FROM users WHERE registered_at >= '2024-01-01'
)
);
SQL本身没问题,能跑出结果,但读起来很费劲。稍微有点经验的人一眼就能看出,子查询里的 inner SELECT 完全可以直接放到外层JOIN之前做过滤。用集合思维的话,你首先会想:最终结果是一个“用户名集合”,这个集合来自“用户表”和“订单表”的交集,同时用户表还要满足注册时间条件。于是写法变成:
sql复制SELECT DISTINCT u.name
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.registered_at >= '2024-01-01';
两条语句的结果一样,但第二条明显更贴近业务语义:我需要的是一个集合,而不是一段“操作流程”。数据库优化器拿到第二条语句时反而更容易选择最优执行路径,因为它的语义更干净,没有多余嵌套。
1.2 当你“拼接”SQL时,数据库优化器也在遭罪
还有一个反向的问题:有些人写SQL时特别爱“炫技”,一个查询里堆五个子查询、两个CTE、三个窗口函数,仿佛写得越复杂越专业。SQL写出来不算完,数据库还得把它翻译成执行计划。你写得越绕,优化器能做的等价变换空间就越小。
我经常跟团队讲:不要以为自己写得长就是考虑得周全。SQL的艺术恰恰是能用最简单的语义表达出完整的需求,就绝不用多余结构。这条原则贯穿筛选、联结和分组统计的每一个环节。
不信你可以去EXPLAIN一下那种嵌套五层的查询,再看一眼同样需求用JOIN + WHERE实现的执行计划,很多情况下后者的预估行数和扫描方式会明显更优。这就是集合思维的实用价值,不止是好读、好维护,它还能直接影响查询性能。所以你问我学SQL第一课学什么,我一定会说:不是学某个函数,而是把你的思维方式从“逐行处理”切换成“集合变换”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. WHERE基本功:筛选条件怎么写,查询才不容易翻车
选好集合之后,第一个真正动手的操作就是筛选。别小看WHERE,这个子句的写法决定了你后面所有操作的准确性。我见过太多线上Bug,根因都是WHERE的一句话写歪了。
2.1 先记住逻辑执行顺序,很多问题迎刃而解
很多教材一上来就让你背SQL各子句的书写顺序:SELECT、FROM、WHERE、GROUP BY、HAVING、ORDER BY、LIMIT。这个当然要记,但更重要的是逻辑执行顺序:
FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT
注意,WHERE是在分组之前执行的,HAVING是在分组之后执行的。这个顺序看起来不起眼,实际上决定了你能不能理解“为什么WHERE里不能直接用聚合函数”,也决定了“为什么HAVING能过滤组,WHERE不能”。
一条实用的经验:能下推到WHERE的筛选条件,绝不要留到HAVING。因为WHERE提前过滤掉大量行,后面分组、排序、联结的负担都会小很多。很多慢查询的根源,就是把本可以在WHERE做掉的过滤写到了外层或HAVING里,导致数据库处理了大量无关数据。
2.2 NULL、BETWEEN和函数包裹:筛选条件里的三个大坑
第一坑:NULL。
SQL里的NULL不是0,也不是空字符串,它代表“未知”。所以判断一个字段是否为NULL,绝对不能写成 字段 = NULL,因为NULL = NULL的结果不是true,而是unknown。你需要用 IS NULL 或 IS NOT NULL。
sql复制-- 错误写法:查不出任何结果
SELECT * FROM users WHERE deleted_at = NULL;
-- 正确写法
SELECT * FROM users WHERE deleted_at IS NULL;
很多人刚学的时候都在这里栽过跟头,因为WHERE条件里一旦混入NULL判断,结果集会凭空少掉一批行,而且很难察觉。
第二坑:BETWEEN AND的边界。
BETWEEN a AND b 在SQL标准里是闭区间,也就是包含a和b本身。如果你拿它来处理日期区间,特别容易把下一天的零点数据多算进去。比如:
sql复制WHERE order_date BETWEEN '2024-01-01' AND '2024-01-31'
这条语句会把 2024-01-31 00:00:00 之后、2024-01-31 23:59:59 之前的数据都包含进来,如果订单时间带有时分秒,你可能把2月1日零点整的数据也算进去,但那已经不属于1月了。更稳的写法是半开区间:
sql复制WHERE order_date >= '2024-01-01'
AND order_date < '2024-02-01'
这样既不会漏,也不会多。处理“最近7天”这类需求同理,用 >= 当前日期 - 7天 AND < 当前日期 + 1天 这种方式控制边界,才不会出现时间边界上的脏数据。
第三坑:对索引字段套函数。
如果你在WHERE里写 WHERE DATE(order_date) = '2024-01-01',很多数据库会放弃该字段上的索引,因为它需要对每一行先执行DATE()函数,再做比较,索引帮不上忙。大数据量下这就是全表扫描的元凶。
正确做法是让字段本身处于裸比较状态,把计算放在条件值一侧:
sql复制WHERE order_date >= '2024-01-01'
AND order_date < '2024-01-02'
一句话总结:你动字段,索引就废;你动值,索引还能救。
2.3 去重到底用DISTINCT还是GROUP BY?
“sql语句去重查询”一直是搜索热词,但我发现很多人把DISTINCT当万能药,一看到重复就用它。这里得区分两种场景。
如果只是对整行或某几个字段去重,DISTINCT简单直接:
sql复制SELECT DISTINCT user_id
FROM orders
WHERE status = 'paid';
但如果你的目标不是“去掉重复行”,而是“按某个维度取最新/最大的一条”,DISTINCT就失效了。比如“每个用户最近一个订单”,用DISTINCT做不到,因为它没法告诉你“去重之后该保留哪一行”。这种场景要么用窗口函数ROW_NUMBER(),要么用关联子查询,后面章节会展开。
想明白一点:DISTINCT是去重,GROUP BY是分组聚合。它们的执行逻辑不同,GROUP BY会把行压缩成组,而且能和SUM、COUNT这类聚合函数配合。若只是去重,用DISTINCT更简单;如果还要算每个分组的总和、平均值,那就必须GROUP BY。这两者的边界搞清楚了,你写出去的SQL会明显干净很多。
3. 视图到底能不能加速?先搞懂“结果集复用”的边界
有一个问题在网络上反复出现:“视图可以加快查询速度吗”。每次看到这种问题我都想说,问这个问题的人大概率是还没分清视图和物化视图的区别。
3.1 普通视图只是“保存下来的查询”,不是“保存下来的结果”
普通视图的本质,就是把一条SELECT语句保存成一个虚拟表。你查询视图的时候,数据库要做的事情其实是把视图的定义SQL和你外面的查询SQL合并起来,再重新执行一遍。绝大多数数据库(MySQL、PostgreSQL)的普通视图都不存储实际数据,它存储的是“如何获取数据”的定义。
所以,指望普通视图本身带来性能提升,基本是误会。它真正的价值在于三点:
- 简化复杂查询:把常用的一堆JOIN封装成视图,让上游用户只查视图就行。
- 权限控制:只开放部分列或部分行给某些角色。
- 逻辑隔离:底层表结构改了,只要视图定义同步调整,调用方SQL不用大改。
比如你经常要把订单表和用户表拼在一起查,可以建一个订单用户宽表视图:
sql复制CREATE VIEW v_order_user AS
SELECT o.id AS order_id,
o.user_id,
o.total_amount,
o.ordered_at,
u.name AS user_name,
u.level AS user_level
FROM orders o
JOIN users u ON o.user_id = u.id;
以后查订单带用户名就直接从视图取。但你要知道,每次SELECT * FROM v_order_user WHERE user_level = 'VIP',数据库实际执行的是那段JOIN,只不过在视图外再叠加了一层过滤。
3.2 两层以上的视图嵌套,性能下降是从“看不见”开始的
普通视图最大的坑不是它不快,而是你感觉不到它慢在哪。如果A视图关联B视图,B视图又关联C视图,数据库优化器不一定总能像人一样聪明地把所有过滤条件下推到最底层。层数越多,产生的中间结果集可能越庞大,最终查询自然越来越慢。
我的建议是,视图最多套两层,超过这个深度就老老实实评估是不是该用物化视图,或者直接写一条大SQL。视图是用来“复用语义”的,不是用来“无限套娃”的。
如果业务上确实需要固定结果集反复查询,并且对实时性要求不高,那就得考虑物化视图。物化视图会把查询结果真正落盘存储,每次查它不需要重新执行底层SQL。很多数据仓库、OLAP数据库都有这个能力。相应的代价是:基础表更新后,物化视图需要手动或定期刷新,否则数据是旧的。它换来的是查询速度,付出去的是数据新鲜度。
所以回到那个问题:普通视图本身不能加速,真正的加速来自三件事——合理的索引、良好的过滤条件下推,以及用物化视图把高频查询结果“冻”起来。
4. 多表联结的本质:从笛卡尔积到JOIN,一场排列组合的游戏
如果说筛选是SQL的基本功,那JOIN就是SQL想从单表迈向多表时必须跨过的一道坎。我见过很多人在第一步就卡住,不是不会写JOIN语法,而是搞不懂JOIN到底在做什么。
4.1 先忘掉“交集”,回到笛卡尔积
很多人把INNER JOIN理解成“取两张表的交集”。这个类比不算全错,但如果只停在交集层面,你后面理解LEFT JOIN、理解为什么JOIN后行数会变多、理解为什么需要去重就会很别扭。
更本质的理解方式是:JOIN之前,数据库先对两张表做笛卡尔积。
什么是笛卡尔积?如果左表有N行,右表有M行,笛卡尔积就是把左表每一行都和右表每一行拼一起,最终得到N × M行。JOIN的ON条件,就是从这个庞大的组合结果里筛选出符合条件的行。
举个例子,用户表users有3行,订单表orders有2行,如果什么都不加直接FROM users JOIN orders,先得到一个6行的组合结果,ON u.id = o.user_id只留下真正“用户和订单对得上”的行。如果你不写ON,SQL就返回6行,通常不是你想要的结果。
为什么这个理解很重要?因为一旦你意识到JOIN的底层是笛卡尔积加过滤,就能理解两个经典问题:
- 为什么两张表JOIN之后行数可能比左表多?因为右表某用户有多条订单,笛卡尔积里这个用户和每个订单都拼了一遍,自然多行。
- 为什么ON条件写不好会出现“笛卡尔爆炸”?如果两张表分别有十万行,不加过滤直接JOIN,中间组合可能到亿级,数据库自然慢得离谱。
4.2 JOIN类型不是越多越好,够用且准确才重要
各种JOIN的区别,本质是“如何处理没有匹配上的行”。我把最常用的几种整理了一下,方便对照:
| JOIN类型 | 保留规则 | 典型应用场景 |
|---|---|---|
| INNER JOIN | 两边都能匹配上的行 | 订单有用户、明细有主表,通常最常用 |
| LEFT JOIN | 左表全部保留,右侧无匹配补NULL | 查所有用户及其订单,没下过单的用户也要出现 |
| RIGHT JOIN | 右表全部保留,左侧无匹配补NULL | 和LEFT对称,但多数团队习惯用LEFT代替 |
| FULL OUTER JOIN | 两边全部保留,无匹配补NULL | 合并外部数据源、找出两表差异,较少见 |
| CROSS JOIN | 不加ON,直接做笛卡尔积 | 生成商品×日期的组合维度表 |
你自己写查询时要先问一句:哪张表是主体?主体表的每一行是不是都必须出现在结果里?如果是,那多半要用LEFT JOIN,把主体表放左边。
举个例子,需求是“列出所有用户以及他们的订单数,没下过单的用户也要显示0”。这时用户表是主体,订单表是附加信息:
sql复制SELECT u.id,
u.name,
COUNT(o.id) AS order_cnt
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;
这里用INNER JOIN就会丢掉没下过单的用户,业务结果就错了。
4.3 一对多JOIN时,行会膨胀,统计会翻倍
这是多表JOIN里最隐蔽的坑。假设要统计每个用户的订单金额,你可能会先JOIN订单表再GROUP BY。但一个用户可能有多个订单,这没问题,因为订单本来就是多行。真正的麻烦是当一张中间表是A和B两个多对多关系的联合产物时,统计口径会重复计算。
典型的翻车案例是“订单明细关联退货记录”。一张订单明细可能对应一次退货,但也有可能对应多次售后记录。你如果直接把订单明細表LEFT JOIN售后记录表,行数会变多,这时再SUM订单金额,会把同一个订单金额重复加多遍。
解决方案是:先把售后记录按订单ID聚合到“每个订单的售后总次数/总金额”,再把它JOIN回订单表,这样就不会放大订单本身的行数。
sql复制SELECT o.id,
o.total_amount,
COALESCE(a.after_sale_amount, 0) AS after_sale_amount
FROM orders o
LEFT JOIN (
SELECT order_id, SUM(amount) AS after_sale_amount
FROM after_sale
GROUP BY order_id
) a ON o.id = a.order_id;
先聚合、再JOIN,可以避免聚合后JOIN造成的重复计算。这是多表JOIN里最值得养成肌肉记忆的套路之一。
5. 最容易翻车的多表场景:ON和WHERE的边界,你分清楚了吗
如果说上面还是JOIN的基础知识,那下面这个场景就是区分“会用JOIN”和“懂JOIN”的分水岭。很多线上数据Bug,不是语法写错,而是把条件放在了ON后面和WHERE后面,结果大不相同。
5.1 INNER JOIN里ON和WHERE等价,但LEFT JOIN不是
先明确一个结论:对INNER JOIN来说,ON和WHERE里的过滤条件在结果上是等价的。
sql复制-- 这两种写法结果一样
SELECT * FROM a JOIN b ON a.id = b.a_id AND b.status = 1;
SELECT * FROM a JOIN b ON a.id = b.a_id WHERE b.status = 1;
但对于LEFT JOIN,两者语义有本质差别。LEFT JOIN的ON负责决定“左表行和右表哪些行匹配”,即使右表没有匹配行,左表行本身依然会保留;而WHERE是在联结完成之后,对最终结果集再做一次过滤,这时候右表补出来的NULL行如果被WHERE过滤掉,左表的行也跟着消失了,也就反向丢失了主表数据。
5.2 一个例子看懂差别
假设users表有张三、李四、王五三个人,orders表里只有张三和李四有订单,且张三有一个VIP订单一个普通订单。现在想“列出所有用户,并显示其VIP订单信息”。
如果这样写:
sql复制SELECT u.name, o.total_amount
FROM users u
LEFT JOIN orders o
ON u.id = o.user_id
AND o.is_vip_order = 1;
结果会是:张三显示VIP订单,李四因为没有VIP订单所以显示NULL,王五因为压根没订单也显示NULL。三个用户都保留,符合“列出所有用户”的语义。
如果换成:
sql复制SELECT u.name, o.total_amount
FROM users u
LEFT JOIN orders o
ON u.id = o.user_id
WHERE o.is_vip_order = 1;
结果就只剩张三和李四??不,李四也会消失,因为他没有VIP订单,WHERE条件把o.is_vip_order为NULL的行直接过滤掉了。最终结果跟INNER JOIN没区别,王五、李四全没了,业务需求完全破功。
这个Bug在真实项目里出现频率极高。凡是看到LEFT JOIN后面紧跟WHERE去过滤右表字段,就要停下来问一句:你到底是想要“左表全保留,只是不匹配的显示为空”,还是“只要匹配成功的行”?如果只是“带过滤条件的外连接”,请把过滤条件放ON里。
5.3 “反连接”技巧:查左表中没有匹配行的数据
还有一个JOIN隐藏玩法值得单独说,就是利用LEFT JOIN加IS NULL来找出“左表里有,右表里没有”的行。比如查“从没下过单的用户”:
sql复制SELECT u.id, u.name
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;
原理很清晰:如果用户没有订单,LEFT JOIN后订单表的字段全是NULL。这里用 o.id IS NULL 就能精准把没订单的用户捞出来。相比之下,有人会用NOT IN子查询:
sql复制SELECT id, name FROM users
WHERE id NOT IN (SELECT user_id FROM orders);
但NOT IN在子查询结果包含NULL时会导致整个查询返回空集,埋雷概率很高。LEFT JOIN + IS NULL这套写法在做“差集”时更安全,也更容易跟其他JOIN组合,算是多表查询里的“万能螺丝刀”。
5.4 外联结不等于性能差,但别滥用
很多开发同学有个刻板印象:LEFT JOIN比INNER JOIN慢,所以能不用就不用。这话只对了一半。LEFT JOIN确实可能需要处理更多行,因为要保留所有左表行,还要给缺失匹配补NULL;但真正让性能崩掉的通常不是LEFT JOIN本身,而是你在JOIN时没在联结字段上建索引,导致数据库要做大量笛卡尔积加逐行匹配。
我建议的原则是:该用INNER JOIN就用INNER JOIN,该用LEFT JOIN就大胆用LEFT JOIN。前提是你明确知道自己要保留哪张表的完整行,并且ON字段两端都尽量有索引。比“不用LEFT JOIN”更重要的,是别用FULL OUTER JOIN做那些可以拆成UNION解决的场景——那个往往才是性能杀手。
6. 聚合、去重与慢SQL:从“查得对”到“查得快”
筛选和联结做得再熟练,如果聚合统计的姿势不对,结果照样会错;如果查询效率不考虑,线上一个报表接口能把数据库拖垮。这一部分我们把聚合统计和慢查询优化放在一起看,因为它们本质上是一件事的两面:先算得对,再算得快。
6.1 分组后SELECT的列必须“讲规矩”
GROUP BY之后,每一组只能保留一个汇总值,所以出现在SELECT里的非聚合列,要么写进GROUP BY,要么被包进聚合函数。这是一个硬性规则。不同的数据库对此严格程度不同,比如MySQL早期允许SELECT非聚合列但不报错,最终返回的是“组内随机一行”的值。这种不确定性特别可怕:今天查到的数据是对的,明天同一句SQL可能因为执行计划变化吐出不同结果。
sql复制-- 错误示范:department没进GROUP BY,也没包聚合函数
SELECT department, employee_name, COUNT(*)
FROM employees
GROUP BY department;
-- 正确写法:如果想看每个部门人数最多的员工,请用窗口函数或子查询
SELECT department, COUNT(*) AS emp_cnt
FROM employees
GROUP BY department;
6.2 COUNT、SUM里的统计陷阱
关于计数,有一个非常容易踩的坑:COUNT(*)和COUNT(某字段)并不完全等价。COUNT(*)统计集合内所有行数,不管某一列是不是NULL;COUNT(某字段)只统计该字段非NULL的行数。如果你以为两者一样,拿COUNT(user_id)去统计用户量,但user_id存在NULL,最终就会悄悄少掉几行。
SUM同理。SUM(amount)遇到全是NULL时会返回NULL,而不是0。很多人写报表时没加COALESCE,前端拿到NULL直接显示空白或者直接报错,用户看到的就是“金额消失”。
sql复制SELECT user_id,
COUNT(order_id) AS paid_order_cnt,
COALESCE(SUM(total_amount), 0) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY user_id;
另外,COUNT(DISTINCT 字段)能算不重复的个数,但在数据量大的时候会显著变慢。能用GROUP BY后COUNT(*)替代的场景,未必更优;具体使用要看业务复杂度。我的建议是:报表里偶尔用没问题,接口里高频查询要谨慎。
6.3 慢SQL优化:EXPLAIN会让问题现形
先别急着把慢SQL归咎于数据库不好或者服务器性能差。绝大多数慢查询,用EXPLAIN看一下执行计划就原形毕露了。MySQL可以用EXPLAIN,PostgreSQL可以用EXPLAIN ANALYZE,Oracle有执行计划工具。它们都能告诉你:查询走了哪些表、用的什么连接方式、预估扫描多少行、有没有用到索引、有没有临时表和文件排序。
我平时排查慢SQL,主要看几个点:
- type列:如果从
ALL变成ref或者eq_ref,说明索引开始生效。 - rows列:预估扫描行数,数值越大越危险。
- Extra列:出现
Using filesort、Using temporary就可能需要优化排序或分组逻辑。
最常见的几个优化手段:
第一,WHERE和JOIN条件涉及的字段,优先考虑加索引。尤其是JOIN的关联字段,两边都应该有索引,否则联结性能会很差。
第二,避免在索引字段上套函数或做隐式类型转换。比如字符串字段和数字比较,数据库可能把每行都转一遍,索引就失效了。
第三,不要一股脑SELECT *。你只需要三列,就别把一张20列的宽表所有字段都捞出来,这既浪费IO,又让覆盖索引无从谈起。覆盖索引是什么意思?就是查询所需字段全部包含在索引中,数据库可以只扫索引就返回结果,根本不用回表。
第四,排序、分组字段尽量和WHERE过滤字段配合,如果能走联合索引,会省掉Using filesort。
分享一个实际优化案例。有个订单列表页接口,表里几百万行,原来查询要扫全表,平均3秒。我加上了一个 (user_id, status, paid_at) 联合索引,再把SQL从SELECT *改成只查页面需要的字段,压测后降到100毫秒以内。没有改一行业务逻辑,只是让数据类型、索引策略和执行计划匹配上了。
6.4 慢查询日志不是用来“看”的,是用来“排除”的
很多数据库默认关了慢查询日志,或者开了但没人看。我建议新项目上线时就把慢查询日志打开,阈值先设1秒,后面根据业务再调。日志里记录了完整的SQL文本、执行时间、扫描行数,每个DBA或者后端开发都应养成定期翻慢查询日志的习惯。
有慢查询不可怕,可怕的是每一条慢SQL都要等用户投诉了才发现。把慢查询日志当成“查询体检报告”,每周花10分钟扫一遍,很多潜在性能问题会提前暴露。
7. 超越多数人的窗口函数:ROW_NUMBER、SUM OVER的实战拆解
如果你看到这里已经能把WHERE、JOIN、GROUP BY用得很熟练,就已经超过一大半日常写SQL的人了。但“掌握数据操控的终极武器”这个说法里,还有一个隐藏的高级玩家工具,就是窗口函数。很多编程多年的老手也不一定常用,但它解决的那类问题,恰好是普通SQL很难优雅表达的场景。
7.1 窗口函数和聚合函数最大的区别:不压行
普通GROUP BY会把一组行压缩成一行,窗口函数不会。它依然给每个原始行返回一个计算结果,只是这个计算结果是从一个“窗口”范围内算出来的。这个特性让它特别适合做排名、环比、累计、分组TopN等分析需求。
sql复制SELECT user_id,
total_amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY total_amount DESC) AS rn
FROM orders;
这条语句会为每个用户的每张订单生成一个编号,按订单金额从高到低排序,金额最高的订单编号是1。注意,结果里每个订单都还在,没有被压缩,只是额外多了一列序号。如果需要找每个用户金额最高的一笔订单,在外面套一层过滤WHERE rn = 1即可。
7.2 几个日常最刚需的窗口函数场景
第一个场景是“分组TopN”。比如每个品类下销量最高的3件商品。不用窗口函数时,你得写复杂的关联子查询;用窗口函数后逻辑非常清晰:
sql复制SELECT category_id,
product_id,
sales_amount
FROM (
SELECT category_id,
product_id,
sales_amount,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY sales_amount DESC) AS rn
FROM product_sales
) t
WHERE rn <= 3;
第二个场景是“累计求和”。比如统计用户从注册到每个月的累计消费金额,想看用户消费曲线。这时用SUM配合OVER里的ORDER BY,就能生成一个逐月累加的值:
sql复制SELECT user_id,
month,
monthly_amount,
SUM(monthly_amount) OVER (PARTITION BY user_id ORDER BY month) AS cum_amount
FROM user_monthly_spend;
第三类是排名函数 RANK() 和 DENSE_RANK()。它们和ROW_NUMBER()的区别在于:重复值怎么处理。ROW_NUMBER()会强行给出1、2、3、4这样唯一编号;RANK()遇到并列会跳过名次,得到1、1、3;DENSE_RANK()不跳号,得到1、1、2。搞业务排行时要选对函数,否则第一名并列时,第三名的序号会和你预期不一样。
这些窗口函数在标准SQL里已经支持得很好,MySQL 8.0、PostgreSQL、SQL Server都支持。如果你的项目还在用MySQL 5.7,心里要有点数:很多现代SQL写法没法用,需要靠变量或连接子查询绕路,这也是不少团队积极升级数据库版本的原因。
7.3 窗口函数和前面内容的组合拳
窗口函数不是用来替代WHERE和JOIN的,它通常在你已经把集合过滤、多表联结做完之后才登场。先用WHERE把订单时间范围收窄,再用JOIN把订单表和商品表连接上,最后用窗口函数对结果集做排名和累计分析。层级关系非常清楚。
还有一点值得注意:窗口函数尽量不要在同一个查询里嵌套使用,比如直接写SUM(SUM(x) OVER())这类结构,很多数据库会直接报错。稳妥做法是先算一层,包一层子查询,再在外层继续做窗口计算。这和前面说的SQL表达保持干净一致——每一层只做一件清晰的事。
8. 给正在学SQL的人:三条来自实战的练习建议
最后不讲新知识,分享几个我自己带人时反复强调的练习方法。SQL这门技能,看十篇教程不如亲手调通十段查询。但怎么练才有效,是另一门学问。
第一条建议:动手写任何SQL前,先用一句自然语言把目标说清楚。比如“我要找出2024年注册、下过至少3笔有效订单、总金额超过5000元的用户”。然后在纸上标出:数据源来自用户表、订单表;条件是注册时间、订单状态、订单数、订单总额;最终只输出用户名和总金额。把这句话理清了再写SQL,基本不会跑偏。
第二条建议:每写完一条查询,先预判结果行数和关键字段的取值范围,再执行。比如你写LEFT JOIN,就应问自己:结果行数会比左表多还是少?为什么?如果实际返回行数和预判不一致,那说明你对表结构和JOIN语义的理解出现了偏差,趁早排查。这个习惯能帮你省掉大量“对着结果发愣”的时间。
第三条建议:遇到慢SQL,不要蒙头改写法,先养成看执行计划的习惯。EXPLAIN输出里的type、key、rows、Extra是四个高频关键词,看懂它们比背100条“优化技巧”都管用。你想优化一个查询,前提是知道数据库到底把时间花在了哪个环节。
如果你按这条路线往前走,从基础筛选到多表联结,再到聚合和窗口函数,每一步都遵循“先理解集合,再琢磨语法”的原则,SQL的学习周期会比你想象中短得多。那些曾经困扰你的重复统计、LEFT JOIN丢数据、慢查询问题,也会慢慢变得不再神秘。
