2993 字
约 9 分钟
17
S11-A17 · 索引越多越慢:写入代价与膨胀的账
2024-12-31
2026-11-09

为了优化一个慢查询,给订单表加了三个索引。查询从 800 毫秒降到 40 毫秒,然后没人再提起这件事——直到半年后批量导入任务从四十分钟变成两小时。翻出来看,这张表上有十一个索引,其中四个从上线起就没有任何查询用过。

索引不是免费的加速器,它是用写入换读取的一次交易。这笔交易在加索引的那一刻就成立了,但账单不在那一刻结算:它分散在之后每一次写入、每一次批量导入、每一份存储里,因此长期不被看见。


1. 一个索引的三笔代价

每建一个索引,代价分三处结算,只有第一处会被立刻感知。

代价 结算时机 是否被看见
写入放大:每次 INSERT/UPDATE/DELETE 要多维护一棵 B+ 树 每次写入 单条操作看不出,批量写时才显形
写放大还有个二阶效应:索引越多,单次事务产生的 redo 与 undo 越多,缓冲池脏页回刷的压力越大。表现出来往往不是"写变慢",而是主从延迟变大、检查点更频繁——这两个症状很少被追溯到索引上。

| 存储放大:索引本身占空间,通常接近被索引列的规模 | 建索引时一次性 | 只有看磁盘账单时可见 |
| 优化器选择变差:可选的执行路径变多,选错的概率上升 | 查询时随机 | 表现为"偶发的慢查询",最难归因 |

写入放大是最容易被低估的一项。单条 INSERT 多维护三个索引,耗时可能只多几十微秒;但一次 5 万行的批量导入,放大倍数会接近线性叠乘,而且它同时把 redo 日志、缓冲池脏页、主从延迟一起推高。批量导入从四十分钟变成两小时,不是某个索引坏了,而是十一个索引的正常成本。

一次批量导入前后各测一次,比任何估算都准:

// 导入前记录一次基准,导入后再测一次,两次之间只允许索引数变化
time mysql -e "LOAD DATA LOCAL INFILE '/tmp/orders.csv' INTO TABLE orders
  FIELDS TERMINATED BY ',' IGNORE 1 LINES"

// 同时看两个容易被忽略的数
mysql -e "SHOW GLOBAL STATUS LIKE 'Innodb_rows_inserted'"
mysql -e "SHOW SLAVE STATUS\G" | grep -E "Seconds_Behind|Running"
// 耗时上升而 inserted 行数不变,才是索引带来的净成本
// 把"写入代价"从直觉变成数:单表索引数与批量导入耗时的对照
SELECT t.table_name,
       COUNT(s.index_name)                        AS idx_cnt,
       ROUND(SUM(s.stat_value) / 1024 / 1024, 1)  AS idx_mb
FROM information_schema.tables t
JOIN information_schema.statistics s
  ON s.table_schema = t.table_schema AND s.table_name = t.table_name
WHERE t.table_schema = 'app'
GROUP BY t.table_name
ORDER BY idx_cnt DESC
LIMIT 20;
// idx_cnt > 8 且 idx_mb 接近表本身时,先按 §2 找冗余,再谈加索引

2. 复合索引的列顺序与冗余索引

复合索引 (a, b, c) 能被 a、(a,b)、(a,b,c) 三种条件复用,但不能被 b、(b,c) 复用。这条规则人人知道,但落到评审现场,真正的浪费来自另一个方向:前缀重复。

已有索引 新提出的索引 是否冗余 原因
(user_id, status) (user_id) 冗余 前者是最左前缀的超集
(user_id, status) (status) 不冗余 状态列不是最左前缀
(a, b) (b, a) 不冗余但低效 两条都能用,通常留一条即可
(a, b, c) (a, c) 冗余 a 后跳过 b 无法命中,但 (a,c) 的需求多由 (a,b,c) 覆盖大部分场景
(a) (a) WHERE deleted = 0 视场景 部分索引体积小得多,可替代全量索引

冗余索引在评审时极难看出来,因为它需要把新索引和全表已有索引逐个比对,而评审者通常只看到这次改动涉及的几个文件。可行的做法是把比对交给工具:读 information_schema.statistics 或 SHOW INDEX,按最左前缀做包含判断。

// 冗余索引粗筛:对每张表列出"被其他索引以最左前缀包含"的索引
mysql -N -e "
SELECT table_name, index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) cols
FROM information_schema.statistics
WHERE table_schema='app'
GROUP BY table_name, index_name" \
| awk '{k=$1; c[k]=c[k] " " $3; n[k]=n[k] " " $2}
END {for (t in c) {split(c[t],a," "); for (i in a) for (j in a)
  if (i!=j && index(a[j],a[i])==1) print t, "可能冗余:", a[i], "被", a[j], "包含"}}'
// 命中项逐条人工确认,部分索引与函数索引不适用此判据

部分索引是压缩索引成本的另一个方向。它把索引体积按条件比例缩减,代价是查询条件必须与索引条件一致才会命中:

// 只为未删除行建索引,体积通常只有全量索引的一个零头
CREATE INDEX idx_orders_open ON orders (status, created_at)
  WHERE deleted = 0;

// 能命中:条件里带 deleted = 0
SELECT id FROM orders WHERE deleted = 0 AND status = 'PAID' ORDER BY created_at DESC LIMIT 50;

