带新人的时候我经常发现一个有意思的现象:很多人写了两三年SQL,增删改查看起来都熟,但一问到DML和DQL的底层逻辑、执行顺序、索引匹配规则,就开始支支吾吾。写是能写,遇到数据量上来、查询变慢、误操作删错数据,就完全不知道从哪里排查。说白了,很多人的MySQL水平卡在“会用Ctrl+C复制别人的SQL”这个阶段,没有真正把DML和DQL这两块地基打扎实。
这篇文章就是冲这个来的。DML指的是数据操纵语言,对应INSERT、UPDATE、DELETE这些写操作;DQL是数据查询语言,核心就是SELECT。两者的使用频率占了日常开发SQL总量的八成以上,但恰恰是这八成,藏着最多细节和坑位。无论你是刚入行的开发新人,还是写了好几年SQL但没系统整理过的老手,这篇学习笔记都值得你花半小时过一遍。我会把语法拆开讲透,每一步都解释为什么这么做,顺带附上我这些年实战踩过的坑。
1. 先搞懂DML与DQL在SQL世界里的位置
1.1 SQL语言的完整分类框架
MySQL的SQL语句按功能可以分成五大类:DDL、DML、DQL、DCL、TCL。DDL(Data Definition Language)负责定义数据结构,建表、删表、改表结构就是它,典型命令是CREATE、ALTER、DROP、TRUNCATE。DML(Data Manipulation Language)负责操作数据,就是INSERT、UPDATE、DELETE这三驾马车。DQL(Data Query Language)专管查询,核心是SELECT。DCL(Data Control Language)管权限,GRANT、REVOKE就是。TCL(Transaction Control Language)管事务,COMMIT、ROLLBACK、SAVEPOINT属于这一类。
需要说明一个细节:MySQL官方文档其实把SELECT也划进了DML的范畴,因为SELECT也会获取行锁,在事务层面有副作用。但在实际工作中,大家习惯把DQL单独拎出来讲,因为查询逻辑的复杂度远高于增删改,值得一个独立分类。我这里也遵循主流习惯,DML指增删改,DQL指查询。
很多人觉得学SQL应该从建表开始,也就是DDL先行。我的建议恰恰相反:先彻底搞懂DML和DQL,再回头理解DDL,你会发现表结构设计时很多约束和索引设置,都是为了这两类操作服务的。数据最终是要被写入、被查询的,表结构只是载体,载体设计得再好,写数据和查数据的姿势不对,照样翻车。
1.2 DML和DQL的设计哲学差异
DML和DQL最大的区别,一个在“影响面”,一个在“副作用”。DML操作会修改数据,一旦执行出错,影响的是真实数据,轻则数据错乱,重则全表被清空。所以DML的核心命题是“如何安全地改数据”,围绕着事务、锁、条件过滤展开。DQL操作只读取数据,不会改动任何行,它的核心命题是“如何高效地查数据”,围绕执行计划、索引、连接策略展开。
这两者的思维方式完全相反。写DML时,我脑子里第一根弦是“我要动哪些行,条件会不会误伤别的行”;写DQL时,我脑子里第一根弦是“这条查询怎么走索引,扫描多少行”。你可以对比感受一下:UPDATE语句没有WHERE就是灾难,SELECT语句没走索引就是性能事故,两者的失败模式完全不同。
理解了这层设计哲学,后面学具体语法就不会觉得散。INSERT的批量写、UPDATE的事务包裹、DELETE的TRUNCATE替代,都是围绕“安全”做的设计;WHERE的顺序调整、JOIN的驱动表选择、LIMIT的深分页优化,都是围绕“高效”做的设计。这就是两种语言的底层逻辑。
2. DML核心语法详解:从插入到删除的完整闭环
2.1 INSERT:三种写法与自增主键那些坑
INSERT的语法看起来就一句话,但实际使用中有三种写法,适用场景完全不同。
第一种是最常见的单行插入:
INSERT INTO user (id, name, age) VALUES (1, '张三', 25);第二种是多行批量插入:
INSERT INTO user (id, name, age) VALUES (1, '张三', 25), (2, '李四', 30), (3, '王五', 28);第三种是从查询结果直接写入,通常用于表数据备份或临时表加工:
INSERT INTO user_backup (id, name, age) SELECT id, name, age FROM user WHERE age > 30;实操中我几乎不会用单行插入处理批量数据,因为每条INSERT语句都涉及一次SQL解析、权限检查、插入成本。同样插入100行数据,拆成100条单行INSERT,网络往返100次;合并成一条多行INSERT,网络往返只要1次。数据量小的时候差异不明显,一旦量级到了万级,差距就是十倍百倍。所以批量导入场景,能一次INSERT多行就一次多行,这是最粗暴也最有效的优化手段。
但多行INSERT也有个限制:单条SQL语句有max_allowed_packet参数限制包大小,默认值是64MB。真遇到上千万行的数据迁移,更推荐用LOAD DATA INFILE,那是MySQL专门为高速导入设计的工具,速度比INSERT还要快好几个数量级。
自增主键的坑值得单独讲。MySQL的InnoDB引擎有一个参数叫innodb_autoinc_lock_mode,控制自增锁的分配策略。在MySQL 5.7及更早版本里,默认是1,也就是“简单插入”预先分配一批自增值,批量插入的情况下,中间如果某行插入失败回滚,这部分自增值就浪费了。所以你会看到明明只成功插入10行,下一条数据的自增主键可能跳到了15,中间空了几个数字。这是正常的,不要试图去填补那些空洞。自增主键设计之初就只是为了唯一性,为了有序性,不是为了连续性。
还有一个容易被忽略的坑:REPLACE INTO和INSERT ... ON DUPLICATE KEY UPDATE的区别。前者遇到唯一键冲突时,先DELETE旧行再INSERT新行,副作用是如果有外键引用,会触发级联删除;后者是更新冲突行,不动其他行。生产环境我基本只用后者,因为REPLACE的“先删后插”在并发场景下容易产生间隙锁、扩大锁范围,严重时可能引发死锁。
INSERT INTO user (id, name, age) VALUES (1, '张三', 25) ON DUPLICATE KEY UPDATE name = VALUES(name), age = VALUES(age);注意,在MySQL 8.0.20及以上版本,VALUES()语法已被标记为废弃,官方推荐改用别名方式:
INSERT INTO user (id, name, age) VALUES (1, '张三', 25) AS new ON DUPLICATE KEY UPDATE name = new.name, age = new.age;这个细节很多老开发都不知道,新项目建议直接按新写法来。
2.2 UPDATE:条件为王,事务配合
UPDATE的语法骨架是:
UPDATE 表名 SET 列1 = 值1, 列2 = 值2 WHERE 条件;你说它简单吧,确实一句话。但它是最容易出生产事故的DML语句。我见过不止一次有人写UPDATE忘了加WHERE,或者WHERE条件写得太宽,结果整张表的数据都被改成了同一个值。这不是语法问题,是习惯问题。我的个人习惯是:写UPDATE之前,先单独跑一遍等价的SELECT,确认要更新的行数,再加UPDATE。
-- 先确认范围 SELECT id, status FROM order WHERE status = 'pending'; -- 确认无误再更新 UPDATE order SET status = 'paid' WHERE status = 'pending';这个习惯在操作生产库时就是保命符。别嫌麻烦,一行SELECT的事,换回来的是你不需要深夜去捞备份恢复数据。
UPDATE的另一个重点在于事务配合。InnoDB引擎支持行级锁,但这也是双刃剑。假设你要把一个订单的状态从pending改成paid,如果不显式开启事务,每一步操作MySQL都自动提交,更新过程中一旦出现中途报错(比如某个触发器失败、某个字段超长),前面已经更新的行就永久留下了,没有回滚的可能。正确的做法是显式开启事务,必要时加锁读:
BEGIN; SELECT * FROM order WHERE order_id = 123 FOR UPDATE; UPDATE order SET status = 'paid' WHERE order_id = 123; COMMIT;SELECT ... FOR UPDATE的作用是把匹配到的行锁住,防止其他事务同时修改。这在“先读后写”的业务场景里非常关键。比如库存扣减:先查出当前库存,判断大于零,再执行扣减。如果中间没有锁,并发情况下两个请求都能查到库存为1,然后都执行扣减,库存就变成负数了。加上FOR UPDATE之后,第二个请求的SELECT会阻塞在锁上,等第一个事务提交后才能继续执行,天然杜绝了超卖问题。
大批量UPDATE还有一个隐形问题:锁范围过大。比如某个后台任务要把一百万行数据的状态统一变更,直接一条UPDATE执行下去,InnoDB会在扫描过程中对涉及的行加锁,密集的索引页也可能被间隙锁覆盖,轻则阻塞其他会话,重则耗尽锁资源。实际经验是分批更新,比如每次更新一万行,用主键范围或自增ID范围分段处理。
UPDATE big_table SET status = 1 WHERE id BETWEEN 1 AND 10000; -- 循环处理下一批为什么每次要控制在一万行左右?因为单条UPDATE涉及的行越多,持有锁的时间越长,事务日志的量也越大。分批以后,每批短小精悍,可以在两次批次之间让出锁,给其他请求喘息空间。
2.3 DELETE与TRUNCATE:同样删数据,性格完全相反
DELETE删除数据是逐行删除,记录操作日志,支持事务回滚,可以带WHERE条件只删部分行:
DELETE FROM log WHERE create_time < DATE_SUB(NOW(), INTERVAL 30 DAY);TRUNCATE则是直接重建表,删掉所有行,速度飞快,但是不能加WHERE条件,也不能回滚,执行后自增计数器会重置。对比一下:
| 对比维度 | DELETE | TRUNCATE |
|---|---|---|
| 删除范围 | 支持WHERE条件 | 只能全表清空 |
| 事务支持 | 支持回滚 | 不参与事务,执行即生效 |
| 执行效率 | 逐行删除,慢 | 重建表,极快 |
| 自增计数器 | 不清零 | 清零 |
| 触发触发器 | 会触发 | 不触发 |
| 锁行为 | 行级锁 | 表级锁(DDL级别) |
DELETE还有一个需要注意的坑:它不会立刻释放磁盘空间。因为InnoDB删除行时只是标记删除,空间会保留给后续复用,表文件大小不会马上缩小。如果只是清空历史数据又不想重建表,可以执行OPTIMIZE TABLE释放碎片空间,但注意这个操作会锁表,大表执行时务必选在业务低峰期。
我在真实项目中更常用的一种清理方式是“分步删除+定时执行”,比如每小时删除一小时之前的过期日志,每次删五千行,用主键范围约束。这样既不会持有大事务的锁,也不会让binlog爆增。而TRUNCATE我只在两类场景使用:数据完全废弃的临时表、重建报表期间的中间表。
3. DQL查询语法:从单表过滤到多表关联
3.1 SELECT基本流程与WHERE、GROUP BY、HAVING的执行顺序
DQL的核心是SELECT,但很多写了几年代码的人并不知道SELECT语句的内部执行顺序。键盘上你写的顺序是SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT,但MySQL真正的逻辑执行顺序是这样的:
FROM → ON → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT这个顺序不是死记硬背用的,它直接决定了你写的很多“理所当然”的代码到底能不能跑。举个例子,WHERE子句里不能使用SELECT中定义的别名,因为WHERE在SELECT之前执行,别名还没生成。反过来,ORDER BY可以使用别名,因为排序发生在SELECT之后,别名已经算好了。
-- 这样写报错:Unknown column 'avg_age' SELECT dept_id, AVG(age) AS avg_age FROM employee WHERE avg_age > 30 GROUP BY dept_id; -- 正确写法:HAVING在GROUP之后执行 SELECT dept_id, AVG(age) AS avg_age FROM employee GROUP BY dept_id HAVING avg_age > 30;WHERE和HAVING的分工是这个流程里最容易被搞混的地方。记住一个口诀:WHERE过滤的是原始行,发生在分组之前;HAVING过滤的是分组之后的结果集。所以WHERE里不能用聚合函数,HAVING里就可以。如果你要“筛选年龄大于30的员工再按部门分组”,条件放WHERE;如果你要“统计各部门平均年龄后只保留平均年龄大于30的部门”,条件放HAVING。一个在数据进入分组前就已经筛掉了不需要的行,一个是在分组统计完成后筛掉不需要的组。
GROUP BY还有一个高频报错场景,尤其是MySQL 5.7及以上默认开启了only_full_group_by模式。你在SELECT子句里写了非聚合列,这一列却没有出现在GROUP BY里,会直接报错。这是因为SQL标准规定:分组后,每组的非聚合列取值可能不唯一,SQL标准不允许这种不确定行为。解决办法有两个:把这一列加到GROUP BY,或者用聚合函数包裹这一列(比如MAX、MIN)。实际业务中,如果你只是想让分组后的每一行带上某个维度,推荐用MAX或MIN绕过,但前提是你确定这个值在组内是同一个值,否则结果可能与预期不符。
3.2 排序与分页:LIMIT的深层机制
ORDER BY负责排序,LIMIT负责取前N行,这两个语法放在一起说,是因为它们在性能上的坑经常一起出现。
先看LIMIT的两种常用写法:
-- 取前10条 SELECT * FROM article ORDER BY create_time DESC LIMIT 10; -- 跳过20条取10条,即第21到第30条 SELECT * FROM article ORDER BY create_time DESC LIMIT 20, 10;第一种写法在任何场景下都很快,因为它只要找到前10条。第二种写法性能上藏着一个大坑:LIMIT 20, 10并不是“先找到第20条,再向后取10条”那么轻松,MySQL的机制是先扫描到第30条,然后丢弃前20条,只返回后10条。数据量小无所谓,但如果你做的是“翻页到第10000页”,相当于MySQL要把前100000行数据全部读出来再丢掉,这就是经典的“深分页”问题。偏移量越大,查询越慢。
解决这个问题的办法之一叫“延迟关联”或“覆盖索引分页”:先用覆盖索引快速定位目标行的主键,再通过主键回表取完整数据。
-- 优化前:偏移量大时非常慢 SELECT * FROM article ORDER BY create_time DESC LIMIT 20000, 10; -- 优化后:先取主键,再关联 SELECT a.* FROM article a INNER JOIN ( SELECT id FROM article ORDER BY create_time DESC LIMIT 20000, 10 ) tmp ON a.id = tmp.id;为什么这样更快?因为内层子查询用到了覆盖索引,不需要回表读取完整行记录,扫描成本大幅降低;外层再按主键关联,只取了10行完整数据,整个查询的IO开销被压到了最低。
ORDER BY排序本身也有一个经典性能点:如果排序字段上建有索引,MySQL可以直接按索引顺序读取,不需要额外的filesort;如果排序字段没有索引,MySQL会把数据加载到内存或者临时文件里做排序,这就是EXPLAIN里Extra列出现Using filesort的原因。filesort并不是不能用,数据量小的时候无感,但数据量大了就要想办法让排序走索引,或者限制排序结果集大小。
还有一点容易被忽略:字符集和排序规则会影响字符串的排序结果。MySQL默认的utf8mb4_general_ci是不区分大小写的排序,如果你需要一个“按大小写区分”的排序结果,需要显式指定排序规则,比如utf8mb4_bin。这个细节在多语言场景、用户名排序场景下特别容易踩坑。
3.3 多表JOIN:内连接、左连接的最优选型
JOIN可能是DQL里最让人头大的部分。但其实搞明白执行逻辑之后,JOIN没有想象中那么复杂。
INNER JOIN(内连接)返回的是两个表中满足连接条件的交集行:
SELECT u.name, o.order_no FROM user u INNER JOIN order o ON u.id = o.user_id;LEFT JOIN(左连接)返回的是左表的全部行,右表没有匹配的列填充NULL:
SELECT u.name, o.order_no FROM user u LEFT JOIN order o ON u.id = o.user_id;这两种连接之间的选型逻辑是:业务上“必须两边都存在才显示”用INNER JOIN;“左表为主,右表可有可无”用LEFT JOIN;RIGHT JOIN在很多场景下可以直接改写为LEFT JOIN(把表顺序调换),可读性更好,我个人几乎不用RIGHT JOIN。
LEFT JOIN隐藏着一个特别深的坑:ON条件与WHERE条件的区别。看这两条SQL:
-- 写法A:过滤条件写在ON里 SELECT u.name, o.order_no FROM user u LEFT JOIN order o ON u.id = o.user_id AND o.status = 'paid'; -- 写法B:过滤条件写在WHERE里 SELECT u.name, o.order_no FROM user u LEFT JOIN order o ON u.id = o.user_id WHERE o.status = 'paid';写法A中,o.status = 'paid'是连接条件的一部分,它只影响右表哪些行参与连接,左表行全部保留,没有匹配到paid订单的用户依然会出现在结果里,order_no为NULL。写法B中,WHERE条件是在连接完成后再过滤结果集,order_no为NULL的行会被直接过滤掉,实际效果跟INNER JOIN等价。这是LEFT JOIN最容易出“明明数据存在却查不到”类Bug的根源。我把这类Bug总结为:左连接后,右表的过滤条件放ON还是放WHERE,结果可能完全不同,写之前想清楚你到底要哪种结果。
JOIN还存在一个“驱动表”的概念。在优化器看来,连接操作是一层层嵌套循环:先从驱动表中取第一行,再去被驱动表里匹配。优化的核心逻辑是“小表驱动大表”,也就是让行数少的表当外层循环,减少匹配次数。MySQL 8.0的优化器会自动为你选择合适的连接顺序,但前提是你的统计信息准确,ANALYZE TABLE要定期跑。如果你发现一个复杂的多表JOIN执行计划明显不合理,可以在不影响业务的前提下用STRAIGHT_JOIN强制指定驱动顺序,但这属于高阶调优手段,不要轻易在生产环境尝试。
4. 实战进阶:聚合函数、子查询与常见SQL性能陷阱
4.1 聚合与分组统计的常见误区
聚合函数包括COUNT、SUM、AVG、MAX、MIN,它们把多行数据浓缩成一行结果。这一节我挑三个高频误区来聊。
第一个误区是COUNT()和COUNT(某列)的区别。COUNT()统计的是行数,包含NULL值;COUNT(某列)统计的是该列非NULL的值的数量。如果一列有一半是NULL,COUNT(该列)的结果会和COUNT()差一半。业务上统计“有多少用户填写了手机号”,得用COUNT(phone);统计“总共有多少用户”,用COUNT()。两个函数语义不同,别混用。
第二个误区是SUM和AVG对NULL的处理。SUM(某列)如果该列全为NULL,返回NULL;AVG(某列)计算时直接忽略NULL行。这带来一个实际后果:用AVG算字段的平均值时,如果某个样本字段为空,它不会被计入分母。如果你希望把NULL当0处理,要用COALESCE包裹:
SELECT AVG(COALESCE(score, 0)) FROM exam;第三个误区是GROUP_CONCAT的长度限制。MySQL的GROUP_CONCAT用来把组内的多行拼接成一个字符串,看起来很方便,但它默认的最大长度是1024字节,超过部分会被静默截断。业务上需要拼接长文本时,要先执行SET SESSION group_concat_max_len = 102400;调整长度,否则你会在毫不知情的情况下丢失数据。这个问题排查起来极其隐蔽,因为没有任何报错。
GROUP BY还有一个性能问题值得注意:分组字段如果没有索引,MySQL需要把全表数据先加载到临时表再做分组。当分组结果集很大时,临时表不够用会落到磁盘,性能瞬间下降。这时EXPLAIN的Extra列会出现Using temporary,属于需要重点优化的信号。
EXPLAIN SELECT dept_id, COUNT(*) FROM employee GROUP BY dept_id;如果看到Using temporary,优先检查dept_id上有没有索引。加索引后分组操作可以直接利用索引的有序性,边扫描边统计,连临时表都省了。
4.2 子查询与JOIN:怎么选更合理
子查询是指嵌套在SELECT、FROM、WHERE里的完整查询语句。很多开发者一遇到“带条件的关联查询”就下意识用子查询,但子查询和JOIN的选择有讲究。
-- 子查询方式:查出有订单的用户 SELECT id, name FROM user WHERE id IN (SELECT user_id FROM order WHERE status = 'paid'); -- JOIN方式:同样的效果 SELECT DISTINCT u.id, u.name FROM user u INNER JOIN order o ON u.id = o.user_id WHERE o.status = 'paid';MySQL早期版本的优化器对IN子查询的处理比较笨拙,容易产生“逐行调用子查询”的执行计划,所以老经验常说“能JOIN就别用子查询”。但MySQL 5.6之后引入了子查询的半连接优化,5.7、8.0的优化器已经能很好地把IN子查询转换为半连接执行。在这个背景下,我的选择原则变成了:可读性优先,性能兜底。简单的IN子查询读起来直观,直接写;复杂的多级嵌套子查询,改成JOIN或临时表,因为优化器处理复杂嵌套时仍然可能选错执行计划。
不过在特定场景下,子查询有JOIN无法替代的优势。比如需要“按分组取每个组最新一条记录”时,用关联子查询配合窗口函数,比JOIN加聚合的写法简洁得多。这是一个相对新但极其好用的语法——MySQL 8.0的窗口函数ROW_NUMBER():
SELECT id, user_id, amount, order_time FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn FROM `order` ) t WHERE rn = 1;这条SQL的意思很直白:按user_id分组,组内按order_time降序编号,取编号为1的行,也就是每个用户的最新一笔订单。它比JOIN + GROUP BY的写法清晰太多,而且性能通常更好,因为数据库用排序过程直接完成了分组取最新的逻辑。窗口函数是我认为MySQL 8.0最值得掌握的新特性之一,强烈建议用上。
4.3 常见性能问题定位:索引失效、隐式类型转换
性能问题在实操中占比最重,我把它单列一节。先从最容易出的索引失效说起。
索引列上做函数操作,索引就会失效。这句话背后的原理是:B+树索引存储的是列本身的原始值,如果查询时对列应用了函数,比如DATE(create_time) = '2024-01-01',MySQL无法直接利用索引找到这些行,因为索引里没有“DATE(create_time)”这个计算结果。解决方法是改写成范围条件:
-- 失效写法 SELECT * FROM `order` WHERE DATE(create_time) = '2024-01-01'; -- 高效写法 SELECT * FROM `order` WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00';第二条写法让条件变成一个范围,可以直接走create_time列的索引做区间扫描,性能差距在百万级表上是天壤之别。
隐式类型转换是另一个常见索引失效元凶。最典型的例子是varchar类型的列,用数字去比较:
-- 假设phone是varchar类型 SELECT * FROM user WHERE phone = 13800138000;MySQL会把phone列的值全部转成数字再与数字比较,相当于对列执行了隐式CAST函数,索引同样失效,全表扫描。正确做法是条件里带引号:
SELECT * FROM user WHERE phone = '13800138000';这个坑最容易发生在传参的时候,前端传数字、代码不转类型,SQL直接拼进去就出问题。我的习惯是写SQL时始终参照列的数据类型给值,是字符串就加引号,从源头上杜绝隐式转换。
LIKE模糊查询也有类似问题。LIKE '%abc%'因为通配符在前,无法利用B+树的顺序查找特性,索引失效;LIKE 'abc%'前缀匹配则可以走范围扫描。业务上实在需要后模糊匹配,考虑使用全文索引或者外部搜索引擎。这是一个“看起来能查出来就行”和“大并发下扛得住”的分水岭。
OR条件的索引利用也值得提。假设表上存在联合索引(a, b),查询条件是WHERE a = 1 OR b = 2,MySQL无法把这个OR条件整段压进联合索引,可能退化为全表扫描。改写思路是拆成两个查询用UNION ALL合并,或者改成IN的形式,让优化器有更灵活的访问路径。这类优化没有固定公式,核心方法是拿EXPLAIN看执行计划,重点看type列:从最好的system、const、eq_ref、ref到range、index再到最差的ALL。看到ALL基本就是全表扫描,该优化了。
EXPLAIN是MySQL性能排查的第一工具,我在定位每条慢SQL时第一步永远是EXPLAIN。别凭感觉猜,执行计划会直接告诉你哪一步走了全表扫描,哪一步用了临时表,哪一步排序压到了磁盘。把EXPLAIN的习惯练成本能,SQL水平至少上一个台阶。
5. 高频踩坑实录与自查清单
5.1 常见错误对照表
把我这些年见过的真实事故和错误习惯整理成一张表,每一行都是一次血泪教训。
| 错误写法 | 问题表现 | 正确做法 |
|---|---|---|
| UPDATE 不带 WHERE | 全表数据被改 | 先SELECT确认范围,再加WHERE |
| DELETE FROM 表名 | 直接清空全表 | 确认业务需求,考虑TRUNCATE还是有限条件删除 |
| COUNT(某列) 当 COUNT(*) 用 | 统计结果偏少 | 统计行数用COUNT(*),统计非空值用COUNT(列) |
| 隐式类型转换 | 索引失效、全表扫描 | 字符串列条件加引号,类型严格匹配 |
| 对索引列用函数 | 索引失效 | 改写成范围条件或等值条件 |
| 深分页 LIMIT 大偏移 | 查询越来越慢 | 延迟关联或基于游标的分页方案 |
| 汇总统计用OR拼接条件 | 索引利用率低 | 改写UNION ALL,结合执行计划调优 |
| GROUP BY 后SELECT非聚合列 | 报错或结果不确定 | 加GROUP BY列或用聚合函数包裹 |
| 事务未提交直接改数据 | 崩溃后数据无法回滚 | 显式BEGIN/COMMIT,条件复杂时配合FOR UPDATE |
这张表不是让你背的,是建议你收藏起来每次写SQL前扫一眼。每一条我都见过真实案例,尤其前两条,基本是所有数据事故发生的第一现场。
5.2 查询思路自查清单
写完一条SQL,我会按下面这个清单问自己几轮,每次都帮我拦住不少问题:
第一问:我要查的数据来自几张表?如果超过一张,连接条件是什么,用内连接还是左连接?需要特别注意:如果LEFT JOIN的右表在WHERE里加了过滤条件,这个查询很可能已经退化成INNER JOIN,这是不是你想要的结果?
第二问:过滤条件能走索引吗?WHERE里的每个字段在对应表上有没有索引?索引列有没有被函数包住或者发生隐式类型转换?如果有,先修条件写法,别急着去看要不要加索引。
第三问:分组和排序的结果集有多大?GROUP BY之后的数据量是变小了还是维持全表规模?如果分组结果很大,临时表可能落到磁盘,考虑加索引优化分组字段;ORDER BY字段能不能顺便复用索引的有序性?
第四问:如果这条SQL是线上高频查询,它的执行计划长什么样?有没有全表扫描?有没有深分页?每次返回的数据行数是不是远超实际需求?如果只是要前20条,LIMIT 20写了吗?
第五问:写操作执行后能不能安全回滚?UPDATE和DELETE都影响真实数据,有没有测试环境先跑一遍,有没有导出备份?这不是SQL语法问题,是工程素养问题,但它同样决定你会不会出大事故。
这套清单是我处理线上慢SQL和开发评审时固定使用的框架。每次评审别人代码发现SQL写得有问题,我都是按这个逻辑一步步问出来,通常不用查资料就能定位个八九不离十。
5.3 事务边界与并发场景的实操经验
最后补充一波事务相关的实操经验,这是很多开发写DML时最薄弱的一环。你的SQL再对,事务边界没控制住,一样会出大问题。
控制事务的第一原则是“事务越短越好”。事务从BEGIN到COMMIT之间的时间越长,持有的锁越多,与并发事务冲突的概率越大。别在一个事务里执行10条SQL还夹杂着外部接口调用,那会把数据库的锁牢牢攥在手里,其他请求全部卡住。我在项目里经常看到某条接口变慢,一查就是代码里一个大事务调用了第三方支付接口,外部响应3秒,事务就锁了3秒。
第二原则是“查询和更新分离”。核心数据变更前先做一次独立的SELECT确认状态,再开事务做更新。别在UPDATE里面通过复杂子查询临时判断状态,那样逻辑复杂且容易漏掉并发场景。明确业务状态边界,用FOR UPDATE或者乐观锁版本号控制并发,比什么技巧都实用。
第三原则是“批量操作分批提交”。之前提到的分批UPDATE就是例子,每批一万行左右提交一次。这不仅能减少锁持有时间,还能让binlog和undo log的体积保持可控,避免大批量操作把磁盘IO打到极限。真倒腾过上亿数据的人都明白,慢是能接受的,卡死才是灾难。
关于死锁,我只想补充一句:死锁的产生几乎总是“两个事务以不同顺序申请同一组锁”。解决办法通常是统一所有事务的加锁顺序,比如总是先锁用户表的行再锁订单表的行,从设计上消除循环等待。MySQL检测到死锁会自动回滚牺牲者事务,业务代码里要做好重试机制,别让死锁变成P0故障。
我从刚开始写SQL时也是“能跑就行”的思路,直到有一次在测试环境写了一条没加WHERE的UPDATE把整张配置表刷了,才真正意识到DML和DQL的每一处细节都不是废话。那些语法糖背后,是无数人踩坑换来的设计。这篇文章把我这些年和SQL缠斗的经验都整理出来了,希望你能拿去就用,少走几趟我已经走平的弯路。最后再念叨一遍:写DML之前先SELECT,写DQL之后跑一下EXPLAIN,这两个习惯养成了,你的SQL想写差都难。