好久没系统整理SQL这块了,这次趁着重写lectrue2高级SQL讲义,我把这几年在项目里踩过的坑、调过的慢SQL、面试别人和被别人面试的经验全部过了一遍。如果你已经过了会写SELECT、JOIN、GROUP BY这个阶段,想往窗口函数、CTE、执行计划、动态SQL这些更硬核的方向走,这篇文章应该能帮你少走不少弯路。
先说清楚这篇文章解决什么问题:教你用更高级的SQL语法处理复杂查询,学会定位和优化慢SQL,搞懂动态SQL在真实业务里的落地姿势,顺带把高频面试题怎么答也盘一遍。适合刚入门想进阶的开发者、天天被慢查询折磨的后端,以及准备SQL面试的候选人。
1. 高级SQL的知识地图:从基础查询到复杂分析
1.1 为什么基础SQL不够用
很多人在基础SQL阶段的感觉是:增删改查全会,索引知道一点,JOIN也能写,但一到真正的业务需求就卡住。比如“查每个部门薪资排名前3的员工”“统计连续登录天数”“计算累计销售额”,用基础语法也能做,但写出来的SQL又臭又长,跑起来还特别慢。
核心问题在于基础SQL是面向“取数”设计的,而高级SQL是面向“处理”设计的。取数思维是:给我一堆符合条件的行。处理思维是:我要在数据集内部做排序、分组内比较、跨行计算、累积汇总。这两种思维的差距,决定了你的SQL是应用层写一堆Java/Python循环,还是数据库里一条语句干净利落地出结果。
我之前在项目里遇到一个统计报表需求,需要用每条订单算累计金额占比。基础写法是把数据查出来,在Java里遍历计算,200万行数据跑了将近10秒。换成窗口函数SUM() OVER()之后,SQL一条搞定,耗时降到了400毫秒。这个案例最能说明问题:不是数据量大了才需要高级SQL,而是你的分析需求从单行变成了“行与行之间的关系”,就必须升级语法工具。
1.2 高级SQL的核心能力分布
以我自己的理解,高级SQL的知识体系可以拆成四大块:
第一块是查询能力升级。包括CTE(WITH AS)、窗口函数、子查询嵌套、集合操作(UNION/INTERSECT/EXCEPT),以及各种高级过滤和去重技巧。这一块解决的是“怎么写”的问题,让复杂逻辑变得可读、可维护、性能可控。
第二块是性能优化。包括索引原理、执行计划解读、慢SQL定位、SQL改写技巧、并行查询调优。这一块解决的是“跑得快”的问题。很多开发者在语法层面已经很熟练,但写出来的SQL连索引都命中不了,数据量一上来就直接把数据库压垮。
第三块是动态SQL与工程化落地。包括MyBatis动态SQL、JDBC拼接、SQL模板引擎、批量处理和大事务处理。这一块解决的是“怎么落到业务代码里”的问题,属于高级SQL的项目实战部分。
第四块是逻辑陷阱与安全。包括NULL的坑、AND/OR优先级、NOT IN与NULL的相爱相杀、SQL注入原理和防范。这一块解决的是“不出错、不背锅”的问题。
1.3 学好高级SQL的正确姿势
我见过太多人学高级SQL的方式是刷题,刷了上百道题以后感觉全会了,但一到真实业务又歇菜。根本原因是刷题只训练了语法套路,没有训练优化器思维和业务拆解能力。
正确的进阶路径我总结为四步:第一步,把SQL执行顺序彻底搞清楚,FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT,这个顺序决定了你脑子里要建立的计算模型。第二步,学会看执行计划,每条SQL跑一下EXPLAIN,看懂走了什么索引、扫了多少行、有没有临时表和文件排序。第三步,把窗口函数和CTE练成肌肉记忆,因为这两样东西能解决80%的复杂查询需求。第四步,拿着慢查询日志里的真实SQL反复改写,对比优化前后的执行计划变化。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 查询能力升级:CTE、窗口函数与去重实战
2.1 WITH AS:让复杂查询变成搭积木
CTE(Common Table Expression)是我在实际项目里使用频率最高的高级语法。它的本质是给一段子查询起个名字,然后在后面的查询里反复引用。这样说可能有点抽象,我举个实际例子。
假设运营要一份报表:每个品类下销量前3的商品,同时还要带上该品类的总销量做对比。不用CTE的写法是把子查询嵌在JOIN里,一层套一层,别人根本看不懂。用CTE的话,先定义每个品类的销量排名,再定义品类汇总,最后JOIN出结果:
sql复制WITH product_rank AS (
SELECT
category_id,
product_id,
SUM(sales_amount) AS sales_amount,
ROW_NUMBER() OVER(PARTITION BY category_id ORDER BY SUM(sales_amount) DESC) AS rn
FROM sales_order
GROUP BY category_id, product_id
),
category_total AS (
SELECT
category_id,
SUM(sales_amount) AS category_amount
FROM sales_order
GROUP BY category_id
)
SELECT
a.category_id,
a.product_id,
a.sales_amount,
b.category_amount
FROM product_rank a
LEFT JOIN category_total b ON a.category_id = b.category_id
WHERE a.rn <= 3;
这个例子里CTE带来的好处特别明显:一是可读性,逻辑被拆成了一块块积木,从下往上读就能理解整个查询;二是可复用,同一个子查询可以被多次引用,不用重复粘贴一大段。还有一点容易被忽略,CTE可以做到递归查询,比如查组织架构树、BOM物料清单这类层级数据,递归CTE是标准解法。
需要提醒的是,CTE不是性能银弹。数据库优化器不一定把CTE结果物化,可能每次都重新执行一遍,所以如果一个CTE数据量特别大又被多次引用,反而要测试一下性能。我在SQL Server和PostgreSQL里都遇到过这种情况,遇到性能问题先看执行计划再说。
2.2 窗口函数:行号、排名与累计计算的利器
窗口函数是高级SQL和基础SQL最明显的分水岭。前面说到的输出序号、取TopN、排名对比,都是窗口函数的典型场景。
窗口函数的基本结构是:函数() OVER(PARTITION BY 分组字段 ORDER BY 排序字段)。PARTITION BY负责把数据分成多个“窗口”,ORDER BY决定窗口内数据的顺序。理解这一点很重要,因为窗口函数与GROUP BY的最大区别就是:GROUP BY会把多行聚合为一行,窗口函数则保留每一行,只是在行旁边附加一个计算结果。
我用最多的几个窗口函数:ROW_NUMBER()给每组内行编号,适合取TopN;RANK()和DENSE_RANK()处理并列排名,区别是RANK会跳号,DENSE_RANK不跳号;SUM()/AVG()/COUNT()配合OVER()做累计计算;LAG()和LEAD()取前一行或后一行的值,适合算环比、同比。
比如算每个用户每笔订单的累计消费金额:
sql复制SELECT
user_id,
order_id,
order_amount,
SUM(order_amount) OVER(PARTITION BY user_id ORDER BY order_date) AS cumulative_amount
FROM orders;
这里SUM OVER会从窗口起点一路累加到当前行,这就是“累计”两个字的核心。LAG函数做环比也很常用:
sql复制SELECT
sale_date,
sales_amount,
LAG(sales_amount, 1) OVER(ORDER BY sale_date) AS prev_day_amount,
ROUND((sales_amount - LAG(sales_amount, 1) OVER(ORDER BY sale_date))
/ LAG(sales_amount, 1) OVER(ORDER BY sale_date) * 100, 2) AS day_over_day_rate
FROM daily_sales;
窗口函数刚开始不太好理解,我建议你记住一个生活化类比:它就像你在Excel里给一列数据加了个自动筛选和排序,然后旁边多了一列公式,这列公式只对当前筛选组内的行生效,不会把多行合并成一行。
2.3 去重与空值处理:数据清洗的日常
热搜词里“sql语句去重查询”和“sql去除空值”出现频率很高,说明这是日常操作里最常见的需求之一。去重有三个层级,很多人只用了第一层。
第一层是DISTINCT,适合对整行或简单几个字段去重。第二层是GROUP BY,在处理配合聚合函数的去重时更好用。第三层是ROW_NUMBER()分区去重,适合那种“每个用户保留最新一条记录”的需求。
比如有一张用户登录日志表,同一个用户一天有多条记录,现在要取每个用户每天的第一条登录记录:
sql复制WITH ranked_log AS (
SELECT
user_id,
login_time,
login_ip,
ROW_NUMBER() OVER(PARTITION BY user_id, DATE(login_time) ORDER BY login_time) AS rn
FROM login_log
)
SELECT user_id, login_time, login_ip
FROM ranked_log
WHERE rn = 1;
这种方式比DISTINCT灵活得多,因为你可以保留去重之外的其他字段。
空值处理是另一个高频坑。NULL在SQL里代表“未知”,它不等于空字符串,也不等于0。很多人写过滤条件时用column = ''或者column != '值',结果发现NULL的行始终不出现。正确写法是IS NULL或IS NOT NULL,或者在比较前用COALESCE把NULL替换成默认值。
我在实际项目里还发现一个组合场景:统计时要把空值排除掉,但又要单独统计NULL的数量。这时候可以配合CASE WHEN:
sql复制SELECT
COUNT(*) AS total_records,
COUNT(phone) AS has_phone,
SUM(CASE WHEN phone IS NULL THEN 1 ELSE 0 END) AS no_phone_count
FROM customers;
COUNT(phone)在计算时会自动跳过NULL,这是个比较冷门但很实用的小特性。另外,在数据清洗阶段要区分业务上的“空字符串”和数据库里的NULL,我处理过很多脏数据都是空字符串混NULL,统一清洗时建议先把空字符串转成NULL,再统一判断处理。
2.4 BETWEEN、AND/OR和IN的逻辑陷阱
这块属于看着简单但实际中招率极高的知识点。先说BETWEEN AND,很多人以为它只是语法糖,但忽略了它包含边界值。BETWEEN 1 AND 10包含1和10,等价于column >= 1 AND column <= 10。查日期时这个坑特别大,如果字段是datetime类型,BETWEEN '2024-01-01' AND '2024-01-31'会漏掉1月31日00:00:00之后的数据,正确做法是写成BETWEEN '2024-01-01' AND '2024-01-31 23:59:59',或者用column >= '2024-01-01' AND column < '2024-02-01',后者是我更推荐的写法,能绕开一天中最后一秒数据丢失的边界问题。
再说AND和OR的优先级问题。SQL里AND的优先级高于OR,这导致一个经典错误:查询条件是“状态为1或者2,且类型为3”,有人直接写WHERE status = 1 OR status = 2 AND type = 3,结果因为OR优先级低,变成“status=1 或 (status=2且type=3)”,数据全乱了。解决办法就是加括号,WHERE (status = 1 OR status = 2) AND type = 3。
IN的坑主要出在子查询返回NULL的时候。WHERE column NOT IN (SELECT other_column FROM table2),如果子查询结果里包含NULL,整个查询返回空集。原因是SQL的三值逻辑:NULL参与比较时结果是UNKNOWN,NOT IN遇到NULL会全部变成不满足条件。推荐用NOT EXISTS替代NOT IN,既安全性能也更好:
sql复制-- 这样写有问题
SELECT * FROM orders
WHERE customer_id NOT IN (SELECT customer_id FROM blacklist);
-- 推荐这样写
SELECT * FROM orders o
WHERE NOT EXISTS (SELECT 1 FROM blacklist b WHERE b.customer_id = o.customer_id);
3. 慢SQL优化:读执行计划,改索引,调并行
3.1 慢SQL从哪来:先定位再动手
接到慢SQL反馈,第一步不是看SQL语法,而是先确认到底慢在哪。慢查询日志是最直接的入口。MySQL里打开慢查询日志并设置阈值,跑一段时间就能抓到那些超过阈值的SQL。下面这段配置是我在实际环境里用的:
ini复制slow_query_log = ON
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = ON
long_query_time设置成1秒,超过1秒的查询都会被记录下来。这个配置还能把没走索引的查询也记下来,这类SQL可能当前数据量小看不出问题,但数据一涨就最先崩。
拿到慢SQL之后,我一般会按这几类原因排查。第一类是没有走索引,比如WHERE条件列没建索引,或者写了让索引失效的写法。第二类是扫描行数太大,全表扫了上百万行最后只返回10行。第三类是排序和临时表开销,比如ORDER BY没走索引、GROUP BY生成临时表、多表JOIN关联字段类型不一致。第四类是返回了不必要的数据,典型就是SELECT *,把几百列的无关字段全部捞出来。
定位阶段最忌讳直接凭感觉改SQL。有一次同事跟我说一条查询特别慢,因为子查询太多,结果改成JOIN之后更慢了。后来看了一下执行计划,才发现真正慢的原因是驱动表选错了,跟子查询没关系。所以先定位,再动手,这是铁律。
3.2 读懂执行计划的关键指标
执行计划是数据库告诉你“我打算怎么执行你的SQL”的说明书。不同数据库查看方式不同,MySQL用EXPLAIN加SQL,PostgreSQL用EXPLAIN ANALYZE,SQL Server是SET STATISTICS PROFILE ON。但核心指标是相通的。
我最先看的是type列,它反映了表的访问方式。从好到差大概是:const、eq_ref、ref、range、index、ALL。const和eq_ref说明走了主键或唯一索引,性能最好;ref是普通索引查找,也不错;range是索引范围扫描,比如BETWEEN、IN、>这类;index是扫描了整棵索引树,虽然没全表扫但也不理想;ALL是全表扫描,这种情况数据量一大基本就跑不动了。
然后是key列,表示实际用到的索引。这一列如果是NULL,说明没走任何索引。rows列是预估扫描行数,这个数字越大,查询越可能慢。Extra列里出现Using filesort或者Using temporary要特别警觉,这代表排序和去重/分组都用了临时文件,数据量大时会非常慢。
举个实际例子。有一张订单表,查询条件是WHERE status = 1 AND order_date >= '2024-01-01',原本是ALL全表扫,扫描行数87万。后来建了复合索引(status, order_date),执行计划变成ref,rows降到2300,查询时间从1.8秒降到30毫秒。这个例子最直观说明了索引和扫描行数的关系。
3.3 索引优化与常见违规写法
索引优化是慢SQL优化的核心。我经常用字典目录来类比索引:没有目录的字典要一页页翻才能找到字,有目录就能先定位页码再翻到对应页。数据库索引底层一般是B+树,它的特点是叶子节点有序存储,因此不仅支持快速查找,还支持范围查询和排序。
复合索引的“最左前缀原则”是个高频踩坑点。索引(a, b, c)能快速命中a、a+b、a+b+c三种查询组合,但如果你直接查b或者c,这个索引基本用不上。类似地,查询条件里的字段顺序也需要考虑,但更重要的是遵循最左前缀。我见过很多新人建了复合索引,结果查询条件根本没用到第一列,索引白白浪费。
常见的索引失效写法,我列成表格方便你对照自查:
| 违规写法 | 具体示例 | 原因 |
|---|---|---|
| 对索引列做函数运算 | WHERE DATE(create_time) = '2024-01-01' | 索引存的是原始值,函数计算后无法匹配 |
| 隐式类型转换 | WHERE phone = 13812345678 (phone是varchar) | 数据库做了类型转换,索引失效 |
| LIKE前缀通配符 | WHERE name LIKE '%张%' | 无法利用B+树的有序性从中间开始匹配 |
| OR连接非索引条件 | WHERE status = 1 OR remark = 'x' | 必须同时扫索引和全表,优化器可能放弃索引 |
| 索引列参与计算 | WHERE price * 0.8 > 100 | 索引无法走搜索条件 |
针对LIKE模糊搜索,如果业务确实需要中间匹配,一般方案是改用全文索引或者搜索引擎。日期范围查询建议写create_time >= '2024-01-01' AND create_time < '2024-02-01',这样既能让函数类写法失效的问题绕开,也能保证边界值不错。
覆盖索引是性能优化的隐藏技巧。让查询的字段都包含在索引里,数据库可以直接扫索引拿到结果,不用回表查原始行。比如业务经常要查status和order_date,建了(status, order_date)复合索引后,查询这两列就直接走覆盖索引,效率非常高。
3.4 并行SQL优化与UPDATE场景注意
关于并行SQL,先说一个前提:并行不是解决慢查询的万能钥匙。它是让一个SQL在执行时拆成多个子任务,用多个CPU核心同时处理。数据量大但逻辑简单的聚合查询收益最明显,比如几千万行做SUM、AVG。但如果SQL本身已经存在严重的不合理扫描,并行只是把一个错误执行计划加速了,反而更糟。
不同数据库开启并行的方式不同。PostgreSQL可以通过设置work_mem和并行度参数来提升聚合查询性能,MySQL 8.0在部分场景支持并行查询,SQL Server可以设置MAXDOP。我的建议是先用非并行方式把SQL和索引优化好,再考虑并行。我在一个几千万行的报表查询上做过测试:优化索引前开并行,耗时2.4秒;优化索引后不开并行,耗时0.9秒。先把基础优化做扎实,并行才有意义。
UPDATE场景有个热搜词是“sql更新一个表中列为另外一个表中的列”,这是跨表更新,写法在不同数据库有一些差异。MySQL支持UPDATE JOIN:
sql复制UPDATE orders o
JOIN customers c ON o.customer_id = c.customer_id
SET o.customer_level = c.level
WHERE c.level IN ('A', 'B');
PostgreSQL和SQL Server有UPDATE FROM写法:
sql复制UPDATE orders
SET customer_level = c.level
FROM customers c
WHERE orders.customer_id = c.customer_id
AND c.level IN ('A', 'B');
这里最重要的经验是:更新操作务必先SELECT确认关联关系和影响行数,再改成UPDATE执行。尤其是大批量更新,先跑一条相同WHERE条件的SELECT COUNT(*),确认影响范围,然后分批更新。我处理过一个不小心把全表更新错的案例,就是少了这个确认步骤,教训相当深刻。
4. 动态SQL与复杂业务落地:从MyBatis到安全底线
4.1 动态SQL的本质与实现方式
动态SQL,说白了就是运行时才拼接完整的SQL语句。为什么要动态?因为业务条件不固定,比如搜索页面有多个筛选项,用户可能只填了其中一个,也可能填了三个,SQL的WHERE条件必须根据用户输入动态拼出来。
实现动态SQL的方式有几种:最原始的是在Java里手动拼字符串,用if判断拼条件,缺点很明显,空格、逗号、AND这类细节很容易出错,而且有SQL注入风险。稍微规范一点的是用模板引擎,比如Velocity、Freemarker生成SQL模板。再往上就是ORM框架自带的动态SQL能力,MyBatis的
我自己的经验是:如果项目能用MyBatis动态SQL,就不要手动拼字符串。MyBatis的标签设计覆盖了绝大多数场景,而且能自动处理多余的AND和逗号,减少低级失误。
4.2 MyBatis动态SQL实战写法
先看一个最常见的多条件组合查询。用户在前端不固定选择姓名、状态、创建时间范围,用MyBatis怎么处理?核心是
xml复制<select id="searchOrders" resultType="OrderDO">
SELECT id, order_no, customer_name, status, create_time
FROM orders
<where>
<if test="customerName != null and customerName != ''">
AND customer_name LIKE CONCAT('%', #{customerName}, '%')
</if>
<if test="status != null">
AND status = #{status}
</if>
<if test="startTime != null">
AND create_time >= #{startTime}
</if>
<if test="endTime != null">
AND create_time <= #{endTime}
</if>
</where>
ORDER BY create_time DESC
</select>
这段话里有两个细节值得提醒:一是XML里大于小于号要转义成>和<,否则XML解析会报错;二是
批量插入是另一个高频场景,核心是
xml复制<insert id="batchInsert">
INSERT INTO order_item(order_id, product_id, quantity, price)
VALUES
<foreach collection="items" item="item" separator=",">
(#{item.orderId}, #{item.productId}, #{item.quantity}, #{item.price})
</foreach>
</insert>
用
4.3 动态SQL的安全底线:防注入与权限控制
动态SQL最大的坑就是SQL注入。原理很简单:如果直接把用户输入拼接进SQL,用户输入的恶意内容可能改变整个SQL语义。比如密码验证时本来要执行SELECT * FROM users WHERE username = 'admin' AND password = 'xxx',如果password被用户拼成'1' OR '1'='1',就成了恒真条件,相当于万能密码绕过,这类攻击在热搜词里也出现了。
防御的核心是参数化查询。MyBatis里用#{}就是参数化占位符,最终会以预编译参数的形式传给数据库,输入内容永远只是数据,不会被当成SQL执行。而${}是字符串替换,直接把值拼进SQL语句,存在注入风险。我的铁律是:能用#{}的地方绝不用${}。
但有些特殊场景确实必须用${},比如动态表名、动态排序字段。因为数据库的表名和列名不能参数化绑定。这种场景的正确做法是白名单校验:先定义一个允许的表名或字段名集合,传入的值必须在这个集合里,否则拒绝执行。我在项目里写过一个排序字段白名单校验,传入的排序字段只能从预先定义好的字段集合里选,从源头上堵住了注入路径。
还有一个容易被忽视的安全点是权限控制。很多数据库账号用的是高权限账号连接业务库,动态SQL一旦拼接错误,影响面就变成了整库。建议业务账号只授予必要库表的SELECT、INSERT、UPDATE权限,避免DROP、TRUNCATE等高危操作,这在源头减少了误操作和注入后的破坏能力。
4.4 复杂业务落地:SQL文件导入、格式化与工具链
日常开发和排查SQL时,合理的工具链能省下大量时间。DBeaver和HeidiSQL是两款很常用的数据库客户端。DBeaver支持多种数据库,适合开发时连接不同类型的库;HeidiSQL轻量级,在Windows环境下连接MySQL很顺手。导入SQL文件时,如果文件较大,建议先在客户端里执行SET FOREIGN_KEY_CHECKS = 0关闭外键检查,导入完成再打开,可以避免外键顺序导致的导入失败。
SQL格式化工具也值得提一下。公司在代码评审时一般有风格规范,但手写SQL很难保持统一格式。可以用SQL格式化插件或在线工具统一大小写和缩进,让代码可读性高很多。我个人在IDE里装了个SQL格式化插件,写完顺手格式化再提交,评审体验会好很多。
复杂业务场景里还有一个新方向是自然语言转SQL。现在不少团队在尝试用大模型Agent把业务人员的自然语言查询转换成SQL,本质上是把“用户说人话”翻译成“数据库听得懂的话”。这个方向对高级SQL能力的要求反而更高了,因为你需要能判断模型生成的SQL对不对、性能好不好、有没有安全风险。即使工具再智能,底层不懂执行计划、不懂索引优化,还是没法用好这些能力。
5. 高级SQL常见问题排查与面试考点
5.1 高频报错与排查记录
我在日常工作中积累了一批高频报错,整理成速查表,遇到对应问题可以直接对照排查:
| 报错信息 | 可能原因 | 排查建议 |
|---|---|---|
| [28000]用户'sa'登录失败 | SQL Server账号密码错误或认证模式不对 | 检查是否启用了混合认证模式,确认密码和账号权限 |
| Named Pipes Provider无法连接 | 数据库服务未启动或连接字符串配置错误 | 换TCP/IP协议连接,检查服务状态和端口 |
| 函数名或列名不存在的语法错误 | 多表关联时字段归属没写清楚 | 给每张表设置别名,用别名点字段 |
| 值不能为NULL的字段插入NULL | 数据源有脏数据 | 先用SELECT查出来,配合COALESCE清洗 |
| 磁盘空间不足 | 临时表、日志文件过大 | 清理日志,检查临时表空间配置 |
| 查询超时或执行计划不稳定 | 统计信息过旧 | 更新统计信息,重新分析表 |
SQL Server的“用户sa登录失败”我在接手旧项目时遇到过多次,大部分情况是安装时选了仅Windows认证模式,解决方案是用Windows管理员身份登录后,把服务器认证模式改成“混合模式”,然后执行命令重置sa账号密码。SQL Server 2019安装时如果选择了机器学习服务器组件,装完后还要单独处理组件依赖,热搜词里有人遇到,这个组件默认不安装Python和R环境,需要在安装程序里勾选,否则后续无法使用。
如何打开*.sql文件也是新人高频问题。最常见方案是直接用数据库客户端导入执行,DBeaver、HeidiSQL、Navicat都支持。也可以用记事本或VS Code打开查看内容,但如果你在Windows里双击.sql文件默认用记事本打开,注意不要误以为是编辑普通文本,文件大的话打开会卡顿。
5.2 SQL面试题实战拆解
高级SQL面试题翻来覆去就那几类,核心考点就是窗口函数和CTE的运用。我选三道最有代表性的拆解一下。
第一道是“求每个部门薪资前三名的员工”。经典解法用ROW_NUMBER或DENSE_RANK,根据是否允许并列排名选择。用DENSE_RANK更稳妥,因为薪资相同应该算同一名次:
sql复制WITH emp_rank AS (
SELECT
dept_id,
emp_name,
salary,
DENSE_RANK() OVER(PARTITION BY dept_id ORDER BY salary DESC) AS rn
FROM employee
)
SELECT dept_id, emp_name, salary
FROM emp_rank
WHERE rn <= 3;
第二道是“求连续登录天数”。这个题的技巧是用登录日期减去行号,如果日期连续,减出来的日期就是同一个值:
sql复制WITH daily AS (
SELECT DISTINCT user_id, login_date
FROM login_log
),
ranked AS (
SELECT
user_id,
login_date,
DATE_SUB(login_date, INTERVAL ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_date) DAY) AS grp
FROM daily
)
SELECT user_id, MIN(login_date) AS start_date, MAX(login_date) AS end_date, COUNT(*) AS continuous_days
FROM ranked
GROUP BY user_id, grp
HAVING COUNT(*) >= 3;
这套“日期减行号分组”的玩法,是我见过面试里最经典的连续性问题解法,理解了原理之后,连续签到、连续消费、连续活跃等变体都能套。
第三道是“行转列”和“列转行”。统计每个月的销售额并转成月份字段:
sql复制SELECT
year,
MAX(CASE WHEN month = 1 THEN amount END) AS jan_amount,
MAX(CASE WHEN month = 2 THEN amount END) AS feb_amount,
MAX(CASE WHEN month = 12 THEN amount END) AS dec_amount
FROM monthly_sales
GROUP BY year;
这道题考察的是对CASE WHEN和分组聚合的综合运用,在报表类项目里非常常见。
5.3 避坑经验清单
最后分享一份我积累出来的避坑清单,每条都是真实项目里踩过的。
第一,不要轻易对线上大表执行无WHERE条件的UPDATE或DELETE。如果需要清理历史数据,先分成小批次,每条SQL加LIMIT限制删除行数,然后循环执行,避免锁表时间过长和事务日志暴涨。
第二,查询别忘了只取需要的字段。SELECT *在开发时方便,但上线后如果表有几十个字段,IO开销差距不小,而且覆盖索引用不上,回表次数增加。
第三,写完复杂SQL先跑EXPLAIN再上线。我养成习惯后,基本能在发布前拦截90%的慢查询。哪怕只是临时查数,也值得花几秒钟看一眼执行计划。
第四,小心NULL参与的比较和运算。特别是COUNT(字段)、NOT IN、字符串拼接等等。统计时要清楚NULL会被忽略,NULL加任何数还是NULL,一旦业务逻辑里隐藏着NULL,排查起来非常费劲。
第五,动态SQL一定要用参数化写法,无论是MyBatis还是JDBC。安全红线不能碰,一旦SQL注入在线上爆发,轻则数据泄露,重则整库被删。
第六,数据库的时间字段和时区问题。不同数据库对时间类型的处理有微妙差异,跨库迁移数据时一定要测一下边界值,比如“当天最后一条数据”这类查询,因为时间精度不匹配很容易漏数据。
写在最后
高级SQL这条路,往上走空间真的很大。从能写出窗口函数到能读懂执行计划,从会用MyBatis动态SQL到能守住SQL注入的安全底线,每一步都能在真实项目里转化成可衡量的性能提升和稳定性收益。我个人体会最深的一点是:高级SQL不是背出来的,而是拿真实数据、真实慢查询、真实业务场景一遍遍磨出来的。遇到一个慢查询就多问一句为什么,遇到一道面试题就深挖一层原理,久而久之,数据库在你眼里就不再是一个黑盒,而是可以精确预测和控制的计算引擎。
