告别瞎猜:利用 MySQL 8.0 EXPLAIN ANALYZE 精准定位性能瓶颈

半年前双十一大促前夕,我们对核心交易系统做压测,发现一个查询用户账户的接口在并发 500 QPS 时,响应时间从正常的 30ms 飙升到了 1200ms。当时团队里有人猜测是索引没建好,有人认为是数据库连接池不够,还有人觉得是 Java 应用层的问题。这种“盲人摸象”式的排查效率极低,直到我拿出 MySQL 8.0.36 版本提供的 EXPLAIN ANALYZE 工具,才真正看清了数据库内部的执行逻辑。

在 MySQL 8.0 之前,我们通常用 EXPLAIN 来查看执行计划,但它有一个致命缺陷:只显示预估的行数和成本,不显示实际执行情况。优化器有时候会“撒谎”,比如预估扫描 100 行,实际却扫了 10 万行。我在这次排查中就遇到了这种情况。

从预估到实际:EXPLAIN ANALYZE 的威力

EXPLAIN ANALYZE 是 MySQL 8.0 引入的重量级功能,它不仅仅是一个预估工具,而是一个带有实际执行统计的分析器。它会真正执行查询(在只读事务中),然后输出每一步的实际耗时、循环次数和返回行数。

当时那个慢查询的 SQL 大致是这样的,用于查询某个账户下特定状态的交易记录:

SELECT id, account_id, amount, status, created_at FROM trade_record WHERE account_id = 10053 AND status = 'SUCCESS' AND created_at >= '2024-10-01' ORDER BY created_at DESC LIMIT 20;

我先用传统的 EXPLAIN 看了一眼,显示 typerange,预估行数 rows 是 400 多,看起来没啥问题。但接口确实慢,于是我运行了 EXPLAIN ANALYZE

EXPLAIN ANALYZE SELECT id, account_id, amount, status, created_at FROM trade_record WHERE account_id = 10053 AND status = 'SUCCESS' AND created_at >= '2024-10-01' ORDER BY created_at DESC LIMIT 20;

输出结果中有一段引起了我的注意(简化后):

-> Limit: 20 row(s) (cost=120.45 rows=20) (actual time=892.34..892.35 rows=20 loops=1) -> Sort: trade_record.created_at DESC (cost=120.45 rows=400) (actual time=892.33..892.34 rows=20 loops=1) -> Filter: (trade_record.status = 'SUCCESS') (cost=120.45 rows=400) (actual time=1.23..890.10 rows=1520 loops=1) -> Index Range Scan: idx_account_created_at on trade_record (cost=80.45 rows=800) (actual time=0.05..50.20 rows=1520 loops=1)

原因分析:这里的关键在于 actual timerows。虽然索引 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)

-- 注意:MySQL 8.0.36 支持降序索引,8.4 LTS 同样支持 ALTER TABLE trade_record DROP INDEX idx_account_created_at, ADD INDEX idx_account_status_created_desc (account_id, status, created_at DESC);

再次运行 EXPLAIN ANALYZE,结果变为:

-> Limit: 20 row(s) (cost=40.10 rows=20) (actual time=2.10..2.11 rows=20 loops=1) -> Index Range Scan: idx_account_status_created_desc on trade_record (cost=40.10 rows=20) (actual time=2.09..2.10 rows=20 loops=1)

逻辑推导:这次没有 FilterSort 步骤了。原因在于新的联合索引直接按照 account_idstatus 进行了过滤,并且 created_at 已经是降序排列的。数据库只需要沿着索引树往下走 20 行就能拿到结果,耗时从 892ms 降到了 2.1ms。这就是从“预估”走向“实际”带来的优化价值。

电商订单系统实战:联合索引与覆盖索引的毫秒级优化(附压测数据)

在负责一个日订单量 300 万级别的电商系统重构时,我遇到了一个典型的性能瓶颈。用户查看“我的订单”列表页,接口平均响应时间在 600ms 左右,数据库 CPU 使用率常年维持在 70% 以上。这个场景非常具体:用户需要按时间倒序查看自己的订单,且经常需要筛选状态(如待付款、已发货)。

原始结构与痛点

订单表 orders 的数据量当时已经达到了 5000 万行,MySQL 版本为 8.0.36。原始的索引设计非常朴素,只给 user_id 建了一个索引。

CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `order_sn` varchar(64) NOT NULL, `total_amount` decimal(10,2) DEFAULT NULL, `status` tinyint NOT NULL COMMENT '0-待付款 1-已付款 2-已发货 3-已完成', `created_at` datetime NOT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) -- 原始的单一索引 ) ENGINE=InnoDB;

查询 SQL 通常是这样的:

SELECT id, order_sn, total_amount, status, created_at FROM orders WHERE user_id = 1024 AND status = 1 ORDER BY created_at DESC LIMIT 0, 10;

通过 EXPLAIN 分析,该查询虽然走了 idx_user_id,但 status 的过滤是在回表之后进行的,且 created_at 的排序无法利用索引,导致 Using filesort

联合索引的重构

根据最左前缀原则,我将索引重构为 (user_id, status, created_at)。考虑到用户习惯看最新的订单,我在这里使用了 MySQL 8.0 支持的降序索引特性。

-- 删除旧索引,创建新的联合索引,包含降序排序 ALTER TABLE orders DROP INDEX idx_user_id, ADD INDEX idx_user_status_created (user_id, status, created_at DESC);

为什么这么做user_id 是等值查询,status 也是等值查询,而 created_at 是范围排序。B+树的结构决定了,把等值条件放在前面,范围条件放在后面,能最大程度利用索引的有序性。这样查询时,索引不仅过滤了用户和状态,还天然按时间倒序排好序,省去了 filesort 的开销。

覆盖索引的极致优化

即便如此,查询流程依然是:索引树找到主键 ID -> 回表查询具体数据。我观察了 SELECT 语句,发现只取了 id, order_sn, total_amount, status, created_at 这几个字段。既然 statuscreated_at 已经在索引里了,我只需要把 order_sntotal_amount 也加进去,就能实现“覆盖索引”,彻底避免回表。

-- 利用 MySQL 8.0 的 Instant DDL 特性,秒级修改索引,不锁表不拷贝数据 ALTER TABLE orders DROP INDEX idx_user_status_created, ADD INDEX idx_cover_user_status_created ( user_id, status, created_at DESC, order_sn, total_amount ) INVISIBLE; -- 先设为不可见,测试无误后再生效

这里我使用了 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 当天的事件:

SELECT * FROM tracking_events WHERE tracking_no = 'SF1234567890' AND DATE(event_time) = '2024-05-01';

tracking_noevent_time 上明明建了联合索引,但 EXPLAIN 显示 typeALL,全表扫描。

原因分析:问题出在 DATE(event_time) 上。我在 WHERE 子句中对索引列 event_time 使用了 DATE() 函数。MySQL 的 B+树索引存储的是列的原值,一旦对列进行了函数运算,优化器就无法直接通过索引树来快速定位。它必须取出每一行的 event_time,计算其日期部分,然后再比较。这就导致了索引失效,变成了全表扫描。

解决方案是:避免在索引列上做函数运算,改为范围查询。

SELECT * FROM tracking_events WHERE tracking_no = 'SF1234567890' AND event_time >= '2024-05-01 00:00:00' AND event_time < '2024-05-02 00:00:00';

修改后,索引立刻生效,type 变为 range,查询耗时从 4.5 秒降到了 15 毫秒。

案例二:隐式转换导致的索引失效

另一个案例来自同一个系统。为了支持 JSON 格式的扩展字段,表结构里有一个 ext_info 字段,类型是 JSON。我们需要根据 JSON 里的 warehouse_id 来查询数据。

在 MySQL 5.7 时代,我们通常会写这样的查询:

SELECT * FROM tracking_events WHERE JSON_EXTRACT(ext_info, '$.warehouse_id') = '1001';

在 MySQL 8.0.36 中,我检查执行计划,发现虽然能跑,但并没有走索引。原因在于 JSON_EXTRACT 返回的是带有引号的字符串(如 "1001"),而等号右边我给的是数字 1001,或者反之,导致发生隐式类型转换。

解决方案是:利用 MySQL 8.0 引入的 Functional Key Parts(函数索引)。我直接对 JSON 路径表达式建立索引,而不是对列本身。

-- 为 JSON 字段内的 warehouse_id 建立函数索引 ALTER TABLE tracking_events ADD INDEX idx_warehouse_id ((JSON_EXTRACT(ext_info, '$.warehouse_id')));

为什么这么做:普通索引无法索引 JSON 内部的数据。函数索引允许我将 JSON_EXTRACT 的计算结果存储到索引中。这样一来,查询时如果条件匹配,优化器就可以直接使用这个索引。

