多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MySQL查询优化实战:慢查询定位、索引设计与SQL改写全攻略

MySQL查询优化实战:慢查询定位、索引设计与SQL改写全攻略 这套MySQL查询优化的东西我本来是想写一篇速查清单式的技术笔记但回头想想真正在工作中救人于水火的往往不是一个孤立技巧而是一整套排查思路。就比如之前线上有个订单列表接口上线时明明很快跑了三个月后突然从200毫秒飙到7秒多最后定位下来就是一条WHERE条件里的字段没走索引导致全表扫描了几百万行。这种问题如果你不懂怎么用慢查询日志和EXPLAIN去定位光靠猜根本解决不了。所以这篇我打算换个角度不列那些背了就忘的优化条目而是把我平时排查慢查询的完整套路、索引设计里最容易踩的坑、几个高频反模式SQL的改写方法以及JOIN和排序场景下的真实优化过程一步步拆开讲。无论是刚接触MySQL的初级开发还是负责线上数据库性能的DBA思路都是通用的。文章里所有SQL和参数我都基于实际生产环境验证过你照着操作基本能复现效果。1. 先搞清楚SQL到底慢在哪慢查询日志与EXPLAIN的组合用法很多人在优化时第一反应就是给表加索引这其实是个极大的误区。索引不是万能药有时候就算有一堆索引SQL还是会慢。所以我的习惯是拿到一个慢SQL先做两件事确认它确实被记录到了慢查询日志然后立刻EXPLAIN看执行计划。1.1 慢查询日志的配置边界MySQL的慢查询日志默认是关闭的生产环境需要手动打开。修改方式有两种一种是直接改my.cnf后重启另一种是运行时SET GLOBAL动态开启。我个人更推荐后者因为不用重启数据库不影响在线业务。-- 查看当前慢查询日志状态 SHOW VARIABLES LIKE slow_query_log%; SHOW VARIABLES LIKE long_query_time; -- 动态开启慢查询日志线上操作前确认变更窗口 SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 2; -- 单位秒建议从2秒起步 SET GLOBAL log_queries_not_using_indexes ON;这里的long_query_time是一个很容易被忽略的配置。默认是10秒说实话在大部分业务系统里10秒才记录一次慢查询等你发现的时候数据库早就被拖垮了。我一般建议从2秒开始如果业务本身压力不大甚至可以调到1秒。但注意调太低会在高并发下产生大量日志磁盘写入也会成为一个瓶颈所以2秒是个比较折中的起点。还有一个参数叫log_queries_not_using_indexes打开后会把没走索引的查询也记录进来哪怕它执行时间没超过阈值。这个参数在排查全表扫描类问题时非常有用尤其是那种单次执行只要0.5秒、但被频繁调用导致总开销巨大的查询。查看慢查询日志除了直接登录服务器翻文件也可以用mysqldumpslow这个工具做汇总。它能按执行次数、耗时、返回行数给你排个序比从原始日志里一条条翻效率高太多。# 按平均查询时间排序列出前10条最耗时的慢SQL mysqldumpslow -s at -t 10 /var/lib/mysql/xxx-slow.log1.2 EXPLAIN输出每一列到底该怎么读拿到一条慢SQL之后最核心的就是EXPLAIN。我在面试时经常问候选人你平时怎么分析慢SQL如果对方只说出加索引三个字基本就能判断出他平时没有系统性地定位过问题。EXPLAIN输出里的关键列其实就那几个但每列都有它自己的潜台词。先看type列这一列是判断查询效率的第一指标。按性能从好到差排序常见的有type类型含义是否理想system表中只有一行数据极少出现const主键或唯一索引等值查询非常理想eq_ref被驱动表通过主键或唯一索引关联很理想ref普通二级索引等值查询理想range索引范围扫描可接受index遍历二级索引全索引扫描一般ALL全表扫描需要避免如果看到ALL基本就是两个原因要么这张表压根没建索引要么SQL写法导致索引失效。这时候再看possible_keys和key列possible_keys表示理论上可以用哪些索引key表示实际用了哪个索引。如果possible_keys有值但key是NULL说明优化器在这个场景下判断走索引反而更慢或者你的SQL写法让索引无法被生效。Extra列同样信息量很大。出现Using filesort意味着查询里有ORDER BY但无法使用索引完成排序MySQL得额外做一次文件排序。出现Using temporary说明派生表或GROUP BY等操作创建了临时表数据量一大就会把磁盘IO拖爆。如果能看到Using index就非常舒服说明这是一个覆盖索引扫描不需要回表这种查询再快不过了。我自己的排查习惯是先改一条SQL就在EXPLAIN底下跑一次盯着type、key、rows、Extra这四列动态比较变化。很多人只看type不看rows这是不对的。rows是优化器估算的扫描行数结合type一起看能判断出优化器选这条路线的真实成本。1.3 从EXPLAIN到一个真实优化案例举一个我印象深刻的案例。有一张订单流水表order_flow接近800万行业务方反馈按用户查订单的接口很慢。原始SQL长这样SELECT order_id, user_id, amount, status, create_time FROM order_flow WHERE user_id 10231 ORDER BY create_time DESC LIMIT 20;EXPLAIN的结果让我大吃一惊typeALLrows792万Extra里写着Using filesort。这说明优化器直接选择了全表扫描并且排序也没走索引。问题根源在于这张表的联合索引根本不存在只有一个主键索引而user_id上压根没有单独的索引。我当时的第一反应不是立刻建索引而是先确认业务场景里这个查询是不是高频次、实时性要求高。确认之后我加了一个联合索引ALTER TABLE order_flow ADD INDEX idx_user_create (user_id, create_time);再次EXPLAINtype变成了refrows从792万降到了几百Extra里的Using filesort也消失了。接口响应时间从7秒直接掉到了80毫秒以内。这个案例看起来简单但背后的逻辑很清晰查询条件里的user_id负责缩小范围ORDER BY里的create_time负责消除排序二者合成一个联合索引既覆盖了等值筛选又覆盖了排序一举两得。这个案例也告诉我们EXPLAIN不是用来读的是用来对比的。优化前后各跑一次EXPLAIN你才能知道自己这一步有没有改到点子上。2. 索引设计决定查询性能的底层逻辑如果说EXPLAIN能告诉你病在哪那索引设计就是治病的方法。很多开发对索引的理解停留在查询慢就加索引但加在哪个字段上、用什么样的联合索引、会不会失效这些才是真正拉开性能差距的地方。2.1 B树索引的读法为什么要关心回表MySQL的InnoDB引擎默认使用B树作为索引结构。B树的好处是数据量再大树的层数也基本控制在3到4层这意味着从根节点走到叶子节点的IO次数非常稳定。二级索引非主键索引的叶子节点存的是主键值加上索引字段本身当你需要查询的列不在索引里时就得根据拿到的主键值再回主键索引B树里查一次完整的行数据这个过程就叫回表。回表本身不是错误它是InnoDB存储引擎的正常机制。但如果一张表有几百万行你通过二级索引筛选出了几万行那就要回表几万次这个成本瞬间就会把查询拖垮。所以这里就引出了一个最重要的优化手段——覆盖索引。覆盖索引的意思是你查询的所有列都能在同一个二级索引的叶子节点里找到不需要再回表。比如有一张用户表user_info在user_id上建了普通索引当你执行SELECT user_id, nickname FROM user_info WHERE user_id 1001;如果索引只是(user_id)那nickname字段还得回表才能拿到。但如果你把索引改成(user_id, nickname)的联合索引那么查询需要的两个字段都在索引里Extra列就会显示Using index回表彻底消失。对于那种高频执行的查询把SELECT的字段精确地喂进联合索引里收益会非常直接。2.2 最左前缀联合索引必须遵守的游戏规则联合索引的查询优化绕不开最左前缀这四个字。它是MySQL里最基础也最容易被忽略的规则。举个例子如果你的表上有一个联合索引(a, b, c)那么实际上MySQL会把它拆成三套索引逻辑单用a的索引、只用a和b的索引、a和b和c全用的索引。也就是说索引的有效性是从最左列开始一直连续到你使用的最后一个字段。我见过太多同事建了(a, b, c)联合索引然后写WHERE b xx发现查询还是全表扫描就骂MySQL不走索引。其实不是MySQL不想走而是联合索引里b不是最左列优化器没法从这个索引的中间位置开始进行有效定位。同理如果你写WHERE a x AND c y那么只有a能利用索引c用不上因为中间少了b。但这不等于说你永远不能在非最左列上去查只是这时候联合索引帮不上忙。所以设计联合索引时一定要先想清楚你的查询会以哪些字段为过滤条件然后再决定列的先后顺序。这个顺序不是拍脑袋定的而是要根据实际业务里的查询模式反推。2.3 联合索引的字段顺序区分度不是唯一标准很多文章会告诉你把区分度高的字段放前面这个建议不能说错但过于绝对。我在实际项目里发现字段顺序更核心的决策依据是你最频繁的查询能不能用最左前缀命中这个联合索引。举个例子订单表上想建(user_id, status, create_time)这个联合索引。如果把user_id放在最左边那么所有按用户查订单、再按状态过滤、最后按时间排序的查询都能走索引。但如果你把status放最左边因为status的取值只有几个比如待支付、已支付、已取消区分度太差即便它命中了很多行联合索引的过滤价值也不大。更重要的是如果业务查询习惯都是先给用户ID再看状态那把user_id放左边就是唯一合理的选择。还有一个容易犯的误区是把所有查询条件字段都塞进联合索引。索引不是越多越好每个索引在写入时都要付出维护成本。一张频繁INSERT的系统里索引太多会把写入拖得很慢。我一般建议单表索引数量控制在5个以内联合索引字段控制在3个以内优先服务最核心的两三个查询模式其余的查询能用其中一个索引的前缀覆盖就尽量覆盖。判断一个索引设计好坏最简单的方法还是回到EXPLAIN看key列用到了哪个索引看key_len的长度。key_len越长说明索引中参与过滤的字段越多索引设计得越充实。如果key_len只有4字节但你在联合索引里建了三个int字段那就说明优化器只用了第一个字段后两个字段的查询条件并没能完全利用上这个索引。3. 高频慢SQL反模式这些写法我正在劝退所有人这一节我要说的不是那种特别复杂的性能问题而是就在我们日常代码里反复出现、又极其容易被忽视的写法。它们单个看好像没多慢但在生产环境的数据量下一放大就是灾难。3.1 SELECT *、隐式类型转换、函数包裹列先说SELECT *。很多新手觉得SELECT *省事不想一个个字段打出来。但在真实生产中SELECT *至少带来两个问题第一如果表里有一个很大的TEXT字段查询时会把大量无用数据从磁盘读到内存再通过回表取出来极端浪费IO和网络带宽第二SELECT *几乎不可能利用覆盖索引因为你会把表里所有列都查出来辅助索引永远没法完全覆盖这些列。我见过一个团队从SELECT *改成只查需要的5个字段之后同一条SQL耗时直接降了65%左右只改这一行就有效果。再说隐式类型转换。这个坑很隐蔽但遇到一次就能记住一辈子。比如一张表的user_id字段类型是VARCHAR(20)但你在SQL里写WHERE user_id 10231这里的10231是整数类型。MySQL会按照规则把user_id列强制转换成数字再和10231比较结果就是user_id上的索引完全失效变成全表扫描。解决办法很简单保证SQL里的值与字段类型严格一致。如果你不确定最简单的方式是养成在代码里打印参数类型的习惯或者统一用字符串形式拼SQL。函数包裹列的问题就更常见了。典型的是下面这种写法SELECT * FROM order WHERE DATE(create_time) 2024-06-01在create_time上做了DATE函数处理之后create_time列的索引就没法被正常使用了因为优化器需要对每一行都执行函数运算才能判断是否匹配。正确的写法是改成范围查询SELECT * FROM order WHERE create_time 2024-06-01 00:00:00 AND create_time 2024-06-02 00:00:00这样优化器能直接用B树的范围扫描能力性能完全是两个量级。3.2 LIKE、OR、IN和EXISTS的选择LIKE模糊查询里有一个很典型的认知误区LIKE abc%是可以走索引的因为它相当于一个范围查询从abc开头的位置往后扫就行。但LIKE %abc或者LIKE %abc%就没办法走索引了因为以通配符开头时B树的顺序检索逻辑无法确定起点。如果你确实需要做后缀模糊查询而又不希望每次都全表扫描我一般会在表里冗余一个反转字段也就是把原字段的字符串反转后存储然后查询时把条件也反转一下LIKE cba%就又能走索引了。这个方案在数据量大的场景里还挺实用。OR的问题要谨慎看。如果OR两侧的字段都各自有索引优化器通常会把两个条件分别处理然后做合并效率还行。但如果OR的一侧是无索引字段那整个查询就可能退化成全表扫描。我的建议是如果你的表经常出现多个条件OR组合可以先把OR改写为UNION ALL然后强制每个子查询都走各自的索引。示例如下SELECT user_id FROM t WHERE user_id 1001 UNION ALL SELECT user_id FROM t WHERE nickname testIN和EXISTS的选择也很容易引发争论。在MySQL 5.6之后的版本里优化器对IN和EXISTS的处理策略已经越来越智能。在子查询数据量小、外层表数据大的场景下IN往往表现更好反过来在外层表数据小、子查询数据大时EXISTS可能更有优势。我自己不会盲目地在所有场景下替换它们而是直接用EXPLAIN看执行计划让优化器的成本估算来替我做决定。3.3 深分页问题为什么越往后翻越慢深分页是面试中特别容易被问到、实际落地又特别坑的一个问题。假设你执行SELECT * FROM order_flow ORDER BY create_time DESC LIMIT 1000000, 20这条SQL表面的逻辑是取第100万行之后的20条但MySQL的执行方式是先按索引或排序找出前1000020行然后丢弃前1000000行只返回最后20条。前面那100万行的扫描开销每一行都不会省所以越往后翻页越慢。解决思路有两种。第一种是延迟关联延迟连接也叫内层查询只取主键ID再通过主键回表取值SELECT o.* FROM order_flow o INNER JOIN ( SELECT id FROM order_flow ORDER BY create_time DESC LIMIT 1000000, 20 ) t ON o.id t.id这种写法的核心逻辑是让内层尽可能只扫描索引列避免在回表阶段就把大量数据取出来。第二种方案是从业务层面入手用上一页的最大create_time代替LIMIT偏移。也就是把翻页改成基于游标的模式SELECT * FROM order_flow WHERE create_time 2024-05-01 10:00:00 ORDER BY create_time DESC LIMIT 20这种方式不会因为翻到第100页就多扫描100页的数据每次查询的代价基本恒定。但也有限制就是业务里得有上一页最后一条记录的排序字段值可以传入不是所有场景都适用。无状态的网页分页稍微麻烦一点接口需要多返回一个游标参数。4. 排序和GROUP BY为什么会慢filesort与临时表的优化排序和聚合查询是除了JOIN之外最容易被拖慢的场景。很多人的疑惑点是我已经建了索引为什么ORDER BY还是慢这一节我专门拆开讲。4.1 filesort你看到的排序可能根本不是索引排序MySQL里的排序有两种实现方式。一种叫索引排序也就是ORDER BY的字段恰好和索引顺序一致这样查询直接在索引的有序链表上遍历即可速度极快EXPLAIN的Extra列不会出现任何排序字样。另一种叫filesort就是MySQL在内存或磁盘上单独做一次排序。当待排序的数据量超过sort_buffer_size的大小时就会把中间结果写到磁盘临时文件里再进行多路归并排序性能直线下降。我见过大量慢查询的根源都是filesort。比如你的表有索引(user_id)但SQL是SELECT * FROM order_flow WHERE user_id 100 ORDER BY amount DESC这个查询利用user_id索引精确命中了一个用户的数据但amount字段没有和user_id在同一个联合索引里所以MySQL必须在拿到所有数据后再按amount做一次文件排序。要优化它最简单的办法就是把索引改成(user_id, amount)让联合索引同时覆盖筛选和排序。4.2 排序方向的坑降序和升序不能混用还有一个特别容易被忽视的点MySQL 8.0之前的版本索引默认是按升序存储的。如果你的SQL是ORDER BY create_time DESC优化器可以通过反向扫描索引来满足这在早期版本里也行。但如果一个索引是(create_time ASC, user_id DESC)这种混合方向旧版优化器就没办法直接使用索引完成了因为它要么按全部升序遍历要么按全部降序遍历没法在一个索引里同时升序和降序。MySQL 8.0开始支持降序索引创建方式也很简单ALTER TABLE order_flow ADD INDEX idx_create_desc_user (create_time DESC, user_id ASC);但就算有了降序索引我还是建议写SQL时明确排序方向不要依赖数据库的默认行为这样执行计划更容易稳定地命中预期索引。4.3 GROUP BY 和 DISTINCT 的优化方向GROUP BY的慢一般来自两个方面一是通过临时表做分组聚合二是排序。MySQL对GROUP BY的处理逻辑默认是先按分组字段排序再聚合所以如果你能建一个和分组字段顺序一致的索引很多情况下可以直接避免临时表。比如SELECT user_id, COUNT(*) FROM order_flow GROUP BY user_id如果user_id上有索引整个分组聚合的代价就很小如果user_id是联合索引的最左列也依然有效。但如果你写GROUP BY user_id, status而索引只有(user_id)那么status上的分组就只能靠临时表了。这种情况要么加联合索引要么改造业务让分组维度尽可能贴合已有索引的最左前缀。DISTINCT和GROUP BY在底层处理上非常相似。很多场景下DISTINCT的慢就是因为没有合适的索引让它快速去重。我见过有同事用DISTINCT去拿某个用户的全部商品分类如果分类字段是游离在索引之外的那就是一个典型的大排序操作。这时候可以考虑在对应的列上建立二级索引让去重操作直接在索引上进行。5. JOIN优化小表驱动大表以及被驱动表上的索引红线多表JOIN是慢查询的另一个重灾区。我遇到过一条20多秒的多表关联查询最终改成0.3秒以内——关键不在于什么高深的魔法而是遵守了两条最基本的JOIN优化原则。5.1 驱动表选择谁该当外层谁该当内层在MySQL里JOIN执行时有一个驱动表的概念。简单理解驱动表就是循环外层的表被驱动表是循环内层循环体里反复被访问的表。业内传得最多的一句话是小表驱动大表——用小表当外层大表当内层并用大表的索引去执行等值匹配。为什么这样效率高因为外层每一行都要到内层表里查一次。如果外层是10万行的表内层是10行的表那每个外层行去内层查内层不管怎么查都很快。但如果反过来外层10行内层10万行整体循环次数虽然少但每次在大表里精确匹配的成本也会更高。总体上让访问次数少、每次命中率高的组合成为执行计划是最优解。MySQL优化器通常会自行选择驱动表但有些场景它会判断错误尤其是统计信息不准或者多表关联存在多个可能路径时。这时候可以用STRAIGHT_JOIN强制指定连接顺序。我一般在确认了实际行数后才会在开发环境验证STRAIGHT_JOIN的效果线上不轻易用因为会削弱统计信息的自我适应能力。5.2 被驱动表关联字段必须建索引JOIN优化里最硬核的一条规则是被驱动表的关联字段必须有索引。因为被驱动表会被外层循环反复访问每次访问如果能走索引那么单次查询的代价几乎是常数级如果关联字段上没有索引每次匹配都要做全表扫描整体复杂度直接变成外层行数乘以内层行数那就是指数级的灾难。举一个简单的例子SELECT a.id, a.order_no, b.user_name FROM order_flow a INNER JOIN user_info b ON a.user_id b.user_id WHERE a.status PAID这条SQL里order_flow大概率是驱动表user_info是被驱动表。那么user_info.user_id上就必须有索引。如果没有JOIN时每来一个a表的user_id就要去user_info里全表扫一遍如果一个批次匹配出1万行user_id那就要扫1万次user_info全表想不慢都难。5.3 一个20秒到0.3秒的完整优化案例之前做过一个订单管理后台里面有个报表查询需要JOIN三张表订单表、用户表、商户表。SQL长这样字段已经简化SELECT o.order_no, u.nickname, m.shop_name, o.amount, o.status FROM order_flow o LEFT JOIN user_info u ON o.user_id u.user_id LEFT JOIN merchant_info m ON o.merchant_id m.merchant_id WHERE o.create_time 2024-01-01 AND o.create_time 2024-02-01 ORDER BY o.create_time DESC LIMIT 10;第一次EXPLAIN三张表的type分别是ALL、ALL、ALLExtra全部是Using where和Using filesort。原因也很明显order_flow只按create_time做了范围过滤但create_time上没有索引user_info的user_id和merchant_info的merchant_id也都没有索引。也就是说整个查询在三张表上全部退化成了全表扫描加文件排序。优化分两步走。第一步在order_flow上创建(create_time, merchant_id, user_id, amount, status, order_no)的联合索引同时满足过滤、排序以及SELECT字段的覆盖索引需求第二步在user_info.user_id和merchant_info.merchant_id上分别建索引。再跑EXPLAINorder_flow的type变成了rangerows从几百万降到了几千Extra里也没有Using filesort了两张关联表都通过二级索引命中type变成了ref。最终接口从20秒优化到了0.3秒而且这个查询是后台管理报表用的查询频率不高所以这0.3秒完全在可接受范围。5.4 JOIN时还有一个容易被忽视的隐性条件两个表JOIN时关联字段的类型必须完全一致否则索引会失效。这个和前面提到的隐式类型转换是同一类问题。比如a表的user_id是BIGINTb表的user_id是VARCHARJOIN时MySQL会把VARCHAR转成BIGINT去比较那么b表上的索引就废了。遇到这种问题核对的优先级甚至比加索引更高——我就曾花了一个下午排查一条JOIN慢SQL最后发现是因为一张表的关联字段是字符串类型却没加引号。我建议在设计表结构时凡是会参与JOIN的字段类型和长度尽量保持一致。一旦上线后发现问题尽早通过ALTER TABLE把两边统一成同一种数值类型或同一种定长字符串不要用数据库的隐式转换去兜底。写在最后的几句实在话优化MySQL查询这件事我做了这么多年总结下来就一句先定位后优化别拍脑袋。每次动手之前先慢查询日志确认目标SQL再EXPLAIN确认执行路径改完之后再EXPLAIN对比一次还要在测试环境确认写入性能和稳定性没有明显恶化。我个人最常用的流程是看到慢SQL先看type和rows如果type是ALL优先检查WHERE条件和JOIN关联字段的索引如果Extra里出现Using filesort优先考虑联合索引是否覆盖了ORDER BY字段如果出现Using temporary大概率是GROUP BY或DISTINCT的字段和索引顺序不匹配。还有一点可能很多人忽略索引真的是双刃剑。我遇到过一张表建了12个索引结果每天凌晨的批量INSERT被拖到严重超时后来删掉8个冗余索引写入性能立刻恢复了。查询优化的本质是权衡不是堆砌技巧。宁可少两个索引也别让数据库背着沉重的行李跑业务。
返回列表