笔记做到第二期,我越来越发现一个事实:SQL写不好,大多不是语法背得不熟,而是脑子里没有建立“从业务问题到查询结构”的映射。第一期我们聊了SELECT、JOIN、GROUP BY这些基础部件怎么拼装,这期我把最近工作中踩过的坑、帮同事排查过的报错、以及面试里反复被问到的点,整理成几块相对独立但又相互关联的内容。每一块我都会给出可复现的示例和排查思路,不是教科书式的罗列,而是“当时我是怎么想的、后来怎么改的、改完效果如何”。
这套笔记适合三类人:正在系统学SQL但总觉得“看得懂、写不出”的初学者,写SQL能跑但一遇到慢查询和报错就发怵的进阶者,以及准备面试前想快速过一遍核心知识点的求职者。内容不需要你一次性读完,按需取用即可。
1. 写查询前先做一件事:把业务需求翻译成查询骨架
我见过太多人拿到需求就开写,结果写了三行发现卡住了,又回来改。其实SQL查询的正确打开方式不是动手敲代码,而是先在纸上(或脑子里)完成一次“翻译”:用户说的业务语言,对应哪一类SQL结构。这一步做扎实了,写出来的代码不仅正确率高,后期维护也省事。
1.1 从业务动词映射到SQL关键字
需求里出现“哪些用户下了超过5单”,这里藏着两个信息:一是“哪些用户”告诉你最终要返回用户维度,二是“超过5单”暗示需要聚合计数。于是骨架就出来了:FROM users + JOIN orders + GROUP BY user_id + HAVING COUNT(*) > 5。如果直接写成:
sql复制SELECT user_id, COUNT(*) AS order_cnt
FROM orders
GROUP BY user_id
HAVING order_cnt > 5;
执行没问题,但如果需求还有“把用户姓名、注册时间也带出来”,你就得回头去JOIN用户表。我现在的习惯是:先确定“主语”(返回哪张表的哪些列),再确定“动词”(是过滤、聚合、还是按需关联),最后才写SELECT那一行。顺序反了,八成要返工。
有一个很典型的情况就是“去重”。需求说“找出所有下过单的不同用户”,很多人第一反应是SELECT DISTINCT user_id FROM orders。这个写法没错,但如果你还想同时知道每个用户的订单数,DISTINCT就帮不上忙了,得换成GROUP BY user_id。反过来说,如果只是单纯去掉结果里的重复行、不做任何聚合,GROUP BY会显得多余且语意误导。所以DISTINCT对应“结果集去重”,GROUP BY对应“按维度聚合”,两者服务的业务问题完全不同。我见过有人在只需要去重的地方用了GROUP BY,结果在线上被人追问“你按这个分组到底是想算什么”。
1.2 BETWEEN的边界问题:一个坑了我一整天的日期区间
BETWEEN AND的用法看起来太简单了:WHERE order_date BETWEEN '2024-01-01' AND '2024-12-31'。但请注意,BETWEEN是闭区间,等价于>= '2024-01-01' AND <= '2024-12-31'。如果order_date是DATETIME类型,那么2024年12月31日这天里任何时间大于00:00:00的数据都会被排在外面。也就是说,你以为查的是整个2024年,实际丢掉了12月31日几乎全天。
我在一次对账场景里就是这样漏了数据,后来排查到凌晨才发现是日期边界问题。从那以后,凡是查“某一天”或“某个月”的区间,我统一改用左闭右开写法:
sql复制WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01'
换成时间戳也一样成立:>= '2024-01-01 00:00:00' AND < '2024-01-02 00:00:00'。这个写法语义清晰、不依赖字段是DATE还是DATETIME,而且还能顺便利用上索引——有些老版本的优化器对BETWEEN的处理没那么聪明,虽然现在的MySQL、PostgreSQL基本都能正确优化,但“左闭右开”可读性更好,团队评审时一眼就能看出边界逻辑。
1.3 WITH AS:把嵌套子查询拆成人话
遇到一个查询里嵌了两三层子查询,新手最容易懵,因为括号一多,根本分不清哪个子查询属于哪一层。我强烈建议用CTE(Common Table Expression)来拆,也就是WITH AS的写法。它本质上就是给一段子查询起个名字,让主查询可以反复引用,编译器会把它当成临时结果集处理。
举个例子,需求是“找出消费总额大于1000且最近30天有登录的用户”。直接写:
sql复制SELECT u.id, u.name, s.total_spend
FROM users u
JOIN (
SELECT user_id, SUM(amount) AS total_spend
FROM orders
WHERE order_status = 'paid'
GROUP BY user_id
) s ON u.id = s.user_id
WHERE s.total_spend > 1000
AND u.last_login_at >= NOW() - INTERVAL 30 DAY;
这段嵌套子查询还不算狰狞,但一旦再加一个“最近一次订单时间”“累计积分”之类的计算,SQL瞬间变成一坨。改成CTE之后:
sql复制WITH user_spend AS (
SELECT user_id, SUM(amount) AS total_spend
FROM orders
WHERE order_status = 'paid'
GROUP BY user_id
)
SELECT u.id, u.name, us.total_spend
FROM users u
JOIN user_spend us ON u.id = us.user_id
WHERE us.total_spend > 1000
AND u.last_login_at >= NOW() - INTERVAL 30 DAY;
读起来像讲故事:先算每个用户的消费总额,再拿这个结果去关联用户表做筛选。调试的时候也方便,你可以先单独执行CTE里的那段,确认统计口径对不对,再执行整个查询。这个习惯帮我节省了大量排查时间。
1.4 空值处理:COUNT(*)和COUNT(column)根本不是一回事
书上有这么个经典结论:COUNT(*)统计行数,COUNT(column)统计该列非NULL的个数。但我发现实际工作中很多人会无意间用错,尤其是在“统计参与某个活动的人数”这类场景。如果activity_id存在NULL,COUNT(activity_id)会少算没参加过活动的人,而业务方想要的是“所有用户数”,那应该用COUNT(*)。
同理,SUM(amount)遇到全NULL会返回NULL而不是0,很多人这时直接在业务代码里拿到一个NULL,导致前端报错。SQL里对NULL的判断也必须用IS NULL / IS NOT NULL,不能用= NULL——这是一个说过一万遍还是会踩的坑。我习惯在查询里显式处理:COALESCE(SUM(amount), 0) AS total_amount,这样下游拿到的永远是数值,少很多不必要的防御代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MySQL那条“语法错误”报错,到底在提示什么
如果你用过MySQL,一定会被这句话折磨过:You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '...'。它只告诉你“你错了”,却不告诉你“错在哪”。我第一次看到时整个人是懵的,后来总结出来一套排查思路,现在基本能在两分钟内定位。
2.1 最常见的几个语法翻车点
排在第一的,用了保留字做列名或表名,还没有加反引号。比如有一张表里有个字段叫order,直接SELECT order FROM table一定会报错,因为ORDER是排序关键字。更隐蔽的是desc(既是DESCENDING关键字,又常被用来做“描述”字段)、level、condition、group。解决方式有两个:要么建表时避开这些词,要么查询时老老实实加上反引号:SELECT `order` FROM `table`。
排在第二的,多了一个逗号或者少了一个逗号。尤其是多字段SELECT时,最后多写一个逗号特别常见:
sql复制SELECT user_id, user_name, order_id,
FROM orders;
这句在order_id,后面的逗号会让MySQL在FROM附近报错。我自己排查这类问题的方法是:从报错信息里给出的“near '...'”位置往前看两到三个词,因为MySQL经常把真正出错的位置往前偏移。报错说near 'FROM orders',那问题多半就在FROM之前那个逗号上。
还有一类非常“中国特色”的坑,就是中文标点混进SQL。比如:
sql复制SELECT user_id, COUNT(*)
FROM orders;
user_id后面的逗号实际上是中文全角逗号,MySQL完全不认识,报错还经常指向下一行,特别迷惑。我的习惯是:SQL写完后先肉眼扫一遍,主要看关键字之间是不是都是半角空格、标点是否都是半角。写久了的人可能觉得这不算问题,但在输入法自动切换的场景下,新人很容易中招。
2.2 一次完整的语法报错排查过程
之前同事跑一个统计SQL一直报错,贴给我的报错内容是:
code复制ERROR 1064 (42000): You have an error in your SQL syntax;
check the manual that corresponds to your MySQL server version
for the right syntax to use near 'ORDER BY created_at DESC LIMIT 10' at line 8
注意报错说near 'ORDER BY ...',但ORDER BY本身没有拼错,说明问题出在它前面。我顺着往上翻,发现SQL是这样的:
sql复制SELECT user_id, SUM(amount)
FROM orders
WHERE status = 'paid'
GROUP BY user_id
HAVING SUM(amount) > 100
ORDER BY created_at DESC
LIMIT 10;
看起来没毛病,可报错就在ORDER BY。关键点来了:HAVING子句里SUM(amount) > 100后面没有写分号,但HAVING本身也没有问题。真正的问题是,ORDER BY created_at里的created_at不在GROUP BY子句中,而且不是聚合函数。MySQL的ONLY_FULL_GROUP_BY模式——默认开启——会直接拒绝这种查询,但报错信息却指向了ORDER BY附近,让人误以为是语法问题。改法也简单:把created_at换成MAX(created_at),或者把created_at加进GROUP BY。这类“报错位置误导人”的情况在MySQL里太常见了,所以我现在的排查习惯是:先看SQL整体结构,再看报错位置附近,最后查SQL_MODE。
还有一次是LIMIT写在了子查询外面但前面少了个右括号,报错也指向LIMIT。排查这种错最好用的办法是注释法:把SQL一层层拆开,从最内层子查询开始逐段执行。哪一段报错了,问题就缩小到那个范围内。这个方法听起来土,但比盯着屏幕干想高效得多。
2.3 平台工具能帮你少一半这类问题
我日常用DBeaver、HeidiSQL这类客户端写SQL,它们自带语法高亮和简单的括号匹配提示。一旦括号没闭合,高亮颜色会发生明显变化。如果你还在用记事本写SQL,建议尽早换掉,这不是工具崇拜,而是效率问题。DBeaver里还能一键格式化SQL,把乱成一团的语句整理成结构化缩进,很多肉眼难找的逗号、括号问题在格式化之后会立刻暴露出来。
3. 慢SQL优化,先学会看执行计划这张“体检报告”
慢SQL优化是面试高频题,也是线上事故的高发区。但我发现很多人的优化思路停留在“加个索引”这个层面。索引固然重要,但要不要加、加在哪个字段上、为什么加了也没用,这些问题的答案全在执行计划里。
3.1 EXPLAIN到底怎么读:先看type和rows
MySQL里用EXPLAIN SELECT ...可以查看一条查询的执行计划。输出结果里有一列type,它描述的是MySQL如何访问表。按性能从好到差大致排列:
| type | 含义 | 常见场景 |
|---|---|---|
| system/const | 最多匹配一行 | 主键或唯一索引等值查询 |
| eq_ref | 每次关联只返回一行 | 主键关联 |
| ref | 非唯一索引等值匹配 | 普通索引上过滤 |
| range | 索引范围扫描 | BETWEEN、>、IN |
| index | 扫描整个索引树 | 覆盖索引,但一般还是要小心 |
| ALL | 全表扫描 | 没走到索引,典型问题场景 |
我拿到执行计划会先看有没有ALL,只要有,基本可以断定这个查询会随着数据量增长而线性变慢。再看rows,它是个估算值,告诉你MySQL预估要扫多少行。如果预估值是几十万行,但最终结果只有几十行,说明过滤条件没用好索引。
举个例子,之前公司有个订单查询接口,用户按订单状态和创建时间查列表,数据量到两百万行时页面卡成狗。EXPLAIN一跑,发现type = ALL,rows = 200万。原因是状态字段status上有索引,但SQL里写的是WHERE status IN ('PAID','REFUNDED'),如果这个字段的值分布很不均匀,MySQL优化器可能认为全表扫描比走索引回表更快,直接放弃索引。后来把status索引改成(status, created_at)联合索引,SQL则保持WHERE status = 'PAID' AND created_at >= ...这种写法,rows直接从200万降到了几千。
3.2 索引为什么“加了也白加”:最左前缀原则
联合索引(status, created_at)能同时服务status = 'PAID'和status = 'PAID' AND created_at >= '2024-01-01',但如果查询条件只写了created_at >= '2024-01-01',这个联合索引就帮不上忙了。原因是最左前缀原则:联合索引里字段的顺序定义了一个“从左到右”的匹配链路,跳过了最左边的status直接去匹配created_at,服务器无法用它索引做范围查询。
实际工作中常犯的错误是,把区分度最高的字段放到索引最前面,却忽略了查询条件里最常出现的字段。比如订单表里user_id区分度高,但业务查询绝大多数按order_status过滤,那么联合索引应该优先考虑把order_status放在第一个位置。索引设计不是看字段本身多独特,而是看它对应的查询模式。
还有一种“加了索引也没用”的情况是隐式类型转换。假如user_id是VARCHAR类型,你写WHERE user_id = 123456,MySQL会把索引列转成数值类型再比较,导致索引失效。这种问题用EXPLAIN一眼就能看出来:type变成了ALL,rows异常大。修复很简单,查询参数带上引号即可:WHERE user_id = '123456'。
3.3 为什么我建议你用覆盖索引代替SELECT *
面对慢查询,很多人第一反应是把SELECT *改成只查需要的列。这不只是“少传输几列数据”的问题,更关键的是它开启了**覆盖索引(Covering Index)**的可能性:如果查询所需的列都在某个索引里,MySQL就直接扫索引树返回结果,不需要回表。回表意味着每行数据都要根据主键再去一次聚簇索引,在大量行扫描时开销成倍增加。
我优化过一个统计报表,原本SQL是SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 20,加了(user_id, created_at)索引后,效果一开始并不明显。后来把SELECT *改成SELECT user_id, order_amount, created_at,再建了一个(user_id, order_amount, created_at)的覆盖索引,查询时间直接降到原来的三分之一。原因就是MySQL只需要扫索引,就能把三列全拿到,不用回表。
当然,覆盖索引不是越多越好,索引写也要占用空间,但线上核心查询、高频查询值得这么做。这是我在优化类问题里最推荐优先尝试的方案,因为它改动最小、收益最大。
3.4 分页优化:LIMIT 100000, 20 的隐藏代价
分页查询里LIMIT 100000, 20意味着MySQL要把前10万行全部找出来,然后丢弃掉,再返回最后20行。数据越多,越往后翻页越慢。这就是为什么很多后台系统的“第100页”会明显变卡。
一个比较实用的改法是“回到上一页的最后一条记录”这种游标分页,用WHERE id > last_id ORDER BY id LIMIT 20,前提是你有唯一递增的主键。这种写法天然走主键索引,不管翻到第几页,扫描量都是固定的20行。缺点是用户不能自由跳页,但很多后台报表、信息流场景本来就不需要跳页。另一种思路是延迟关联:
sql复制SELECT t1.*
FROM orders t1
JOIN (
SELECT id FROM orders ORDER BY id LIMIT 100000, 20
) t2 ON t1.id = t2.id;
先只查主键ID完成分页,再通过ID回表拿完整数据,能明显减少回表次数。这个技巧在MySQL 5.7及之前的版本作用很大,8.0里优化器更聪明了,但原理仍然值得掌握。
4. SQL注入不是网络安全课的专属,写SQL的人就得懂
“SQL注入”“万能密码绕过”这类搜索词一直很热,说明大家既好奇又有点畏惧。但作为普通后端开发或数据分析师,你不需要成为安全专家,但一定要懂注入是怎么发生的,否则可能亲手在业务代码里埋下一颗雷。
4.1 注入的本质:拼接让用户输入变成了SQL逻辑
假设登录SQL是这样写的:
sql复制SELECT * FROM users WHERE username = 'admin' AND password = '123456';
如果代码里是字符串拼接:
code复制sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'"
用户在用户名框里输入admin' --,密码随便填,拼出来的SQL变成:
sql复制SELECT * FROM users WHERE username = 'admin' -- ' AND password = 'xxx';
MySQL中--后面是注释,因此真正的查询变成了:
sql复制SELECT * FROM users WHERE username = 'admin'
只要存在用户名为admin的账号,这条查询就直接通过了,密码校验被绕过了。这就是“万能密码”类攻击的简化版。再比如' OR 1=1 --,拼出来是:
sql复制SELECT * FROM users WHERE username = '' OR 1=1 -- ' AND password = 'xxx'
OR 1=1恒为真,整条查询会返回所有用户,业务侧如果只判断“查到了数据就放行”,整个后台就裸奔了。
这就是为什么我一直强调:写SQL的人嘴上可以不说“安全”,但心里必须有一条底线——凡是用户输入的内容,永远不能直接拼进SQL字符串。
4.2 正确的姿势:参数化查询
参数化查询(Prepared Statement)的核心思想是SQL结构里只放占位符,用户输入作为参数传给数据库,数据库先编译SQL结构,再用参数值去填充。这样用户输入再奇葩,也只会被视为“值”,不会被当成SQL语法执行。
以Python的pymysql为例:
python复制cursor.execute(
"SELECT * FROM users WHERE username = %s AND password = %s",
(username, password)
)
以Java的JDBC为例:
java复制PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM users WHERE username = ? AND password = ?"
);
ps.setString(1, username);
ps.setString(2, password);
这两种写法都能让数据库把第一参数当普通字符串处理,你输入' OR 1=1 --也只会被当成一个奇怪的用户名去匹配,不会影响SQL逻辑。这是防御SQL注入最有效、成本最低的手段,没有之一。
如果你用的是ORM框架(MyBatis、Hibernate这些),也有同样的原则。MyBatis里#{}是预编译占位符,${}是字符串拼接,能用#{}就不要用${}。有些场景(比如动态表名、动态列名)确实只能用${},这时必须做严格白名单校验,把可接受的表名或列名写死在代码里,而不是信任用户输入。
4.3 顺带说说权限和审计
即使代码里做好了参数化,也不能在数据库账号上放任不管。我的习惯是:不同应用角色用不同的数据库账号。比如只读报表的账号只给SELECT权限,管理后台账号只给DML权限,DDL权限(DROP、ALTER这些)只留给DBA使用。万一某个环节被攻破,攻击者能做的事也会被限制在一个小圈子里。这属于纵深防御的思路,多花几分钟配一下,关键时候能拦一刀。
5. 让SQL既好看又好维护的格式化细节
“SQL代码排版工具”这个搜索词的热度,说明很多人跟我一样忍受过一长串没有缩进的SQL。SQL天生是声明式语言,语句一多,如果没有统一的排版规范,读起来就是灾难。代码是写给同事看的,也是写给三周后的自己看的。
5.1 我的一套格式化约定
不同团队的规范略有差异,但大体上这几条是通用且低成本的:
- 关键字统一大写:SELECT、FROM、WHERE、GROUP BY、ORDER BY这些统一大写,字段名和表名小写。这样一眼就能扫出SQL的结构骨架。
- 字段一行一个,便于diff:SELECT后面有多个字段时每个字段换一行,字段多时不仅看着清爽,代码评审时哪一列动了也一目了然。
- JOIN、ON换行并对齐:多个表关联时,每个JOIN单独一行,ON条件对齐写。如果条件特别长,把AND换行。
- 子查询和CTE按层级缩进:CTE体、括号里的子查询整体缩进两格。
举个例子,格式化前:
sql复制select users.id,users.name,orders.order_id,orders.amount from users left join orders on users.id=orders.user_id where users.level>3 order by orders.created_at desc limit 100;
格式化后:
sql复制SELECT
u.id,
u.name,
o.order_id,
o.amount
FROM users u
LEFT JOIN orders o
ON u.id = o.user_id
WHERE u.level > 3
ORDER BY o.created_at DESC
LIMIT 100;
两段逻辑完全一样,但可读性天差地别。刚才那个例子顺便还体现了另一个习惯:多表关联一定用别名,别名简短且统一(u表示users,o表示orders),写条件和SELECT时都不会出现几十个字符长的表名。
5.2 SQL格式化工具怎么选
我平时写临时查询直接依赖DBeaver自带的SQL格式化快捷键,通常能应付90%的场景。遇到那种巨复杂的报表SQL,我会用在线的SQL格式化工具再处理一遍,但要注意:在线工具处理敏感生产环境数据时务必脱敏,不要让真实数据流经第三方网页。更稳妥的做法是在本地装一个格式化插件,比如VS Code里的SQL Formatter扩展,离线使用,不担心数据外泄。
格式化工具不是万能的。它们能把缩进整理好,但语义上的分块还是要靠人。我习惯在一段较复杂SQL前写注释,说明“这个查询是干什么的、统计口径如何”,尤其是那种一屏放不下的长SQL。注释不需要长,两三句话讲清楚即可:
sql复制-- 每个付费用户最近30天消费总额,用于运营活动筛选
WITH paid_orders AS (
SELECT
user_id,
SUM(amount) AS total_amount
FROM orders
WHERE order_status = 'paid'
AND created_at >= NOW() - INTERVAL 30 DAY
GROUP BY user_id
)
SELECT
u.id,
u.name,
po.total_amount
FROM users u
JOIN paid_orders po
ON u.id = po.user_id
WHERE po.total_amount > 1000;
这样三个月后再翻到这段SQL,你不需要回忆当时怎么就写出了这段逻辑。
5.3 别忘了导入导出SQL文件也是SQL基本功
很多人用DBeaver、HeidiSQL这类图形工具,主要都是点按钮导数据,却忽略了它们背后的SQL文件操作也是有讲究的。
用DBeaver导出一张表的数据时,它默认生成的SQL文件里会包含建表语句(如果勾选了)、DELETE或TRUNCATE语句(可选)、以及INSERT语句。导入时如果你没注意文件开头有没有SET FOREIGN_KEY_CHECKS=0,在有关联约束的库里导入就很可能因为外键顺序问题报错。我的习惯是:导入前先打开SQL文件看一眼头部和尾部,确认字符集、是否关闭外键检查、是否有DROP TABLE这类危险操作,然后再执行。
HeidiSQL连接SQL Server或MySQL都挺方便,导入SQL文件的入口在“文件 -> 从SQL文件运行”之类的位置。但有一个注意事项:大SQL文件(几百MB以上)在图形工具里导入经常卡死或内存溢出。遇到这种情况,我一般改用命令行方式导入:
bash复制mysql -u root -p database_name < backup.sql
这比在GUI里等半天稳得多。Windows上装SQL Server、处理离线RPM包之类的安装教程网上很多,我不再展开,但核心原则一致:安装环境前先确认版本与操作系统匹配,备份数据永远在做危险操作之前。
SQL学习到一定阶段,你会发现真正的瓶颈不是记住多少函数和语法,而是能不能把一个模糊的业务问题拆成清晰的查询步骤。上面这几块内容——查询骨架的搭建、语法报错的定位、执行计划的解读、安全底线、格式化规范——都是我从实际项目里反反复复提炼出来的。笔记我会继续写下去,下一期大概会集中在窗口函数和更复杂的统计场景上,这两个主题在报表需求里出现频率极高,也是面试官特别喜欢用来区分“背过”和“真会”的试金石。
