拓冰建站拓冰建站
首页 / 资讯中心 / 正文

MySQL索引优化与覆盖查询:从原理到性能压测实践

很多做后端的朋友都遇到过这种场景接口平时跑得挺快一到数据量上来就突然变慢数据库CPU飙高慢查询日志刷屏一看都是同一条SQL。排查下来十有八九是索引没设计好或者是查询没走到覆盖索引白白让MySQL做了大量回表。所谓“MySQL性能优化”绝大多数时候就是围绕索引做文章。这篇我把这些年压测和优化过程中的实践经验整理出来从索引原理、覆盖查询设计到sysbench压测方法一条线讲透。这篇内容适合已经被慢SQL折磨过的后端开发也适合刚接触MySQL调优、想系统理解索引怎么用的新人。你会看到索引为什么能加速查询覆盖查询到底覆盖了什么优化前后压测数据能差多少倍以及我在实际项目中踩过的一些坑。1. 索引工作原理与核心设计拆解1.1 为什么索引能加速查询B树到底是什么MySQL Innodb的索引使用B树结构这个知识点教科书里反复提但很多人没有真正理解它为什么能加速。我尽量用直白的话讲清楚。B树可以想象成一个非常扁平的树形目录。Innodb默认页大小16KB每一页能放很多索引条目。假设主键用的是bigint8字节加上6字节的行指针一个页大约能存1000个左右的索引条目。树的高度从2层到3层就能支撑几千万甚至上亿的数据量。也就是说无论表里有多少行按照主键查找只需要2到3次磁盘IO就能命中目标。相比之下全表扫描需要把每一页都读一遍这个成本差距是完全数量级的。B树的另一大特点所有数据都存储在叶子节点叶子节点之间用双向链表串联。这意味着范围查询特别高效只需要定位到起点然后沿着链表往后读就行。像EXPLAIN里看到的type range、ref这类访问类型都依赖这个结构。Innodb的索引分两类聚簇索引和二级索引。聚簇索引就是InnoDB表本身数据行存放在主键索引的叶子节点上。二级索引的叶子节点存的是索引列的值和主键值。这个设计是理解覆盖查询的关键后面我仔细讲。1.2 联合索引顺序与最左前缀原则的底层逻辑联合索引从原理上没有名字上看起来复杂它就是按照字段顺序逐层排序的一棵B树。假设idx_abc(a, b, c)索引先按a排序a相同的记录再按b排b也相同就按c排。正是因为这种复合排序规则决定了“必须带着a走”的最左前缀原则。举个例子按a查能命中索引因为a是第一层排序关键字按a和b查也能命中因为定位到a区间后b是有序的按a、b、c查是最完整的情况。但如果直接按b查B树无法跳过a直接定位b的顺序关系就只能全索引扫描或者回表了。最左前缀原则还有一个常见误区不是“查询条件里必须包含a字段才准用索引”。更准确的说法是能在索引上过滤的条件必须从最左字段开始连续匹配中间被范围条件截断后后面的字段就无法用于定位了。例如WHERE a 1 AND b 100 AND c 5这里a能精确匹配b能用于range过滤而c无法再由索引精确定位只能对满足a和b条件的记录额外进行c的过滤。实际建索引的时候我一般遵循几个经验等值条件的字段放最前面范围条件放后面把区分度高的字段前置考虑覆盖查询把要返回的字段放进索引这种后面单独扩展说。2. 覆盖查询的核心原理与实战设计2.1 回表为什么那么慢前面提到二级索引叶子存的是“索引列 主键”。当你的查询返回的列不在二级索引里MySQL就得根据拿到的每一条主键再到聚簇索引的B树里“二次查找”完整数据行。这个过程叫回表。回表慢的点在于一次性范围查询可能匹配几千甚至几万条索引记录每条记录都要回表一次每次回表都是一次随机IO。随机IO和顺序IO的耗时相差两个数量级。大量回表会把数据库的IO能力瞬间打满这是慢SQL最常见的原因之一。还有一个细节容易被忽略如果二级索引的过滤条件区分度不高匹配到大量记录即使单次回表很快乘上几千几万次之后延迟也是灾难性的。所以很多时候type ref看起来访问类型还不错但如果rows估算行数很大依然需要警惕回表量。2.2 覆盖索引到底是什么覆盖索引听着高大上本质就一句话查询要读的列全都解决在二级索引里不需要回表。SQL执行过程中只在二级索引的B树上完成定位和数据读取不需要去聚簇索引里拉取完整行。应用场景最简单的例子-- 假设表里有 idx_status_name(status, name) SELECT id, name FROM user WHERE status 1;id是二级索引里必然存在的主键name是索引字段。这条查询在idx_status_name里就直接拿到了所有需要返回的数据EXPLAIN的Extra列会显示Using index这个标志就是覆盖查询的实锤。覆盖查询的最大价值是大幅降低查询延迟和数据库IO压力。尤其在高并发场景下让一条查询的IO成本从“回表N次”降为“只扫二级索引”效果立竿见影。压测数据对比我后面会展示。2.3 覆盖查询的代价与使用边界覆盖索引不是银弹设计的时候需要权衡写放大。二级索引本身也是B树索引字段越多占用的写入空间越大更新成本越高。如果一个表加了三四个“宽索引”写入性能会被拖累而且在内存有限的情况下大量索引页会挤压buffer pool反而影响其他查询的命中率。我的一般判断标准高频查询涉及的返回列尽量覆盖别为了“可能用到”就把一大把字段塞进索引大字段如TEXT、超长VARCHAR不要放进索引InnoDB对索引列长度有限制超长列不适合索引如果一个查询同时要支持多个不同的过滤组合优先建立“等值字段前置”的两到三个联合索引而不是给每个字段建立独立索引。3. 慢SQL分析与索引优化完整实操3.1 使用EXPLAIN定位慢查询拿到一条慢SQL我第一件事就是跑EXPLAIN把执行计划完整看一遍。这里列出我每次都会关注的几个关键字段字段重点看什么代表性含义type访问类型ALL全表扫描、index全索引扫描、range范围、ref非唯一等值匹配、eq_ref唯一匹配、const主键或唯一键等值匹配possible_keys理论上可用的索引如果为空说明没有可用索引key实际选择的索引如果没用上预期索引需要分析原因key_len使用到的索引字节长度判断联合索引实际使用了几个字段rows预估扫描行数越小越好Extra附加信息看到Using filesort、Using temporary要警惕看到Using index表示覆盖优先看type和rows。type ALL基本就是全表扫逐行读数据页rows很大说明即使走索引过滤率也不高需要重新设计索引或查询方式。举个例子之前在一个订单表上遇到过一条慢SQLSELECT id, order_no, amount, status FROM orders WHERE status 1 ORDER BY created_at DESC LIMIT 20;表里有几十万条订单status 1的记录占了大头。EXPLAIN显示走了idx_status索引rows很小但Extra里有Using filesort排序在内存里完成的数据量一大就会慢。后来把索引改成idx_status_created(status, created_at)排序直接在索引里完成Using filesort消失查询从800多毫秒降到30毫秒左右。3.2 一个完整案例从500ms到8ms的优化过程拿一个近期做的会员积分系统来完整演示。这个表存着用户积分流水量级在千万级。业务方反馈用户积分明细页打开很慢经常一秒多。表结构缩略如下CREATE TABLE points_log ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, points int(11) NOT NULL DEFAULT 0, type tinyint(4) NOT NULL DEFAULT 0, created_at datetime NOT NULL, remark varchar(255) DEFAULT , PRIMARY KEY (id) ) ENGINEInnoDB;慢查询对应的SQL是SELECT id, points, type, created_at FROM points_log WHERE user_id 123456 ORDER BY id DESC LIMIT 20;这个SQL看着简单问题在于表里只有主键索引user_id过滤条件没有索引可用只能全表扫再排序取最新的20条。EXPLAIN结果里type是ALLrows扫了接近全表。做法是加一个联合索引把用户过滤和排序合并解决ALTER TABLE points_log ADD INDEX idx_user_id_id (user_id, id);为什么加这个索引因为查询是按id DESC排序的id是聚簇主键B树本身按id有序。联合索引(user_id, id)让每个user_id下的id天然有序排序这一步就直接利用索引顺序不需要filesort。优化后EXPLAINtype从ALL变成refkey是idx_user_id_idrows从千万级变成该用户的流水条数Extra不再有Using filesort压测对比原来接口P95延迟约530ms优化后P95降到15ms以内。再进一步如果返回的字段也不多可以把查询改成完全覆盖索引的方式比如把points、type、created_at都放进索引Extra显示Using indexIO次数还会进一步下降。但考虑到remark字段偶尔需要查询且写入频率不低我最终选择了只加(user_id, id)这个索引保证高频接口极致快同时不给写入引入太大负担。3.3 深分页与排序优化的通用手段分页越深越慢做后台管理系统的同学感受最深。LIMIT 100000, 20这种写法MySQL需要扫描前100020条记录再丢弃前100000条扫描量非常浪费。我的做法是“延迟关联”先把主键或唯一键取出来再关联原表获取完整行SELECT a.* FROM points_log a INNER JOIN ( SELECT id FROM points_log WHERE user_id 123456 ORDER BY id DESC LIMIT 100000, 20 ) b ON a.id b.id;子查询首先走(user_id, id)索引只读取主键值不回表不取大字段扫描代价显著降低。拿到20个主键后再回原表取完整数据回表次数只有20次完全可控。这个技巧在深分页场景下基本是通用解。ORDER BY导致filesort时优化方式有几种排序字段直接放入联合索引利用索引天然有序ORDER BY的字段与WHERE条件满足最左匹配避免额外排序如果无法避免排序考虑减少参与排序的行数先用索引把过滤做彻底再排剩余小数据集。4. 性能压测方案设计与优化效果验证4.1 压测工具选型我为什么用sysbench优化做完了得用数据说话。压测工具我用过sysbench、mysqlslap、tpcc-mysql、JMeter不同场景各有优势。日常验证索引优化效果我最常用sysbench因为它能控制并发线程数能模拟oltp读写混合内置多种场景脚本输出QPS、TPS、延迟分布等关键指标命令行就能跑方便在CI里重复执行。mysqlslap适合快速压力测试但表达式和场景控制不如sysbench灵活。tpcc-mysql更贴业务事务模型适合全链路压测。你想裸测一条SQL本身的索引优化效果sysbench配合自定义lua脚本最合适。4.2 sysbench压测流程与指标解读先准备数据我用sysbench往目标表灌入了100万行测试数据。sysbench /usr/share/sysbench/oltp_read_only.lua \ --mysql-host127.0.0.1 \ --mysql-port3306 \ --mysql-userroot \ --mysql-password123456 \ --mysql-dbtestdb \ --tables1 \ --table-size1000000 \ --threads16 \ --time60 \ --report-interval5 \ prepare准备完成后为了单独验证某条SQL我会写一个简单的lua脚本核心逻辑是对目标SQL做循环查询。压测命令sysbench /tmp/select_test.lua \ --mysql-host127.0.0.1 \ --mysql-port3306 \ --mysql-userroot \ --mysql-password123456 \ --mysql-dbtestdb \ --threads16 \ --time60 \ --report-interval5 \ run输出结尾一般长这样SQL statistics: queries performed: read: 124884 transactions: 62442 (1040.70 per sec.) queries: 124884 (2081.40 per sec.) latency (ms): avg: 15.38 min: 3.21 max: 82.44 percentile 95: 38.19 percentile 99: 51.03重点看四个指标QPS/TPS单位时间能处理多少请求直观体现吞吐能力平均延迟和P95/P99延迟反映绝大多数请求的体感P99比均值重要得多线程数模拟并发量需要根据线上实际情况调整错误数/超时数如果压测过程出现超时说明系统已达到瓶颈。4.3 优化前后压测数据对比同样的数据、同样的线程数、同样的压测时间分别在加索引前和加索引后跑了两轮结果非常能说明问题。以points_log那条用户积分明细SQL作为测试用例指标优化前优化后平均延迟84.6 ms4.2 msP95延迟213.7 ms11.5 msP99延迟340.2 ms21.3 msQPS4868490优化前因为全表扫描线程数一提高数据库IO立即打满加索引后相同的并发量QPS翻了约17倍延迟降低到原来的二十分之一。这个对比数据足以说明覆盖查询和索引设计对系统整体承载力的影响。压测过程还有一个容易忽略的点压测机和数据库不要在同一台机器上否则压测结果会互相干扰压测前重启一下MySQL清掉buffer pool或者对比相同预热状态下的数据才能保证前后数据有可比性。4.4 压测中常见的坑压测时间太短结果不可信。MySQL有预热机制数据页缓存在buffer pool之后性能会明显提升如果只跑10秒可能刚过预热期就结束了。我一般至少压60秒。再一个坑是只压只读场景。线上真实流量是读写混合索引优化虽然写放大会增加一点但联合索引只要设计合理影响通常可控。建议跑完只读场景后再用oltp_read_write脚本验证写性能没有明显回退。5. 常见问题与排查技巧实录5.1 索引失效的十大排查方向遇到“明明加了索引可还是慢”的情况奉劝不要上来就怪MySQL。按下面清单挨个排查大概率能找到原因查询条件里对索引列做了函数操作比如WHERE DATE(created_at) 2024-06-01函数导致索引失效尽量改成created_at 2024-06-01 AND created_at 2024-06-02这种范围写法隐式类型转换比如varchar类型的手机号字段查询用WHERE phone 13800000000数字会被转成字符串后比较索引失效LIKE以通配符开头LIKE %abc无法走索引但LIKE abc%可以联合索引没遵守最左前缀原则优化器判断走索引不如全表扫比如过滤条件能筛掉的数据量太小索引列上做算术运算WHERE num 1 100索引失效重写成WHERE num 99not null问题和IS NULL、IS NOT NULL在某些情况下可能不走索引数据分布极度不均优化器选择全表扫反而更快这通常是统计信息问题大范围IN匹配当集合过大时优化器可能放弃索引字符集不一致导致关联查询无法使用索引这个问题比较隐蔽多表JOIN遇到性能差时优先检查字符集是否统一。5.2 统计信息不准导致选错索引线上遇到过一种情况SQL优化器明明知道有索引却走了全表扫描或者选择了一个区分度很差的索引。这多半是统计信息过期。InnoDB的统计信息是抽样估算的大批量数据增删后优化器手里的统计值就可能失真。解决办法简单粗暴对相关表重新分析ANALYZE TABLE points_log;执行后再看EXPLAIN一般能恢复正常。需要注意的是线上大表执行ANALYZE TABLE会短暂锁表尽量放在低峰期操作。如果分析后优化器依然走偏可以在SQL里加FORCE INDEX做临时兜底但长期解决方案依然是优化SQL和索引结构。5.3 大表加索引的在线DDL经验在千万级大表上直接执行ALTER TABLE ADD INDEX在MySQL 8.0中虽然支持INPLACE算法但依然会增加主从延迟严重时拖垮从库复制。我处理大表索引变更一般用pt-online-schema-changePercona Toolkit的一部分原理是通过临时表重建数据用触发器同步增量变更业务不停服就能完成。pt-online-schema-change \ --alter ADD INDEX idx_user_id_id (user_id, id) \ --host127.0.0.1 \ --userroot \ --password123456 \ Dtestdb,tpoints_log \ --max-load Threads_running50 \ --chunk-size1000 \ --execute重点是--max-load和--chunk-size这两个参数控制对线上负载的影响。--max-load限制系统线程数超过50时暂停操作--chunk-size限制每次处理的行数避免一条SQL锁太多行。操作前后务必检查从库延迟。5.4 一个隐蔽的坑覆盖索引写在SELECT里的负优化有一种很隐蔽的负优化为了让查询命中覆盖索引在SELECT列表里塞了一大堆字段结果索引建得太宽。联合索引字段多B树页能容纳的记录数就少查找和遍历就慢。我就见过这样一个例子一个表的高频查询是SELECT a, b, c, d, e FROM t WHERE a ?为了覆盖查询把5个字段全部建进索引。结果这个表每天有几百万条写入每个写入都要维护宽索引日志量暴涨整体性能反而下降。后来的方案是只保留(a, b, c)三个核心字段的联合索引剩余的字段接受一次回表写入和查询整体表现反而更好。覆盖索引是个工具不是目的。判断标准永远看业务场景高频读多写少的表宽一点没关系写多读少的表索引越精简越好。5.5 慢查询日志的进阶用法除了slow_query_log文件我还会定期把慢查询汇总到一张表里SET GLOBAL log_output TABLE; SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 0.5;log_output TABLE会把慢查询记录写入mysql.slow_log表方便用SQL分析TopN慢SQL模式。不过要注意log_output TABLE本身也会消耗性能生产环境长期开启需要权衡。我的习惯是压测或排查高峰期慢SQL时临时开启日常只在日志文件里保留慢SQL记录。分析慢SQL时按照“查询次数多、平均耗时高、扫描行数多”三个维度排序优化优先级一目了然。写在最后索引优化和覆盖查询这块说到底是理解MySQL存储引擎的数据组织方式再结合业务场景取舍。我在实际项目中一次次验证过一条SQL从全表扫到走覆盖索引延迟降两个数量级是常态数据库整体负载也能得到明显缓解。优化的核心从来不是背“万能公式”而是学会看EXPLAIN理解回表的代价读懂热词里那些“慢SQL优化”“覆盖查询”“性能压测”背后真正指向的工程问题。掌握这套排查和压测方法你在任何规模的MySQL实例上都能快速定位问题。最后分享一个我自己的习惯每次上线涉及索引变更之前把优化前后的EXPLAIN结果和压测数据存一份快照放到项目的文档里。这些数据不仅是性能优化的证据也是后来者避免重蹈覆辙的重要参考。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门