KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

09 · 索引变更的验收:写侧代价与前后对照 — keel 龙骨

这一章回答:索引加对了之后,怎么证明它真的在起作用、又怎么知道自己付了多少。

这一章回答:索引加对了之后,怎么证明它真的在起作用、又怎么知道自己付了多少。

前八章都在回答"怎么改":怎么读计划、怎么排字段、哪些写法会让索引失效、大表怎么变更。这一章回答最后一个问题——改完之后拿什么说这次改动是成功的。

在真实团队里,索引变更是这样翻车的:

列表页 P99 从 800 ms 降到 40 ms,上线成功。三天后上游的订单导入任务从 8 分钟涨到 34 分钟。

改动本身没错,读确实变快了。错在验收只看了读这一侧。

所以一次索引变更要验收三件事,缺一件就不算完成:

  1. 读是真的变快了(用页数,不是用感觉);
  2. 写要付多少(这是加法,必须写进变更单);
  3. 有没有连带影响(别人会不会变慢)。

这三件事的做法和判据是本章的全部内容。至于怎么选列、怎么排查失效、大表上用什么工具做 Online DDL,分别是 02、03、04 章的事,本章不重复。

本章实测出自 PostgreSQL 16.15(Windows,页大小 8 KB)。之所以用它取证,是因为本章要讲清的"更新为什么有时不碰索引"这个机制在它这里有一个可读的计数器;换到别的引擎,结论的结构一样,观测手段和名字不同(见第三节末的对照表)。

一、读侧验收:凭据是页数,不是毫秒

毫秒是所有验收指标里最不可靠的一个。同一套 SQL,跑第一次和跑第二次能差一个数量级——因为区别不在 SQL,在页在不在缓存里。

所以读侧验收的凭据要落在"这次查询碰了多少个页"上,它由数据分布和索引结构决定,不受当时的负载和缓存状态影响(只影响 hit 与 read 的分配,两者加起来是稳定的)。

未建索引:Buffers: shared hit=14775 read=10225   → 25000 页
建索引后:Buffers: shared hit=1     read=3       →     4 页

这两行才是"改对了"的证据。 耗时(347.865 ms → 0.050 ms)只是它的一个后果。

验收要点:

这条判据不只用在变更后,也用在决定要不要改的时候:先量出比值,再决定值不值得付下面第二节的代价。

二、写侧代价:多一个索引,到底多付多少

"索引会带来写放大"这句话人人会说,但它几乎从不被量化,于是"要不要加这个索引"的讨论就永远停在感觉上。下面把它量化。

同一份数据(20 万行,行内容完全一样)、同一台机器,只改索引数量,各插一次:

附加索引数 插入耗时 写入的 WAL 索引占用空间
0(只有主键) 1130 ms 35 MB 4408 kB
1 1708 ms 51 MB 6216 kB
3 4640 ms 97 MB 27 MB

(这份数字复跑过一次,耗时差异在 15% 以内、WAL 差异在 10% 以内、索引体积完全一致。)

读出来的东西有三个,第三个最容易被忽略:

① 三个索引让插入慢到 4.1 倍,WAL 涨到 2.8 倍。 这不是"慢一点",是一次主键写放大的量级变化。

② 边际成本不是常数。 0 → 1 多付了 578 ms,1 → 3 多付了 2932 ms(每多一个约 1466 ms)。因为第三个索引建在一个 md5 文本键上,键更宽、树更深、每页装的条目更少。"每多一个索引增加固定百分比"是错的,成本取决于键的宽度。

③ 看 WAL,不只看着 SQL 的耗时。 在这次变更里真正会伤到别人的,是 WAL 从 35 MB 涨到 97 MB:

SQL 的耗时只影响发起写入的那个进程;WAL 的量影响整条复制链路。只盯着"导入任务慢了多少秒"会低估这事。

一个必须写进验收纪律的坑:这类测量要跑两趟

上面这张表如果只跑一趟,数字是不可信的。实测第一次跑和第二次跑的同一件事:

第一趟:  89 MB / 108 MB / 103 MB
第二趟:  48 MB /  74 MB /  66 MB      ← 同一批表、同一批更新

差一倍。原因是 checkpoint 之后,每个页的第一次修改要先写一整页的镜像进 WAL。第一趟承担了这些整页写,第二趟没有。

所以:任何以 WAL 为指标的测量,都要先跑一趟丢掉,报第二趟的数。 只用耗时做指标时没有这个问题,但那又会回到"毫秒不可靠"。这个坑本身就是"验收纪律"存在的理由——没跑对的东西不要写成跑对了。

三、更新路径:为什么有时不维护索引

插入的代价很好理解:新行要进每一棵树。更新的代价就有点反直觉了——有时候更新 20 万行,一个索引条目都不用写。

这件事决定了一个很实际的设计取舍:一列该不该进索引。实测:三张结构相同的表,各更新 20 万行,只改两个变量(更新的列在不在索引里、页内有没有空位)。

