MySQL 8.0 联合索引走错:为什么加了索引查询反而从 200ms 变成 8s

本文摘要:线上订单加联合索引后从 200ms 变 8 秒,索引命中却更慢。根因是 MySQL 8.0 偏好免排序索引,低选择性前缀致回表暴涨。仅适用于非覆盖索引场景,统计信息过期需先刷新再判断。

一、问题与结论

环境:Docker 启动的 MySQL 8.0(InnoDB),Spring Boot 3.x 订单服务通过 JdbcTemplate 调同一条查询。表 orders 约 50 万行,status='shipped' 占 15%,user_id 范围 1~10000,初始只有 idx_user_id。

sql 复制代码
SELECT * FROM orders
WHERE status = 'shipped' AND user_id = 42
ORDER BY created_at DESC
LIMIT 20;

上线前该查询约 200ms;为消除排序,有人加了 idx_status_created (status, created_at),同一查询涨到 8s。

结论 :MySQL 8.0 默认 prefer_ordering_index=on,优化器看到联合索引能省掉 filesort 就选了它,用 status='shipped'(约 7.5 万行)作前缀扫描,每条都回表取 user_id 再过滤,回表次数从约 50 次涨到数万次。索引确实命中,只是命中了错的那个------这不是索引没建,而是优化器的取舍在低选择性前缀下翻车。

二、排查与选择依据

先看优化器选了什么:

sql 复制代码
EXPLAIN FORMAT=JSON
SELECT * FROM orders
WHERE status = 'shipped' AND user_id = 42
ORDER BY created_at DESC
LIMIT 20;

三个判据依次确认:

  1. key 从 idx_user_id 变成 idx_status_created,Extra 却没有 Using filesort;
  2. FORMAT=JSON 的 cost_info 里 query_cost 更低,但 rows_examined_per_scan 上万------代价模型按统计采样估算索引扫描,低估了非覆盖索引的回表随机 I/O(回表是乘法不是加法);
  3. EXPLAIN ANALYZE(8.0.18+)的 actual rows 远大于估算,直接暴露真实扫描量。

若换索引后立刻出现,且 SHOW INDEX FROM orders 显示新索引 cardinality 为 0 或异常,则是统计信息未刷新导致的临时错选,先执行 ANALYZE TABLE orders; 再判断。

替代方案与取舍

方案 选择条件 代价 边界(什么时候不该用)
重建联合索引 (user_id, status, created_at)(本文推荐) 该索引主要服务本查询 多一个索引的写入开销,需删旧索引 其他查询依赖 idx_status_created 免排序时,删改影响面大,不该直接动索引
IGNORE INDEX (idx_status_created) 线上需快速止血 SQL 与索引名强耦合,索引改名即失效 只适合临时,数据分布变化后不会自动换回
/*+ SET_VAR(optimizer_switch='prefer_ordering_index=off') */ 单条语句修正、不改表结构 该语句必然走 filesort 排序数据量本身很大时,filesort 才是真瓶颈,不该用

不建议 FORCE INDEX:它会阻止优化器在数据分布变化后自动切换,比 IGNORE INDEX 更危险。

三、关键原理

三个事实串起根因:

  • 免排序偏好 :prefer_ordering_index 启用时,只要某索引能满足 ORDER BY,优化器就优先选它,哪怕前缀列过滤选择性很差。这是设计取舍,不是 bug。
  • 回表代价被低估:非覆盖索引每条匹配都要一次二级索引到聚簇索引的随机 I/O,扫描条目数与回表次数是乘法关系;前缀选择性越低,估算偏差越被放大。
  • 统计信息延迟 :innodb_stats_auto_recalc 默认开启但采样有延迟,新增索引后立即查询可能拿着过期统计做决策。

四、可运行示例

环境:Docker + MySQL 8.0,需 8.0.18+ 才有 EXPLAIN ANALYZE。

bash 复制代码
docker run -d --name mysql8 -p 3306:3306 \
  -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=demo mysql:8.0

建表与造数(20 万行,completed 约 80%、shipped 约 15%、pending 约 5%)。用 n DIV 10000 与 n % 10000 生成互不相关的状态和用户,避免纯取模产生固定关联:

