数据库性能优化:SQL 语句的优化(原理+解析+面试)
一、基础查询 SQL 优化(最常用)
这类优化主要针对单表查询,核心是减少数据扫描范围、避免索引失效。
1. 杜绝「全字段查询」和「冗余字段」
问题:SELECT * 会查询所有字段,不仅增加网络传输量,还无法利用「覆盖索引」(只查索引字段就能返回结果,无需回表)。
优化:只查询业务需要的字段。
sql
-- 糟糕写法:查所有字段,即使只需要姓名和手机号
SELECT * FROM user WHERE id = 100;
-- 优化写法:精准查询需要的字段
SELECT name, phone FROM user WHERE id = 100;
2. 避免在 WHERE 子句中对字段做「函数 / 运算」
问题:对字段做函数 / 运算会导致索引失效,数据库只能全表扫描。
原因:索引是按字段原始值排序的有序结构(如 B + 树),能快速二分查找;而对字段做函数 / 运算后,数据库无法直接匹配索引里的原始值,只能放弃索引,逐行计算后再判断(即全表扫描)。
举个通俗例子
比如索引里存的是原始时间 2026-01-19 10:00:00,你查 DATE(create_time) = '2026-01-19':
索引里没有 "日期格式" 的排序数据,数据库无法直接找;
只能全表扫描,给每一行的 create_time 执行 DATE() 函数,再判断是否等于目标值。
优化:将运算逻辑移到「常量侧」,让字段保持 "原始状态"。
sql
-- 糟糕写法:对字段create_time做运算,索引失效
SELECT * FROM order WHERE DATE(create_time) = '2026-01-19';
SELECT * FROM goods WHERE price * 0.8 < 100;
-- 优化写法:运算移到常量侧,字段保持原始值
SELECT * FROM order WHERE create_time >= '2026-01-19' AND create_time < '2026-01-20';
SELECT * FROM goods WHERE price < 100 / 0.8;
3. 模糊查询避免「前导通配符」
问题:LIKE '%xxx' 会让索引失效,LIKE 'xxx%' 则可以利用索引。
原因:这和索引的有序存储特性直接相关:
LIKE 'xxx%' 可以利用索引:索引是按字段值的字符顺序排序的。xxx% 是前缀匹配,意味着所有以 xxx 开头的字符串在索引里是连续排列的。数据库可以在索引里快速定位到开头是 xxx 的范围,再进行精确匹配,所以能走索引。
LIKE '%xxx' 会让索引失效:%xxx 是后缀匹配,所有以 xxx 结尾的字符串在索引里是分散的,没有连续的范围。数据库无法通过有序的索引快速定位,只能逐行扫描全表来判断是否符合条件,所以索引失效。
优化:业务允许的情况下,尽量使用前缀匹配;若必须后缀匹配,可考虑全文索引。
sql
-- 糟糕写法:%开头,索引失效
SELECT * FROM user WHERE name LIKE '%张三';
-- 优化写法:%结尾,可走索引
SELECT * FROM user WHERE name LIKE '张三%';
4. 分页查询优化(OFFSET 过大)
问题:LIMIT 10000, 10 会让数据库先扫描前 10010 条数据,再丢弃前 10000 条,效率极低。
原因:LIMIT offset, rows 的执行逻辑是先扫描并获取前 offset+rows 条数据,再舍弃前 offset 条------ 因为数据库无法直接定位到第 offset 行,必须从第一条开始逐行遍历计数,直到找到目标位置。而索引而优化写法 WHERE id > 10000 LIMIT 10 能高效,是因为主键 id 有索引(有序),数据库可直接定位到 id=10000 的位置,再取后续 10 条,无需扫描前面的无效数据。
优化:利用「主键 / 索引字段」做范围查询,替代 OFFSET。
sql
-- 糟糕写法:OFFSET越大,查询越慢
SELECT * FROM order ORDER BY id LIMIT 10000, 10;
-- 优化写法:通过主键定位,直接取后续数据
SELECT * FROM order WHERE id > 10000 ORDER BY id LIMIT 10;
6.避免隐式类型转换(索引失效的 "隐形杀手")
这是新手最容易踩的坑,字段类型和查询值类型不匹配,会触发隐式转换,直接导致索引失效。
原因:索引是按字段原始数据类型(如字符串 / 数字)排序存储的,隐式转换会改变查询值的类型,导致数据库无法直接匹配索引里的原始值,只能放弃索引、全表扫描后再做类型转换匹配。
sql
-- 场景:user表的phone字段是VARCHAR类型(字符串)
-- 糟糕写法:用数字查询字符串字段,触发隐式转换,索引失效
SELECT * FROM user WHERE phone = 13800001234;
-- 优化写法:保持类型一致,走索引
SELECT * FROM user WHERE phone = '13800001234';
7. 合理使用 DISTINCT 和 LIMIT
DISTINCT 会触发排序去重,尽量用 WHERE 先过滤数据,再去重;(DISTINCT 的去重逻辑是基于查询结果集排序后去重,先通过 WHERE 过滤掉无关数据,能大幅减少排序 / 去重的数据量,降低资源开销;)
LIMIT 要放在最后,避免先全表扫描再限制结果。(LIMIT 是对最终结果集做限制,若放前面会先限制部分数据再过滤,既可能逻辑错误,也会导致数据库先扫描大量无关数据,再过滤 / 关联,浪费资源。)
sql
-- 糟糕写法:先去重再过滤,数据量大时极慢
SELECT DISTINCT name FROM user WHERE age > 18;
-- 优化写法:先过滤再去重,减少去重数据量
SELECT DISTINCT name FROM (SELECT name FROM user WHERE age > 18) t;
-- 错误顺序:LIMIT放中间,逻辑错误且性能差
SELECT * FROM (SELECT id FROM user LIMIT 10) t WHERE age > 18;
-- 正确顺序:先过滤再限制结果
SELECT id FROM user WHERE age > 18 LIMIT 10;
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)时,数据库能直接在索引里定位到所有空字符串的位置,快速返回结果,无需全表扫描。
sql
-- 场景:user表的email字段允许NULL,需查询有邮箱的用户
-- 糟糕写法:IS NOT NULL可能不走索引
SELECT * FROM user WHERE email IS NOT NULL;
-- 优化写法:设置默认值为空字符串,用普通条件查询
ALTER TABLE user MODIFY COLUMN email VARCHAR(50) DEFAULT '';
SELECT * FROM user WHERE email != ''; -- 走索引
二、关联查询(JOIN)优化
关联查询是性能问题高发区,核心是减少关联数据量、确保关联字段有索引。
1. 小表驱动大表(核心原则)
原理:让小表的数据先循环,再去大表中匹配,减少循环次数和 IO。
优化:MySQL 中INNER JOIN会自动优化表顺序,但LEFT JOIN需手动将小表放左边。
sql
-- 场景:订单表(order,100万条)关联用户表(user,10万条),查用户的订单
-- 糟糕写法:大表驱动小表
SELECT o.* FROM `order` o LEFT JOIN `user` u ON o.user_id = u.id WHERE u.city = '北京';
-- 优化写法:小表驱动大表(先筛选小表数据,再关联大表)
SELECT o.* FROM (SELECT id FROM `user` WHERE city = '北京') u
LEFT JOIN `order` o ON u.id = o.user_id;
2. 关联字段必须加索引
问题:JOIN 的关联字段无索引,会导致大表全表扫描(如 order.user_id 无索引)。
优化:为关联字段创建索引(如 order 表的 user_id 字段)。
sql
-- 给关联字段创建索引(只需执行一次)
CREATE INDEX idx_order_user_id ON `order`(user_id);
-- 关联查询会走索引,效率大幅提升
SELECT u.name, o.order_no FROM `user` u
INNER JOIN `order` o ON u.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 的字段,数据库无需额外排序。
优化:创建包含排序 / 聚合字段的联合索引。
sql
-- 场景:按创建时间排序查订单
-- 糟糕写法:无索引,会触发Using filesort
SELECT * FROM `order` ORDER BY create_time;
-- 优化写法:创建索引,避免排序
CREATE INDEX idx_order_create_time ON `order`(create_time);
SELECT id, order_no FROM `order` ORDER BY create_time; -- 覆盖索引,无需回表
2. 避免在 GROUP BY 中使用函数
问题:对字段做函数运算会导致索引失效,无法优化聚合操作。
优化:提前预处理数据(如新增字段存储按天 / 月聚合的结果)。
sql
-- 糟糕写法:按日期聚合,触发全表扫描
SELECT DATE(create_time) AS day, COUNT(*) FROM `order` GROUP BY DATE(create_time);
-- 优化写法:新增字段order_day,提前存储日期
ALTER TABLE `order` ADD COLUMN order_day DATE;
UPDATE `order` SET order_day = DATE(create_time); -- 初始化数据
CREATE INDEX idx_order_day ON `order`(order_day);
SELECT order_day, COUNT(*) FROM `order` GROUP BY order_day; -- 走索引
四、写入类 SQL 优化(INSERT/UPDATE/DELETE)
写入操作的优化核心是减少锁竞争、降低索引维护开销。
1. 批量写入替代单条写入
问题:单条 INSERT 循环执行,会频繁触发日志刷盘和索引更新。
优化:使用批量 INSERT,减少 IO 次数。
sql
-- 糟糕写法:单条插入(循环1000次)
INSERT INTO user(name, phone) VALUES ('张三', '1380000');
INSERT INTO user(name, phone) VALUES ('李四', '1390000');
-- 优化写法:批量插入(1次执行)
INSERT INTO user(name, phone) VALUES
('张三', '1380000'),
('李四', '1390000'),
... -- 最多建议批量1000条左右,避免SQL过长
;
2. UPDATE/DELETE 先筛选再操作
问题:无 WHERE 条件的 UPDATE/DELETE 会锁全表,有 WHERE 但无索引会全表扫描 + 锁表。
优化:WHERE 条件加索引,且先筛选少量数据再更新 / 删除。
sql
-- 糟糕写法:无索引,全表扫描+锁表
UPDATE `order` SET status = 2 WHERE create_time < '2025-01-01';
-- 优化写法:给create_time加索引,精准更新
CREATE INDEX idx_order_create_time ON `order`(create_time);
UPDATE `order` SET status = 2 WHERE create_time < '2025-01-01' LIMIT 1000; -- 分批更新,避免锁表过久
五、特殊场景的SQL优化
1. 大批量删除 / 更新(避免锁表)
针对百万级以上数据的删除 / 更新,直接操作会锁表,导致业务阻塞,优化方案:
sql
-- 场景:删除order表中2025年之前的历史数据(100万条)
-- 糟糕写法:一次性删除,锁表+IO暴涨
DELETE FROM `order` WHERE create_time < '2025-01-01';
-- 优化写法:分批删除,每次删少量数据
SET @row_count = 1;
WHILE @row_count > 0 DO
DELETE FROM `order` WHERE create_time < '2025-01-01' LIMIT 1000;
SET @row_count = ROW_COUNT(); -- 获取本次删除的行数
SELECT SLEEP(1); -- 暂停1秒,降低数据库压力
END WHILE;
2. 子查询优化(避免 "嵌套地狱")
MySQL 对多层子查询的优化能力弱,尽量用 JOIN 替代子查询,尤其是相关子查询(子查询依赖外层表)。
原因:
执行逻辑:
子查询(尤其是IN + 子查询):数据库通常会先执行子查询得到一个临时结果集,再用这个结果集去外层表匹配;如果是相关子查询(子查询依赖外层表字段),则外层表每一行都会触发一次子查询,相当于 "嵌套循环",时间复杂度会从O(n)变成O(n²)。
关联查询(JOIN):数据库优化器会根据表的大小、索引情况,选择最优的连接方式(如嵌套循环连接、哈希连接、合并连接),优先用小表驱动大表,减少扫描次数,整体是 "一次扫描、一次匹配"。
资源消耗:
子查询会生成临时表存储中间结果,增加内存 / 磁盘 IO 开销;而 JOIN 可以直接利用索引做关联,避免临时表的额外消耗。
sql
-- 场景:查询有订单的用户信息
-- 糟糕写法:相关子查询,外层每一行都执行一次子查询
SELECT * FROM user WHERE id IN (SELECT user_id FROM `order`);
-- 优化写法:JOIN替代子查询,效率提升数倍
SELECT DISTINCT u.* FROM user u
INNER JOIN `order` o ON u.id = o.user_id;
3. 统计类查询优化(避免实时计算)
高频的统计查询(如 "今日订单数""本月销售额"),实时计算会消耗大量资源,优化方案:
方案 1:预计算 + 定时更新(用定时任务 / 触发器更新统计结果到专用表);
方案 2:使用数据库的汇总表 / 物化视图(MySQL 8.0 + 支持物化视图)。
sql
-- 示例:创建统计汇总表
CREATE TABLE order_stat (
stat_date DATE PRIMARY KEY, -- 统计日期
order_count INT DEFAULT 0, -- 当日订单数
sale_amount DECIMAL(10,2) DEFAULT 0 -- 当日销售额
);
-- 定时任务(如每天凌晨)更新汇总表
REPLACE INTO order_stat (stat_date, order_count, sale_amount)
SELECT
DATE(create_time) AS stat_date,
COUNT(*) AS order_count,
SUM(amount) AS sale_amount
FROM `order`
WHERE DATE(create_time) = CURDATE() - INTERVAL 1 DAY
GROUP BY DATE(create_time);
-- 查询时直接查汇总表,无需实时计算
SELECT order_count, sale_amount FROM order_stat WHERE stat_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
-- 查看SQL执行计划
EXPLAIN SELECT name, phone FROM user WHERE id = 100;
重点关注 3 个字段:
type:执行类型,最优为const(常量查询),其次range(范围查询),避免ALL(全表扫描);
key:实际使用的索引,NULL 表示未走索引;
Extra:避免Using filesort(文件排序)、Using temporary(临时表)。
优化后不仅要看执行计划,还要量化耗时和资源消耗:
bash
-- 1. 查看SQL执行耗时(MySQL)
SET profiling = 1; -- 开启性能分析
SELECT * FROM user WHERE age > 18; -- 执行SQL
SHOW PROFILES; -- 查看所有SQL的耗时
SHOW PROFILE FOR QUERY 1; -- 查看第1条SQL的详细耗时(如CPU、IO)
-- 2. 查看索引使用情况(判断索引是否有效)
SHOW INDEX FROM user; -- 查看索引信息
SELECT * FROM sys.schema_unused_indexes; -- MySQL 8.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 一下效率高