条件 更新列是否在索引里 页内是否有空位 HOT 比例 WAL
A 否 有(fillfactor=70) 54.0% 48 MB
B 否 无(fillfactor=100) 0.0% 74 MB
C 是 有(fillfactor=70) 0.0% 66 MB

(HOT = heap-only tuple,"只留在堆里的新版本"。它表示这一行的更新没有动到任何索引。)

三条结论,都是从上表直接读出来的:

① 更新的列在索引里,就必然要维护索引(C:0.0%)。 即使页内有空位、新版本能放在同一页,索引条目也必须新增一条——因为键变了。这条最实用:把一列放进索引,不是"只在查询时付成本",而是在每一次更新它时都付。

② 页内没有空位,HOT 就完全失效(B:0.0%,只剩 19 次)。 新版本放不进原来的页,就只能去新页,行引用变了,所有索引都得更新。

③ fillfactor 留出的空位只买到有限的几次 HOT(A:54.0%,不是 100%)。 页内空位会在反复更新中被吃掉,吃满之后就退回到 B 的行为。所以"我设了 fillfactor 就应该全走 HOT"是错的期待——它只是把 HOT 的窗口往后推了一段。

再看 WAL 那一列:48 / 66 / 74 MB。把两件事拆开就清楚了:

48 MB  A:新版本同页 + 无索引列变化     → 两件都满足
66 MB  C:新版本同页 + 索引列变化       → 不满足索引那一条
74 MB  B:新版本换页 + 无索引列变化     → 不满足页内空间那一条

换到另一个引擎:为什么有些库根本没有 HOT

这不是"谁的实现更好",而是原理章那张"行引用存什么"的表的直接后果:

二级索引里存的行引用 非索引列更新时,二级索引要不要动 于是需不需要 HOT
PostgreSQL 物理位置(页号 + 槽号) 要——行换了位置,旧引用就失效了 需要,用它来避免"只为挪个位置就重写所有索引"
InnoDB 主键值 不要——主键没变,指向关系就没变 不需要

所以:"一级索引存的是键还是地址"这一个设计选择,同时决定了读路径(回表要不要多走一次)和写路径(更新要不要维护索引)。 同一件事在两边都成立,只是名字和观测手段不同。

四、连带影响:同一次变更,还有谁会受影响

写路径只是其中一个面。开工前应该把受影响面列出来,逐条问"我要怎么验证它没坏":

受影响面 为什么会受影响 怎么验证
写入吞吐 每行要进更多的树 第二节那张表:插入耗时、WAL 体积
主从复制延迟 WAL 量上涨,从库要重放的量跟着涨 复制延迟曲线;WAL 体积前后对比
别的查询的执行计划 新索引进入优化器候选,别人可能换计划 把该表上的高频 SQL 全部跑一遍 EXPLAIN,前后对照
磁盘与备份 索引是等比增长的副本 表 + 索引总体积、单次全备体积
统计信息 新索引需要自己的统计,采样窗口可能还没到 依赖该索引的 SQL 是否出现"计划抖动"

其中"别的查询换计划"是唯一可能让读也变慢的一项。 加索引一般让读变快,但它改变了优化器的选择空间——原本走索引 A 的查询,现在可能改走索引 B,而那条路径对它是更差的。验证办法很朴素:把该表上的高频 SQL 列出来,前后各跑一遍 EXPLAIN,逐条比对计划有没有变。 别只测你优化的那一条。

五、上线前:采样验证能证明什么、不能证明什么

生产上通常不允许你拿全量数据预演,于是大家在测试库上用 10% 的采样数据验证。这件事能证明一半,另一半会骗你。

能证明的:这个索引能不能被规划器用上(计划里出现预期的访问方式,不再是全表扫描)、扫描页数与返回行数的比值是否有改善。这些是结构性的,10% 的数据上做出来的结论可以迁移。

不能证明的:上线后的绝对耗时。行数变了,优化器的估算就变了——在 10 万行上它选索引,在 1000 万行上它可能就选全表扫描(反过来也可能)。更别说缓存命中率和 IO 延迟完全不同。

所以采样的正确用法是:

采样数据上验证:能不能用上、比值有没有改善   → 可以下结论
采样数据上断言:上线后会快 N 倍              → 不能
绝对耗时与计划的生产形状:只能在上线后实测,或用一个与生产同量级的影子环境

这条是真话,而且它是索引变更最常翻车的地方:测试环境结论漂亮,上线之后计划变了,性能没有改善甚至退化。所以上线的验收必须包含"计划有没有按预期变"这一项,而不是只有耗时。

六、上线后的验收单

跨过一个业务高峰(至少包含一次周期任务,比如对账或批处理),然后按这七项填:

# 验收项 判据 不通过怎么办
1 目标查询的计划 出现了预期的索引访问方式 先查统计信息与条件写法(03 章)
2 目标查询的页数比值 明显下降,且返回行数没变 比值没改善说明索引没被用上,回滚
3 目标查询的 P50 / P99 跨过高峰后仍然改善 只在低峰好是缓存效应,不算通过
4 写入耗时与 WAL 与变更前对比,涨幅在预期内 超预期说明索引收益不抵代价,回滚
5 复制延迟 峰值不超过变更前的水平 需评估是否削峰或延后变更窗口
6 该表上其它高频 SQL 计划无翻转,或翻转后不劣化 发现劣化要定位到是哪条 SQL、哪个索引
7 回滚路径 重建/删除语句已备好,且预估过耗时 没有回滚路径就不该上线

"有没有副作用"和"有没有变快"是同一张单子上的两项。 只填第 2、3 项,就是开头那个故事。

七、回滚:两种方向,成本差很远

变更 回滚动作 代价
加了一个索引 删掉它 小:写路径立刻恢复,读路径退回到变更前
删了一个索引 把它建回来 大:索引要在线上重建,期间占 IO 与磁盘,且重建期间查询一直是慢的

所以:

一次索引变更的完整回合

flowchart TD
    S["① 量出基线:扫描页数、返回行数、耗时、WAL"] --> NEED{"② 比值差<br/>且读写比支持加索引?"}
    NEED -- 否 --> STOP["不改:收益不抵写侧代价"]
    NEED -- 是 --> SAMPLE["③ 采样数据预演<br/>只验证「能不能用上」"]
    SAMPLE --> OK{"④ 计划里出现预期的索引访问?"}
    OK -- 否 --> FIX["先查条件写法与统计信息<br/>见 03 章"]
    FIX --> OK
    OK -- 是 --> PLAN["⑤ 写变更单:回滚语句 + 受影响面清单"]
    PLAN --> ONLINE["⑥ 上线(在线变更走 04 章)"]
    ONLINE --> WATCH{"⑦ 跨过一个业务高峰后验收<br/>七项全过?"}
    WATCH -- 是 --> DONE["收口:记录前后数据,归档变更单"]
    WATCH -- 否 --> RB{"哪一项没过?"}
    RB -- "读没改善 / 写超预期" --> ROLLBACK["回滚:删掉新索引<br/>(成本低,立刻执行)"]
    RB -- "别的 SQL 计划翻转" --> TUNE["定位受影响 SQL<br/>调整索引或改写查询"]
    style S fill:#e3f2fd,color:#0d47a1
    style DONE fill:#e8f5e9,color:#1b5e20
    style STOP fill:#fff3e0,color:#e65100
    style ROLLBACK fill:#ffebee,color:#b71c1c

生产边界

动手:可观察结果

产出 判断标准
一份读侧基线 目标 SQL 的扫描页数、返回行数、比值、Rows Removed by Filter,前后各一份
一份写侧代价表 同数据、同机器,0/1/3 个索引下的插入耗时与 WAL;且知道要跑两趟取第二趟
一次 HOT 实验 能用计数器说明"HOT 比例"分别受哪两个条件控制
一张受影响面清单 写出该表上全部高频 SQL,并逐条给出验证办法
一份变更单 含回滚语句、预估回滚耗时、七项验收表、观察窗口
一次回滚演练 加索引后按变更单回滚,确认写侧指标回到基线

完成标志:给定一次已上线的索引变更,你能在不看内容的情况下说出"它应该提供了哪几份数据",并指出缺哪一份会让这次验收失效。

故障注入

注入方式 观察
只测目标查询,不测该表上其它 SQL 是否有别的查询换了计划、或耗时上升
WAL 测量只跑一趟 第二趟数字与第一趟差多少(判断结论是否被整页写污染)
把 fillfactor 设成 70 后连做三轮更新 HOT 比例是否逐轮下降、最后是否退回到 0
更新一列刚被加进索引的列 更新耗时与 WAL 相对"该列不在索引里"时的变化
在 10 万行采样上验证计划,再去生产看 计划形状是否一致;不一致时结论还成立吗
观察窗口只覆盖工作日 是否把月末任务专用的索引判成"未使用"
验收只看耗时 P99 写侧与复制延迟是否被漏掉

自测题

  1. 为什么读侧验收要用"扫描页数 ÷ 返回行数"而不是耗时?这个比值为什么可以跨机器比较?
  2. 实测里 0 → 1 个索引插入慢 51%,1 → 3 个索引又多慢 172%。为什么边际成本不是常数?
  3. 什么时候更新一行可以完全不动索引?列出必须同时满足的条件,并说明 fillfactor 为什么只能推后而不能保证它。
  4. 为什么 InnoDB 没有 HOT 也不需要 HOT?这个差别源自哪个设计选择?
  5. 用 10% 采样数据预演索引变更,哪些结论可以迁移到生产、哪些一定不行?为什么?
  6. 加索引和删索引,哪个的回滚更贵?各自应该在变更单里留下什么?
  7. 一次索引变更的验收七项里,哪一项最容易被跳过而不被发现?跳过它的后果是什么形态?

现在能解释什么

上一章:08 热点、削峰与一致性收口。索引的原理部分在 《数据库与缓存调优》00 章。

进入 keel 阅读