带新人的时候我经常发现一个有意思的现象很多人写了两三年SQL增删改查看起来都熟但一问到DML和DQL的底层逻辑、执行顺序、索引匹配规则就开始支支吾吾。写是能写遇到数据量上来、查询变慢、误操作删错数据就完全不知道从哪里排查。说白了很多人的MySQL水平卡在“会用CtrlC复制别人的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。DDLData Definition Language负责定义数据结构建表、删表、改表结构就是它典型命令是CREATE、ALTER、DROP、TRUNCATE。DMLData Manipulation Language负责操作数据就是INSERT、UPDATE、DELETE这三驾马车。DQLData Query Language专管查询核心是SELECT。DCLData Control Language管权限GRANT、REVOKE就是。TCLTransaction 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条件也不能回滚执行后自增计数器会重置。对比一下对比维度DELETETRUNCATE删除范围支持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左连接返回的是左表的全部行右表没有匹配的列填充NULLSELECT u.name, o.order_no FROM user u LEFT JOIN order o ON u.id o.user_id;这两种连接之间的选型逻辑是业务上“必须两边都存在才显示”用INNER JOIN“左表为主右表可有可无”用LEFT JOINRIGHT 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的值的数量。如果一列有一半是NULLCOUNT(该列)的结果会和COUNT()差一半。业务上统计“有多少用户填写了手机号”得用COUNT(phone)统计“总共有多少用户”用COUNT()。两个函数语义不同别混用。第二个误区是SUM和AVG对NULL的处理。SUM(某列)如果该列全为NULL返回NULLAVG(某列)计算时直接忽略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-01MySQL无法直接利用索引找到这些行因为索引里没有“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 2MySQL无法把这个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确认范围再加WHEREDELETE 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想写差都难。