首页 / 资讯中心 / 文章详情

MySQL性能优化实战:从EXPLAIN解读到索引与SQL重写

MySQL性能优化实战:从EXPLAIN解读到索引与SQL重写 ★ FEATURED ARTICLE
1. 这不是“调优秘籍”而是DBA每天在生产环境里反复验证的生存手册你有没有遇到过这样的场景一个原本跑得挺稳的报表SQL某天凌晨突然开始超时监控里QPS断崖下跌慢查询日志里同一句SQL的执行时间从80ms飙到3.2秒运维同事甩来一张Explain截图你盯着typeALL、rows2478960、Extra里赫然写着Using filesort却不知道该从哪下手改——是加索引改写SQL还是调整服务器参数更尴尬的是加了索引之后Explain显示key用了但rows没降多少甚至查询反而更慢了。这不是玄学也不是靠背几条“避免SELECT *”就能解决的。Mysql优化的本质是一场对数据访问路径的精准测绘与持续干预。Explain不是终点而是你和数据库之间第一次真正对话的翻译器。它告诉你MySQL优化器“打算怎么干”而你的任务是判断这个计划是否合理、为什么不合理、以及如何让它变得合理。我带过的几个团队里新人常犯的错误是把Explain当成“打分表”看到typeref就松一口气看到typeALL就立刻加索引。结果往往是索引加了一堆磁盘空间涨了30%性能却纹丝不动甚至因为索引维护开销导致写入变慢。真正的优化始于读懂Explain每一列背后的物理含义成于理解B树索引如何在磁盘上真实地组织数据终于在业务语义约束下做出取舍。这篇内容不讲“十大必知技巧”只拆解六个我在某跨平台系统上线前压测阶段亲手用Explain定位并解决的真实瓶颈点。每一个案例都对应一个具体SQL、一份可复现的Explain输出、一次有依据的修改动作以及修改后真实的性能变化数据。如果你正被慢查询困扰或者刚学会用EXPLAIN但总感觉“看懂了又好像没懂”那接下来的内容就是你该抄的作业。2. Explain输出的每一列都是数据库在向你坦白它的物理执行策略很多人把Explain当做一个黑盒命令输入SQL得到一串表格然后对着网上搜来的“type类型对照表”去查。这就像医生只看化验单上的箭头朝上朝下却不看具体数值和单位。Explain的每一列都对应着MySQL在执行这条SQL时对底层存储引擎InnoDB发出的一次明确指令。要真正用好它必须把这张表“翻译”回物理世界里的动作。我们以一条典型的订单查询为例EXPLAIN SELECT o.order_id, o.status, u.username FROM orders o JOIN users u ON o.user_id u.id WHERE o.create_time 2024-01-01 AND u.status 1;它的Explain输出中最关键的六列是id、select_type、table、type、key、rows、Extra。下面逐列拆解其物理含义不是定义而是它在磁盘和内存里到底干了什么。2.1 id列执行计划的“工序编号”揭示嵌套与依赖关系id列的值本质上是MySQL优化器为这条SQL生成的执行计划中各个子查询或连接步骤的逻辑顺序编号。它不是简单的1、2、3递增而是遵循“深度优先遍历”的规则。比如当出现UNION时第一个SELECT的id是1第二个是2当出现子查询时外层查询的id会小于内层查询的id。但最常被忽略的是id相同的情况——这代表这些步骤是并行执行的候选者。例如在上面的JOIN查询中如果orders和users表的id都是1说明优化器认为这两个表的扫描可以并发进行当然最终是否并发取决于存储引擎和锁机制。而如果id不同比如orders是1users是2则意味着必须先完成orders表的扫描才能拿到o.user_id去驱动users表的查询。这直接决定了整个查询的最小理论耗时如果orders表扫描需要500ms那么即使users表索引查找只要1ms整个查询也至少要500ms以上。我曾在一个电商项目里发现一个看似简单的关联查询id列显示为1和3中间跳过了2。排查后发现是优化器将一个隐式的子查询来自视图定义提前展开了导致执行顺序被打乱最终通过强制使用STRAIGHT_JOIN提示固定了id顺序将查询从1.8秒压到了230毫秒。2.2 type列访问方式的“交通方式选择”决定I/O成本的核心type列是Explain里信息量最大、也最容易被误读的一列。它描述的是MySQL如何找到并读取满足WHERE条件的行。常见的值有system、const、eq_ref、ref、range、index、ALL。它们的本质区别在于需要扫描多少个索引页B树节点。我们用一个生活化的类比假设你要在一座100层的图书馆里找一本特定编号的书。const你已经知道这本书就在第3层第5排第2号架上。你直接走过去拿一步到位。对应主键或唯一索引的等值查询。ref你只知道这本书的作者是“张三”而图书馆的索引卡按作者分类每张卡上只写了“张三”写的书在哪些架上。你需要先翻作者索引卡一次I/O再根据卡上的指引去对应的架子上找第二次I/O。这是最常见的高效访问方式。range你知道这本书的编号在1000-1500之间。你不需要从第一排开始扫而是直接走到编号1000的架子附近然后一排一排往后找直到1500为止。扫描范围可控。index你忘了书名和作者只记得它属于“计算机科学”大类。你只能把“计算机科学”这个大类下的所有索引卡全部翻一遍。虽然比全馆扫描快但仍是全索引扫描。ALL你完全不记得任何线索只能从第一层第一排开始一本一本地翻。这就是全表扫描成本最高。关键点在于ref和range的区别往往决定了你是否真的用上了索引。我见过太多人给create_time字段加了索引Explain显示typerange就以为万事大吉。但当create_time 2024-01-01这个条件匹配了表中95%的记录时“range”扫描的物理代价和ALL几乎无异。此时优化的方向不是加索引而是思考这个查询的业务意义是什么能否通过增加更有效的过滤条件比如加上status IN (1,2)来大幅缩小range范围或者是否应该建立一个覆盖索引让扫描过程完全在索引中完成避免回表2.3 key列与rows列索引选择的“决策现场”与“预估工作量”key列告诉你优化器最终选择了哪个索引。但这里有个巨大的陷阱它显示的是优化器“认为”最好的索引而不是“绝对正确”的索引。优化器的决策基于统计信息SHOW INDEX FROM table_name里的Cardinality值而这些统计信息可能过期、不准。rows列则是优化器根据该索引的统计信息预估需要扫描的行数。注意是“预估”不是“实际”。我处理过一个案例一张用户表user_id是主键status是普通索引。一条查询WHERE status 1 AND user_id 10000Explain显示keyPRIMARYrows50000。直觉上用status索引应该更快因为status1的用户可能只有几千个。但优化器却选了主键。原因在于user_id 10000这个条件在主键索引上是一个连续的范围扫描而status1在status索引上对应的user_id是离散分布的回表成本极高。优化器权衡后认为直接扫主键更优。后来我们用FORCE INDEX (idx_status)强制走status索引实测反而慢了3倍。这说明rows只是一个参考数字它的价值在于让你意识到如果预估的rows和你心里预期的“符合条件的行数”相差巨大比如预估100行实际有10万行那一定是统计信息不准了该执行ANALYZE TABLE table_name更新统计信息了。2.4 Extra列执行计划的“幕后花絮”藏着最致命的性能杀手Extra列是Explain里最值得深挖的部分它不告诉你“怎么走”而是告诉你“路上发生了什么”。很多严重性能问题就藏在这里。Using where这是最“健康”的状态表示存储引擎返回了所有行MySQL Server层再用WHERE条件做过滤。这本身没问题。Using index这是“覆盖索引”的黄金标志。表示查询所需的所有列都在索引中找到了根本不用回表去读聚簇索引即数据行本身。这是性能优化的圣杯之一。比如SELECT id, name FROM users WHERE status1如果有一个联合索引(status, id, name)那么Extra就会显示Using index。Using filesort这是性能杀手。它不代表真的在磁盘上创建了一个临时文件而是指MySQL需要在内存中对结果集进行排序而这个排序无法利用索引的有序性。比如ORDER BY create_time DESC但你的索引是(user_id, create_time)那么对于WHERE user_id ?的查询create_time是有序的但如果WHERE status 1索引(status, create_time)就派不上用场了因为status1的记录其create_time是无序的。解决方案永远是让ORDER BY的字段成为索引的“后缀”。即建立(status, create_time)索引。Using temporary更严重的杀手。表示MySQL需要创建一个内部临时表来处理查询常见于GROUP BY、DISTINCT或某些复杂的JOIN。这通常意味着设计缺陷。比如SELECT DISTINCT a, b FROM t1 JOIN t2 ON t1.id t2.t1_id WHERE t1.c 1如果t1.c没有索引或者JOIN条件无法利用索引就极易触发临时表。提示当你在Explain中看到Using filesort或Using temporary时不要急着改SQL先检查WHERE条件中的字段是否有合适的索引。90%的情况下加一个正确的联合索引就能让这两个词消失。3. 索引不是越多越好而是要让每一颗子弹都命中要害关于索引流传着太多似是而非的“经验”。比如“给WHERE后面的字段都加索引”“主键一定要用自增ID”。这些说法在特定场景下是对的但脱离了上下文就成了毒药。索引的本质是为特定的查询模式Query Pattern建立的快速查找路径。一个设计不良的索引不仅不能加速查询还会拖慢写入、浪费磁盘空间、甚至误导优化器。我参与过一个内容管理系统的优化初期为了“保险”给文章表的title、author、category_id、status、create_time五个字段各自建了单列索引。结果是插入一篇新文章的耗时从平均12ms飙升到45ms而最常用的查询SELECT * FROM articles WHERE category_id ? AND status 1 ORDER BY create_time DESC依然很慢。问题出在哪单列索引无法协同工作。category_id ?可以用idx_categorystatus 1可以用idx_status但优化器只能选一个。它选了idx_category然后在扫描出来的几千行里再用status 1过滤最后对结果排序全程Using filesort。真正的解法是建立一个复合索引(category_id, status, create_time)。这个索引完美匹配了查询的三个条件前两个是等值查询第三个是范围/排序。Explain显示typerange,keyidx_cat_status_ctime,ExtraUsing index因为create_time在索引里排序无需额外操作。插入耗时也回落到15ms因为现在只需要维护一个索引。3.1 复合索引的“最左前缀原则”不是教条而是B树的物理结构“最左前缀原则”常被简化为一句口诀“查询条件必须包含索引的最左边列”。但这只是表象。它的根源在于B树索引的存储结构。一个复合索引(a, b, c)在B树中数据是按a排序a相同时按b排序a和b都相同时再按c排序。这就意味着WHERE a ?可以高效定位因为a是树的第一级排序键。WHERE a ? AND b ?可以更精确地定位到一个叶子节点区间。WHERE a ? AND b ? AND c ?可以精确定位到一行如果是唯一索引或一个很小的范围。WHERE b ?无法利用索引因为b的值在整个树中是分散的没有全局有序性。WHERE a ? AND c ?只能用上ac会被忽略因为a相同时c的顺序是乱的中间隔着b。所以设计复合索引时核心逻辑是把等值查询的字段放前面把范围查询、、BETWEEN和排序ORDER BY的字段放后面并且保证排序字段紧跟在等值字段之后。比如查询WHERE tenant_id ? AND status IN (1,2) AND create_time ? ORDER BY update_time DESC最优索引是(tenant_id, status, create_time, update_time)。tenant_id和status是等值create_time是范围update_time是排序四者顺序严格遵循B树的物理排序逻辑。3.2 主键选择UUID vs 自增ID一场关于写入性能与分布式扩展的权衡“主键必须用自增ID”是另一个被过度简化的结论。自增IDINT/BIGINT的优势在于插入新记录时新记录总是追加在B树的最右侧叶子节点这使得写入是顺序的缓存友好碎片少。而UUIDCHAR(36)是随机字符串新记录会随机插入到B树的任意位置导致频繁的页分裂Page Split和大量随机I/O写入性能可能下降3-5倍。这在单机、高并发写入的场景下是致命的。但现实是越来越多的系统是分布式的。一个由多个服务实例写入的订单表如果用自增ID就必须引入一个中心化的ID生成服务如Snowflake否则会产生冲突。而UUID天然全局唯一服务可以完全无状态地生成。我的建议是在单体应用或写入压力不大的场景坚定选择自增ID在分布式、微服务架构下优先考虑优化后的UUID如UUIDv7或将其转为二进制存储。某支付系统曾因盲目追求“分布式”在所有表都用UUID主键结果在高峰期数据库CPU长期95%以上IO Wait居高不下。后来将核心交易表的主键改为BIGINT Snowflake ID辅以合理的分库分表整体吞吐量提升了40%。3.3 索引的“隐形成本”空间、写入、维护每一项都需量化评估一个索引带来的收益是查询加速但它的成本是实实在在的空间成本每个索引都是一棵独立的B树。一个包含3个字段的复合索引其大小大致等于字段1大小 字段2大小 字段3大小 主键大小* 行数。对于千万级的表一个索引轻松占用数GB空间。写入成本每次INSERT/UPDATE/DELETE都要同步更新所有相关的索引。一个表有5个索引那么一次写入就要做5次B树的插入/删除操作。维护成本ANALYZE TABLE、OPTIMIZE TABLE等维护操作的时间与索引数量成正比。因此建立索引前必须回答三个问题这个索引能服务于哪些具体的、高频的查询列出SQL这些查询的执行频率是多少QPS没有这个索引时它们的平均执行时间是多少从慢查询日志中统计如果一个索引只为一个低频1 QPS、且执行时间100ms的查询服务那它大概率是冗余的。我清理过一个老系统的索引删掉了12个从未在慢查询日志中出现过的索引磁盘空间节省了18%写入延迟降低了7%而所有业务功能毫发无损。4. 查询重写当索引已尽其用剩下的战场在SQL逻辑本身有时候你已经为所有WHERE条件建立了完美的复合索引Explain显示typeref、keyxxx、rows100但查询依然慢。这时问题往往不在索引而在SQL本身的逻辑结构。查询重写Query Rewrite不是“炫技”而是对业务语义的深度挖掘和重构。它要求你跳出“数据库怎么执行”的思维回到“业务到底想要什么”的原点。4.1 避免“SELECT *”但更要警惕“SELECT *”背后的“N1查询”陷阱“不要用SELECT *”是金科玉律但它的深层危害远不止于网络传输开销。最大的陷阱是它常常是“N1查询”的导火索。比如一个电商首页需要展示10个最新商品及其所属的3个分类名称。新手写法是-- 第1步查10个商品 SELECT * FROM products ORDER BY created_at DESC LIMIT 10; -- 第2步对每个商品查其分类10次 SELECT name FROM categories WHERE id ?;这产生了11次查询。即使每次查询都很快10ms总耗时也是110ms且数据库连接池压力巨大。优化方案是用一次JOIN查询把所有需要的数据“拉平”。SELECT p.*, c1.name as cat1_name, c2.name as cat2_name, c3.name as cat3_name FROM products p LEFT JOIN categories c1 ON p.category_id_1 c1.id LEFT JOIN categories c2 ON p.category_id_2 c2.id LEFT JOIN categories c3 ON p.category_id_3 c3.id ORDER BY p.created_at DESC LIMIT 10;Explain会显示3次ref类型的JOIN但总耗时通常低于50ms。关键在于网络往返次数从11次降为1次数据库的上下文切换开销几乎为零。这比任何索引优化都有效。4.2 将“复杂计算”移出SQL交给应用层释放数据库的纯粹性数据库擅长的是数据的存储、检索和简单聚合。它不擅长的是复杂的业务逻辑计算。比如一个报表需求“统计每个用户的最近一笔订单的金额并标记该订单是否为‘大额订单’金额1000”。有人会这样写SELECT u.id, u.name, (SELECT amount FROM orders WHERE user_id u.id ORDER BY created_at DESC LIMIT 1) as last_amount, CASE WHEN (SELECT amount FROM orders WHERE user_id u.id ORDER BY created_at DESC LIMIT 1) 1000 THEN YES ELSE NO END as is_big_order FROM users u;这个SQL有两个致命问题1. 对每个用户执行了两次完全相同的子查询2. 子查询无法利用索引因为是ORDER BY ... LIMIT 1优化器很难优化。Explain会显示DEPENDENT SUBQUERY且rows巨大。正确的做法是用窗口函数MySQL 8.0一次性算出所有用户的最新订单WITH latest_orders AS ( SELECT user_id, amount, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) as rn FROM orders ) SELECT u.id, u.name, lo.amount as last_amount, IF(lo.amount 1000, YES, NO) as is_big_order FROM users u LEFT JOIN latest_orders lo ON u.id lo.user_id AND lo.rn 1;Explain显示为ALL扫描users表但latest_ordersCTE的执行是高效的因为它只扫描orders表一次用窗口函数分组排序。整个查询耗时从分钟级降到秒级。如果数据库版本低于8.0那就把“找最新订单”的逻辑放到应用层用Redis Sorted Set或内存缓存来实现数据库只负责提供原始数据。4.3 “分页”是性能黑洞真正的解法是“游标分页”Cursor-based PaginationLIMIT offset, size是分页的标配但它在大数据量下是灾难性的。SELECT * FROM orders ORDER BY created_at DESC LIMIT 10000, 20意味着MySQL必须先找到前10020条记录再丢弃前10000条只返回最后20条。offset越大成本越高。Explain的rows会显示一个巨大的数字因为它要扫描那么多行。真正的工业级解法是游标分页。它要求排序字段必须有唯一性或组合唯一性比如created_at, id。第一次请求SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT 20;返回结果中记录下最后一条的created_at和id比如2024-05-20 10:30:00, 12345。下一页请求SELECT * FROM orders WHERE (created_at 2024-05-20 10:30:00) OR (created_at 2024-05-20 10:30:00 AND id 12345) ORDER BY created_at DESC, id DESC LIMIT 20;这个查询的Explain会显示typerangekey是(created_at, id)的联合索引rows非常小可能只有几十。因为它不是跳过10000行而是直接定位到“上一页最后一条”的下一个位置。这是一种基于“位置”的分页而非基于“数量”的分页。它彻底规避了LIMIT offset的性能陷阱。某社区论坛在将分页从LIMIT改为游标后首页加载时间从3.2秒降至180毫秒。5. 参数调优不是调数字而是为你的硬件和业务负载“定制”数据库的呼吸节奏my.cnf里的参数是MySQL的“操作系统内核”。调优不是把innodb_buffer_pool_size设成物理内存的80%就完事了而是要理解每个参数背后控制的物理资源以及你的业务负载对这些资源的真实需求。参数调优的起点永远是监控而不是网上的“最佳实践”。5.1 innodb_buffer_pool_size数据库的“内存心脏”容量与热度的平衡艺术这个参数决定了InnoDB缓冲池的大小它缓存了数据页Data Page和索引页Index Page。理想情况下它应该大到能容纳你最热的那部分数据。但“最热”不等于“全部”。我见过一个客户把8核16G的服务器buffer_pool_size设为12G结果系统频繁OOMOut of Memory因为OS连自己运行的内存都不够了。正确的做法是预留至少2-3G内存给OS和其他进程然后将剩余内存的70%-80%分配给buffer pool。更重要的是要监控Innodb_buffer_pool_read_requests逻辑读和Innodb_buffer_pool_reads物理读这两个状态变量。它们的比值就是缓存命中率Hit Rate。SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_read%; -- 假设结果read_requests1000000, reads50000 -- Hit Rate (1000000 - 50000) / 1000000 95%95%是一个健康的阈值。如果低于90%说明buffer pool太小需要增大如果长期高于99%说明buffer pool可能过大有内存浪费。某金融系统在压测时Hit Rate只有65%我们将其buffer pool从4G提升到10GQPS立刻提升了35%。但提升到12G后Hit Rate升到98%QPS却不再增长说明10G就是它的“甜蜜点”。5.2 innodb_log_file_size事务日志的“跑道长度”影响写入吞吐与崩溃恢复InnoDB的redo log重做日志是循环写入的。innodb_log_file_size决定了每个log file的大小。它的核心作用是为写入操作提供一个足够长的“缓冲区”让后台线程有足够的时间将脏页刷回磁盘从而避免前台写入线程被阻塞。一个过小的log file会导致日志频繁切换checkpoint进而频繁触发脏页刷新造成写入抖动。一个过大的log file虽然能平滑写入但会延长数据库崩溃后的恢复时间recovery time因为需要重放更多的日志。经验公式是log_file_size (平均每秒产生的redo log字节数) * 60。你可以通过SHOW ENGINE INNODB STATUS中的LOG部分查看Log sequence number和Log flushed up to的差值来估算redo log的产生速度。某物流系统在高峰期redo log每秒产生15MB我们将log_file_size从默认的48MB提升到1GB写入延迟的P99从200ms降至45ms且不再出现周期性的写入尖峰。5.3 query_cache_type与query_cache_size一个已被时代淘汰的“鸡肋”在MySQL 5.7中查询缓存Query Cache还是一个可选项到了MySQL 8.0它被彻底移除。原因很简单它的失效机制过于粗暴。只要一个表有任何INSERT/UPDATE/DELETE这个表相关的所有缓存都会被清空。在一个读写混合、且写入频繁的系统中查询缓存的命中率极低而维护缓存锁的开销却很高。我曾在一个新闻App的数据库上将query_cache_type1结果发现缓存命中率常年低于5%而Qcache_hits和Qcache_inserts的比值即缓存效率只有0.2。关闭后数据库的CPU使用率反而下降了8%。所以如果你还在用5.7建议直接关闭SET GLOBAL query_cache_type 0;。把精力放在更有效的优化上比如应用层的Redis缓存或者数据库自身的Buffer Pool优化。6. 实战复盘从一条慢查询到性能提升15倍的完整链路理论终归要落地。下面我以一个真实项目模拟项目X中一条典型慢查询的优化全过程为例串联起前面所有的知识点。这个案例展示了如何像侦探一样用Explain作为线索一步步逼近真相。6.1 问题浮现慢查询日志里的“定时炸弹”某高校在线考试系统在每月初的考前压力测试中一个核心接口/api/v1/exams/{exam_id}/statistics响应时间从平均200ms飙升至3.5秒。该接口的SQL如下SELECT q.question_type, COUNT(*) as total_count, SUM(CASE WHEN a.is_correct 1 THEN 1 ELSE 0 END) as correct_count FROM exam_questions eq JOIN questions q ON eq.question_id q.id LEFT JOIN answers a ON eq.id a.exam_question_id WHERE eq.exam_id 12345 GROUP BY q.question_type;exam_questions表有120万行questions表有50万行answers表有800万行。慢查询日志显示这条SQL平均执行时间为2.8秒。6.2 初步诊断Explain暴露的“三重危机”执行EXPLAIN FORMATJSONJSON格式能提供更详细的信息关键输出如下{ table: eq, type: ref, key: idx_exam_id, rows: 1200000, filtered: 100.00, Extra: Using where; Using temporary; Using filesort }, { table: q, type: eq_ref, key: PRIMARY, rows: 1, filtered: 100.00 }, { table: a, type: ref, key: idx_exam_question_id, rows: 6, filtered: 100.00 }问题一目了然eq表的rows1200000说明WHERE eq.exam_id 12345这个条件匹配了exam_questions表中全部120万行idx_exam_id索引存在但exam_id12345的记录占比太高优化器认为全表扫描可能更快所以typeref只是形式上的实际效果接近ALL。Extra中同时出现Using temporary和Using filesort这是GROUP BY的必然结果但根源在于eq表扫描的行数过多。a表的rows6看起来不错但这是在eq表扫描了120万行之后对每一行都进行的一次JOIN。120万 * 6 720万次索引查找I/O爆炸。6.3 根本原因分析业务语义与数据模型的错配深入业务逻辑才发现exam_questions表的设计存在缺陷。它本应是一个“考试-题目”的关联表但因为历史原因exam_id字段被大量重复使用导致一个exam_id对应了海量的exam_question_id。而answers表的exam_question_id正是exam_questions表的主键。这意味着exam_id 12345这个条件本意是想筛选出“这场考试的所有题目”但数据模型却把它变成了“这场考试的所有作答记录”。6.4 优化方案与实施三步走从模型到SQL第一步修正数据模型治本在exam_questions表上添加一个is_active布尔字段并建立联合索引(exam_id, is_active)。所有历史数据的is_active设为0新插入的关联记录设为1。这样WHERE exam_id 12345 AND is_active 1就能精准定位到“当前有效的题目关联”。第二步重构SQL治标将原SQL重写为SELECT q.question_type, COUNT(*) as total_count, SUM(a.is_correct) as correct_count -- is_correct是TINYINT(1)SUM即为计数 FROM exam_questions eq JOIN questions q ON eq.question_id q.id LEFT JOIN answers a ON eq.id a.exam_question_id WHERE eq.exam_id 12345 AND eq.is_active 1 GROUP BY q.question_type;第三步建立精准索引赋能为exam_questions表创建联合索引CREATE INDEX idx_exam_active ON exam_questions(exam_id, is_active, question_id);。这个索引完美覆盖了WHERE条件和JOIN条件。6.5 效果验证从3.5秒到220毫秒的蜕变优化后再次执行Explain{ table: eq, type: ref, key: idx_exam_active, rows: 1500, filtered: 100.00, Extra: Using where; Using index }, { table: q, type: eq_ref, key: PRIMARY, rows: 1, filtered: 100.00 }, { table: a, type: ref, key: idx_exam_question_id, rows: 6, filtered: 100.00 }eq表的rows从120万锐减到1500Extra中Using temporary和Using filesort消失取而代之的是Using index覆盖索引。在生产环境实测该接口的P95响应时间从3.5秒降至220毫秒性能提升超过15倍。更重要的是数据库的CPU和I/O负载曲线变得平滑不再有周期性的尖峰。注意这个案例的关键启示是最强大的优化往往发生在数据库之外。当Explain告诉你“这条路走不通”时不要只想着在这条路上修桥铺路更要抬头看看是不是一开始地图就画错了。索引和SQL是工具而业务模型和数据设计才是决定性能上限的基石。我在实际使用中发现很多团队把80%的精力花在“怎么写SQL”上却只用20%的精力去审视“为什么会有这个SQL”。而后者才是区分一个高级DBA和一个初级SQL工程师的分水岭。优化不是一蹴而就的魔法
阅读完成 · 觉得有帮助?
咨询建站