直接上结论:这是我在后台开发和数据分析工作中每天都在用的SQL查询语句,从基础到进阶,从简单查询到复杂关联统计,全部拆开揉碎讲清楚。不管你是刚接触数据库的新手,还是写了几年SQL但总觉得细节不牢的老手,这篇文章就是帮你把常用的查询语句一次性理清。
正文里面的例子我尽量用了贴近业务的场景,比如订单、用户、商品这些最常见的表结构,每个SQL你都可以直接抄去改表名和字段用。涉及函数的部分会标注不同数据库的语法差异,免得你从MySQL换到SQL Server时踩坑。
1. 查询语句的核心:SELECT的完整骨架
1.1 完整查询语句的执行顺序
很多初学者刚学SQL时,觉得SELECT就是“查一下”,其实一条完整的SQL从写法到执行,顺序是有严格逻辑的。我见过太多人把WHERE和HAVING混用、把ORDER BY写在LIMIT后面结果排序错乱。先把这个骨架记住,后面所有内容都是在往里面填东西。
SELECT DISTINCT 列名列表 FROM 表名 JOIN 另一张表 ON 关联条件 WHERE 过滤条件 GROUP BY 分组字段 HAVING 分组后的过滤条件 ORDER BY 排序字段 LIMIT 行数限制这里最关键的是执行顺序和书写顺序不一样。SQL引擎实际上先做FROM和JOIN,把表关联起来,然后走WHERE逐行过滤,接着GROUP BY分组,之后HAVING过滤分组,再SELECT投影出列,最后才是ORDER BY排序和LIMIT截断。我以前排查一个慢查询,反复看WHERE条件没问题,后来发现是JOIN把数据量放大得太厉害,才知道FROM阶段才是性能瓶颈。
日常写查询时,只要记住这个口诀:先定表,再过滤,再分组,再排序,最后限量。
1.2 SELECT子句的细节:别用星号的坑
SELECT *是新手最喜欢写的,我也写过,但后来在真实项目里吃了亏。有一次只是想把一个表的数据导出来看一看,结果表有60多列,里面还带着几个TEXT大字段,查询直接把数据库IO拖满了。从那以后我定了规矩:生产环境查询一律显式列出字段名。
显式列名有几个实实在在的好处:
- 只读取需要的列,IO和内存开销明显降低
- 如果表结构调整,SQL不会因为字段缺失而暴露出莫名其妙的问题
- 结果集结构清晰,配合ORM映射时不容易出错
- 做UNION等集合操作时,列数量和类型必须对齐,显式列名方便检查
如果你真的想快速了解表的字段结构,可以查系统表或者用DESCRIBE命令查看,而不是直接SELECT *。
1.3 给列起别名的经验
别名用AS关键字,也可以省略AS直接空格加别名。我习惯写AS,因为可读性更好。别名的使用有几个技巧:
- 多表关联时,同名字段必须用别名区分
- 聚合函数列、计算列必须有别名
- 中文别名在MySQL里可以直接用反引号包裹,但在SQL Server里要用中括号
SELECT u.nick_name AS 用户昵称, COUNT(o.order_id) AS 订单总数, SUM(o.pay_amount) AS 总消费金额 FROM user u LEFT JOIN orders o ON u.user_id = o.user_id GROUP BY u.user_id, u.nick_name;2. WHERE过滤:把数据范围锁死
2.1 条件运算符的完整对照
WHERE子句是查询里用得最多的过滤手段。常用的运算符包括比较运算符、逻辑运算符、范围判断、集合判断、模糊匹配和空值判断。
我整理了一个速查表,平时写SQL拿不准时可以对照着看:
| 运算符 | 含义 | 示例 |
|---|---|---|
| = | 等于 | WHERE age = 18 |
| <> 或 != | 不等于 | WHERE status <> 1 |
| > < >= <= | 大小比较 | WHERE price >= 100 |
| BETWEEN ... AND ... | 闭区间范围 | WHERE create_time BETWEEN '2024-01-01' AND '2024-01-31' |
| IN () | 匹配集合内任意值 | WHERE city_id IN (1, 2, 3) |
| LIKE | 模糊匹配 | WHERE nickname LIKE '张%' |
| IS NULL | 判断空值 | WHERE email IS NULL |
| AND / OR | 逻辑与 / 或 | WHERE status = 1 AND amount > 10 |
| NOT | 取反 | WHERE NOT status = 0 |
2.2 LIKE模糊查询的三种通配符
模糊查询是排查数据时特别常用的一种方式,比如按姓名搜用户、按标题搜文章。通配符有三种:
%:匹配任意长度任意字符,包括0个字符。比如LIKE '张%'能匹配“张三”,也能匹配“张”本身;LIKE '%张%'匹配所有名字中带“张”的_:匹配单个任意字符。比如LIKE '张_'匹配“张三”,但匹配不了“张明伟”[]:匹配方括号内任意单个字符,只在部分数据库(SQL Server)里支持。MySQL不支持[],要用了可以用正则函数REGEXP代替
重要提醒:LIKE查询如果通配符在开头,即'%关键字'这种写法,索引基本失效。举个例子,搜索一个用户名字段上建的索引,WHERE name LIKE '张%'还能用索引,但WHERE name LIKE '%张%'就老老实实全表扫描了。数据量小无所谓,数据量大了就是几秒和几毫秒的区别。
2.3 NULL值处理:IS NULL和空字符串是两码事
这是极其容易踩坑的一个点。在SQL里,NULL代表“未定义”或“未知”,它不等于任何值,包括它自己。所以判断NULL必须用IS NULL,不能用= NULL,后者永远不会返回任何行。
更隐蔽的问题是NULL与空字符串的区分。比如用户输入了手机号又清空,有的接口存NULL,有的存'',这会导致WHERE phone = ''查不到NULL的行,WHERE phone IS NULL查不到空字符串的行。
我的处理习惯是:在设计表结构时明确规范,每个字段都要能预期它处于“没有值”时的存储方式。如果历史数据已经乱了,查询时就把两种情况都考虑进去:
-- 查询手机号未填写的用户,兼容NULL和空字符串的写法 SELECT user_id, nick_name FROM user WHERE phone IS NULL OR phone = '';2.4 用IN还是用OR
遇到要匹配N个值的情况,用IN比一串OR条件清晰得多,而且性能也更好。比如:
-- 推荐写法 WHERE status IN (1, 3, 5) -- 不推荐写法 WHERE status = 1 OR status = 3 OR status = 5当IN后面跟的是子查询时,如果子查询结果集很大,比如超过1000条,有些数据库对IN列表数量有限制,或者性能下降明显。这时候可以换成JOIN方式去关联,后面章节会讲到。
3. 排序和限量:结果集的控制技巧
3.1 ORDER BY多字段排序的优先级
排序看起来简单,但多字段排序很多人写错了方向。ORDER BY 字段1, 字段2的含义是先按字段1排,如果字段1的值相同,再按字段2排。这里最容易出错的场景是:字段1倒序、字段2正序的混合需求。
-- 按金额从大到小排,金额相同则按时间从旧到新排 SELECT order_id, user_id, amount FROM orders ORDER BY amount DESC, create_time ASC;注意DESC和ASC分别作用于自己的字段,而不是全局。我见过同事写成ORDER BY amount, create_time DESC,结果金额是升序,时间才是降序,完全反了。
3.2 TOP N和分页查询
不同数据库对限量查询的语法不一样,这个要分清:
| 数据库 | 语法 |
|---|---|
| MySQL | LIMIT offset, count 或 LIMIT count OFFSET offset |
| PostgreSQL | LIMIT count OFFSET offset |
| SQL Server | SELECT TOP N 字段列表 |
| Oracle | FETCH FIRST N ROWS ONLY 或 ROWNUM <= N |
分页查询的写法以MySQL为例:
-- 取第1页,每页20条 SELECT * FROM orders ORDER BY create_time DESC LIMIT 0, 20; -- 取第3页,每页20条,偏移量是 (3-1)*20 = 40 SELECT * FROM orders ORDER BY create_time DESC LIMIT 40, 20;这里有一个深坑:分页查询必须配合ORDER BY。不写ORDER BY时,数据库返回的顺序是不确定的,每次翻页可能看到重复数据。我排查过线上问题,就是有个报表分页没排序,用户翻几页下来订单数都对不上。
另外,MySQL的LIMIT在大偏移量下性能非常差,比如LIMIT 100000, 20,它需要先读取100020行再丢掉前100000行。数据量大的场景,更优的方案是用游标分页或基于主键定位:
-- 游标式分页:记住上一页最后一条记录的ID SELECT * FROM orders WHERE create_time < '2024-03-01 10:00:00' ORDER BY create_time DESC LIMIT 20;3.3 DISTINCT去重的正确姿势
DISTINCT会对结果集所有列组合去重。它适合快速看有哪些不同的值,但不适合在复杂查询里用来“去重留一条”。比如想查询每个用户最新一条订单,用DISTINCT根本实现不了,因为DISTINCT无法指定保留哪一行。
-- 查看订单表里一共有几个支付渠道 SELECT DISTINCT pay_channel FROM orders; -- 这种写法查的是 支付渠道+订单状态 的组合去重 SELECT DISTINCT pay_channel, order_status FROM orders;当DISTINCT和聚合函数一起用时,语义有细微差别,这个放到聚合统计部分详细讲。
4. 聚合统计:GROUP BY与HAVING的完整使用
4.1 五个聚合函数
聚合函数把多行数据汇总成一行,是报表统计的基石。常用的有五个:
| 函数 | 含义 | 注意事项 |
|---|---|---|
| COUNT(*) | 统计行数 | 包含所有行,不论NULL |
| COUNT(字段) | 统计该字段非NULL的行数 | NULL不计入 |
| SUM(字段) | 求和 | NULL值自动忽略,结果仍为数字 |
| AVG(字段) | 求平均值 | NULL不计入分母 |
| MAX/MIN | 最大值/最小值 | 数值、字符串、日期都可以比较 |
关于COUNT的误区,我看到很多人在统计行数时纠结用COUNT(1)还是COUNT(*),其实两者在主流数据库里执行计划几乎一样。真正要区分的是COUNT(*)和COUNT(具体字段),前者的统计口径是行数,后者的统计口径是该字段非NULL的数量。
4.2 GROUP BY的分组逻辑
GROUP BY把相同值的行归为一组,每组输出一行。这是个很形象的逻辑:列相同放一组,每一组算一个统计结果。
实践中有一个反复出现的需求:按天、按月、按年统计。在MySQL里可以通过DATE_FORMAT函数实现:
-- 按自然月统计订单量和总金额 SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, COUNT(order_id) AS order_count, SUM(pay_amount) AS total_amount FROM orders WHERE create_time >= '2024-01-01' GROUP BY DATE_FORMAT(create_time, '%Y-%m') ORDER BY month;需要注意:SELECT里出现的非聚合列,原则上必须都出现在GROUP BY里。MySQL默认的ONLY_FULL_GROUP_BY模式如果没开启,查询不会报错但结果可能毫无意义。我在某公司的分析库里碰到过这种历史遗留问题,很多SQL靠这种“宽松模式”跑出了错误结果,后来却没人发现。
4.3 WHERE和HAVING的分工
很多人在这个点上搞混。一句话区分:
- WHERE在分组之前过滤行,作用于每一行原始数据
- HAVING在分组之后过滤组,作用于每组聚合后的结果
所以同一条件放哪里,语义完全不一样。比如:
-- 查询金额超过100元的订单,再按用户分组统计 -- 这里先用WHERE过滤订单,只统计大额订单数 SELECT user_id, COUNT(*) FROM orders WHERE amount > 100 GROUP BY user_id; -- 查询总消费金额超过1000元的用户 -- 这里的1000是用户维度的汇总结果,只能用HAVING SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id HAVING SUM(amount) > 1000;两个SQL看起来都有100和1000这样的数字,但一个是行级过滤,一个是组级过滤。如果非要用WHERE实现第二个需求,没有太好的办法,必须派生表或子查询,HAVING就是为此而生的。
4.4 一个完整的统计案例
把前面的知识点串起来,写一个贴近实际业务的例子:统计2024年第一季度,每个支付渠道下,消费超过500元的用户数量。
SELECT pay_channel, COUNT(DISTINCT user_id) AS active_user_count FROM orders WHERE create_time BETWEEN '2024-01-01' AND '2024-03-31' AND order_status = 'paid' GROUP BY pay_channel HAVING COUNT(DISTINCT user_id) > 500;这里COUNT(DISTINCT user_id)表示对组内user_id去重后计数,避免同一用户下单多次被重复统计。这是一个很常见的需求,也是DISTINCT与聚合函数结合的正确场景。
5. JOIN多表查询:真正体现SQL价值的地方
5.1 各种JOIN类型一网打尽
单表查询只是基本功,实际业务里数据一定分散在多张表中。用户表、订单表、商品表、物流表、评价表……要拿出有意义的信息,必须把表关联起来。
JOIN的核心逻辑是:指定两张表之间的关联条件,让一行数据根据条件匹配到另一张表的行。根据匹配方式不同,分为几类:
| JOIN类型 | 结果集包含的内容 |
|---|---|
| INNER JOIN | 两张表中都能匹配上的行 |
| LEFT JOIN | 左表全部行 + 右表匹配行,右表无匹配则为NULL |
| RIGHT JOIN | 右表全部行 + 左表匹配行,左表无匹配则为NULL |
| FULL OUTER JOIN | 两张表的全部行,无匹配处为NULL |
MySQL原生不直接支持FULL OUTER JOIN,需要UNION实现。下面逐个说重点。
5.2 INNER JOIN:交集查询
INNER JOIN是日常最常用的JOIN方式。它只返回两张表中都满足条件的记录。
-- 查询有订单记录的用户及其订单信息 SELECT u.user_id, u.nick_name, o.order_id, o.pay_amount FROM user u INNER JOIN orders o ON u.user_id = o.user_id ORDER BY o.create_time DESC;这里的INNER可以省略,直接写JOIN,默认就是内连接。但为了代码可读性,我建议显式写INNER。
5.3 LEFT JOIN:左表为基准
LEFT JOIN是另一种高频用法。它返回左表的全部数据,右表匹配得上的列补全,匹配不上的列是NULL。
什么场景用LEFT JOIN?最常见的是“某表的数据要全展示,另一张表只是补充信息”。例如用户列表要显示每个用户最近的订单时间,没有订单的用户也要展示:
SELECT u.user_id, u.nick_name, MAX(o.create_time) AS last_order_time FROM user u LEFT JOIN orders o ON u.user_id = o.user_id GROUP BY u.user_id, u.nick_name ORDER BY last_order_time DESC;实战提醒:LEFT JOIN的WHERE条件一定要谨慎。如果写了WHERE o.order_status = 'paid',这个条件会把右表没匹配上的NULL行过滤掉,LEFT JOIN就变成了INNER JOIN,这是我在代码审查里最常抓到的问题。如果需要对右表的字段做条件限制,条件放到ON子句里:
-- 保留所有用户,只看每个用户已支付订单的数量 SELECT u.user_id, u.nick_name, COUNT(o.order_id) AS paid_order_count FROM user u LEFT JOIN orders o ON u.user_id = o.user_id AND o.order_status = 'paid' GROUP BY u.user_id, u.nick_name;5.4 自连接:与自身关联
自连接经常让人头皮发麻,但它威力巨大。它本质上是同一张表当两张表用,复制一份再按关联条件匹配。常见的场景包括:员工表里查上下级关系、分类表里查父子分类、连续登录天数统计等。
举个经典的例子,员工表里有emp_id和manager_id(该员工的上级),查每个员工的姓名及其上级姓名:
SELECT e.emp_name AS 员工姓名, m.emp_name AS 上级姓名 FROM employee e LEFT JOIN employee m ON e.manager_id = m.emp_id;自连接的关键是给同一张表起不同别名加以区分。管理不规范的表可能没有唯一的上级关联ID,此时需要用其他业务维度来关联,例如按时间区间判断。
5.5 多表JOIN的顺序建议
三张以上表关联时,JOIN顺序会影响性能,但一般情况下的最优策略是:先通过过滤条件缩小数据量,再关联小表到大表。比如:
-- 查询近30天已支付订单对应的用户昵称和商品名称 SELECT u.nick_name, g.goods_name, o.pay_amount FROM orders o JOIN user u ON o.user_id = u.user_id JOIN goods g ON o.goods_id = g.goods_id WHERE o.create_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND o.order_status = 'paid';这里的JOIN顺序是orders先关联user再关联goods,但实际的索引利用才是性能关键。JOIN的关联字段是否有索引,对查询性能影响巨大,没有索引的JOIN在大表场景下会直接拖垮数据库。这个在后面的优化章节展开。
6. 子查询与派生表
6.1 三种子查询类型
子查询就是嵌套在SQL内部查询,按返回值不同划分成三类:
- 标量子查询:返回单个值,一个数字或一个日期。常用在SELECT列或WHERE条件里
- 列子查询:返回一列多行。常用在IN关键词后面
- 表子查询:返回多行多列,即一张临时表。常用在FROM后面作为派生表
看三个例子就明白了:
-- 标量子查询:查询商品价格高于平均价格的商品 SELECT goods_name, price FROM goods WHERE price > (SELECT AVG(price) FROM goods); -- 列子查询:查询下过订单的用户 SELECT user_id, nick_name FROM user WHERE user_id IN (SELECT DISTINCT user_id FROM orders); -- 表子查询:按用户分组统计后,再过滤 SELECT user_id, total_amount FROM ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) AS t WHERE total_amount > 1000;6.2 IN子查询与EXISTS的取舍
在“查询A表中与B表有某种关系的记录”时,可以用IN也可以EXISTS。很多人纠结它们等价吗?
一个经验性原则:当子查询结果集非常小,或者外层表非常大而子查询表也有索引时,IN效果尚可;当子查询表数据量非常大,且外层表每行都需要判断时,EXISTS往往更优。因为EXISTS是逐行判断是否存在,一旦找到就终止,而IN需要先物化完整子查询结果集。
不过现在的数据库优化器重写能力比较强,很多场景下两者执行计划会被优化成一样。我的建议是:写哪个可读性好就先写哪个,遇到慢SQL再通过执行计划调整。
6.3 派生表与CTE的现代写法
传统写法中,子查询放在FROM后面被称作派生表。但嵌套多层后用起来很痛苦,一段SQL里全是括号和别名,容易看花眼。现代SQL提供了WITH子句,也叫CTE(公共表表达式),把每段子查询独立命名,代码清晰度大幅提升。
WITH monthly_stats AS ( SELECT user_id, DATE_FORMAT(create_time, '%Y-%m') AS month, SUM(amount) AS month_total FROM orders GROUP BY user_id, DATE_FORMAT(create_time, '%Y-%m') ), user_rank AS ( SELECT user_id, month, month_total, ROW_NUMBER() OVER (PARTITION BY month ORDER BY month_total DESC) AS rn FROM monthly_stats ) SELECT user_id, month, month_total FROM user_rank WHERE rn <= 3;这个SQL里,第一个CTE算每个用户每个月消费额,第二个CTE在每月里按消费额排名,最后取每月前三名。如果全塞进一个SQL里,阅读难度会指数上升,用CTE逐步拆解后一目了然。强烈建议日常写复杂报表时多使用CTE。
7. 窗口函数:数据分析的利器
7.1 窗口函数和GROUP BY的区别
窗口函数是处理“每条记录保留自身信息,同时又能看到统计汇总”这种需求的最好工具。它和GROUP BY最大的区别是:GROUP BY会把多行压成一行,丢失明细;窗口函数不会合并行,而是在保留所有行的基础上,额外生成一列统计结果。
-- 查询每个订单,并附带该用户所有订单的总金额 SELECT order_id, user_id, amount, SUM(amount) OVER (PARTITION BY user_id) AS user_total_amount FROM orders;PARTITION BY在这里相当于分组的作用,但它不压缩行数。每行都能拿到所属用户的总金额作为参考列。
7.2 排名三兄弟
排名是窗口函数最典型的使用场景,三个函数看起来很像,但细节不一样:
| 函数 | 排名逻辑 | 示例(分数100,99,99,98) |
|---|---|---|
| ROW_NUMBER() | 顺序编号,相同值随机排,编号不重复 | 1,2,3,4 |
| RANK() | 相同值排名相同,下一位跳跃 | 1,2,2,4 |
| DENSE_RANK() | 相同值排名相同,下一位连续 | 1,2,2,3 |
需求是“每个类别里查前几名”,三个函数都可以通过PARTITION BY + ORDER BY完成:
-- 每个商品分类下价格最高的3个商品 SELECT goods_id, goods_name, category_id, price FROM ( SELECT goods_id, goods_name, category_id, price, ROW_NUMBER() OVER ( PARTITION BY category_id ORDER BY price DESC ) AS rn FROM goods ) AS t WHERE rn <= 3;7.3 移动汇总与前后值对比
窗口函数的聚合能力还体现在移动计算上。比如计算每个用户最近30天的累计消费变化,或者对比上一笔订单的时间间隔:
-- 获取每个订单与其上一笔订单的下单时间间隔(分钟) SELECT order_id, user_id, create_time, LAG(create_time) OVER (PARTITION BY user_id ORDER BY create_time) AS prev_create_time, TIMESTAMPDIFF(MINUTE, LAG(create_time) OVER (PARTITION BY user_id ORDER BY create_time), create_time) AS interval_minutes FROM orders;LAG函数取出前一条记录的值,LEAD函数取出后一条记录的值。这在分析用户行为路径、判断连续登录场景中非常实用,比自连接写法简洁得多。
8. CASE WHEN与常用函数
8.1 CASE WHEN的两种写法
CASE WHEN本质是条件表达式,用于把逻辑判断放进查询里,根据条件返回不同值。写法有两种:
-- 简单CASE表达式(等值判断) SELECT order_id, CASE pay_channel WHEN 1 THEN '微信支付' WHEN 2 THEN '支付宝' WHEN 3 THEN '银行卡' ELSE '其他' END AS pay_channel_name FROM orders; -- 搜索CASE表达式(支持范围判断) SELECT order_id, amount, CASE WHEN amount < 100 THEN '小额' WHEN amount < 500 THEN '中额' WHEN amount < 1000 THEN '大额' ELSE '超大额' END AS amount_level FROM orders;简单CASE写法更紧凑,但只能做等值判断。搜索CASE写法更通用,范围、多条件组合都能处理,实际使用频率更高。
8.2 用CASE WHEN做行转列
CASE WHEN一个很实用的场景是做“行转列”,把一列中的多个值变成多列。比如统计各业务线的订单量,业务线是一个维度列,想让它变成几个独立的列:
SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, SUM(CASE WHEN business_line = '手机数码' THEN amount ELSE 0 END) AS phone_amount, SUM(CASE WHEN business_line = '家用电器' THEN amount ELSE 0 END) AS appliance_amount, SUM(CASE WHEN business_line = '服装鞋包' THEN amount ELSE 0 END) AS fashion_amount FROM orders GROUP BY DATE_FORMAT(create_time, '%Y-%m');这种写法在报表统计里特别常见,不少BI工具底层也是这么干的。
8.3 常用函数速查
不同数据库的函数名差异很大,这里以MySQL为主,同时标注通用部分。
字符串函数:
CONCAT('a', '-', 'b') -- 拼接字符串,结果为 a-b LENGTH('abc') -- 返回字节长度 CHAR_LENGTH('abc') -- 返回字符个数 LEFT(name, 3) -- 取左边3个字符 RIGHT(name, 3) -- 取右边3个字符 SUBSTRING(name, 2, 3) -- 从第2个字符开始取3个字符 REPLACE('abc', 'b', 'x') -- 替换,结果为 axc UPPER(name) / LOWER(name) -- 大小写转换日期函数:
NOW() -- 当前日期时间 CURDATE() -- 当前日期 CURTIME() -- 当前时间 DATE(create_time) -- 从datetime中提取日期部分 DATE_FORMAT(create_time, '%Y-%m-%d') -- 格式化日期 DATEDIFF('2024-03-01', '2024-02-01') -- 日期差值,结果为29 DATE_ADD('2024-01-01', INTERVAL 1 MONTH) -- 日期加1个月条件与转换函数:
IF(条件, 值1, 值2) -- 条件成立返回值1,否则返回值2 IFNULL(字段, 默认值) -- 字段为NULL时返回默认值 COALESCE(字段1, 字段2, 默认值) -- 返回第一个非NULL值 CAST(amount AS DECIMAL(10,2)) -- 类型转换排查数据时一定要小心日期格式字符串。MySQL里%Y-%m-%d和%y-%m-%d是有区别的,后者年份只有两位,统计跨世纪数据时会出错。我见过有人把2025年统计成25年,最后聚合结果直接错了。
9. 查询性能优化:让SQL跑得更快
9.1 EXPLAIN解读:定位慢查询的第一步
写SQL不能只看结果对不对,还要关心性能。当一个查询慢得无法接受时,第一步永远是查看执行计划。
MySQL用EXPLAIN,PostgreSQL用EXPLAIN ANALYZE,SQL Server用SET STATISTICS PROFILE ON。以MySQL为例:
EXPLAIN SELECT u.nick_name, o.pay_amount FROM orders o JOIN user u ON o.user_id = u.user_id WHERE o.create_time >= '2024-01-01';重点关注几个字段:
- type:访问类型。从好到差依次是system、const、eq_ref、ref、range、index、ALL。看到ALL就说明全表扫描,需要警惕
- key:实际使用的索引
- rows:预估扫描的行数
- Extra:Using filesort、Using temporary通常意味着查询排序或分组没有走索引,存在额外开销
9.2 索引使用的几个原则
走索引的查询和全表扫描的性能差距可能达到成百上千倍。索引设计是个大话题,但日常优化查询能立刻落地的原则有这些:
- WHERE条件里的列优先建索引
- ORDER BY和GROUP BY的列,尽量和索引顺序一致
- 多条件查询时考虑联合索引,注意最左前缀原则
- JOIN的关联字段两端尽量都建索引,类型必须一致
举个例子,WHERE category_id = 5 AND price > 100,如果建了(category_id, price)联合索引,过滤效率最高;如果单独建category_id索引,price还得回表过滤。
9.3 三个一定要避免的写法
有些写法会导致索引失效,查询变慢,属于长期踩坑的重灾区:
函数包裹列:WHERE条件中左列被函数包裹后索引失效。比如WHERE DATE(create_time) = '2024-01-01',全表扫描的可能性很大。正确写法是写范围条件:WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02'。
前缀模糊匹配:LIKE '%关键字'通配符在开头时索引失效,前面已经提到。
隐式类型转换:字段是字符串类型,查询条件用数字;或者字段是数字类型,查询条件用字符串。数据库可能会放弃索引,甚至得到错误结果。
-- 假如user_id是字符串类型,以下写法容易导致索引失效 WHERE user_id = 1001; -- 正确写法 WHERE user_id = '1001';9.4 分页深度偏移的优化
前面的章节提到过大偏移量的LIMIT问题,在生产环境中这是一个常见的性能杀手。解决思路有三种:
- 记住上一页的最后一条主键,用主键定位翻页
- 延迟关联:先查询出主键ID,再用主键ID关联回原表拿完整行
- 在业务上限制最大可翻页数
延迟关联写法示例:
-- 先只查符合条件的ID,再关联回明细 SELECT o.* FROM orders o INNER JOIN ( SELECT order_id FROM orders WHERE create_time >= '2024-01-01' ORDER BY create_time DESC LIMIT 100000, 20 ) tmp ON o.order_id = tmp.order_id;这种方式让最耗时的排序和偏移只发生在一张主键表上(数据小、有索引),再回表取明细,性能可以提升数倍。
10. 高频实战场景与完整案例
10.1 场景一:新用户首单统计
需求:统计2024年用户的注册时间、首单时间、首单金额。用窗口函数可以非常优雅地完成:
WITH first_order AS ( SELECT user_id, order_id, pay_amount, create_time, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY create_time ASC) AS rn FROM orders WHERE order_status = 'paid' ) SELECT u.user_id, u.register_time, f.create_time AS first_order_time, f.pay_amount AS first_order_amount FROM user u LEFT JOIN first_order f ON u.user_id = f.user_id AND f.rn = 1 WHERE u.register_time BETWEEN '2024-01-01' AND '2024-12-31';这里LEFT JOIN里带f.rn = 1很关键,它能在关联阶段直接过滤掉非首单记录,同时保留没有下过单的用户。
10.2 场景二:留存率计算
需求:统计2024年1月新增用户在其后第1个月、第2个月的留存数量。计算留存率本身可以拆解为两个独立统计,再用JOIN拼起来:
WITH jan_users AS ( SELECT user_id, DATE_FORMAT(register_time, '%Y-%m') AS register_month FROM user WHERE register_time >= '2024-01-01' AND register_time < '2024-02-01' ), monthly_activity AS ( SELECT user_id, DATE_FORMAT(create_time, '%Y-%m') AS active_month FROM orders WHERE create_time >= '2024-02-01' ) SELECT j.register_month, COUNT(DISTINCT j.user_id) AS base_users, COUNT(DISTINCT CASE WHEN a.active_month = '2024-02' THEN j.user_id END) AS retain_1m, COUNT(DISTINCT CASE WHEN a.active_month = '2024-03' THEN j.user_id END) AS retain_2m FROM jan_users j LEFT JOIN monthly_activity a ON j.user_id = a.user_id GROUP BY j.register_month;这种SQL在用户运营部门非常常见,核心思想是“先圈出新用户集合,再判断他们在后续时间段是否有活跃行为”。
10.3 场景三:订单数据质量检查
查询中的自查逻辑也经常用。比如排查金额异常过高、状态为已支付但没有支付时间等畸形数据:
-- 排查状态与时间矛盾的脏数据 SELECT order_id, user_id, order_status, create_time, pay_time FROM orders WHERE order_status = 'paid' AND pay_time IS NULL; -- 排查同一用户在同一秒内产生多笔订单的异常情况 SELECT user_id, create_time, COUNT(*) AS cnt FROM orders GROUP BY user_id, create_time HAVING COUNT(*) > 1;这就是我日常排查数据的标准动作,先把异常特征写成查询条件,快速定位问题行,再去查源头逻辑。
11. 常见问题与避坑指南
这一节整理了我在实际工作中最常遇到的SQL查询问题,写成速查表,方便在有疑问时直接对照。
| 问题表现 | 根因 | 解决方案 |
|---|---|---|
| 查询结果包含重复行 | 多表JOIN造成笛卡尔积放大 | 检查关联条件是否完整,是否漏了JOIN条件 |
| LEFT JOIN后行数变少 | WHERE误过滤右表字段 | 右表条件移入ON子句 |
| COUNT统计数值不对 | 误用COUNT(字段)与COUNT(*) | 明确统计口径,NULL是否有意义 |
| 分页出现重复或跳跃 | 没有ORDER BY稳定排序 | 补充分页排序字段 |
| 查询特别慢 | 未建索引 / 索引失效 / 大偏移分页 | EXPLAIN分析执行计划 |
| 日期范围查询部分数据丢失 | 边界条件使用不当 | 用[起始, 下一个起始)左闭右开写法 |
| 大小写导致匹配不到 | 数据库排序规则区分大小写 | 统一使用LOWER/COLLATE控制 |
| 子查询性能极差 | 依赖优化器重写,但大表场景下没优化好 | 改写为JOIN或CTE |
再补充两条我在审查别人SQL时见到最多的问题:
问题一:N+1查询没有合并成JOIN。很多初级开发者会在循环里逐条执行SQL查询,比如先查出100个用户ID,再在程序里循环100次查订单表。这种做法在数据量小时没感觉,但一旦用户量上百上千,数据库压力会瞬间上去。正确做法是用一条IN查询或JOIN把数据全部取出。
问题二:WHERE条件顺序影响“性能”。虽然数据库优化器会自动调整执行顺序,但把过滤效果最强的条件放在前面仍然是好习惯,尤其在复杂视图中,读取明确、维护清晰,出了问题也容易排查。
还有一点关于NULL的运算陷阱。NULL参与四则运算时,结果依旧是NULL。比如算amount * discount,如果discount字段有的是NULL,结果就是NULL,不是0。处理这类问题要养成习惯,先IFNULL兜底:
SELECT order_id, amount * IFNULL(discount, 1) AS final_amount FROM orders;这个坑在财务核算里特别致命,我经手过一个报表就是因为折扣率字段里有NULL值,最终金额一片空,害得业务部门以为是系统故障。
最后,SQL查询的进阶方向可以往后探索索引设计与执行计划深挖、存储过程与触发器的业务封装、时序数据库和列式存储的差异等方向。但对于日常开发、数据分析来说,把本文这些基础活用熟,已经能覆盖绝大多数的真实需求。真想练好,除了多用没有捷径。把自己手上最常跑的报表SQL拿出来,逐条用EXPLAIN过一遍,看看扫描行数能不能再降,逐步优化起来,比看多少文章都有用。