PostgreSQL HOT机制:一次UPDATE如何避免索引膨胀?
聊到PostgreSQL性能优化HOTHeap-Only Tuple也就是堆内元组更新是个绕不开的话题。刚接触数据库内核时我一度以为UPDATE就是简单的标记旧记录、写入新记录直到在线上看到一张更新非常频繁的表把索引空间撑到堆的几倍才意识到索引更新这件事的代价有多高。HOT要解决的核心问题正是“一次更新明明没动索引列为什么还要更新一堆索引项”。如果你负责维护高并发更新场景的库或者想深入理解PostgreSQL存储与MVCC的配合这篇内容值得一看。1. 为什么UPDATE会拖累索引1.1 一条UPDATE语句实际做了什么在PostgreSQL里UPDATE并不像很多人直觉想的那样“找到那行原地修改”。它走的是MVCC路线先定位到目标行然后生成一个全新的行版本tuple再把这个新版本插入到堆页面中。旧版本并不会被立即删除而是通过指针链指向新版本。已经开启的事务、老快照可能还需要读取旧版本所以旧版本要留到所有可能看到它的事务结束后由VACUUM来回收。这就带来一个连锁问题堆页面里多了一个版本索引项怎么办索引项里保存的不只是键值还有行版本的位置信息TID也就是“页面号行号”。新版本出现后如果索引里的TID还指向旧版本那么索引扫描就有风险拿到一个对当前事务不可见、甚至已经被清理掉的旧版本。为了维持索引的正确性PostgreSQL必须保证索引能定位到“当前应该看到的那一版”。在没有HOT的情况下这个“保证”靠的是更新索引项本身。1.2 索引更新的开销大头在哪里我们来拆一下一次普通UPDATE的写放大堆页写入新版本插入堆页这是一次必做的写入。索引页写入每一个包含该行的索引都需要更新对应索引项的TID。WAL日志追加以上每一处页面变更都要写对应的WAL记录。B-tree结构调整索引更新不只是改一个叶子项那么简单。B-tree在插入、删除索引项时可能触发页分裂、节点平衡严重时要从叶子页一路波及到根节点。假设一张表上有5个索引一次更新如果没有走HOT最坏情况下可能需要写5个索引页其中任何一个索引页分裂都会让写入量再翻几倍。热点行被高频更新时这种放大效应会直接演变成锁等待和IO抖动。我见过一个实际案例某业务订单表只有几千万行更新频率很高结果索引文件膨胀到接近堆表的三倍VACUUM跑得再勤也没用。后来排查发现绝大部分UPDATE都只改了一个普通字段却因为页面空间不足导致每次更新都要把多个索引重写一遍。1.3 索引更新的本质TID变化引起的连锁反应关键在于索引项一旦建立它的主要检索目标是“尽快定位到行版本”。普通情况下索引项直接指向新版本所在的页面和行号。所以新旧版本只要不是同一个物理位置索引项就必须变。那能不能不让索引项变让索引项永远指向旧版本然后顺着版本链找新版本从理论上说似乎可行但这里有一个工程上的难点VACUUM清理旧版本时如果索引项还挂在一个“即将被物理移除”的旧版本上就会悬空。而且一条版本链可能跨越很多页面扫描时的链式跳转代价不可控。HOT的真正巧妙之处就是把“链式跳转”限制在一个堆页面内部并且用一套特殊标记让索引项不失效。它本质上是把“每条索引都更新一次”换成了“堆内的一次短链跳转”。2. HOT的运作机制索引不动堆内跳转2.1 先理解两个基础结构行指针与ctid要理解HOT先看PostgreSQL堆页面的两个基本概念。每个8KB数据页里除了元组数据本身还有一个“行指针数组”ItemId数组你也可以把它理解为目录。每个行指针会指向该页内某个元组的实际位置。当VACUUM清理元组时它不会动行指针结构本身只是改变标记所以行指针是个非常稳定的定位入口。每个元组的头部有一个字段叫ctid它表示“我自己所在的位置”一般写作“页面号,行号”。还有一个字段叫t_ctid它表示“这个元组的下一个更新版本在哪里”。普通更新下旧版本的t_ctid指向新版本所在的位置比如“第5页第3行”。索引更新后索引项里存的是新版本的ctid。HOT场景下这个机制被特殊处理了。2.2 触发HOT的两个硬条件先说结论再讲细节。第一个条件新版本元组必须能放进旧版本所在的同一个堆页面。如果页面里放不下新版本HOT就不会启用。第二个条件这次更新不能让任何索引键值发生变化。注意这里不是简单看“UPDATE语句里写了哪些列”。PostgreSQL实际判断的是本次更新之后所有索引项的键值是否都和原来完全一样。哪怕你写的是SET name name只要最终算出来的索引键值和原来相同也算满足条件。反之如果某个索引是表达式索引比如lower(email)你UPDATE了email字段导致表达式结果变了那么HOT同样不适用。这两条缺一不可。2.3 HOT链旧指针指向新版本当两个条件都满足时PostgreSQL会做一件“看起来绕路、实际上省钱”的事它把新版本元组放到同一个数据页的空闲空间里然后让旧版本的t_ctid直接指向新版本。索引项继续保留原来的TID仍然指向旧版本所在的行指针。这个由“旧版本 - 新版本”构成的一条链就是HOT链Heap-Only Tuple chain。当索引扫描到达旧版本的位置时发现这个元组已经过时了就沿着t_ctid找到最新版本。由于新旧版本在同一页内这种跳转只是页面内部的指针移动成本极低。所有索引项一步更新都省了。为了维持这条链的稳定性PostgreSQL还把链上根版本的行指针做了一个特殊标记叫LP_REDIRECT意为“重定向指针”。它不再指向具体的元组数据而是直接指向链上的有效版本。VACUUM清理中间版本时只要更新这个重定向目标即可索引项从头到尾都不用动。2.4 为什么内核强行要求“同一页面”很多人会问新版本只要放在任意页面索引项保持指向旧版本不也能顺着链找吗何必强行要求同一页关键在于VACUUM和扫描效率的平衡。如果版本链跨页面索引扫描为了找到一个可见版本可能要从第5页跳到第200页再跳到第800页。多版本可见性判断本身就要做一堆事务快照检查如果还要跨页跳链性能会非常不可预测。更麻烦的是VACUUM。跨页版本链的清理需要在多个页面之间维护一致性索引项悬挂的风险也会成倍上升。把链限制在一个页面内VACUUM处理的是一个页面内部的事情可以用一套简单的“重定向指针”机制搞定逻辑清晰得多。所以说HOT不是不想跨页而是从工程上衡量之后“限定单页”是保证健壮性的前提。2.5 条件不满足时的冷更新如果页面空间不够或者索引键值发生了变化PostgreSQL只能走普通更新路径也就是“冷更新”。冷更新时所有包含该行数据的索引项都要重新定位到新版本。如果更新本身确实改了索引列这个更新是躲不掉的但如果只是页面满了更新一个非索引列那就属于“HOT本可以帮忙但没帮上”的场景。后者就是我们在调优时需要重点盯住的情况。一个表如果频繁更新非索引列HOT命中率却很低十有八九是页面空间安排出了问题。3. HOT的收益、代价和边界3.1 四条看得见的收益HOT带来的收益并不是减少一次堆页面写入而是砍掉了整个索引侧的连锁消耗。第一索引IO显著减少。没有HOT时一次UPDATE可能触发N个索引更新。启用HOT后这些索引页的读写一概不发生。第二WAL日志量下降。每个索引页的变更都要记录WAL索引更新少了WAL写入自然减少。对于高并发更新场景这能直接降低主从复制延迟和磁盘IO压力。第三索引膨胀被遏制。索引里不再频繁产生“旧条目已删、新条目已插”的碎片B-tree页面分裂的次数变少索引文件体积更稳定。第四VACUUM压力变小。VACUUM在清理旧版本时如果索引项还指着旧版本它要做索引清理HOT场景下索引项没变清理工作就大幅简化。autovacuum的循环周期可以拉得更长系统抖动也少。3.2 要付出的代价HOT不是没有成本。最直接的成本是HOT链上的扫描跳转。虽然单页内的跳转很快但如果一个链上叠了非常多版本索引扫描就需要做很多次版本可见性判断。每个版本的t_ctid要逐级读下去直到找到当前事务可见的那一版。链特别长时CPU消耗会比直接索引定位高。所以PostgreSQL对HOT链也有隐性约束如果链过长或者页面空间反复不够用执行计划会自然退化到普通更新。这也是为什么HOT的收益高度依赖“更新频率和页面空闲空间”的配合。另外要明确HOT解决的是索引膨胀不是堆表膨胀。每次UPDATE产生的旧版本仍然会留在堆页面里仍然需要VACUUM来回收空间。不要因为启用了HOT就完全不管VACUUM。3.3 HOT和B-tree去重不是一回事PostgreSQL 12之后给B-tree索引增加了“重复键值去重”deduplication能力很多人会把这两件事混淆。索引去重解决的是“大量记录拥有相同索引键值”的场景。比如一张表里性别字段作为索引几百万行都是同一个值B-tree索引页里就会堆满重复键索引去重会把它们压缩成一种特别的表示减少空间占用。HOT解决的是“更新操作导致索引TID不断变化”的场景。一个是针对重复键一个是针对频繁更新两者解决的问题方向完全不同但都能有效控制索引膨胀。线上排查索引膨胀时这两个机制都要看。4. 亲手验证HOT监控与实验4.1 三行SQL查看当前HOT命中率理论说再多不如看一眼实际数据。PostgreSQL在统计视图里专门给HOT留了计数器。SELECT relname, n_tup_upd AS updates, n_tup_hot_upd AS hot_updates, round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_pct FROM pg_stat_user_tables WHERE schemaname public ORDER BY n_tup_upd DESC LIMIT 20;重点看两个字段n_tup_upd表示表上累计发生的UPDATE次数n_tup_hot_upd表示其中走了HOT的次数。两者相除就是HOT命中率。健康的高频更新表hot命中率一般会在90%以上。如果你看到一张表大量UPDATE但hot_pct接近0那基本可以断定HOT没被用上每个更新都在硬扛索引重写。这个统计是累计值从最后一次统计重置开始计算。要看当前负载的实时效果建议先做一次重置再压测-- 全局重置所有统计 SELECT pg_stat_reset(); -- 只重置某张表的计数器 SELECT pg_stat_reset_single_table_counters(t_hot_demo::regclass);有一点要注意从库上拿不到这个统计必须连到主库观察。另外如果表上没有索引更新其实也不会有大量索引开销此时HOT意义不大统计值偏高不代表真实收益。4.2 实验fillfactor70时的HOT表现我们直接在开发环境复现一下HOT效果。先建一张测试表为了让更新后页面里有空间容纳新版本把fillfactor设为70CREATE TABLE t_hot_demo ( id int PRIMARY KEY, val text, payload text ); ALTER TABLE t_hot_demo SET (fillfactor 70); CREATE INDEX idx_t_hot_demo_val ON t_hot_demo(val); -- 插入10万行初始数据 INSERT INTO t_hot_demo (id, val, payload) SELECT g, v- || g, payload- || g FROM generate_series(1, 100000) AS g; -- 重置统计 SELECT pg_stat_reset_single_table_counters(t_hot_demo::regclass);接下来更新非索引列payload。payload不属于任何索引键页面又有30%预留空间新版本可以放在原页面里HOT应该正常工作DO $$ BEGIN FOR i IN 1..30000 LOOP UPDATE t_hot_demo SET payload changed- || i WHERE id i; END LOOP; END $$;执行完再查一下统计视图n_tup_hot_upd应该接近3万hot命中率基本是100%。这说明30000次UPDATE没有一次触发索引重写。4.3 实验对比更新索引列后发生了什么现在换一个操作更新索引列val。这一步必然导致索引键值变化不可能走HOTDO $$ BEGIN FOR i IN 1..30000 LOOP UPDATE t_hot_demo SET val new- || i WHERE id i; END LOOP; END $$;再查统计你看到的数据会是hot_updates还是3万整但updates累积到了6万。hot命中率从100%掉到了50%左右。新增的3万次更新一次HOT都没走。这里出现了一个很典型的监控陷阱只看hot命中率从100%掉到50%你会以为是“整体性能下降了一半”但其实新增的3万次更新本来就全是冷更新这个命中率的下降完全符合预期。如果还想复现“非索引列更新也无法HOT”的场景可以再建一张fillfactor100的表插入足够多的数据把页面撑满再执行同样的payload更新。由于每个页面都没有多余空间新版本放不进原页PostgreSQL只能把它放到其他页面于是索引项必须全部更新。这就是很多线上表明明只改普通字段hot命中率却低得可怜的根本原因。4.4 用pageinspect看HOT链的真实面貌如果只看统计数字还不够过瘾可以使用pageinspect扩展直接查看页面内部结构。CREATE EXTENSION IF NOT EXISTS pageinspect; SELECT lp, lp_off, lp_flags, t_ctid, t_infomask FROM heap_page_items(get_raw_page(t_hot_demo, 0)) LIMIT 30;重点看lp_flags字段lp_flags值含义0LP_UNUSED未使用1LP_NORMAL正常元组2LP_REDIRECTHOT链上的重定向指针3LP_DEAD等待清理的死元组如果能看到一批lp_flags2的行指针说明这些位置就是HOT链的根。它们不再指向实际数据而是指向链上的后续版本。这个细节在实际运维中很有用当你想确认某张表是不是真的在走HOT除了看视图统计也可以抽样检查页面里的LP_REDIRECT数量。5. 提高HOT收益的调优实践5.1 fillfactor给同页更新留空间默认情况下普通表的fillfactor是100意味着每个页面要尽量塞满数据新插入的行会填满整个8KB页面。页面一旦满了后续任何更新都放不下新版本HOT直接失效。所以对高频更新的表第一步就是调整fillfactor。ALTER TABLE t_hot_demo SET (fillfactor 80);我通常的建议是更新频率很高、读多写也多fillfactor设置在70到85之间。更新频率中等90左右。几乎不更新、只读表保持100没必要浪费空间。注意ALTER TABLE ... SET (fillfactor80)只是更新了表的元数据对已有页面不会立刻重写。旧页面依然可能处于接近满的状态只有新插入的数据会按照新fillfactor排布。想让存量数据立即生效需要重写表比如VACUUM FULL或CLUSTER。但要先想清楚这个操作会长时间锁表线上使用务必谨慎我一般会安排维护窗口或者用在线重写方案来处理。5.2 索引设计对HOT的影响这是最容易被忽略的一块。HOT能不能生效关键看更新是否触碰了索引键。所以索引设计直接决定了你的UPDATE语句有多少概率走HOT。举个简单的例子一张表上有status字段你建了一个idx_status索引然后业务每次接单都要更新status。这个场景下HOT永远失效因为每次更新都改了索引键。遇到这种情况你有几个选择如果status的过滤性很差比如只有两三种取值它作为索引本身价值有限可以考虑去掉这个单列索引改用其他复合索引覆盖。如果必须保留该索引那就要接受冷更新通过调整fillfactor减少跨页概率但无法完全消除索引更新。如果高频更新的字段本身过滤性好比如更新时间、状态变化等可以思考是否真的需要单列索引或者用更宽的覆盖索引来替代。另一个常见坑是“明明业务更新的不是索引列但表上有一个表达式索引恰好受影响了”。比如表上有lower(email)的表达式索引业务更新了email字段表达式结果变了HOT就失效。排查时如果发现hot命中率异常低记得把所有索引的定义都过一遍不只盯着单列索引。5.3 常见误区与排查清单误区实际情况设置了fillfactor就能一直走HOT如果更新动到了索引键值fillfactor再低也没用n_tup_hot_upd高说明表很健康还要看堆膨胀率、autovacuum是否及时、是否有超长事务HOT能替代VACUUM不能旧版本在堆里仍然要靠VACUUM回收HOT稳定后索引就永远不会膨胀冷更新只要占一小部分长期累积也可能带来明显膨胀我在线上排查HOT失效问题时通常按这个顺序走先看n_tup_upd和n_tup_hot_upd确认命中率。如果命中率低看表的fillfactor是否太低或太高页面是否有充分空闲。再看表上有哪些索引和业务UPDATE语句的字段是否有交集。检查是否有长事务导致VACUUM长期无法推进旧版本堆积后被迫使用冷更新。最后看是否在更新后马上跟随大批量操作把刚腾出来的页面空余又填满了。最后分享一个我踩过的坑曾经负责一张业务表每天有几十万次UPDATE索引从几百MB涨到几十GB最初以为是重复键值太多或者VACUUM不勤后来查统计视图发现hot_upd竟然是0。根因是那张表的fillfactor一直保持默认100而且业务更新语句里带了一个冗余索引列。后来把那个冗余索引拆掉再把fillfactor调到85索引膨胀问题基本就消失了。所以监控HOT命中率这件事应该纳入常态巡检。你如果发现一张表更新量很大但索引长得比堆还快第一步别急着加存储或跑VACUUM FULL先看一眼n_tup_hot_upd很多时候答案就在那里。