ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MySQL索引失效的6种场景及执行计划分析

MySQL索引失效的6种场景及执行计划分析 前言线上遇到慢 SQL 时有时候我们会发现原来是查询索引失效了所以导致查询时间特别长。那么针对于索引失效的情况本文就以MySQL 8.0 和 InnoDB为例准备一张 10 万行的订单表复现 6个常见场景。每个场景都包含问题 SQL、执行计划判断方法和可落地的改写方案。一、准备可复现的实验数据1、建表DROP TABLE IF EXISTS orders; CREATE TABLE orders ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, order_no VARCHAR(32) NOT NULL, phone VARCHAR(20) NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL, amount DECIMAL(12, 2) NOT NULL, remark VARCHAR(255) NOT NULL DEFAULT , PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_phone (phone), KEY idx_user_time_status (user_id, created_at, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2、插入10万行数据当前测试使用Python批量插入数据大家也可以通过Java程序、SQL语句等进行订单数据的批量插入。如果你已有Python环境的话可以使用下面的程序进行数据插入。注意⚠️需要安装pymysql库用于MySQL数据库的连接及操作#!/usr/bin/env python3 向 MySQL orders 表批量插入测试数据。 import argparse import getpass import random import time import uuid from datetime import datetime, timedelta from decimal import Decimal import pymysql INSERT_SQL INSERT INTO orders (user_id, order_no, phone, status, created_at, amount, remark) VALUES (%s, %s, %s, %s, %s, %s, %s) def parse_args(): parser argparse.ArgumentParser(description批量生成并插入 orders 测试数据) parser.add_argument(--count, typeint, default100_000, help插入条数默认 100000) parser.add_argument(--batch-size, typeint, default1_000, help每批条数默认 1000) parser.add_argument(--host, default127.0.0.1, help数据库地址) parser.add_argument(--port, typeint, default3306, help数据库端口) parser.add_argument(--user, defaultroot, help数据库用户) parser.add_argument(--database, help数据库名未提供时由程序提示输入) return parser.parse_args() def build_row(sequence: int, now: datetime): # 31 位订单号可在脚本重复执行时继续保持极低的冲突概率。 order_no O uuid.uuid4().hex[:30] phone 1 str(random.choice((3, 5, 7, 8, 9))) f{random.randint(0, 999_999_999):09d} created_at now - timedelta(secondsrandom.randint(0, 365 * 24 * 60 * 60)) amount Decimal(random.randint(1, 10_000_000)) / Decimal(100) return ( random.randint(1, 20_000), order_no, phone, random.randint(0, 4), created_at, amount, f批量测试数据-{sequence}, ) def main(): args parse_args() if not args.database: args.database input(请输入数据库名).strip() if not args.database: raise SystemExit(数据库名不能为空) password getpass.getpass(请输入 MySQL 密码无密码直接回车) if args.count 0 or args.batch_size 0: raise SystemExit(--count 和 --batch-size 必须大于 0) connection pymysql.connect( hostargs.host, portargs.port, userargs.user, passwordpassword, databaseargs.database, charsetutf8mb4, autocommitFalse, ) started_at time.perf_counter() inserted 0 now datetime.now() try: with connection.cursor() as cursor: while inserted args.count: current_batch_size min(args.batch_size, args.count - inserted) rows [build_row(inserted index 1, now) for index in range(current_batch_size)] try: cursor.executemany(INSERT_SQL, rows) connection.commit() except Exception: connection.rollback() raise inserted current_batch_size elapsed time.perf_counter() - started_at print( f已插入 {inserted:,}/{args.count:,} 条 f平均 {inserted / max(elapsed, 0.001):,.0f} 条/秒 ) finally: connection.close() elapsed time.perf_counter() - started_at print(f完成共插入 {inserted:,} 条耗时 {elapsed:.2f} 秒) if __name__ __main__: main()二、看懂EXPLAIN中的关键信息先执行一个正常使用唯一索引的查询EXPLAIN SELECT * FROM orders WHERE order_no O116770401e134636b6db774679f3d7;传统表格格式的执行计划中排查索引问题主要看下面几个字段。字段含义排查重点type表访问方式常见值有const、eq_ref、ref、range、index、ALLpossible_keys优化器认为可能使用的索引有值不代表最终会用key实际选中的索引NULL表示没有选择索引进行访问key_len本次计划使用的索引键长度可辅助判断联合索引用到了哪几列不要机械对照固定字节数ref与索引列比较的常量或列等值查询中常见constrows预计需要检查的行数是估算值不是实际扫描行数filtered经过本表条件过滤后预计保留的百分比rows × filtered%可粗略估算输出行数Extra额外执行信息常见值有Using where、Using index、Using index condition、Using filesorttype中最需要警惕的是ALL全表扫描index扫描整棵索引仍可能读取很多条目range按一个或多个索引区间扫描ref通过非唯一索引等值查找const通过主键或唯一索引与常量比较最多匹配一行。容易理解错误的点Using where只表示还要应用过滤条件不等于没有使用索引Using index表示覆盖索引即所需列可以直接从索引取得Using index condition表示使用了索引条件下推ICP存储引擎先在索引层过滤再决定是否回表Using filesort表示需要额外排序并不保证一定会写磁盘rows是优化器估算值想看实际行数要用EXPLAIN ANALYZE。三、场景一联合索引缺少最左列当前表中有联合索引idx_user_time_status(user_id, created_at, status)下面查询跳过第一列user_idEXPLAIN SELECT * FROM orders WHERE created_at 2025-10-01 00:00:00 AND status 2;1、执行计划分析type: ALLpossible_keys: NULLkey: NULLrows: 接近总行数Extra: Using where虽然created_at和status都在联合索引里但它们没有构成(user_id, created_at, status)的最左前缀BTree 不能直接定位扫描起点。先按user_id排序只有user_id相同才继续按created_at排序。因此不指定user_id时全局的created_at并不是连续有序的。2、修改建议如果业务经常按状态和时间查询应建立与访问模式匹配的索引。新增索义ALTER TABLE orders ADD KEY idx_status_time (status, created_at);EXPLAINSELECT *FROM ordersWHERE status 2 AND created_at 2026-05-01 00:00:00;然后我们再看一下执行计划type: range如果查询的数据命中率比较高有时候全表扫描反而更快typeALLpossible_keys: idx_status_timerows: 明显下降四、场景二索引列上做函数或运算order_no上有唯一索引uk_order_no。直接等值查询可以快速定位EXPLAIN SELECT * FROM orders WHERE order_no O116770401e134636b6db774679f3d7;如果在列上调用函数EXPLAIN SELECT * FROM orders WHERE LOWER(order_no) O116770401e134636b6db774679f3d7;1、执行计划会退化为type: ALLpossible_keys: NULLkey: NULLExtra: Using where索引保存的是原始order_no不是LOWER(order_no)的结果普通 BTree 无法根据函数结果直接找到对应的原始键值。2、修复建议1优先改写SQL如果数据本身已经统一为大写订单号应规范入参。不在列上调用LOWER函数、日期查询改成左闭右开区间。2确实要按表达式查询时使用函数索引MySQL 8.0.13 起支持函数索引CREATE INDEX idx_lower_order_no ON orders ((LOWER(order_no))); EXPLAIN SELECT * FROM orders WHERE LOWER(order_no) O116770401e134636b6db774679f3d7;五、场景三隐式类型转换发生在索引列一侧phone的类型是VARCHAR(20)下面两条 SQL 看起来只差一对引号-- 参数是字符串 EXPLAIN SELECT * FROM orders WHERE phone 13000000100; -- 参数是数字 EXPLAIN SELECT * FROM orders WHERE phone 13000000100;第一条通常使用idx_phone索引访问方式为ref。第二条很可能全表扫描。1、为什么字符串列和数字比较会丢索引MySQL 比较字符串和数字时会做类型转换。问题不只是把13000000100转成数字这么简单因为多个不同字符串可能转换成同一个数字例如1、 1和1a。因此MySQL 不能直接用字符串索引完成phone 13000000100的查找只能逐行转换和比较。所以执行计划常常是这样的type: ALLkey: NULLrows: 接近总行数2、修复建议1应用参数类型要与字段类型一致比如手机号是字符串类型就用字符串方式查找。在JDBC中也可以使用preparedStatement.setString(1, phone);2联表字段也要检查类型和字符集比如下面例子SELECT * FROM orders o JOIN user_profile u ON o.user_id u.user_id;如果两边一个是BIGINT、另一个是VARCHAR或者字符串列的字符集、排序规则不兼容执行计划可能出现转换导致某一侧索引不能用于高效查找。六、场景四LIKE以通配符开头前缀匹配可以利用 BTree 的有序性EXPLAIN SELECT * FROM orders WHERE phone LIKE 1300000%;典型访问方式是rangekeyidx_phone。但是如果把%放到最前面后索引无法确定起始位置常见结果如下EXPLAIN SELECT * FROM orders WHERE phone LIKE %00100;type: ALLkey: NULLExtra: Using where修复建议模糊查询时使用前缀匹配。七、场景五OR中有一个分支没有可用索引order_no有索引remark没有索引EXPLAIN SELECT * FROM orders WHERE order_no O71c3b1448217428aa5b6323c34aa87 OR remark 批量测试数据-2;左边可以通过uk_order_no找到一行右边却要扫描整张表。既然右边无法通过索引取得候选主键优化器很可能直接选择一次全表扫描type: ALLpossible_keys: uk_order_nokey: NULLrows: 接近总行数Extra: Using wherepossible_keys有值但 key 为 NULL正好说明“存在候选索引”和“最终使用索引”是两回事。修复建议两个分支都可索引时可能使用Index Merge如果remark的等值查询足够常见可以增加索引ALTER TABLE orders ADD KEY idx_remark (remark); ANALYZE TABLE orders; #作用是重新收集表和索引的统计信息 EXPLAIN SELECT * FROM orders WHERE order_no O71c3b1448217428aa5b6323c34aa87 OR remark 批量测试数据-2;修改后可能看到type: index_mergekey: uk_order_no,idx_remarkExtra: Using union(uk_order_no,idx_remark); Using whereindex_merge会分别扫描多个索引再合并行 ID。它比全表扫描好还是差取决于各分支返回的数据量以及合并成本。八、场景六WHERE走了索引ORDER BY仍然发生额外排序例如索引为idx_user_time_status(user_id, created_at, status)查询某个用户的订单并要求先按状态、再按时间排序EXPLAIN SELECT id, user_id, status, created_at FROM orders WHERE user_id 2150 ORDER BY status, created_at LIMIT 20;WHERE user_id100可以使用idx_user_time_status但索引在user_id之后的顺序是(created_at, status)与ORDER BY status, created_at不一致。所以执行计划可能这样的type: refkey: idx_user_time_statusExtra: Using where; Using index; Using filesort这不是 WHERE 索引失效而是索引不能同时满足排序。MySQL 先找到该用户的记录再额外排序。修复建议如果这个查询频繁执行可以建立同时匹配过滤和排序的索引user_id是常量后面的(status, created_at)正好提供所需顺序Using filesort通常会消失。CREATE INDEX idx_user_status_time ON orders (user_id, status, created_at); EXPLAIN SELECT id, user_id, status, created_at FROM orders WHERE user_id 2150 ORDER BY status, created_at LIMIT 20;九、生产环境加索引前还要考虑什么1、索引有写入成本每增加一个二级索引INSERT、UPDATE、DELETE都要维护它。宽索引还会占用更多缓存和磁盘。能用一个设计合理的联合索引覆盖多个稳定查询时通常比堆很多单列索引更好。2、DDL要评估锁和资源大表执行ALTER TABLE ... ADD INDEX前应确认当前版本支持的在线 DDL 能力评估元数据锁、临时空间、I/O、主从延迟以及失败回滚时间。不要在业务高峰直接试。3、先验证再删除旧索引MySQL 8 可以把索引设为不可见用于观察优化器不使用该索引时的影响不可见索引仍会被写入维护并不会降低写入成本它主要用于安全评估删除影响。确认没有计划回退后再按变更流程删除。ALTER TABLE orders ALTER INDEX idx_phone INVISIBLE; -- 验证完成后恢复 ALTER TABLE orders ALTER INDEX idx_phone VISIBLE;十、结语“索引失效”不是一个根因只是执行计划表现出来的结果。排查时先分清索引是完全不能定位、只使用了一部分还是被优化器基于成本放弃。接着用EXPLAIN看估算计划用EXPLAIN ANALYZE验证实际扫描和耗时再决定改 SQL、调列顺序还是补索引。
返回列表