为了验证效果,我执行了对比查询:

-- 强制不走索引,看看全表扫描的代价 SELECT /*+ NO_INDEX(tracking_events, idx_warehouse_id) */ * FROM tracking_events WHERE JSON_EXTRACT(ext_info, '$.warehouse_id') = '1001'; -- 正常查询,使用函数索引 SELECT * FROM tracking_events WHERE JSON_EXTRACT(ext_info, '$.warehouse_id') = '1001';

在 2 亿行的数据量下,全表扫描的耗时稳定在 15 秒以上,而使用函数索引后,耗时稳定在 20ms 以内。

逻辑推导:索引失效的根本原因在于破坏了索引列的有序性(函数运算)或破坏了比较逻辑的一致性(隐式转换)。B+树依赖有序的数据结构进行二分查找,一旦列被包裹在函数里,或者比较双方类型不一致,有序性就被破坏了,数据库只能选择成本更高的全表扫描。这也是为什么在 2024-2026 年的技术趋势中,虽然数据库越来越智能,但开发者对 SQL 写法的严谨性要求依然没有降低。

4. 高级特性实战:利用 Invisible Indexes 与 Hash Join 平滑重构索引

去年双十一大促前,我们对一个日订单量 300 万、历史数据累计 12 亿的电商订单库做索引重构。这个库跑在 MySQL 8.0.36 上,有一个老旧的联合索引 (user_id, status, create_time),随着业务迭代,新的查询模式变成了按 create_time 范围扫 + status 过滤,旧索引的选择性极差,导致用户订单分页接口 P99 耗时长期在 800ms 以上,大促时甚至冲到 2.3 秒。

我没有直接 DROP INDEXADD INDEX,那样在 8.0.36 里虽然能在线做,但依然会触发短暂的行锁和负载抖动。我用了 MySQL 8.0 引入的 Invisible Indexes 特性,先建新索引并设为不可见,验证优化器行为后再切换。

具体操作流程如下:

-- 环境:MySQL 8.0.36,表 orders,数据量 1.2B 行 -- 1. 创建新的联合索引,直接设为 INVISIBLE -- 为什么?新索引建好后默认会被优化器考虑,如果直接可见,可能导致正在跑的生产查询瞬间切换执行计划,风险不可控 CREATE INDEX idx_status_ctime ON orders (status, create_time) INVISIBLE; -- 2. 确认索引已存在且不可见 SELECT INDEX_NAME, IS_VISIBLE FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = 'order_db' AND TABLE_NAME = 'orders' AND INDEX_NAME = 'idx_status_ctime'; -- 3. 针对核心查询强制使用新索引进行测试 -- 这是一条典型的用户订单分页查询,原语句走 old_index 需要扫描 12 万行 EXPLAIN ANALYZE SELECT * FROM orders FORCE INDEX (idx_status_ctime) -- 手动指定新索引 WHERE status = 2 AND create_time >= '2024-10-01' AND create_time < '2024-11-01' ORDER BY create_time DESC LIMIT 20; -- 4. 观察 EXPLAIN ANALYZE 的实际执行数据 -- 实际测试:新索引扫描行数降到 800 行,执行时间从 750ms 降到 110ms -- 确认无误后,切换索引状态:旧索引隐藏,新索引生效 ALTER TABLE orders ALTER INDEX idx_status_ctime VISIBLE; ALTER TABLE orders ALTER INDEX old_index INVISIBLE; -- 5. 观察生产监控 30 分钟,确认 QPS 和耗时正常后,再删除旧索引 -- 此时删除操作仅修改元数据,无阻塞风险 DROP INDEX old_index ON orders;

除了索引重构,我们还遇到了一个大表关联的问题。订单表要关联用户表做实时报表,用户表有 5000 万数据。原来的嵌套循环关联(Nested Loop Join)在缺少合适索引时直接拖垮了从库 CPU,利用率飙到 95%。

MySQL 8.0 引入的 Hash Join 救了场。我们当时的查询是:

-- 场景:实时报表,关联订单和用户信息 -- 原语句:SELECT ... FROM orders o JOIN users u ON o.user_id = u.id WHERE o.status = 1 -- 在 8.0.36 中,如果关联字段没有索引,优化器会自动选择 Hash Join -- 我们可以验证优化器的选择 EXPLAIN FORMAT=TREE SELECT o.id, o.order_sn, u.user_name FROM orders o JOIN users u ON o.user_id = u.id WHERE o.status = 1 AND o.create_time >= '2024-11-01' LIMIT 1000;