// 命中不了:查询没写 deleted = 0,优化器无法证明与索引条件相容
SELECT id FROM orders WHERE status = 'PAID' ORDER BY created_at DESC LIMIT 50;

列顺序的选择还有个常被忽略的约束:等值列在前、范围列在后。(status, created_at) 能让"状态等值 + 时间范围"一次命中;反过来 (created_at, status) 在时间范围跨多天时几乎退化成只用第一列。

3. 膨胀:删除不回收,更新会分裂

B+ 树的页在删除后不会立刻归还。删掉一半数据,表文件大小可能几乎不变,页利用率降到三四成,扫描同样的行数要多读一倍的页。

现象 成因 重建能否解决
表很大但行数不多 大量删除后页未回收 能
扫描行数不多却读了很多页 页利用率低、页内碎片 能
查询计划突然变差 统计信息陈旧,不是膨胀 不需要重建,更新统计即可
写入集中在少数页 单调递增主键之外的随机插入 部分能,根本解法是调整主键或分区

这里有个经典的归因错误:重建索引之后查询变快,就认定是"索引碎片被清理了"。实际上重建会把数据重新读进缓冲池,第一次查询快很可能只是缓存效应。要分离两个因素,得在重建后把缓冲池清掉再测一次,或者对比重建前后的页利用率与逻辑读次数,而不是对比耗时。

4. 删索引的风险其实低于直觉

团队不敢删索引,通常的理由是"万一有查询在用"。但两件事值得分开看:

顾虑 真实风险 应对
删了之后某查询变慢 中,且可观测 先设 invisible(MySQL 8.0 支持),观察一个周期再真删
删了之后写入变快但没人注意 无风险,是收益 记录删前删后的批量导入耗时
删错导致事故 低,回滚就是重建 重建是可控操作,代价是可预期的时间
不知道有没有查询在用 高,但这是观测缺失不是删除风险 先补索引使用统计,再谈删除

真正的问题不是"删索引危险",而是没人知道哪个索引在用。多数数据库都能给出索引使用计数(MySQL 的 performance_schema.table_io_waits_summary_by_index_usage、PostgreSQL 的 pg_stat_user_indexes),但这些数据默认不采集或不看。补上这个观测之后,"哪些索引可以删"会从争论变成看数。

// MySQL 8.0:从未被读过的索引(自实例启动以来)
SELECT object_schema, object_name, index_name
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE index_name IS NOT NULL
  AND count_star = 0
  AND object_schema NOT IN ('mysql','performance_schema','sys')
ORDER BY object_name;
// 注意 count_star 自实例启动累计,重启会清零;至少观察一个完整业务周期

5. 排错:五种"加了索引反而更慢"

现象 成因 处理方向
批量导入明显变慢 索引数随表增长,写入放大叠乘 导入前临时关闭非唯一索引,导入后重建
某查询偶发变慢,重跑又正常 优化器在多索引间选错执行计划 用 FORCE INDEX 定位,再看统计信息是否陈旧
表文件持续增长但行数平稳 删除不回收 + 更新导致页分裂 评估重建,同时检查是否有高频更新的大字段
加索引后另一条查询变慢 新索引改变了优化器成本估算 对比两条查询的 EXPLAIN,必要时用索引提示
建索引期间业务卡顿 在线 DDL 仍占用 IO 与元数据锁 放到低峰,控制单次 DDL 规模,观察主从延迟

补充一条判据:排错时先问"这个慢是新出现的,还是一直如此"。一直如此的慢属于成本结构,改法是调整索引设计;新出现的慢才可能是索引坏了或被优化器绕过了。这两类问题的排查方向完全相反。

第一条最常见的误解是"导入慢是索引坏了"。索引没坏,是它的正常成本在批量场景被放大。临时关闭非唯一索引再重建,比逐条排查索引有效性更直接——但要注意这一步只在可停机或可接受期间不可检索的场景适用。

6. 边界:什么情况下多索引的代价可以忽略

场景 为什么代价低 做法
读多写极少的表(配置、字典) 写入放大几乎不发生 按查询需要加,不必严格评审
数据量小到全表扫描更快 索引的价值不存在 连索引都不必加,先确认数据量级
写入完全离线(每日批量重建的报表表) 写入成本在非高峰结算 索引可以多,但重建顺序要排
索引只服务于一条关键查询 收益明确、可验证 保留并标注归属查询,避免被误删
分区分表的单分区 单表规模被切小,页利用率稳定 膨胀问题转移成跨分区查询问题

判断口径可以压成一句:这个索引是否有一条明确归属的查询,且它的读取收益大于它对写入的放大。两条都答不上来时就该删,而不是"留着看看"。


7. 自测

  1. 一个索引的三笔代价分别在哪三个时刻结算?哪一笔最难归因?
  2. 复合索引 (a, b, c) 能被哪些条件复用?(a, c) 为什么不算在内?
  3. 重建索引后查询变快,如何排除缓存效应?
  4. MySQL 8.0 的 invisible index 在删除决策中解决的是哪个具体顾虑?
  5. 批量导入前临时关闭非唯一索引,这个操作的适用前提是什么?
  6. 为什么"数据量小到全表扫描更快"时,加索引连收益都没有?
S11-A17 · 索引越多越慢:写入代价与膨胀的账
https://www.clxhxhhr.top/posts/6378/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。