数据库性能优化:SQL 语句的优化(原理+解析+面试)
一、基础查询 SQL 优化(最常用)
这类优化主要针对单表查询,核心是减少数据扫描范围、避免索引失效。
1. 杜绝「全字段查询」和「冗余字段」
问题:SELECT *会查询所有字段,不仅增加网络传输量,还无法利用「覆盖索引」(只查索引字段就能返回结果,无需回表)。
优化:只查询业务需要的字段。
-- 糟糕写法:查所有字段,即使只需要姓名和手机号SELECT*FROMuserWHEREid=100;-- 优化写法:精准查询需要的字段SELECTname,phoneFROMuserWHEREid=100;2. 避免在 WHERE 子句中对字段做「函数 / 运算」
问题:对字段做函数 / 运算会导致索引失效,数据库只能全表扫描。
原因:索引是按字段原始值排序的有序结构(如 B + 树),能快速二分查找;而对字段做函数 / 运算后,数据库无法直接匹配索引里的原始值,只能放弃索引,逐行计算后再判断(即全表扫描)。
举个通俗例子
比如索引里存的是原始时间2026-01-19 10:00:00,你查DATE(create_time) = '2026-01-19':
索引里没有 “日期格式” 的排序数据,数据库无法直接找;
只能全表扫描,给每一行的create_time执行DATE()函数,再判断是否等于目标值。
优化:将运算逻辑移到「常量侧」,让字段保持 “原始状态”。
-- 糟糕写法:对字段create_time做运算,索引失效SELECT*FROMorderWHEREDATE(create_time)='2026-01-19';SELECT*FROMgoodsWHEREprice*0.8<100;-- 优化写法:运算移到常量侧,字段保持原始值SELECT*FROMorderWHEREcreate_time>='2026-01-19'ANDcreate_time<'2026-01-20';SELECT*FROMgoodsWHEREprice<100/0.8;3. 模糊查询避免「前导通配符」
问题:LIKE '%xxx'会让索引失效,LIKE 'xxx%'则可以利用索引。
原因:这和索引的有序存储特性直接相关:
LIKE 'xxx%'可以利用索引:索引是按字段值的字符顺序排序的。xxx%是前缀匹配,意味着所有以xxx开头的字符串在索引里是连续排列的。数据库可以在索引里快速定位到开头是xxx的范围,再进行精确匹配,所以能走索引。
LIKE '%xxx'会让索引失效:%xxx是后缀匹配,所有以xxx结尾的字符串在索引里是分散的,没有连续的范围。数据库无法通过有序的索引快速定位,只能逐行扫描全表来判断是否符合条件,所以索引失效。
优化:业务允许的情况下,尽量使用前缀匹配;若必须后缀匹配,可考虑全文索引。
-- 糟糕写法:%开头,索引失效SELECT*FROMuserWHEREnameLIKE'%张三';-- 优化写法:%结尾,可走索引SELECT*FROMuserWHEREnameLIKE'张三%';4. 分页查询优化(OFFSET 过大)
问题:LIMIT 10000, 10会让数据库先扫描前 10010 条数据,再丢弃前 10000 条,效率极低。
原因:LIMIT offset, rows的执行逻辑是先扫描并获取前 offset+rows 条数据,再舍弃前 offset 条—— 因为数据库无法直接定位到第 offset 行,必须从第一条开始逐行遍历计数,直到找到目标位置。而索引而优化写法WHERE id > 10000 LIMIT 10能高效,是因为主键 id 有索引(有序),数据库可直接定位到 id=10000 的位置,再取后续 10 条,无需扫描前面的无效数据。
优化:利用「主键 / 索引字段」做范围查询,替代 OFFSET。
-- 糟糕写法:OFFSET越大,查询越慢SELECT*FROMorderORDERBYidLIMIT10000,10;-- 优化写法:通过主键定位,直接取后续数据SELECT*FROMorderWHEREid>10000ORDERBYidLIMIT10;6.避免隐式类型转换(索引失效的 “隐形杀手”)
这是新手最容易踩的坑,字段类型和查询值类型不匹配,会触发隐式转换,直接导致索引失效。
原因:索引是按字段原始数据类型(如字符串 / 数字)排序存储的,隐式转换会改变查询值的类型,导致数据库无法直接匹配索引里的原始值,只能放弃索引、全表扫描后再做类型转换匹配。
-- 场景:user表的phone字段是VARCHAR类型(字符串)-- 糟糕写法:用数字查询字符串字段,触发隐式转换,索引失效SELECT*FROMuserWHEREphone=13800001234;-- 优化写法:保持类型一致,走索引SELECT*FROMuserWHEREphone='13800001234';7. 合理使用 DISTINCT 和 LIMIT
DISTINCT 会触发排序去重,尽量用 WHERE 先过滤数据,再去重;(DISTINCT 的去重逻辑是基于查询结果集排序后去重,先通过 WHERE 过滤掉无关数据,能大幅减少排序 / 去重的数据量,降低资源开销;)
LIMIT 要放在最后,避免先全表扫描再限制结果。(LIMIT 是对最终结果集做限制,若放前面会先限制部分数据再过滤,既可能逻辑错误,也会导致数据库先扫描大量无关数据,再过滤 / 关联,浪费资源。)
-- 糟糕写法:先去重再过滤,数据量大时极慢SELECTDISTINCTnameFROMuserWHEREage>18;-- 优化写法:先过滤再去重,减少去重数据量SELECTDISTINCTnameFROM(SELECTnameFROMuserWHEREage>18)t;-- 错误顺序:LIMIT放中间,逻辑错误且性能差SELECT*FROM(SELECTidFROMuserLIMIT10)tWHEREage>18;-- 正确顺序:先过滤再限制结果SELECTidFROMuserWHEREage>18LIMIT10;8. 慎用 NULL 值判断
NULL 值的判断(IS NULL/IS NOT NULL)可能导致索引失效,建议:
字段尽量设置默认值(如用 0 / 空字符串替代 NULL);
若必须存 NULL,创建索引时包含 NULL 值(MySQL 8.0 + 默认支持,低版本需注意)。
拆解问题:
为什么 IS NULL/IS NOT NULL 会让索引失效?
假设user表的email字段允许 NULL,索引里只存储了有值的email(如xxx@163.com),NULL 值并未按顺序存入索引;
执行WHERE email IS NULL时,索引里找不到 NULL 值的位置,数据库只能逐行扫描全表,检查每条数据的email是否为 NULL。
为什么默认值能解决问题?
把email默认值设为空字符串'',索引会将''和其他邮箱地址一起按字符顺序存储;
执行WHERE email = ''(替代IS NULL)时,数据库能直接在索引里定位到所有空字符串的位置,快速返回结果,无需全表扫描。
-- 场景:user表的email字段允许NULL,需查询有邮箱的用户-- 糟糕写法:IS NOT NULL可能不走索引SELECT*FROMuserWHEREemailISNOTNULL;-- 优化写法:设置默认值为空字符串,用普通条件查询ALTERTABLEuserMODIFYCOLUMNemailVARCHAR(50)DEFAULT'';SELECT*FROMuserWHEREemail!='';-- 走索引二、关联查询(JOIN)优化
关联查询是性能问题高发区,核心是减少关联数据量、确保关联字段有索引。
1. 小表驱动大表(核心原则)
原理:让小表的数据先循环,再去大表中匹配,减少循环次数和 IO。
优化:MySQL 中INNER JOIN会自动优化表顺序,但LEFT JOIN需手动将小表放左边。
-- 场景:订单表(order,100万条)关联用户表(user,10万条),查用户的订单-- 糟糕写法:大表驱动小表SELECTo.*FROM`order`oLEFTJOIN`user`uONo.user_id=u.idWHEREu.city='北京';-- 优化写法:小表驱动大表(先筛选小表数据,再关联大表)SELECTo.*FROM(SELECTidFROM`user`WHEREcity='北京')uLEFTJOIN`order`oONu.id=o.user_id;2. 关联字段必须加索引
问题:JOIN 的关联字段无索引,会导致大表全表扫描(如 order.user_id 无索引)。
优化:为关联字段创建索引(如 order 表的 user_id 字段)。
-- 给关联字段创建索引(只需执行一次)CREATEINDEXidx_order_user_idON`order`(user_id);-- 关联查询会走索引,效率大幅提升SELECTu.name,o.order_noFROM`user`uINNERJOIN`order`oONu.id=o.user_id;3. 避免 JOIN 过多表
问题:JOIN 超过 3-4 张表,会导致执行计划复杂,性能急剧下降。
优化:拆分 SQL,分步查询(先查核心表,再根据结果查关联表)。
4.减少使用 IN/NOT IN,用 EXISTS/NOT EXISTS 或关联查询替代
原因:
IN 的问题:
当 IN 后是子查询时,数据库会先执行子查询生成临时结果集,再将外层表的每条数据与临时集做匹配;如果临时集过大(如上万条),匹配开销会急剧增加。
NOT IN 更糟:如果子查询结果包含 NULL 值,NOT IN 会直接返回空集(逻辑陷阱),且数据库无法利用索引,只能全表扫描。
EXISTS 的优势:
EXISTS 是 “存在性判断”,只要找到满足条件的第一条数据就停止扫描(类似 “短路逻辑”),无需生成完整的临时结果集;且能更好地利用索引,尤其是关联字段有索引时,效率远高于 IN/NOT IN。
注意:
关联字段必须加索引:比如order.user_id要加索引,否则 EXISTS 和关联查询都会退化为全表扫描;
EXISTS 子查询里用 SELECT 1:无需查询具体字段(如 SELECT *),SELECT 1 只是判断 “是否存在”,减少数据传输;
DISTINCT 的使用:关联查询(如 INNER JOIN)可能返回重复行,需用 DISTINCT 去重(EXISTS 无需,因为只判断存在性)。
子查询场景:优先用 EXISTS/NOT EXISTS;
常量列表(大量):优先用关联查询 + 临时表;
常量列表(少量):保留 IN 即可(无需过度优化);
5.OR 换 UNION/UNION ALL
原因:
OR 的核心问题是破坏索引的有效性:
如果 OR 连接的多个条件字段都有独立索引(如 id 和 phone 各有索引),数据库优化器通常无法同时使用多个索引,会直接走全表扫描;
如果 OR 连接的条件字段无索引,则必然触发全表扫描,数据量越大越慢。
而 UNION/UNION ALL 是将多个独立查询的结果合并,每个子查询都能单独使用对应索引,最终合并结果,效率远高于 OR。
例外:OR 连接同字段少量值(<10 个)时,可保留 OR(数据库会优化为 IN,走索引),无需替换。
三、聚合 / 排序 SQL 优化(GROUP BY/ORDER BY)
这类 SQL 容易出现「文件排序」「临时表」,核心是利用索引避免排序 / 临时表。
1. 索引覆盖 GROUP BY/ORDER BY 字段
原理:如果索引包含 GROUP BY/ORDER BY 的字段,数据库无需额外排序。
优化:创建包含排序 / 聚合字段的联合索引。
-- 场景:按创建时间排序查订单-- 糟糕写法:无索引,会触发Using filesortSELECT*FROM`order`ORDERBYcreate_time;-- 优化写法:创建索引,避免排序CREATEINDEXidx_order_create_timeON`order`(create_time);SELECTid,order_noFROM`order`ORDERBYcreate_time;-- 覆盖索引,无需回表2. 避免在 GROUP BY 中使用函数
问题:对字段做函数运算会导致索引失效,无法优化聚合操作。
优化:提前预处理数据(如新增字段存储按天 / 月聚合的结果)。
-- 糟糕写法:按日期聚合,触发全表扫描SELECTDATE(create_time)ASday,COUNT(*)FROM`order`GROUPBYDATE(create_time);-- 优化写法:新增字段order_day,提前存储日期ALTERTABLE`order`ADDCOLUMNorder_dayDATE;UPDATE`order`SETorder_day=DATE(create_time);-- 初始化数据CREATEINDEXidx_order_dayON`order`(order_day);SELECTorder_day,COUNT(*)FROM`order`GROUPBYorder_day;-- 走索引四、写入类 SQL 优化(INSERT/UPDATE/DELETE)
写入操作的优化核心是减少锁竞争、降低索引维护开销。
1. 批量写入替代单条写入
问题:单条 INSERT 循环执行,会频繁触发日志刷盘和索引更新。
优化:使用批量 INSERT,减少 IO 次数。
-- 糟糕写法:单条插入(循环1000次)INSERTINTOuser(name,phone)VALUES('张三','1380000');INSERTINTOuser(name,phone)VALUES('李四','1390000');-- 优化写法:批量插入(1次执行)INSERTINTOuser(name,phone)VALUES('张三','1380000'),('李四','1390000'),...-- 最多建议批量1000条左右,避免SQL过长;2. UPDATE/DELETE 先筛选再操作
问题:无 WHERE 条件的 UPDATE/DELETE 会锁全表,有 WHERE 但无索引会全表扫描 + 锁表。
优化:WHERE 条件加索引,且先筛选少量数据再更新 / 删除。
-- 糟糕写法:无索引,全表扫描+锁表UPDATE`order`SETstatus=2WHEREcreate_time<'2025-01-01';-- 优化写法:给create_time加索引,精准更新CREATEINDEXidx_order_create_timeON`order`(create_time);UPDATE`order`SETstatus=2WHEREcreate_time<'2025-01-01'LIMIT1000;-- 分批更新,避免锁表过久五、特殊场景的SQL优化
1. 大批量删除 / 更新(避免锁表)
针对百万级以上数据的删除 / 更新,直接操作会锁表,导致业务阻塞,优化方案:
-- 场景:删除order表中2025年之前的历史数据(100万条)-- 糟糕写法:一次性删除,锁表+IO暴涨DELETEFROM`order`WHEREcreate_time<'2025-01-01';-- 优化写法:分批删除,每次删少量数据SET@row_count=1;WHILE@row_count>0DODELETEFROM`order`WHEREcreate_time<'2025-01-01'LIMIT1000;SET@row_count=ROW_COUNT();-- 获取本次删除的行数SELECTSLEEP(1);-- 暂停1秒,降低数据库压力ENDWHILE;2. 子查询优化(避免 “嵌套地狱”)
MySQL 对多层子查询的优化能力弱,尽量用 JOIN 替代子查询,尤其是相关子查询(子查询依赖外层表)。
原因:
执行逻辑:
子查询(尤其是IN + 子查询):数据库通常会先执行子查询得到一个临时结果集,再用这个结果集去外层表匹配;如果是相关子查询(子查询依赖外层表字段),则外层表每一行都会触发一次子查询,相当于 “嵌套循环”,时间复杂度会从O(n)变成O(n²)。
关联查询(JOIN):数据库优化器会根据表的大小、索引情况,选择最优的连接方式(如嵌套循环连接、哈希连接、合并连接),优先用小表驱动大表,减少扫描次数,整体是 “一次扫描、一次匹配”。
资源消耗:
子查询会生成临时表存储中间结果,增加内存 / 磁盘 IO 开销;而 JOIN 可以直接利用索引做关联,避免临时表的额外消耗。
-- 场景:查询有订单的用户信息-- 糟糕写法:相关子查询,外层每一行都执行一次子查询SELECT*FROMuserWHEREidIN(SELECTuser_idFROM`order`);-- 优化写法:JOIN替代子查询,效率提升数倍SELECTDISTINCTu.*FROMuseruINNERJOIN`order`oONu.id=o.user_id;3. 统计类查询优化(避免实时计算)
高频的统计查询(如 “今日订单数”“本月销售额”),实时计算会消耗大量资源,优化方案:
方案 1:预计算 + 定时更新(用定时任务 / 触发器更新统计结果到专用表);
方案 2:使用数据库的汇总表 / 物化视图(MySQL 8.0 + 支持物化视图)。
-- 示例:创建统计汇总表CREATETABLEorder_stat(stat_dateDATEPRIMARYKEY,-- 统计日期order_countINTDEFAULT0,-- 当日订单数sale_amountDECIMAL(10,2)DEFAULT0-- 当日销售额);-- 定时任务(如每天凌晨)更新汇总表REPLACEINTOorder_stat(stat_date,order_count,sale_amount)SELECTDATE(create_time)ASstat_date,COUNT(*)ASorder_count,SUM(amount)ASsale_amountFROM`order`WHEREDATE(create_time)=CURDATE()-INTERVAL1DAYGROUPBYDATE(create_time);-- 查询时直接查汇总表,无需实时计算SELECTorder_count,sale_amountFROMorder_statWHEREstat_date='2026-01-19';补:
很多新手误以为的 “优化”,实际会降低性能,一定要避开:
1.给所有字段加索引:索引会减慢 INSERT/UPDATE/DELETE 速度,且数据库优化器会因索引过多选择错误的执行路径;
2.用 COUNT (*) 代替 COUNT (字段):COUNT (*) 是 MySQL 优化过的,效率比 COUNT (字段) 高(COUNT (字段) 会判断字段是否为 NULL);
3.过度拆分 SQL:比如把一次 JOIN 查询拆成多次单表查询,反而增加网络交互和数据库连接开销;
4.忽略数据倾斜:比如 WHERE 条件中 “status=0” 占 90% 数据,给 status 加索引反而变慢(索引选择性差,数据库会直接全表扫描)。
六、验证优化效果的核心方法
优化后必须验证效果,最常用的是EXPLAIN关键字:
-- 查看SQL执行计划EXPLAINSELECTname,phoneFROMuserWHEREid=100;重点关注 3 个字段:
type:执行类型,最优为const(常量查询),其次range(范围查询),避免ALL(全表扫描);
key:实际使用的索引,NULL 表示未走索引;
Extra:避免Using filesort(文件排序)、Using temporary(临时表)。
优化后不仅要看执行计划,还要量化耗时和资源消耗:
--1. 查看SQL执行耗时(MySQL) SET profiling=1;-- 开启性能分析 SELECT * FROM user WHERE age>18;-- 执行SQL SHOW PROFILES;-- 查看所有SQL的耗时 SHOW PROFILE FOR QUERY1;-- 查看第1条SQL的详细耗时(如CPU、IO) --2. 查看索引使用情况(判断索引是否有效) SHOW INDEX FROM user;-- 查看索引信息 SELECT * FROM sys.schema_unused_indexes;-- MySQL8.0+查看未使用的索引七、总结
1.SQL 语句优化的核心是让数据库少干活:减少扫描行数、避免无效排序 / 临时表、利用索引减少 IO;
2.优先优化语法层面(如避免字段运算、杜绝 SELECT *),再优化索引,最后验证执行计划;
3.写入类 SQL 重点减少锁竞争和索引维护开销,查询类 SQL 重点利用索引避免全表扫描。
4.容易忽略的坑:隐式类型转换、NULL 值判断、过度索引,这些是索引失效的高频原因;
5.特殊场景优化:大批量操作要分批执行,子查询优先换 JOIN,统计查询优先预计算;
6.避坑关键:不要盲目加索引,不要过度拆分 SQL,优化后要量化验证效果。
八、为了方便记忆做了个简单的面试总结
1.避免全字段查询
不要用SELECT *,只查需要的字段;优先在WHERE、ORDER BY涉及的字段上建索引,减少数据扫描。
2.子查询换关联查询
少用子查询(尤其是相关子查询),用JOIN替代,减少嵌套循环和临时表开销。
3.IN/NOT IN 换 EXISTS / 关联查询
IN/NOT IN性能差且有逻辑陷阱,用EXISTS/NOT EXISTS(存在性判断)或JOIN替代,提升效率。
4.OR 换 UNION/UNION ALL
用UNION(去重)或UNION ALL(不去重)替代OR,避免全表扫描,稳定利用索引。
5.避免在 WHERE 子句里做运算或用不等于
别对字段用!=/<>,也别在字段上做函数 / 计算,否则会导致索引失效,触发全表扫描。
6.避免 NULL 值判断
别用IS NULL/IS NOT NULL,给字段设置默认值(如空字符串 / 0),用普通条件查询来替代,保证索引可用。
记忆口诀:
少用星号和子查,IN 换 EXISTS 或 JOIN;OR 用 UNION 替代,字段运算 NULL 不查。
分页别用大 OFFSET,小表驱动大表佳;索引不贪多和杂,EXPLAIN 一下效率高