EXPLAIN 输出里出现了 hash join 字样。我们实测发现,对于这个 12 亿行关联 5000 万行的无索引关联场景,Hash Join 的执行时间比原来的全表嵌套循环快了 15 倍(从 45 秒降到 3 秒)。

但我发现 Hash Join 不是银弹。如果关联的驱动表(orders)过滤后的数据量很小(比如只有几千行),优化器有时还是会选 Nested Loop。这时候我会用 Optimizer Hints 强制一下,不过这种情况很少见,8.0 的优化器在大多数情况下判断很准。

这次重构让我意识到,Invisible Indexes 最大的价值不是性能提升,而是变更安全性。在 8.0.36 里,你可以先建好索引,在业务低峰期通过切换可见性来完成索引替换,整个过程对线上查询的影响窗口只有几毫秒的元数据锁时间,比传统的先删后建稳得多。

5. 深度剖析:UUID 与自增 ID 对 B+ 树碎片及插入性能的影响对比

我们公司的用户系统早期是外包团队做的,主键用了 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。

我在实际迁移时,为了验证碎片情况,跑了这段查询:

-- 查看 orders 表的主键索引碎片情况 -- MySQL 8.0 可以通过 information_schema 查看统计信息 SELECT TABLE_NAME, ENGINE, TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH, DATA_FREE -- 这个字段代表碎片空间,UUID 表这里会非常大 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'user_db' AND TABLE_NAME = 'user_profile'; -- 更直观的:通过 mysqlcheck 检查碎片 -- 命令行执行:mysqlcheck -u root -p --analyze user_db user_profile -- 或者直接查索引的统计信息 SHOW INDEX FROM user_profile; -- 在 Cardinality 和 其他统计信息里,如果数据分布极不均匀,且伴随大量 DATA_FREE,基本就是随机主键导致的

如果不做改造会怎样?

我们当时那个 UUID 用户表,因为碎片率高达 35%,同样的数据量占用了近乎两倍的磁盘空间。更要命的是,查询时即使走了索引,由于数据页的物理存储不连续,磁盘预读(Read-Ahead)基本失效,每次读取都伴随着大量的随机 IO。特别是在做全表扫描或者大范围查询时,延迟比自增主键慢了 3 倍不止。

后来我们做主键迁移,采用了“影子表”方案。大致步骤如下:

-- 1. 创建新表,使用 BIGINT 自增主键 CREATE TABLE user_profile_new ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_uuid VARCHAR(36) NOT NULL, -- 保留原 UUID 作为业务唯一标识 username VARCHAR(50), -- ... 其他字段 created_at TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_uuid (user_uuid) -- 给原 UUID 加唯一索引,保证业务兼容 ) ENGINE=InnoDB; -- 2. 分批迁移数据(利用自增 ID 的顺序性,插入性能很高) -- 这里用脚本分批跑,每批 5000 条,避免长事务 -- INSERT INTO user_profile_new (user_uuid, username, ...) SELECT user_uuid, username, ... FROM user_profile ORDER BY create_time LIMIT 5000 OFFSET 0; -- 3. 数据追平后,在低峰期切换表名 RENAME TABLE user_profile TO user_profile_old, user_profile_new TO user_profile;

迁移完成后,同样是 3000 万数据,表文件大小从 52GB 降到了 28GB,插入 TPS 从 800 提升到了 2400。

我的判断是:除非你有极强的分布式 ID 需求且数据量不大(千万级以内),否则在 MySQL 的核心业务表里,尽量用自增 BIGINT 做主键。如果你非要用 UUID,至少要用 MySQL 8.0 支持的 有序 UUID(比如基于时间排序的 UUID v7),或者使用 UUID_TO_BIN(uuid, true) 转成二进制存储,减少碎片和空间占用。

6. 慢查询排查全流程:从监控告警到 Kill Session 的线上救火经验

2024 年 3 月,我们线上一个 SaaS 系统突然告警,数据库 CPU 使用率冲到 100%,接口响应时间从 50ms 暴涨到 5 秒以上。那个库跑在 MySQL 8.0.36 上,数据量大约 2 亿行。

