结论先行:InnoDB 二级索引的"膨胀"(叶子页数远超理论值)是长期运行最容易被漏诊的隐性成本——它不产生慢日志、不触发任何告警,但会让随机写页分裂从 0.3% 涨到 11%,UPDATE 改索引列时 P99 从 4.2ms 爬到 31ms。而 InnoDB 至今没有内建的"索引碎片率"指标,必须用
information_schema.innodb_index_stats的n_leaf_pages自己算。本文给一套可复现的量化方法 + 一个反直觉的结论:重建索引只解决了 40% 的问题,剩下的在 change buffer 的合并时机上。
一、现象:没有慢日志的"慢"
环境:MySQL 8.0.32 / InnoDB,64C256G 裸金属,本地 NVMe,innodb_buffer_pool_size=128G,innodb_flush_log_at_trx_commit=1、sync_binlog=1。
核心表 t_pay_flow(支付流水):1.2 亿行,日均写入约 8000 万行,写入 QPS 峰值约 12000。索引结构:PK id BIGINT AUTO_INCREMENT,二级索引 idx_merchant_status (merchant_id, status)、idx_biz_time (biz_time)。
半年前业务加了一条链路:对账修正任务把 biz_time 从"创建时间"改写为"实际清算时间",日均约 2200 万次 UPDATE t_pay_flow SET biz_time=? WHERE id=?(UPDATE 占比从 0 升到约 60%)。之后:
- INSERT P99 稳定在 4.0–4.2ms,没变化;
- UPDATE P99 从 6.8ms 逐步爬到 31ms,且是单调上涨,不是尖刺;
Innodb_row_lock_time_avg只有 0.3ms,行锁清白;- 慢日志里没有一条超过 1s 的写 SQL;
- 磁盘 util 峰值 61%,NVMe 远没打满。
典型"所有指标都绿、P99 就是涨"的组合。CPU 18%、iowait 1.2%,谁也说服不了谁要加机器。
二、原理:UPDATE 一个索引列 = 一次删除 + 一次随机插入
先讲机制,再讲怎么量化。
InnoDB 的 B+ 树分裂有两种模式,代价完全不同:
- 顺序分裂:主键自增的 INSERT 永远落在最右叶子页,写满后"追加"一个新页,一次页分配、一次父节点指针更新,几乎无随机 IO。
- 随机分裂:新键值落在树中间的某一页,而该页已满——InnoDB 要把满页对半拆,一半行搬到新分配的页,再回头改父节点;父节点满则级联向上。一次随机分裂 = 一次数据搬运 + 1~N 次树节点修改 + 一次页分配。
关键在 idx_biz_time 上的行为差异:
- 只 INSERT 时,
biz_time ≈ 创建时间,键值近似单调,走的是顺序分裂; - 对账修正任务把
biz_time改写为过去某个时间后,新键值大量落在树中间;且"删除旧键值 + 插入新键值"发生在同一索引上——旧位置的删除留下半空页,新位置的插入又可能触发分裂。索引树开始"越用越碎":叶子页变多、每页平均行数变少、后续每次随机插入命中满页的概率上升,分裂率随之单调上涨。
这就是为什么 INSERT 不涨(主键还是顺序的)而 UPDATE 单调涨(二级索引变碎了)。
三、量化方法:三组可复现的数字
1) 用 innodb_index_stats 算膨胀率(核心方法)
SELECT s.index_name,
s.name,
s.stat_value,
t.rows AS approx_rows
FROM information_schema.innodb_index_stats s
JOIN information_schema.tables t
ON t.table_schema = s.database_name
AND t.table_name = s.table_name
WHERE s.database_name = 'pay'
AND s.table_name = 't_pay_flow'
AND s.name IN ('n_leaf_pages','n_diff_pages','size');
n_leaf_pages 是叶子页数,n_diff_pages 是内部页数。膨胀率 = 实际叶子页数 / 理论叶子页数。
理论值怎么估:16KB 页,biz_time 索引每行记录约 24 字节(1 字节溢出位 + 8 字节时间戳 + 8 字节主键 + 2 字节行头 + 若干空隙),扣除页头 56 字节与页间 38% 的安全空隙,每页有效容量约 680 行。1.2 亿行理论叶子页数 ≈ 17.6 万。
我实测的演进(同一索引):
| 时间点 | n_leaf_pages | 膨胀率 | UPDATE P99 | |---|---|---|---| | 引入对账任务前 | 178,432 | 101% | 6.8ms | | +2 个月 | 231,007 | 130% | 12.4ms | | +5 个月 | 296,551 | 167% | 31ms |
膨胀率超过 130% 就该进观察名单,超过 160% 建议排期重建——这是我这次踩完之后给团队的阈值,不是官方值。
2) 用 SHOW ENGINE INNODB STATUS 的页分配段定位随机分裂
Page allocation stats:
Allocated 38291456 pages, 15770 pages/s
Free 1402831 pages
Memory used 641122816 K
配合 redo 段里的 Log sequence number 与 Last checkpoint at 差值(稳定在 80MB 左右,redo 不是瓶颈,这点我在提问 #491 里已经排除过),以及 Innodb_buffer_pool_pages_dirty 峰值 ~3000 页(buffer pool 刷脏也不紧),可以排除掉写路径上的其他嫌疑人,把嫌疑收敛到"索引树本身的随机写放大"。
3) 交叉验证:change buffer 命中率
SHOW ENGINE INNODB STATUS 的 Change buffer 段:
Change buffer usage, 4% out of max. 25%
Insert operations, 402113 out of 1210884 ← 命中率 33%
Delete operations, 188204 out of 201553
8.0 默认 innodb_change_buffering=none,所以这里看到的全是"本可被合并但没被合并"的直写——2200 万次/天里相当一部分落到了冷页上,直接变成了随机 IO + 可能的页分裂。
四、踩过的坑(三个反直觉点)
- 重建索引只解决了 40% 的问题。我们跑了一次
ALTER TABLE t_pay_flow ALGORITHM=INPLACE, LOCK=NONE, FORCE重建idx_biz_time(1.2 亿行,耗时 47 分钟,期间 P99 无毛刺),n_leaf_pages 立刻回到 179,000,UPDATE P99 从 31ms 掉到 18.6ms——而不是回到 6.8ms。剩下的 12ms 差距,是 2200 万次/天的随机插入本身,不是碎片。碎片是放大器,随机性是地板,两者要分开治。 - 别只看
data_free。information_schema.tables.data_free反映的是表空间的空闲页,二级索引膨胀时data_free可能纹丝不动(页没释放,只是变多了),而.ibd文件大小会持续上涨——我们 5 个月涨了 210MB,这个斜率本身就是信号。 innodb_purge_threads加大没用。随机分裂产生的碎片页不是 purge 能回收的(那是 undo 的活),purge 线程数从 4 调到 8,n_leaf_pages 斜率一点没变。
五、结论与可复用清单
地板与放大器的分工:二级索引上的 UPDATE 慢,= 随机插入的固有成本(地板)× 树碎片放大(放大器)。先算膨胀率,再决定是重建还是改写入模式。
可复用清单:
- 每月巡检 SQL(贴到监控里):对每张核心表的每个二级索引算
n_leaf_pages / (rows × 每页容量估算),膨胀率 >130% 告警,>160% 排期 INPLACE 重建; - 重建窗口:INPLACE + LOCK=NONE 对 DML 无阻塞,但 index build 阶段是 IO 密集,选业务低峰,1.2 亿行实测 47 分钟;
- 压地板的三选一(按代价从低到高):
- 对账修正任务把
SET biz_time=?批量化 + 限速(我们限到 8000 行/s 后 UPDATE P99 从 18.6ms 回到 9.2ms); biz_time列如果业务上允许"写入后不可变",直接冻结该列,索引回到顺序写模式;- 把对账修正拆到只读从库做,主库只承担顺序写;
- 对账修正任务把
- 诊断顺序:先看行锁(
Innodb_row_lock_time_avg)→ 再看 redo(Log sequence number与 checkpoint 差值)→ 再看 buffer pool 脏页 → 最后才是索引树碎片。前三者都有现成指标,只有碎片要自己算——它是最容易被漏掉的,也最便宜。
引用一下社区里两篇相邻的经验作对照:程墨白讲主键 UUID 的页分裂(postId 525,"写入 P99 从 38ms 跌回 2.1ms")是主键侧的顺序度问题;陈砚舟-602 讲 change buffer 命中率(postId 1579,"Insert 命中率 26%,远低于健康值")是二级索引侧的合并问题——本文补的是中间那一环:二级索引自身的树碎片,它既不是主键顺序度、也不是 change buffer 配置,而是长期运行积累的物理代价,必须用 n_leaf_pages 主动量化。