news 2026/9/4 7:06:47

PostgreSQL MVCC 机制实战(第 2 篇):一次 UPDATE 为什么留下两个行版本

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostgreSQL MVCC 机制实战(第 2 篇):一次 UPDATE 为什么留下两个行版本

订单表逻辑行数没有增长,每天却执行数千万次状态刷新;一段时间后 Heap 和索引都变大,查询读到的仍然只有每个订单的最新一行。问题不在重复 INSERT,而在 PostgreSQL 的UPDATE会写入新行版本,旧版本要等到不再被任何相关快照需要后才能清理。

MVCC 新版本解释表为什么变大;HOT 只决定这次更新能否少维护一轮索引,并不会把 UPDATE 变成原地覆盖。

这篇文章不讨论 autovacuum 参数大全,只证明三个结果:逻辑一行为什么能对应多个物理版本、HOT 何时成立,以及这些证据如何改变表和索引设计。

第一组证据:逻辑查询一行,Heap Page 两个版本

以下实验仅用于 PostgreSQL 18.6 测试库。pageinspect可以读取原始页面,其中可能包含已经不可见的历史数据;它通常需要较高权限,不应让普通业务账号使用,也不要对敏感生产表随意导出页面内容。

CREATEEXTENSIONIFNOTEXISTSpageinspect;CREATETABLEorder_state(idbigintPRIMARYKEY,statustextNOTNULL,notetext)WITH(fillfactor=70);INSERTINTOorder_stateVALUES(1,'created','v1');SELECTctid,xmin,xmax,id,status,noteFROMorder_stateWHEREid=1;

这里的ctid是当前行版本在 Heap 中的物理位置,由页号和页内 ItemId 组成;它不是稳定业务主键。记录第一次查询的ctid,再更新一个没有被索引引用的列:

UPDATEorder_stateSETnote='v2'WHEREid=1;SELECTctid,xmin,xmax,id,status,noteFROMorder_stateWHEREid=1;

第二次查询仍只返回一行,但ctid通常已经变化。普通 SELECT 按当前 MVCC 快照过滤不可见版本,不能用它证明旧版本已被物理删除。

现在读取当前行所在页面。实验表只有一行,通常位于第 0 页;若自行扩大数据量,应先从可见行的ctid确定页号,不能固定猜测页面:

SELECTlp,t_xmin,t_xmax,t_ctid,raw_flags,combined_flagsFROMheap_page_items(get_raw_page('order_state',0))AShCROSSJOINLATERAL heap_tuple_infomask_flags(h.t_infomask,h.t_infomask2)ASfWHEREh.t_infomaskISNOTNULLORDERBYlp;

heap_page_items会展示页面上的所有元组,不按当前快照隐藏旧版本。预期能看到更新前后两个行版本:旧版本的t_xmax记录使其失效的事务,新版本有新的t_xmin;旧版本的t_ctid指向更新后的版本位置。

不要机械解读某一位 flag。事务是否已提交、是否触发 hint bit、页面何时被再次访问,都会影响具体标志。这个实验的硬证据是同一业务主键在页面上存在版本链,而普通查询只返回当前可见版本。

第二组证据:有新行版本,不一定有新索引项

PostgreSQL 官方文档给出 HOT 的两个必要条件:

  1. 本次 UPDATE 没有修改任何普通索引引用的列;核心内置访问方法中,BRIN 作为 summarizing index 是例外;
  2. 旧行所在 Heap Page 有足够空间容纳新版本。

刚才更新的是note,表上只有主键索引,且fillfactor=70为页内更新预留空间,所以它有机会成为 HOT Update。HOT 发生时,索引仍通过原有 ItemId 进入 Heap,再沿 HOT 链找到当前快照可见的版本,不需要为新版本增加普通索引项。

用累计统计观察,而不是根据 SQL 形状猜测:

SELECTrelname,n_tup_upd,n_tup_hot_upd,round(100.0*n_tup_hot_upd/NULLIF(n_tup_upd,0),2)AShot_ratio_pctFROMpg_stat_user_tablesWHERErelname='order_state';

这些是累计且异步刷新的统计,短实验中可能需要结束事务、重新查询或等待统计刷新。生产判断应记录时间窗口内的增量,不能把数据库启动以来的累计比例直接当成当前发布效果。

接着给status建索引,并修改该列:

CREATEINDEXorder_state_status_idxONorder_state(status);UPDATEorder_stateSETstatus='paid'WHEREid=1;

这次 UPDATE 修改了 B-tree 索引引用列,不满足 HOT 条件,即使原页面仍有空间也需要维护相应索引。再次读取pg_stat_user_tables,应看到n_tup_upd增加,而这次更新不增加n_tup_hot_upd

公平比较的共同前提是同一张表、同一行大小、同一页面空间和同一次单行更新,只改变“被更新列是否被索引引用”。因此差异能归因于 HOT 资格,而不是并发、缓存或数据规模变化。

第三组证据:fillfactor 提高机会,不提供保证

降低fillfactor会在装载页面时预留空间,从而提高同页生成新版本的概率,但不能保证每次 HOT:

  • 行变大后可能仍放不进旧页;
  • 页面空间可能被其他写入消耗;
  • 更新任何普通索引引用列都会失去 HOT 资格;
  • 表达式索引和部分索引同样可能引用业务列,不能只看索引键名称;
  • 更低 fillfactor 会让相同行数占用更多初始页面,增加扫描与缓存压力。

所以调低 fillfactor 不是“PostgreSQL 更新优化开关”。正确实验要比较同一更新负载下的 HOT 增量、表和索引尺寸、缓冲区读写、WAL、吞吐与尾延迟。

从 UPDATE 到清理的最短机制链