sql 复制代码
USE demo;
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT UNSIGNED NOT NULL,
  status VARCHAR(20) NOT NULL,
  amount DECIMAL(10,2) NOT NULL,
  created_at DATETIME NOT NULL,
  KEY idx_user_id (user_id)
) ENGINE=InnoDB;

SET SESSION cte_max_recursion_depth = 200001;
INSERT INTO orders (user_id, status, amount, created_at)
WITH RECURSIVE seq AS (
  SELECT 1 n UNION ALL SELECT n + 1 FROM seq WHERE n < 200000
)
SELECT 1 + n % 10000,
       CASE WHEN (n DIV 10000) % 20 < 16 THEN 'completed'
            WHEN (n DIV 10000) % 20 < 19 THEN 'shipped'
            ELSE 'pending' END,
       10 + n % 990,
       '2024-01-01' + INTERVAL (n * 157) SECOND
FROM seq;

ANALYZE TABLE orders;

操作步骤:

sql 复制代码
-- 步骤 1:只有 idx_user_id
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE status = 'shipped' AND user_id = 42
ORDER BY created_at DESC LIMIT 20;

-- 步骤 2:加联合索引并刷新统计
ALTER TABLE orders ADD INDEX idx_status_created (status, created_at);
ANALYZE TABLE orders;

-- 步骤 3:重复同一查询
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE status = 'shipped' AND user_id = 42
ORDER BY created_at DESC LIMIT 20;

预期输出 :步骤 1 选中 idx_user_id,回表约 20 次(user_id=42 恰好 20 行);步骤 3 的 key 变为 idx_status_created、Extra 无 Using filesort,actual rows 升到 3 万量级(约 15% 的行需回表过滤),耗时明显上涨。这是依据官方文档描述的推断,绝对毫秒数未验证,取决于缓冲池与机器。

实际输出 :本文示例未在本机执行,需在你的 Docker 环境运行后回填;回填时重点记录 EXPLAIN ANALYZE 的 actual rows 与耗时,并与 IGNORE INDEX (idx_status_created) 的结果对照。

常见失败:加完索引立刻 EXPLAIN,key 没变化或仍走旧索引。原因是新索引统计信息未生成(SHOW INDEX 的 cardinality 为 0)。修复:执行 ANALYZE TABLE orders; 后重新 EXPLAIN。

五、验证结果与边界

  • 仅对非覆盖索引成立;索引包含 user_id 变成覆盖索引后无回表,差距会大幅缩小。
  • 前缀列选择性必须低(匹配 15%~80% 的行)才明显;数据量小或缓冲池足够大时,差距可能不到一秒。
  • MySQL 5.7 没有 prefer_ordering_index,从 5.7 升 8.0 后同一查询计划可能改变,需重新验证;8.0.21 调整过代价模型,小版本升级后同样要复测。
  • SET_VAR 只影响当前语句;session 级关闭开关会影响该连接所有带 ORDER BY 的查询,只适合排查阶段,不要长期保留。

参考资料

相关推荐
SL_staff15 分钟前
制造业订单交付总在最后一刻爆雷?用结构化计划模板打通跨系统协同断点
java·开源·全栈
王中阳Go33 分钟前
预算给够了,时间花错了地方:三跳链路给了 27 秒,实际只用掉 9 秒
人工智能·后端·性能优化
做科研的周师兄35 分钟前
【R语言实战】MixSIAR 氢氧同位素水源解析全流程:从混合空间校验到贝叶斯 MCMC 收敛诊断(后附完整代码)
java·大数据·开发语言
有点小困9771 小时前
Spring Boot 异步任务 ClassLoader 踩坑实录:ClassNotFoundException 的诡异真相
java
csdn_aspnet1 小时前
如何在本地 Docker 容器中运行 Redis,并使用 Redis Insight 和 Redis CLI 进行管理
redis·docker·容器
aiks_yuan1 小时前
Java 正则表达式很慢怎么办:Pattern 预编译只快 2~5 倍,真正卡死线程的是回溯(JDK 8、11、17 实测)
java
用户3721574261351 小时前
C# 填写 PDF 交互式表单:文本框、复选框、单选按钮和下拉列表
java
IT枫斗者枫哥1 小时前
Java 退款请求超时,先查原单还是重试?
java·后端
Chen7321 小时前
WebGPU 性能优化:Three.js 千万级点云调度与渲染实践
性能优化·three.js