我介入排查时,第一件事不是看代码,而是先看 MySQL 的慢查询日志正在运行的线程

第一步:定位凶手

我直接连上数据库,运行了下面这个查询,用来抓当前正在跑的“坏家伙”:

-- 查找执行时间超过 2 秒且正在运行的线程 -- 这是在 8.0.36 里最管用的实时监控 SQL SELECT id, user, host, db, command, time, -- 执行时间(秒) state, info -- 具体的 SQL 语句 FROM information_schema.processlist WHERE command != 'Sleep' AND time > 2 AND info IS NOT NULL ORDER BY time DESC LIMIT 10;

结果抓到了一条正在跑了 45 秒的 SQL,内容是这样的:

SELECT * FROM tenant_logs WHERE tenant_id = 123 AND DATE(create_time) = '2024-03-15' AND level = 'ERROR';

第二步:分析原因

这条 SQL 看起来没啥问题,但我一看 DATE(create_time),心里就有数了。这就是典型的索引失效陷阱

WHERE 子句里对索引列做函数运算(DATE()),优化器会放弃使用索引,导致全表扫描。这个表有 2 亿行,全表扫一次,CPU 不炸才怪。

我当时先用 EXPLAIN 确认了一下:

EXPLAIN SELECT * FROM tenant_logs WHERE tenant_id = 123 AND DATE(create_time) = '2024-03-15' AND level = 'ERROR';

输出结果里 typeALLrows 是 1.98 亿。确认无疑,全表扫。

第三步:紧急止血

这时候如果去改代码发版,至少得 10 分钟,用户早炸了。我直接干掉了这个慢查询会话:

-- 假设上面查到的 id 是 89765 KILL 89765;

杀掉会话后,CPU 瞬间从 100% 掉到了 40%。但这只是止血,根本问题没解决,只要这个请求再来一次,还得炸。

第四步:临时修复与验证

我得马上给这个查询找个临时出路。我注意到 tenant_idlevel 是有索引的,但 create_time 被函数包住了。我临时改写了查询逻辑(通过公司内部的数据访问层动态改写,或者直接在数据库层面利用 Optimizer Hints 强制走索引,虽然这里函数索引没建,但可以用范围查询替代):

-- 改写后的 SQL,去掉 DATE() 函数,利用范围查询走索引 -- 这是当时线上临时救急用的版本 SELECT * FROM tenant_logs WHERE tenant_id = 123 AND create_time >= '2024-03-15 00:00:00' AND create_time < '2024-03-16 00:00:00' AND level = 'ERROR';

为了保险起见,我先在从库上用 EXPLAIN ANALYZE 跑了一下,确认 rows 降到了 2000 行,耗时 20ms。

EXPLAIN ANALYZE SELECT * FROM tenant_logs WHERE tenant_id = 123 AND create_time >= '2024-03-15 00:00:00' AND create_time < '2024-03-16 00:00:00' AND level = 'ERROR';

第五步:根本解决

救火结束后,我复盘发现,这个查询模式很常见。为了彻底解决,我在 MySQL 8.0.36 上利用 Functional Key Parts(函数索引) 特性,直接给 DATE(create_time) 建了个索引。

-- 针对这种特定函数查询的优化 -- MySQL 8.0 支持函数索引,可以直接索引表达式的结果 ALTER TABLE tenant_logs ADD INDEX idx_func_date_ctime ((DATE(create_time))), ALGORITHM=INSTANT; -- 利用 8.0 的 Instant DDL,秒级完成,不锁表

用了 ALGORITHM=INSTANT,这个 2 亿行的表加索引只花了 0.2 秒,业务完全无感知。

这次救火给我的教训是

站长实战手记

去年双十一大促前,我们那个日订单量刚破 50 万的电商系统,凌晨两点突然报警,数据库 CPU 飙到 98%。我顶着黑眼圈爬起来,盯着那个慢查询日志,发现是一条看起来人畜无害的 SQL:

SELECT * FROM orders WHERE user_id = 123 AND status IN (1, 2, 3) ORDER BY created_at DESC LIMIT 10;

当时表上有 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%。后来新系统我全换回了雪花算法,既有序又分布式,省心太多了。

最后啰嗦一句:别在开发环境对着几百条数据去测索引性能。很多索引失效和性能瓶颈,只有在百万级数据量加上真实业务流量下才会露馅。多去线上看看慢查询,那才是你技术成长最快的地方。