半年前双十一大促前夕,我们对核心交易系统做压测,发现一个查询用户账户的接口在并发 500 QPS 时,响应时间从正常的 30ms 飙升到了 1200ms。当时团队里有人猜测是索引没建好,有人认为是数据库连接池不够,还有人觉得是 Java 应用层的问题。这种“盲人摸象”式的排查效率极低,直到我拿出 MySQL 8.0.36 版本提供的 EXPLAIN ANALYZE 工具,才真正看清了数据库内部的执行逻辑。
在 MySQL 8.0 之前,我们通常用 EXPLAIN 来查看执行计划,但它有一个致命缺陷:只显示预估的行数和成本,不显示实际执行情况。优化器有时候会“撒谎”,比如预估扫描 100 行,实际却扫了 10 万行。我在这次排查中就遇到了这种情况。
EXPLAIN ANALYZE 是 MySQL 8.0 引入的重量级功能,它不仅仅是一个预估工具,而是一个带有实际执行统计的分析器。它会真正执行查询(在只读事务中),然后输出每一步的实际耗时、循环次数和返回行数。
当时那个慢查询的 SQL 大致是这样的,用于查询某个账户下特定状态的交易记录:
我先用传统的 EXPLAIN 看了一眼,显示 type 是 range,预估行数 rows 是 400 多,看起来没啥问题。但接口确实慢,于是我运行了 EXPLAIN ANALYZE:
输出结果中有一段引起了我的注意(简化后):
原因分析:这里的关键在于 actual time 和 rows。虽然索引 idx_account_created_at 生效了,预估扫描 800 行,实际扫描了 1520 行。但随后的 Filter 操作(过滤 status = 'SUCCESS')发生在排序(Sort)之前,且实际耗时达到了 890ms。这意味着虽然走了索引范围扫描,但因为没有包含 status 字段,数据库不得不先捞出 1520 行数据,然后再逐行过滤 status,最后才进行昂贵的 DESC 排序。
解决方案是:利用 MySQL 8.0 支持降序索引的特性,重建一个联合索引。原来的索引是 (account_id, created_at),我将其调整为 (account_id, status, created_at DESC)。
再次运行 EXPLAIN ANALYZE,结果变为:
逻辑推导:这次没有 Filter 和 Sort 步骤了。原因在于新的联合索引直接按照 account_id 和 status 进行了过滤,并且 created_at 已经是降序排列的。数据库只需要沿着索引树往下走 20 行就能拿到结果,耗时从 892ms 降到了 2.1ms。这就是从“预估”走向“实际”带来的优化价值。
在负责一个日订单量 300 万级别的电商系统重构时,我遇到了一个典型的性能瓶颈。用户查看“我的订单”列表页,接口平均响应时间在 600ms 左右,数据库 CPU 使用率常年维持在 70% 以上。这个场景非常具体:用户需要按时间倒序查看自己的订单,且经常需要筛选状态(如待付款、已发货)。
订单表 orders 的数据量当时已经达到了 5000 万行,MySQL 版本为 8.0.36。原始的索引设计非常朴素,只给 user_id 建了一个索引。
查询 SQL 通常是这样的:
通过 EXPLAIN 分析,该查询虽然走了 idx_user_id,但 status 的过滤是在回表之后进行的,且 created_at 的排序无法利用索引,导致 Using filesort。
根据最左前缀原则,我将索引重构为 (user_id, status, created_at)。考虑到用户习惯看最新的订单,我在这里使用了 MySQL 8.0 支持的降序索引特性。
为什么这么做:user_id 是等值查询,status 也是等值查询,而 created_at 是范围排序。B+树的结构决定了,把等值条件放在前面,范围条件放在后面,能最大程度利用索引的有序性。这样查询时,索引不仅过滤了用户和状态,还天然按时间倒序排好序,省去了 filesort 的开销。
即便如此,查询流程依然是:索引树找到主键 ID -> 回表查询具体数据。我观察了 SELECT 语句,发现只取了 id, order_sn, total_amount, status, created_at 这几个字段。既然 status 和 created_at 已经在索引里了,我只需要把 order_sn 和 total_amount 也加进去,就能实现“覆盖索引”,彻底避免回表。
这里我使用了 MySQL 8.0.36 的 Invisible Indexes 特性。先将索引设为 INVISIBLE,让优化器暂时忽略它,观察系统稳定性,确认无误后再改为 VISIBLE。这比直接删除索引安全得多,一旦出问题可以瞬间恢复。
优化前后的压测数据(使用 JMeter,并发 200 线程,持续 5 分钟):
| 指标 | 优化前 (单一索引) | 优化后 (联合+覆盖索引) | 提升幅度 |
| :--- | :--- | :--- | :--- |
| 平均响应时间 (RT) | 650 ms | 18 ms | 97.2% |
| 95% 响应时间 | 1200 ms | 35 ms | 97.1% |
| QPS | 320 | 8500 | 25.5倍 |
| 数据库 CPU 使用率 | 72% | 8% | 降低 64% |
逻辑推导:优化前,查询需要回表 10 次(LIMIT 10),且伴随文件排序。优化后,所有数据都在二级索引的叶子节点上,被称为“覆盖索引”。数据库无需回到聚簇索引(主键索引)去取数据,减少了一次 IO 查找。对于 5000 万行的大表,这种减少 IO 的优化效果是指数级的。
在接手一个遗留的物流追踪系统时,我遇到过一个非常隐蔽的性能问题。系统里有一个 tracking_events 表,专门记录包裹的物流节点,数据量大约 2 亿条。有一个接口专门用来查询某天某个包裹的所有事件,平时响应很快,但一到月底对账高峰期就超时。
当时的查询 SQL 大致如下,用于查询 2024-05-01 当天的事件:
tracking_no 和 event_time 上明明建了联合索引,但 EXPLAIN 显示 type 是 ALL,全表扫描。
原因分析:问题出在 DATE(event_time) 上。我在 WHERE 子句中对索引列 event_time 使用了 DATE() 函数。MySQL 的 B+树索引存储的是列的原值,一旦对列进行了函数运算,优化器就无法直接通过索引树来快速定位。它必须取出每一行的 event_time,计算其日期部分,然后再比较。这就导致了索引失效,变成了全表扫描。
解决方案是:避免在索引列上做函数运算,改为范围查询。
修改后,索引立刻生效,type 变为 range,查询耗时从 4.5 秒降到了 15 毫秒。
另一个案例来自同一个系统。为了支持 JSON 格式的扩展字段,表结构里有一个 ext_info 字段,类型是 JSON。我们需要根据 JSON 里的 warehouse_id 来查询数据。
在 MySQL 5.7 时代,我们通常会写这样的查询:
在 MySQL 8.0.36 中,我检查执行计划,发现虽然能跑,但并没有走索引。原因在于 JSON_EXTRACT 返回的是带有引号的字符串(如 "1001"),而等号右边我给的是数字 1001,或者反之,导致发生隐式类型转换。
解决方案是:利用 MySQL 8.0 引入的 Functional Key Parts(函数索引)。我直接对 JSON 路径表达式建立索引,而不是对列本身。
为什么这么做:普通索引无法索引 JSON 内部的数据。函数索引允许我将 JSON_EXTRACT 的计算结果存储到索引中。这样一来,查询时如果条件匹配,优化器就可以直接使用这个索引。
为了验证效果,我执行了对比查询:
在 2 亿行的数据量下,全表扫描的耗时稳定在 15 秒以上,而使用函数索引后,耗时稳定在 20ms 以内。
逻辑推导:索引失效的根本原因在于破坏了索引列的有序性(函数运算)或破坏了比较逻辑的一致性(隐式转换)。B+树依赖有序的数据结构进行二分查找,一旦列被包裹在函数里,或者比较双方类型不一致,有序性就被破坏了,数据库只能选择成本更高的全表扫描。这也是为什么在 2024-2026 年的技术趋势中,虽然数据库越来越智能,但开发者对 SQL 写法的严谨性要求依然没有降低。
去年双十一大促前,我们对一个日订单量 300 万、历史数据累计 12 亿的电商订单库做索引重构。这个库跑在 MySQL 8.0.36 上,有一个老旧的联合索引 (user_id, status, create_time),随着业务迭代,新的查询模式变成了按 create_time 范围扫 + status 过滤,旧索引的选择性极差,导致用户订单分页接口 P99 耗时长期在 800ms 以上,大促时甚至冲到 2.3 秒。
我没有直接 DROP INDEX 再 ADD INDEX,那样在 8.0.36 里虽然能在线做,但依然会触发短暂的行锁和负载抖动。我用了 MySQL 8.0 引入的 Invisible Indexes 特性,先建新索引并设为不可见,验证优化器行为后再切换。
具体操作流程如下:
除了索引重构,我们还遇到了一个大表关联的问题。订单表要关联用户表做实时报表,用户表有 5000 万数据。原来的嵌套循环关联(Nested Loop Join)在缺少合适索引时直接拖垮了从库 CPU,利用率飙到 95%。
MySQL 8.0 引入的 Hash Join 救了场。我们当时的查询是:
EXPLAIN 输出里出现了 hash join 字样。我们实测发现,对于这个 12 亿行关联 5000 万行的无索引关联场景,Hash Join 的执行时间比原来的全表嵌套循环快了 15 倍(从 45 秒降到 3 秒)。
但我发现 Hash Join 不是银弹。如果关联的驱动表(orders)过滤后的数据量很小(比如只有几千行),优化器有时还是会选 Nested Loop。这时候我会用 Optimizer Hints 强制一下,不过这种情况很少见,8.0 的优化器在大多数情况下判断很准。
这次重构让我意识到,Invisible Indexes 最大的价值不是性能提升,而是变更安全性。在 8.0.36 里,你可以先建好索引,在业务低峰期通过切换可见性来完成索引替换,整个过程对线上查询的影响窗口只有几毫秒的元数据锁时间,比传统的先删后建稳得多。
我们公司的用户系统早期是外包团队做的,主键用了 UUID(varchar 36),后来数据量到了 3000 万,插入就开始变慢,从库同步延迟经常过秒。我接手后做了一次主键改造,把 UUID 换成了自增 BIGINT,这个过程中我对 B+ 树的物理结构有了更直观的认识。
先说结论:在 InnoDB 这种聚簇索引存储引擎里,主键的顺序插入对性能影响巨大。UUID 是随机字符串,插入时会导致大量的页分裂和碎片。
我在一个测试环境(MySQL 8.0.36,16核 64G 内存,SSD 盘)做过一组对比测试,数据量 5000 万行:
| 指标 | 自增 BIGINT | UUID (varchar 36) |
| :--- | :--- | :--- |
| 插入 5000 万耗时 | 约 45 分钟 | 约 2.5 小时 |
| 数据文件大小 (ibd) | 约 28 GB | 约 52 GB |
| 索引碎片率 (SHOW INDEX) | < 0.5% | 约 35% |
| 随机读取 IOPS | 稳定在 3k 左右 | 峰值冲到 12k |
核心原因是 B+ 树的叶子节点是有序的双向链表。自增 ID 每次插入都是往尾巴后面追加,最多就是最后一个页写满后开一个新页,这种叫“顺序写入”。
UUID 就不一样了。因为 UUID 是随机生成的,新插入的一行数据可能要落在 B+ 树最左边的页,也可能在最右边,或者中间。如果那个页刚好满了,MySQL 就必须进行页分裂:把满页的一部分数据挪到一个新页,腾出空间给新数据。这个过程涉及数据移动、页的重新平衡,极其消耗 IO。
我在实际迁移时,为了验证碎片情况,跑了这段查询:
如果不做改造会怎样?
我们当时那个 UUID 用户表,因为碎片率高达 35%,同样的数据量占用了近乎两倍的磁盘空间。更要命的是,查询时即使走了索引,由于数据页的物理存储不连续,磁盘预读(Read-Ahead)基本失效,每次读取都伴随着大量的随机 IO。特别是在做全表扫描或者大范围查询时,延迟比自增主键慢了 3 倍不止。
后来我们做主键迁移,采用了“影子表”方案。大致步骤如下:
迁移完成后,同样是 3000 万数据,表文件大小从 52GB 降到了 28GB,插入 TPS 从 800 提升到了 2400。
我的判断是:除非你有极强的分布式 ID 需求且数据量不大(千万级以内),否则在 MySQL 的核心业务表里,尽量用自增 BIGINT 做主键。如果你非要用 UUID,至少要用 MySQL 8.0 支持的 有序 UUID(比如基于时间排序的 UUID v7),或者使用 UUID_TO_BIN(uuid, true) 转成二进制存储,减少碎片和空间占用。
2024 年 3 月,我们线上一个 SaaS 系统突然告警,数据库 CPU 使用率冲到 100%,接口响应时间从 50ms 暴涨到 5 秒以上。那个库跑在 MySQL 8.0.36 上,数据量大约 2 亿行。
我介入排查时,第一件事不是看代码,而是先看 MySQL 的慢查询日志 和 正在运行的线程。
我直接连上数据库,运行了下面这个查询,用来抓当前正在跑的“坏家伙”:
结果抓到了一条正在跑了 45 秒的 SQL,内容是这样的:
这条 SQL 看起来没啥问题,但我一看 DATE(create_time),心里就有数了。这就是典型的索引失效陷阱。
在 WHERE 子句里对索引列做函数运算(DATE()),优化器会放弃使用索引,导致全表扫描。这个表有 2 亿行,全表扫一次,CPU 不炸才怪。
我当时先用 EXPLAIN 确认了一下:
输出结果里 type 是 ALL,rows 是 1.98 亿。确认无疑,全表扫。
这时候如果去改代码发版,至少得 10 分钟,用户早炸了。我直接干掉了这个慢查询会话:
杀掉会话后,CPU 瞬间从 100% 掉到了 40%。但这只是止血,根本问题没解决,只要这个请求再来一次,还得炸。
我得马上给这个查询找个临时出路。我注意到 tenant_id 和 level 是有索引的,但 create_time 被函数包住了。我临时改写了查询逻辑(通过公司内部的数据访问层动态改写,或者直接在数据库层面利用 Optimizer Hints 强制走索引,虽然这里函数索引没建,但可以用范围查询替代):
为了保险起见,我先在从库上用 EXPLAIN ANALYZE 跑了一下,确认 rows 降到了 2000 行,耗时 20ms。
救火结束后,我复盘发现,这个查询模式很常见。为了彻底解决,我在 MySQL 8.0.36 上利用 Functional Key Parts(函数索引) 特性,直接给 DATE(create_time) 建了个索引。
用了 ALGORITHM=INSTANT,这个 2 亿行的表加索引只花了 0.2 秒,业务完全无感知。
这次救火给我的教训是:
processlist 是最后一道防线。WHERE 里有函数包裹索引列,直接判定为“高危 SQL”。KILL 命令是救火利器,但必须配合后续的 SQL 改写或索引优化,否则就是扬汤止沸。去年双十一大促前,我们那个日订单量刚破 50 万的电商系统,凌晨两点突然报警,数据库 CPU 飙到 98%。我顶着黑眼圈爬起来,盯着那个慢查询日志,发现是一条看起来人畜无害的 SQL:
当时表上有 user_id 的单列索引,也有 created_at 的索引。我第一反应是加个联合索引,于是在测试环境随手建了 (user_id, status, created_at)。压测一跑,确实快了不少,心里正美呢,结果上线不到半小时,主库写入开始抖动,写入延迟从 5ms 涨到了 30ms。
我这才反应过来,这个表每天新增 200 万单,联合索引虽然救了查询,但三个字段的 B+ 树维护成本太高,尤其是 status 这种低区分度的字段,导致索引页分裂特别频繁。后来我直接把索引改成了 (user_id, created_at),把 status 的过滤放到内存里处理,写入性能瞬间恢复正常,查询也只慢了 2ms,完全在可接受范围内。
对于 Invisible Indexes,我之前在重构老项目时特别迷信,觉得能平滑切换。结果在一个高并发的支付系统里,因为优化器选错索引,导致一个核心接口 RT 翻了三倍。后来我学乖了,这种高级特性只会在从库或者灰度环境先跑一周,确认优化器不抽风才敢动主库。
至于 UUID 还是自增 ID,我吃过亏。之前为了分库分表方便,强行上了 UUID,结果数据量过千万后,插入性能肉眼可见地掉。B+ 树的叶子节点因为随机插入一直在做页分裂,碎片率一度到了 40%。后来新系统我全换回了雪花算法,既有序又分布式,省心太多了。
最后啰嗦一句:别在开发环境对着几百条数据去测索引性能。很多索引失效和性能瓶颈,只有在百万级数据量加上真实业务流量下才会露馅。多去线上看看慢查询,那才是你技术成长最快的地方。