UPDATE 找到当前可见版本 → 在 Heap 写入新版本 → 旧版本记录 xmax,新版本记录 xmin → 同页且不改普通索引引用列:连接为 HOT 链 → 否则为新版本维护相关索引项 → WAL 记录变化 → 旧版本等待全局不再可见 → Page Pruning / VACUUM 清理可回收版本 → 空间优先留给关系内部复用

HOT 带来两项收益:避免为新版本新增普通索引项;在一条 HOT 链多次更新时,已经对所有事务不可见的中间版本可以在正常页面访问期间被剪枝,不必全部等待周期性 VACUUM。

它仍没有消除 MVCC 成本。新 Heap Tuple、WAL、版本可见性判断和最终清理依然存在。

三个常见误判

没有 DELETE,就不会有 dead tuple

错误。UPDATE 会使旧行版本过时。官方 VACUUM 文档明确把被 UPDATE 淘汰和被 DELETE 删除的版本都列为回收对象。

HOT 比例低,只要继续降低 fillfactor

不一定。先检查更新列是否被主键、唯一索引、表达式索引或部分索引引用。如果资格条件已经失败,页内空间再多也不会把这次更新变成 HOT。

VACUUM 跑完,文件就会缩小

普通 VACUUM 的主要目标是清理不可见版本并把空间交给表内后续写入复用;除文件尾部等特殊情况外,不会把大部分空间归还操作系统。VACUUM FULL会重写整表并持有ACCESS EXCLUSIVE锁,不能作为看到 dead tuple 后的默认动作。

生产排查先用只读证据

下面的查询读取累计统计,不修改业务数据;需要有权查看目标关系统计。它适合找候选热表,不能单独证明物理膨胀,也不能证明 autovacuum 已经失效。

SELECTrelname,n_live_tup,n_dead_tup,n_tup_upd,n_tup_hot_upd,last_autovacuum,autovacuum_countFROMpg_stat_user_tablesWHEREn_tup_upd>0ORDERBYn_dead_tupDESC;

结果对应的下一步:

  • n_tup_upd高且 HOT 比例低:核对索引定义和更新列,再检查页空间;
  • dead tuple 持续净增长:检查长事务、复制槽保留边界和 vacuum 吞吐;
  • HOT 比例高但索引仍增长:继续检查删除、非 HOT 更新、索引类型和历史存量;
  • autovacuum 最近运行不代表清理成功:结合日志中的 removable cutoff、仍不可删除版本和运行耗时。

n_live_tupn_dead_tup是估算,不应用来做逐行对账。读取pageinspect比统计视图风险更高,应留在隔离实验或经过审批的诊断中。

设计动作必须按因果顺序

  1. 识别真正高频更新列,避免为低价值查询建立高写放大索引;
  2. 审查重复索引、表达式索引和部分索引谓词是否引用这些列;
  3. 在副本或压测环境比较不同 fillfactor,不直接全表改参数后宣布成功;
  4. 让 autovacuum 的触发与吞吐追上旧版本生成速度;
  5. 治理长事务和异常复制槽,否则旧版本可能根本尚不可回收;
  6. 同时验证业务写入结果、HOT 增量、索引增长、WAL、I/O 和尾延迟。

修改 fillfactor 只影响后续页面填充策略,不会自动重新组织已有页面。若为了立即改变现有物理布局而重写表,必须单独评估锁、额外磁盘、WAL、复制追赶、灰度和回滚限制。

实验边界与清理

本实验能证明 UPDATE 产生新 Heap 版本,以及更新索引引用列会失去 HOT 资格;不能证明某个生产表膨胀只有这一种原因,也不能给出通用 fillfactor 推荐值。

测试结束后执行:

DROPTABLEorder_state;

只有确认测试库中没有其他对象使用pageinspect时,才考虑删除扩展:

DROPEXTENSION pageinspect;

面试表达

PostgreSQL UPDATE 会写新 Heap 版本,旧版本按 MVCC 等待清理。未修改普通索引引用列且旧页有空间时可形成 HOT 链,避免新增普通索引项,但不会消除 Heap、WAL 和 VACUUM 成本。排障要联看索引引用列、HOT 增量、页空间、长快照和清理吞吐。

官方资料

  • Heap-Only Tuples
  • Database Page Layout
  • pageinspect
  • Routine Vacuuming
  • VACUUM
  • Statistics Views
  • PostgreSQL 18.6 源码标签 REL_18_6
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/4 7:05:31

基于STM32F4的模拟调制信号识别与参数估计系统设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/4 7:04:30

香烟盒目标检测数据集实战:YOLO训练避坑指南

简介:本资源是面向YOLO系列算法目标检测任务的专用香烟盒子数据集,适用于计算机视觉初学者、算法工程师及工业质检场景开发者,可直接用于YOLOv5/v7/v8/v9/v10/v11等主流版本的模型训练、验证与测试。压缩包共961个文件,包含320张高…

作者头像 李华
网站建设 2026/9/4 7:03:17

OneRec开源框架:多源信息融合如何破解推荐系统数据孤岛难题

简介:OneRec是一个面向推荐系统研发者的开源多源信息融合算法框架,专为解决工业场景中数据孤岛问题而设计,适用于具备Python与推荐系统基础的中高级开发者及算法工程师。它支持行为数据、多信号(显式/隐式)、长短期兴趣…

作者头像 李华
网站建设 2026/9/4 7:02:42

从监控告警到故障预测:系统稳定性保障的下一代技术演进

凌晨两点,手机在床头振动。我迷迷糊糊翻了个身,看到群里同事发了一条消息:支付接口错误率开始往上走了。打开电脑,监控面板上那条曲线确实像被什么推了一把,正在缓慢但坚定地抬头。等到确认是依赖的上游服务超时&#…

作者头像 李华