做后端开发这几年,最怕遇到的事情之一就是线上突然冒出几条慢SQL。数据库CPU飙高、接口超时、连接池被打满,排查下来往往就是一条没写好索引的查询在作祟。SQL优化这件事,说难不难,说简单也不简单——很多人背了一堆优化口诀,真到排查问题的时候还是无从下手。这篇文章把我整理过的SQL优化15种核心策略一次性讲透,每条都包含原理分析和实战案例,希望能帮你在面对慢SQL时不再靠猜。
先说明白,这篇文章不是让你把所有技巧都用在每一条SQL上,而是帮你建立一个优化决策框架:什么场景该用什么策略,为什么用这个策略,用了之后预期能提升多少。我尽量把每条策略背后的数据库工作原理讲清楚,因为只有理解了原理,你才能在遇到新问题的时候举一反三。
1. 先把思路摆正:SQL优化的本质与四条黄金原则
在细讲15种策略之前,我强烈建议你先花两分钟想清楚一个根本问题:SQL为什么会慢?答案无非三种:要么是数据获取的量太大,要么是数据处理的方式太笨,要么是数据库引擎没能用上最高效的路径。理解了这一点,你去看所有优化手段时就会发现,它们全是围绕这三个方向在使劲。
1.1 优化之前,先回答三个问题
我每次接手一条慢SQL,第一件事不是急着改语句,而是先回答三个问题:
第一个问题:这条SQL需要的数据量到底是多少?比如你要查用户30天内的订单,但SQL先全表扫描了整张订单表,然后再过滤,这就是获取了远超出实际需求的数据量。
第二个问题:数据获取的路径对不对?一张表有没有合适的索引,优化器能不能通过索引快速定位到目标数据。这里我打一个比方:全表扫描就像在一本没有目录的书里找一页内容,只能从第一页翻到最后一页;索引扫描则是通过目录直接翻到目标页,效率天差地别。
第三个问题:数据处理的方式是否高效?比如排序、分组、嵌套循环连接,这些操作有没有办法避免,或者能不能通过更优的执行计划来减少计算量。
这三个问题想清楚,优化方向就自然浮出水面了。
1.2 四条黄金原则,少走弯路
站在我个人的实操经验上,SQL优化有四个原则能帮你少踩很多坑。
原则一:先定位,再优化。不要凭感觉去优化SQL,先用慢查询日志、performance_schema、执行计划等工具把真正的瓶颈找出来。很多情况下,SQL慢的原因根本不在SQL本身,而是数据库服务器配置、磁盘IO、锁等待或者网络延迟。
原则二:索引优先,改写其次。大多数慢SQL通过合理加索引就能解决,改写语句反而有引入业务风险的可能。所以我的习惯是先用EXPLAIN看执行计划,确认是不是索引没走对,再考虑要不要改写SQL。
原则三:小步验证,一次只改一个变量。很多朋友一上来就同时加索引、改语句、调参数,结果SQL确实快了,但根本不知道是哪一步起的作用。正确做法是一次只改一个点,然后对比执行计划和响应时间,这样才能积累真实的优化经验。
原则四:优化是取舍,不是炫技。覆盖索引能提速,但会牺牲写性能、增加存储成本;反范式能省join,但会带来数据一致性问题。每个优化方案都有代价,你要做的是在业务场景下找到平衡点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心策略一:索引篇——用对索引等于成功了一半
索引是SQL优化里性价比最高的手段,没有之一。很多时候一条慢SQL加上合适的索引,性能直接提升几十倍甚至上百倍。但索引也不是无脑加的,用错了反而会让SQL更慢。我整理了四条和索引相关的核心策略,这里面每一个我都踩过坑。
2.1 策略一:联合索引与最左前缀原则
联合索引,也叫多列索引,是指在一个索引中包含多个字段。比如我们要频繁按照"用户ID + 订单状态 + 下单时间"来查询,就可以建一个联合索引 (user_id, status, order_time)。
联合索引最重要的一个规则是"最左前缀原则":查询条件必须从联合索引的最左列开始匹配,索引才会生效。比如上面这个索引,WHERE user_id = 1 走索引,WHERE user_id = 1 AND status = 1 走索引,但 WHERE status = 1 就不会走这个索引。
这个原则怎么理解?你可以把联合索引想象成一本电话号码簿,它先按姓氏排序,再按名字排序。如果你要查"姓张的人",直接翻对应页码就行;但如果你只知道"名字叫伟的人",对不起,这本电话簿帮不了你,只能逐页翻。
实际案例:我之前接手过一个电商后台的订单查询,按 WHERE shop_id = ? AND status = ? AND create_time > ? 查询,结果线上频繁超时。排查发现订单表虽然建了好几个单列索引,但没有一个能同时覆盖这三个条件。后来我把三个字段组合成联合索引 (shop_id, status, create_time),查询时间从1.8秒降到60毫秒,效果立竿见影。
需要注意的一点是,联合索引字段的顺序非常关键。一般原则是:等值条件的字段放在前面,范围条件的字段放在后面。因为等值条件可以用到索引的所有字段,而范围条件之后的索引字段就无法参与匹配了。比如 WHERE shop_id = 1 AND status = 2 AND create_time > '2024-01-01',shop_id 和 status 用等值,create_time 用范围,那么索引顺序应该是 (shop_id, status, create_time),而不是反过来。
2.2 策略二:覆盖索引避免回表
覆盖索引是个很容易被忽略但是非常实用的优化手段。它的原理很简单:如果查询所需的所有字段都在索引中,数据库就不需要回到主表去获取完整数据行,省掉了一次"回表"操作。
举个例子,你执行 SELECT user_id, status FROM orders WHERE status = 1,如果有一个索引 (status, user_id),那数据库在索引中就能拿到 status 和 user_id 两个字段的值,不需要再回到订单表读一整行数据。查询速度自然快很多。
这个手段尤其适合那种"表字段很多、行数很大,但查询只关注少数几个字段"的场景。我在做报表统计类SQL优化时特别喜欢用覆盖索引,因为报表SQL往往要扫描大量数据,如果能省掉回表操作,IO开销能减少一大截。
需要提醒的是,覆盖索引也不是字段越多越好。因为索引文件要占用磁盘空间,写数据时还要维护索引,字段太多会导致写入变慢。我的经验是,只在高频查询的字段组合上建立覆盖索引,低频查询宁可让它回表。
2.3 策略三:避免索引列上的隐式类型转换
这个坑非常隐蔽,排查起来也比较费劲。所谓隐式类型转换,就是查询条件的类型和索引字段的类型不一致时,数据库会自动做类型转换,导致索引失效。
最常见的场景是:表里某个字段是 varchar 类型,但你在查询时用了数值类型。比如 WHERE phone = 13800138000,而 phone 字段是 varchar(20) 类型。MySQL会把 phone 字段转换成数值类型再比较,这种情况下索引就废了,因为函数(哪怕是隐式的)作用在索引列上时,索引会失效。
我在一次排查中就遇到过这个坑。一张用户表几百万数据,按手机号查询居然要扫描全表。刚开始我以为是索引没建,检查后发现 phone 字段上有索引,但查询就是不走。后来用 EXPLAIN 看到 type = ALL,才意识到是类型不匹配导致的隐式转换。把查询条件加上引号改成 WHERE phone = '13800138000' 之后,查询立刻走了索引,耗时从几百毫秒降到几毫秒。
这个坑特别要提醒做接口开发的朋友注意:前端传过来的参数经过JSON解析后往往是字符串类型,但如果代码里做了一次类型转换,比如把字符串转成了long再传给SQL,就会造成类型不匹配。所以排查这类问题的时候,不光要看SQL本身,还要看代码里的参数类型。
2.4 策略四:不要在索引列上进行函数运算
在索引列上使用函数,会导致索引失效,这也是一个高频踩坑点。原因是索引中存储的是原始值,而函数运算后的结果无法直接通过索引B+树查找,优化器只能放弃索引扫描,改为全表扫描。
常见的写法包括:
WHERE DATE(create_time) = '2024-01-01',对时间字段做了日期函数操作WHERE LOWER(email) = 'test@example.com',对字符串做了小写转换WHERE price + 1 > 100,对数值字段做了算术运算
这些写法全部会导致索引失效。正确做法是把函数运算挪到等号右侧,让左侧的索引列保持原始状态。比如上面的例子可以改写成:
sql复制-- 原写法(索引失效)
SELECT * FROM orders WHERE DATE(create_time) = '2024-01-01';
-- 改写法(索引生效)
SELECT * FROM orders
WHERE create_time >= '2024-01-01 00:00:00'
AND create_time < '2024-01-02 00:00:00';
改写的原理也很简单:把"等于某一天"转换成了"一个范围",这个范围查询是可以正常走索引的。同样的,如果业务上必须用函数,就考虑在新建一个冗余字段或者使用生成列(MySQL 5.7+支持),让函数值预先计算好并建立索引。
3. 核心策略二:语句改写篇——不换索引也能提速的9种写法
有时候索引优化已经做到位了,SQL还是不够快,这时候就得靠改写SQL语句来引导优化器做出更优的执行计划。这一节我整理了9种实战中非常有用的语句改写策略。
3.1 策略五:SELECT明确字段,拒绝SELECT *
SELECT * 可能是很多新手最容易犯的错误之一。它有两个危害:一是会把不需要的字段全部查出来,增加网络传输和内存开销;二是会让覆盖索引失效,因为 * 包含了表中所有字段,几乎不可能全部被索引覆盖,数据库只能回表取完整数据行。
你可能觉得,多查几个字段能有多大事?但数据量大了之后差别非常明显。我之前优化过一个分页接口,把 SELECT * 改成只查需要的7个字段后,同样的查询从800毫秒降到了300毫秒。原因就是回表的数据量大幅减少,关键是索引覆盖的命中率提升了。
所以我的建议是:写SQL的时候养成好习惯,只查业务需要的字段,不要图省事直接敲 *。尤其在字段很多的大表上,这个习惯能帮你省下很多麻烦。
3.2 策略六:LIKE模糊查询的前缀匹配问题
LIKE 模糊查询是SQL优化的一个重灾区。很多人不知道的是,LIKE 'abc%'(前缀匹配)是可以走索引的,但 LIKE '%abc%'(包含匹配)和 LIKE '%abc'(后缀匹配)都会导致索引失效。
原理很好理解:B+树索引是按照关键字顺序排列的,前缀匹配能通过索引快速定位到范围,但包含匹配需要判断每个字符串中间是否包含目标内容,这个操作无法利用顺序结构。
实际项目里,搜索类需求很难避免包含匹配。这种情况下我的处理方案有三种:
第一,如果场景是"前缀缺失"的搜索,考虑用全文索引(MySQL的FULLTEXT索引或Elasticsearch等搜索引擎),不要硬用 LIKE。第二,如果数据量不大(万级以下),可以接受全表扫描,但最好加 LIMIT 限制返回行数。第三,如果是特定业务(比如订单号前缀模糊查询),尽量把条件设计成前缀匹配的形式。
我踩过的一个坑是:给一个商品搜索接口写了 WHERE product_name LIKE '%手机%',线上数据量到50万时接口响应直接飙到3秒。后来引入全文索引,响应降到了50毫秒以内,差距就是这么夸张。
3.3 策略七:OR条件改写成UNION ALL或IN
OR 条件在SQL里是一个比较尴尬的存在。当 OR 连接多个条件时,优化器可能只使用其中一个条件的索引,甚至直接放弃索引走全表扫描。特别是在条件涉及多个不同字段时,这个现象尤其明显。
比如 WHERE user_id = 1 OR status = 2,如果 user_id 和 status 上都有索引,优化器需要同时扫描两个索引再做合并,有些数据库版本会干脆选择全表扫描,因为优化器无法准确估算两个索引合并的代价。
解决方案有两种:
sql复制-- 原写法
SELECT * FROM orders WHERE user_id = 1 OR status = 2;
-- 改写法一:UNION ALL
SELECT * FROM orders WHERE user_id = 1
UNION ALL
SELECT * FROM orders WHERE status = 2;
-- 改写法二:拆分成两个查询
这里注意 UNION 和 UNION ALL 的区别:UNION 会去重,UNION ALL 不去重。如果业务上允许重复数据,一定要用 UNION ALL,因为去重操作非常消耗性能。如果你确认两个条件的结果集不会重叠,用 UNION ALL 是绝对安全的,还能省掉排序去重的开销。
另外补充一点:如果 OR 连接的是同一个字段的不同值,比如 WHERE status = 1 OR status = 2,这时候直接改写为 WHERE status IN (1, 2) 更合适,IN 在多数数据库中都能很好地利用索引。
3.4 策略八:深分页OFFSET改游标分页
分页查询是每个业务系统都躲不开的功能。通常在数据量小的时候,LIMIT 100000, 20 这样的写法没什么感觉,但当 OFFSET 值到了几十万上百万,SQL 就会明显变慢。
原因在于 LIMIT offset, count 的实现方式:数据库必须先扫描掉 offset 条记录,然后才返回后面的 count 条。偏移量越大,扫描的无效数据越多,而且这些被跳过的记录也是要付出IO代价的。
深分页优化有几种常见方案,我推荐的是"游标分页"(也叫 keyset pagination)。它的核心思路是:不走 OFFSET,而是基于上一页的最后一条记录的排序字段值来定位下一页。
sql复制-- 传统深分页写法(数据量大时很慢)
SELECT * FROM orders
WHERE status = 1
ORDER BY id
LIMIT 100000, 20;
-- 游标分页写法(通过上次查询的最后一条id定位)
SELECT * FROM orders
WHERE status = 1 AND id > 100000
ORDER BY id
LIMIT 20;
第二种写法可以直接利用主键索引定位到 id = 100000 的位置,然后往后扫20条,整个过程不需要跳过大段无用数据,速度非常稳定。不过这种方案要求排序字段必须是唯一且有序的,通常是主键。如果业务上必须按其他字段排序,可以建联合索引或者退而求其次用"子查询 + JOIN"的方案。
3.5 策略九:用EXISTS替代IN(特定场景下)
IN 和 EXISTS 的取舍,网上的讨论很多,很多结论也是模棱两可。从我的实践经验来看:当子查询结果集很小、外层表很大时,用 IN 通常没问题;但当子查询结果集很大、外层表也很大时,用 EXISTS 往往更优。
原因在于两种写法的执行逻辑不同:IN 会先执行子查询,把子查询结果集加载到内存中,然后对外层表做匹配;EXISTS 则是对外层表的每一行,去子查询里检查是否存在匹配记录。可以理解为:IN 是"先准备一堆数据再匹配",EXISTS 是"逐行检查是否存在"。
实际优化案例:我之前有个统计SQL,用 IN 子查询查一份几十万ID的列表,结果内存溢出了。改成 EXISTS 之后,SQL的执行逻辑变成了外层表逐行与子查询做关联判断,虽然听起来逐行扫描很低效,但因为子查询里能用上索引,实际执行时间反而大幅缩短。
不过要给你一个更实在的建议:在MySQL 5.6+和8.0版本中,优化器对 IN 和 EXISTS 的改写优化已经做得比较好,很多时候两者执行计划是一样的。所以不要迷信某一个写法,遇到具体问题先 EXPLAIN 看执行计划,用数据说话。
3.6 策略十:UNION改UNION ALL需谨慎
这个策略和前面提到的 OR 改写相关——用 UNION 代替 UNION ALL 时的去重开销。UNION 默认会去重,去重的实现方式是排序或者哈希,这对大结果集来说是非常昂贵的操作。
如果你的SQL逻辑上允许重复数据出现,或者说你不在乎去重(比如两个查询条件互斥、结果集天然不重复),那直接用 UNION ALL 性能会好很多。
这里要特别提醒:改 UNION ALL 之前,一定要确认业务逻辑是否真的允许重复。我见过一个新人把报表系统的 UNION 改成了 UNION ALL,结果每天的报表数量都偏大,排查了半天才发现是有重复订单。所以这个"优化"是有业务风险的,改之前务必确认语义一致。
3.7 策略十一:COUNT查询的合理选择
COUNT(*)、COUNT(1)、COUNT(字段) 的区别和选择,很多资料讲得模棱两可。我的结论是:
COUNT(*)和COUNT(1)在MySQL中基本等价,都是统计行数,性能没有差别。COUNT(字段)统计的是该字段非NULL的行数,如果字段允许NULL,统计结果可能和预期不一致。COUNT(DISTINCT 字段)会对字段去重后计数,效率最低,慎用于大表。
大表计数是另一个典型问题。一张千万级的表,COUNT(*) 一次全表扫描可能耗时好几秒,这在实时接口里是绝对不能被接受的。我常用的方案是:
一,用近似值替代精确值。很多场景(比如"我的订单数")其实不需要精确到个位,可以用 EXPLAIN 的 rows 字段或者 SHOW TABLE STATUS 拿到估算值。二,在业务表上维护计数器,每次插入/删除时更新计数,查询时直接读计数器的值。三,如果必须实时精确计数,考虑使用 SELECT COUNT(*) FROM (SELECT id FROM orders WHERE ... ) t 这种写法,有时候能利用二级索引的覆盖扫描减少IO。
3.8 策略十二:批量DML操作,避免逐条循环提交
这个策略更多是写代码层面的事,但它对SQL性能的影响非常大。很多程序员写循环,在循环里逐条执行INSERT或UPDATE,每条SQL一次网络往返、一次事务提交,性能差到极点。
正确做法是批量提交。比如插入1000条数据,一条一条插入需要1000次网络往返和1000次磁盘写入;用一条多值INSERT语句批量插入,只需要1次网络往返,插入速度能提升几十倍。
sql复制-- 慢写法:循环逐条插入
INSERT INTO orders (user_id, amount) VALUES (1, 100);
INSERT INTO orders (user_id, amount) VALUES (2, 200);
-- ... 重复1000次
-- 快写法:批量插入
INSERT INTO orders (user_id, amount) VALUES
(1, 100),
(2, 200),
-- ...
(1000, 999);
批量更新也可以类似处理,比如用 CASE WHEN 语句一次更新多条记录,减少事务提交次数。但注意批量操作的SQL语句长度不要超过数据库的 max_allowed_packet 限制,否则会报错。
3.9 策略十三:ORDER BY排序优化与filesort规避
排序是一个容易被忽视的性能杀手。当你执行 ORDER BY 时,如果查询条件中的字段和排序字段能组成联合索引,那么数据库可以直接按索引顺序读取数据,完全省掉排序操作;如果无法利用索引排序,数据库就要把数据加载到内存或磁盘进行排序,出现 filesort。
EXPLAIN 输出中看到 Using filesort 就代表发生了排序,这是一个需要优化的信号。
具体优化方法就是让排序字段包含在索引中。例如查询经常执行 WHERE status = 1 ORDER BY create_time DESC,那么建一个 (status, create_time) 的联合索引,数据库既可以按 status 筛选,又可以按 create_time 顺序读取,完全不需要排序。
还有一个经验是:如果 filesort 无法避免,尽量减少排序的数据量。比如先用 WHERE 条件尽量过滤掉不需要的行,再对剩余结果排序;或者分页时先查ID再回表取数据,缩小排序范围。
4. 核心策略三:结构设计篇——从源头减少性能损耗
有些时候SQL本身已经写得很漂亮了,索引也加得满满当当,但性能还是不理想。这时候就要跳出SQL本身,从表结构设计的角度寻找切入点。
4.1 策略十四:合理反范式,用空间换时间
数据库三大范式要求数据尽量不冗余,但在高并发查询场景下,过度遵守范式会带来大量表连接操作,反而拖垮性能。合理的反范式设计,即冗余一些字段,往往能大幅减少JOIN次数,提升查询性能。
举个例子:订单表设计时,按第三范式会设计成 order_id, user_id, product_id, amount,要查订单的商品名称就必须 JOIN product 表。如果商品名称查询频率极高,就可以在订单表冗余一个 product_name 字段,这样查询时就不需要 JOIN 了,SQL简单,速度也快。
但反范式设计必须谨慎,核心风险是数据一致性。商品改了名称,冗余字段不会自动更新,这时候就要靠业务代码在更新商品时同步更新订单表的冗余字段,或者用定时任务去同步。我在实际项目中通常会采用"适度冗余+代码补偿更新"的方式,在性能和一致性之间找到平衡。
4.2 策略十五:分区表与数据归档策略
当单表数据量达到亿级时,即使索引设计得再好,查询性能也会因为索引体积过大、缓存命中率下降而明显恶化。这时候就该考虑分区表或者数据归档了。
分区表的核心思想是把一张大表按照某个字段(通常是时间)拆分成多个物理分区,查询时优化器会进行分区裁剪,只扫描相关分区,而不是全表。
sql复制CREATE TABLE orders (
id BIGINT,
user_id BIGINT,
amount DECIMAL(10,2),
create_time DATETIME
) PARTITION BY RANGE (YEAR(create_time)) (
PARTITION p2022 VALUES LESS THAN (2023),
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025)
);
不过这里要提醒一点:MySQL的分区表并不是银弹,如果查询条件不带分区键,优化器还是会扫描所有分区,性能反而更差。而且分区表在DDL操作上有限制,备份恢复也更麻烦。
我的经验是:对时间序列数据(日志、订单、流水)来说,数据归档往往比分区表更实用。把半年或一年前的冷数据迁移到历史表,或者迁移到归档库,业务查询只访问热数据表,表体积小了,索引缓存命中率高了,性能自然就上来了。
5. 实战案例:一条慢SQL从2.3秒到40毫秒的完整优化过程
光讲理论不给案例,总觉得不够过瘾。我拿一个真实优化过的项目来完整走一遍流程,帮你把前面讲的多种策略串起来。
5.1 案例背景与SQL原貌
背景是一家电商平台的运营后台,有一个"订单汇总查询"接口,运营同学按店铺、状态、时间范围查订单列表,同时展示订单金额汇总。线上反馈接口经常超时。
原始SQL简化后长这样:
sql复制SELECT
o.id, o.order_no, o.user_id, o.amount, o.status,
o.create_time, u.nickname
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.shop_id = 1024
AND o.status IN (1, 2, 3)
AND o.create_time BETWEEN '2024-01-01 00:00:00' AND '2024-06-30 23:59:59'
ORDER BY o.create_time DESC
LIMIT 0, 20;
orders表当时约3500万行,users表约800万行。这条SQL每天的调用量不高,但每次执行都很慢,平均耗时2.3秒。
5.2 排查过程:执行计划怎么看
我拿到SQL后先用 EXPLAIN 跑了一遍执行计划,关键信息如下:
orders表的type = ALL,也就是全表扫描,扫描行数约1800万行users表走了主键PRIMARY,type = eq_ref- 存在
Using filesort,也就是说ORDER BY create_time没有走索引 Extra列显示Using where; Using filesort
为什么 orders 表会全表扫描?我检查了表上的索引,发现虽然有 idx_status(status)和 idx_create_time(create_time),但没有包含 shop_id 和 status 的联合索引。优化器觉得用单列索引过滤后的数据量依然很大,干脆直接全表扫了。
5.3 优化方案与效果对比
我做的优化分三步:
第一步,建立联合索引。根据查询条件,把等值条件的 shop_id 和 status 放前面,范围条件的 create_time 放后面:
sql复制ALTER TABLE orders ADD INDEX idx_shop_status_time (shop_id, status, create_time);
第二步,去掉不必要的JOIN。检查业务发现,u.nickname 在列表页根本不展示,这个JOIN是历史遗留代码。去掉 LEFT JOIN users 后,SQL少了一次表连接,也省去了回表查用户信息。
第三步,利用覆盖索引避免回表。列表页其实只需要展示订单ID、订单号、金额、状态和时间,我让查询只查这几个字段。由于前面建立的联合索引已经包含 shop_id、status、create_time,而 order_no 和 amount 还需要回表,于是我再建了一个扩展覆盖索引:
sql复制ALTER TABLE orders ADD INDEX idx_shop_status_time_cover (shop_id, status, create_time, order_no, amount, user_id);
优化后的SQL(去掉了JOIN):
sql复制SELECT
o.id, o.order_no, o.user_id, o.amount, o.status, o.create_time
FROM orders o
WHERE o.shop_id = 1024
AND o.status IN (1, 2, 3)
AND o.create_time BETWEEN '2024-01-01 00:00:00' AND '2024-06-30 23:59:59'
ORDER BY o.create_time DESC
LIMIT 0, 20;
再次 EXPLAIN,结果变化非常明显:type 变成了 range,rows 从1800万降到约8000,Using filesort 消失了,因为 create_time 在联合索引中是有序的,可以直接按索引顺序读取。
从实际执行效果看,这条SQL从平均2.3秒降到了40毫秒,性能提升了50多倍。运营同学反馈接口秒开,问题解决。
这个案例我要表达的核心观点是:SQL优化不是靠某一个技巧瞬间完成的,而是一个"分析执行计划 → 定位问题 → 针对性优化 → 验证效果"的循环。每一步都扎实做,SQL性能优化根本没那么玄乎。
6. 常见问题与排查技巧实录
最后这一部分,我把这些年踩过的坑和常用的排查技巧一次性分享出来,相当于给你一份"避坑速查表"。
6.1 索引失效的几种场景速查
根据我的经验,索引失效的场景其实就那么几种,你可以对着排查:
| 场景 | 示例 | 解决方案 |
|---|---|---|
| 索引列使用函数 | WHERE DATE(create_time) = '2024-01-01' |
改写为范围查询 |
| 隐式类型转换 | WHERE phone = 13800138000(字段是varchar) |
确保参数类型与字段类型一致 |
| 模糊查询前置通配符 | WHERE name LIKE '%张%' |
改用全文索引或前缀匹配 |
| OR连接多个条件 | WHERE user_id = 1 OR status = 2 |
改写为UNION ALL或IN |
| 联合索引违反最左前缀 | 索引(a,b,c)但条件只有b = 1 |
调整索引字段顺序或新建索引 |
| 数据量太小,优化器放弃索引 | 表只有几百行 | 不用管,优化器自己会选 |
| 统计信息不准确 | 刚导完大量数据 | ANALYZE TABLE 更新统计信息 |
这个表建议你截图保存,排查慢SQL的时候对着逐项检查,能省去很多盲目的尝试。
6.2 排查慢SQL的常用工具与方法
工欲善其事,必先利其器。我平时排查SQL性能问题主要靠这几样工具:
慢查询日志是第一步。MySQL里可以通过设置 slow_query_log = ON 和 long_query_time = 1 来开启慢查询日志,记录执行时间超过1秒的SQL。线上一般都会开,但要控制日志量,避免刷爆磁盘。
EXPLAIN 是分析执行计划的核心工具。你需要重点关注几个字段:type(访问类型:ALL是全表扫描,index是索引扫描,range是范围扫描,ref/eq_ref是精确匹配,const是主键/唯一索引匹配,性能从差到好排序);rows(预估扫描行数);Extra(看到 Using filesort、Using temporary 就要警惕)。
performance_schema 里的 events_statements_summary_by_digest 可以按SQL指纹做聚合统计,快速找到哪些SQL消耗的总时间最多,是定位TOP SQL的好帮手。
SHOW PROFILE 或者 performance_schema 的事件分析,可以查看一条SQL执行过程中的耗时分布,是耗时在CPU计算、磁盘IO还是锁等待。
6.3 几个容易被忽略的坑
最后分享几个我在实际项目中踩过的、常规文档里不会写到的坑。
第一个坑:连接池中的同一个连接,执行计划缓存可能误导你。有一次我明明加了新索引,EXPLAIN 也显示走索引了,但线上SQL还是慢。查了半天发现是业务代码里强制指定了旧索引(FORCE INDEX)。如果你遇到了"加索引没效果"的情况,先查查代码里有没有强制索引的写法。
第二个坑:LIMIT 翻页越深越慢的问题,即便是简单的SQL也很严重。我有一个案例,一个只有20万行的表,LIMIT 0, 20 只要20毫秒,但 LIMIT 190000, 20 要1.2秒。这就是因为数据库要扫描前面19万行。所以深分页一定要用我前面说的游标分页方案,或者限制最大翻页深度。
第三个坑:索引并不是加得越多越好。我曾经负责过一个订单核心表,因历史原因,各种索引加起来一共16个。结果每次写入都要维护16个索引,写入性能严重下降,而且索引占用的磁盘空间比数据本身还大。后来按照实际高频查询场景精简到5个索引,写入性能提升了30%以上。
第四个坑:统计信息过期问题。MySQL的优化器是基于统计信息估算代价的,如果统计信息不准,优化器就可能做出错误的执行计划。大数据量导入或者大批量删除后,最好执行一下 ANALYZE TABLE,让优化器重新收集统计信息。
关于SQL优化,我的个人体会是:它不是一个纯技术问题,更多时候是理解业务、理解数据分布、理解数据库底层实现后的综合决策。15种策略只是工具,关键是用工具的人能不能看清问题本质。建议你下次遇到慢SQL时,不要急着改语句,先花10分钟把执行计划和数据分布搞清楚,往往比盲目尝试10种优化方案更有效。
