MySQL 慢查询优化实战:812 万行订单表从 4.2 秒降到 62 毫秒,我加的那 3 个索引一个都没用上

🔑 关键词:MySQL慢查询优化,索引失效,EXPLAIN执行计划,联合索引顺序,覆盖索引

📖 摘要:一次真实的线上慢查询复盘。812 万行订单表,P99 从 3.4 秒打回 240 毫秒。文中给出隐式类型转换、函数包列、联合索引顺序、范围查询、深分页这 5 种索引失效场景的 EXPLAIN 数据对比,包括 key_len 从 8 变成 138 这种可复现的判据。

凌晨 2 点 14 分,第 7 条一模一样的告警

图片

2024 年 5 月 21 日,钉钉机器人推的第 7 条还是那句「订单列表接口 P99 超过 3000ms」,手机在枕头边震得人心烦。爬起来连 VPN,我没先去看 SQL,先翻的 Grafana:接口 P99 从平时的 182ms 涨到 3.4s,QPS 从 820 掉到 210。这个 QPS 不是流量少了,是 Tomcat 的 200 个线程全堵在连接池上——HikariCP 的 maximumPoolSize 设的 50,active 长期钉在 50,等待队列堆到 340 多。

表是 order_main,812 万行,MySQL 8.0.32,16C64G,innodb_buffer_pool_size 给的 40G,而整张表连索引带数据才 6.8G,理论上全在内存里,不该出这种事。所以问题肯定不在机器。

我先干了件蠢事:一口气加了 3 个索引

第一反应是「缺索引」,现在回看这个判断相当粗糙。我让 DBA 老王上午加了三个:

ALTER TABLE order_main ADD INDEX idx_created_at (created_at);
ALTER TABLE order_main ADD INDEX idx_status (status);
ALTER TABLE order_main ADD INDEX idx_shop_id (shop_id);

图片

上线后 P99 从 3.4s 降到 2.9s,只降了差不多 15%。代价是写入 TPS 从 1200 掉到 950,三个二级索引每个都得维护。等于花了钱买了更差的性能,这是那次最打脸的地方。后来我想明白一件事:单列索引在订单这种多条件组合查询上,基本是自我安慰。

把 long_query_time 从 1 调到 0.2,才看见真相

原来线上是 long_query_time = 1。这个默认值在 16 核机器上等于闭着眼睛开车——大量 300 到 800 毫秒的 SQL 一天跑几十万次,永远不会进慢日志,但它一直在吃 CPU。我调到 0.2,用 pt-query-digest 跑了一天,抓到 23184 条,按总耗时排序,第一名占了 62%:

SELECT id, order_no, shop_id, status, amount, created_at
FROM order_main
WHERE shop_id = 10231 AND order_no = 202405210001
ORDER BY created_at DESC
LIMIT 20;

图片

order_no 是 varchar(32),Java 那边实体类写的是 Long,MyBatis 直接 setLong,落到 SQL 里就成了不带引号的数字。MySQL 会把 varchar 列转成 double 再比较,索引当场作废。这个坑我 2019 年在另一家公司踩过一次,隔了五年照样栽进去。

5 种索引失效的情况,EXPLAIN 里一个比一个明显

第一个:隐式类型转换。 改前改后的执行计划差别非常直观:

项 改之前 改之后
type ref ref
key idx_shop_id idx_shop_no
key_len 8 138
rows 86412 1
filtered 10.00 100.00
Extra Using where; Using filesort Using where

key_len 从 8 变成 138 是我最看重的信号:138 = 8(shop_id 是 bigint)+ 130(varchar(32) NOT NULL 的 utf8mb4,128 加 2 字节变长长度)。key_len 变长说明索引里真正参与定位的字节变多了,这一栏比 rows 更可信,因为 rows 是采样估算,会骗人。

图片

第二个:函数包住索引列。 运营那边有个导出,写的是 WHERE DATE(created_at) = '2024-05-01',rows 估算 61 万。改成 created_at >= '2024-05-01 00:00:00' AND created_at < '2024-05-02 00:00:00',rows 掉到 3200。差 190 倍,改的只是写法。

第三个:联合索引顺序搞反。 我们一开始建的是 (status, shop_id, created_at)。status 只有 6 个枚举值,cardinality 是 6;shop_id 有 4.2 万个不同值。最左前缀原则下,只带 shop_id 的查询根本用不上这个索引。换成 (shop_id, status, created_at) 之后,同一个查询从走全表扫变成走 range,rows 从 79 万降到 1200 出头。

第四个:范围查询把它后面的列废掉。 (shop_id, status, created_at) 里如果 status 走了范围(status > 1),created_at 就没法再用于排序定位,只能靠索引下推(Extra 里会出现 Using index condition)做过滤,不能用于 ORDER BY 消除 filesort。这个细节很多人不知道,以为建了联合索引就万事大吉。

第五个:深分页。 后台导出翻到第 1 万页,LIMIT 200000, 20,实际扫描 200020 行再回表。改法是延迟关联,先在索引上拿到 20 个 id 再回表,或者干脆改成游标翻页,用上一页最后一条的 (created_at, id) 做条件。我们从 30 秒超时降到 3.1 秒,代价是前端得改一版翻页逻辑,产品那边磨了两天才同意。

图片

最后的数据

指标 改前 改后
列表页 P99 3420ms 240ms
订单号检索(最慢那条) 4180ms 62ms
导出任务 超时 30s 3.1s
写入 TPS 950(加了三个废索引后) 1380
表上索引总数 9 7

删掉的是最开始加的那三个单列索引,留下的是两个联合索引。索引数量反而少了。

几个跟主流说法不太一样的看法

第一,慢查询日志阈值不调低,等于没有慢查询日志。1 秒这个默认值在现在的硬件上太宽松了,我建议按 P99 的 2 倍来设,多数业务 0.2 到 0.3 秒比较合适,先跑一周看看量级,别怕日志大。

图片

第二,优化器不会告诉你它被什么骗了。type=ALL 你能看见,但隐式转换导致的索引失效,EXPLAIN 里只表现为 rows 变大、key 变了,它不会弹个框说这里类型不匹配。所以 EXPLAIN 要连着 key_len 一起看。

第三,索引不是不够,是用不上。那次加的 3 个索引一次都没被选中过。后来我拿 pt-index-usage 扫了全库,找出 11 个从上线到现在零使用的索引,全是历史遗留,白白拖慢写入。

第四,顺序比数量重要。一个 (shop_id, status, created_at) 顶得上三个单列索引,前提是等值条件放前面、范围条件放后面。

写到这里发现复盘写得有点像教程了。其实那天凌晨 3 点半我还在跟老王争到底是不是连接池参数的问题,他坚持把 maximumPoolSize 从 50 调到 120,调完 P99 只降了 60ms,反而把数据库连接数顶到 480,差点触发 max_connections。方向错了的时候,参数怎么调都是在原地打转。

🏷️ 标签: