从n_tup_hot_upd看PostgreSQL索引膨胀:HOT机制深度拆解与调优实践

举报
这个DBA有点耶 发表于 2026/10/13 16:33:19 2026/10/13
【摘要】 PostgreSQL表没怎么变大,索引却膨胀了几倍——这是频繁更新的表最常见的性能退化模式。HOT(Heap-Only Tuple)更新是PostgreSQL针对这个问题的核心优化:当UPDATE不修改任何索引列,且新行能放在同一页时,可以跳过所有索引更新。但HOT有两个前提条件,任何一个被破坏,索引膨胀就会加速。本文从HOT的机制出发,拆解三个最常见的HOT失效场景,给出fillfactor调优

大家好,我是小耶,写功课只是为了我踩过的坑,你们别再踩了!

PostgreSQL有个很反直觉的现象:一张表的数据量没怎么涨,但索引文件却从几百MB膨胀到了几个GB。磁盘空间被吃掉了,查询反而越来越慢。

这个问题在频繁更新的表上尤其严重。根因就在HOT更新——PostgreSQL针对MVCC更新开销的核心优化机制。理解HOT,就能理解为什么有些表的索引会“偷偷”膨胀。

先搞懂几个词:

HOT(Heap-Only Tuple) :PostgreSQL的更新优化机制。当UPDATE不修改任何被索引引用的列,且新行版本能放在与旧行相同的堆页内时,可以跳过索引更新,只在堆内建立新旧行之间的指针。索引不需要新增条目,旧行版本也可以在日常操作(包括SELECT)中直接清理,不需要等VACUUM。

fillfactor:表的填充因子,控制建表或重建时每个数据页预留多少空闲空间。默认100(不预留)。对频繁更新的表,降低fillfactor可以为新行版本预留空间,提高HOT更新的概率。

n_tup_upd / n_tup_hot_upd:pg_stat_all_tables中的两个统计字段,分别记录总更新次数和其中HOT更新的次数。两者的比值直接反映HOT是否生效。

一、HOT的两个前提条件

HOT更新要生效,必须同时满足两个条件:

条件一:UPDATE不修改任何被索引引用的列。 包括普通B-tree索引、表达式索引和部分索引。如果UPDATE修改了任何一个索引列,该索引就必须插入新条目,HOT失效。

条件二:新行版本能放在与旧行相同的堆页内。 如果旧行所在的数据页没有足够的空闲空间容纳新行,新行会迁移到其他页,HOT也失效。

两个条件同时满足,HOT才会生效。

二、没有HOT时会发生什么

理解HOT失效的代价,才能理解为什么这个问题值得重视。

没有HOT时,每次UPDATE都需要:

  1. 在堆中插入新行版本

  2. 在所有索引中为每个新行版本插入索引条目

  3. 旧行版本和旧索引条目等到VACUUM时才被清理

如果一张表有5个索引,一次UPDATE就要在5个索引中各插入一条新记录。频繁更新的表,索引文件会以远超数据文件的速率膨胀。

更关键的是,索引膨胀后,查询需要扫描更多的索引页才能定位到目标数据,I/O成本上升,缓存命中率下降,形成恶性循环。

三、三个最常见的HOT失效场景

场景一:在频繁更新的列上建了索引

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    status VARCHAR(20),
    updated_at TIMESTAMP
);
CREATE INDEX idx_status ON orders(status);

每次UPDATE orders SET status = 'SHIPPED'都会修改idx_status索引列,HOT失效。即使status值从’SHIPPED’改成’SHIPPED’(语义上无变化),索引仍然需要插入新条目。

这是最容易被忽略的陷阱。 很多人以为“只改了一个索引列,其他索引不受影响”,但实际上PostgreSQL的判断逻辑是“只要UPDATE语句中SET子句引用了索引列,HOT就失效”,而不是“只有值真正变化时才失效”。

场景二:表达式索引和部分索引

CREATE INDEX idx_lower_email ON users(lower(email));

即UPDATE没有修改email列,但如果UPDATE语句的SET子句涉及了email列(即使值没变),表达式索引的HOT也会失效。

场景三:fillfactor=100(默认)

CREATE TABLE orders (...) WITH (fillfactor = 100);

fillfactor=100意味着每个数据页完全填满,没有为新行版本预留任何空间。即使UPDATE不修改索引列,新行也找不到位置,只能迁移到新页,HOT失效。

fillfactor 90意味着每页预留10%空间,fillfactor 70意味着预留30%空间给未来的更新。fillfactor越低,HOT更新的概率越高。

四、fillfactor调优策略

fillfactor是控制HOT概率的唯一直接杠杆。设置策略取决于表的更新频率和行大小。

场景 fillfactor 预留空间 说明
只读或极少更新 100 0% 默认值,最大化存储密度
偶尔更新 90 10% 轻微预留,几乎不影响存储密度
频繁更新 70-80 20-30% 显著提高HOT概率
极频繁更新 50-70 30-50% 更新频率极高的表

注意:fillfactor只对新建表或执行VACUUM FULL/CLUSTER后的表生效。对已有表修改fillfactor后,需要重建表才能生效:

ALTER TABLE orders SET (fillfactor = 80);
VACUUM FULL orders;

五、监控HOT是否生效

SELECT schemaname, relname, 
       n_tup_upd, n_tup_hot_upd,
       CASE WHEN n_tup_upd > 0 
            THEN ROUND(n_tup_hot_upd::NUMERIC / n_tup_upd * 100, 2)
            ELSE NULL END AS hot_pct
FROM pg_stat_all_tables
WHERE relname IN ('orders', 'users')
ORDER BY n_tup_upd DESC;

判断标准:间隔一段时间(如1小时)对比两个快照中n_tup_upd和n_tup_hot_upd的增长量。如果两者增长量接近,说明几乎所有UPDATE都是HOT的;如果n_tup_hot_upd增长量远小于n_tup_upd,说明HOT没有生效。

一个邮件列表中的真实数据:某表n_tup_upd = 5,997,455,764,n_tup_hot_upd = 3,752,865,557,HOT比例约62.6%。说明约37%的更新没有走HOT,索引膨胀的速度远超预期。

六、小结

HOT更新是PostgreSQL控制索引膨胀的核心机制。当UPDATE不修改索引列且新行能放在同一页时,索引不需要新增条目,膨胀速度大幅降低。在频繁更新的列上建索引、使用表达式索引、fillfactor=100——这三个场景都会破坏HOT。fillfactor是唯一的直接调优杠杆,频繁更新的表应设为70-80。监控n_tup_hot_upd / n_tup_upd的比值,是判断HOT是否生效的最直接方法。

小耶在手,SQL 不愁

还有什么想了解的,欢迎留言!小耶一定知无不言言无不尽……我们下次见~

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。