☰
PostgreSQL MVCC 与长事务:为什么 Spring 事务未提交会拖垮 autovacuum 与索引膨胀
2026/10/17 6:43:48 网站建设 项目流程

PostgreSQL MVCC 与长事务:为什么 Spring 事务未提交会拖垮 autovacuum 与索引膨胀

1. 从一次磁盘暴涨说起

先看一个很常见的线上场景。某个 Spring Boot 订单服务加了一个“导出对账单”的接口,逻辑很简单:打开一个事务,先查一批订单,再调用下游对账系统,然后把结果写进数据库。因为下游偶尔超时,接口平均耗时从 200ms 涨到了 3 秒,高峰期甚至几十秒。上线两周后,运维发现这张订单表所在的表空间使用量从 80GB 涨到 190GB,索引大小也跟着翻倍,查询 P99 变慢,但count(*)发现业务数据只多了几百万行。

更奇怪的是,pg_stat_activity里能看到一个idle in transaction的连接,状态一直是空闲,却已经持续了十几分钟。这种连接就是典型的“长事务”:事务开了,但迟迟不提交也不回滚。它本身不消耗 CPU,却像一根钉子,把数据库里本该被清理的旧数据版本钉在原地,让后台负责清理的 autovacuum 进程无从下手。

为什么一个未提交的 Spring 事务会有这么大的破坏力?要回答这个问题,必须先搞清楚 PostgreSQL 是怎么让“读写不互相阻塞”的——也就是 MVCC。

2. 一句话模型与全局框架

可以先记住一句话:PostgreSQL 更新一行不是改原数据,而是写一个新版本、把旧版本标记为过期;旧版本能不能被删掉,取决于还有没有事务“可能看得到它”。

把整条链路拆成四个角色,先看它们怎样串起来:

业务 SQL 请求 | v [事务与快照] --产生--> [行版本 xmin/xmax] --过期--> [死元组 dead tuple] | | | 持有旧快照 v +---------------- 阻止回收 ---------------------> [autovacuum 清理] | | 清理失败则堆积 v [表膨胀 / 索引膨胀]

四个角色分别是:事务快照,决定“我能看到哪些版本”;行版本标记xmin/xmax,记录每一行的生死;死元组,是已经没人需要但还没被清理的旧版本;autovacuum,是负责把死元组真正回收、并把空间标记为可复用的后台进程。长事务之所以致命,就是因为它把第一步的“旧快照”一直握在手里,让第三步永远无法回收第二步产生的垃圾。

下面按“整体结构 → 核心机制 → 完整过程 → 设计取舍 → 工程使用”逐层展开。

3. 整体结构:MVCC 与可见性到底在解决什么

3.1 不阻塞意味着必须保留旧版本

在没有 MVCC 的朴素实现里,读要加锁、写要加锁,读写互斥。PostgreSQL 选择了另一种策略:写操作生成新版本,读操作读取符合自己快照的版本,这样读不会阻塞写、写也不会阻塞读。代价是数据在物理上会存在多个版本,数据库必须判断每个版本对当前事务是否可见,并在合适的时候清理不可见版本。

3.2 可见性判定的最小规则

每一行在物理上都带有xmin(这行是由哪个事务插入/更新的)和xmax(这行是被哪个事务删除/更新的)。判断一个版本对当前事务是否可见,简化成两句话:

  • 创建它的事务xmin必须已经提交,且提交时间早于当前事务快照;
  • 删除它的事务xmax不能是一个已经提交、且早于当前快照的事务。

关键在于:一个版本只要对“某个还活着的事务”仍然可见,它就不能被删除。

概念普通中文解释在链路中的位置
MVCC多版本并发控制,用多版本换取读写不阻塞整体机制
事务快照事务开始时看到的一个“数据版本切面”决定可见性
xmin行版本由哪个事务创建每个物理行
xmax行版本由哪个事务删除或更新每个物理行
死元组已经过期但尚未被清理的旧版本autovacuum 的处理对象
表膨胀表里死元组占比过高,物理空间远超有效数据结果现象
索引膨胀索引条目仍指向旧位置,条目数远超有效行结果现象

3.3 为什么索引也会一起膨胀

更新一行,PostgreSQL 会为这一行在新位置插入一个新索引条目,旧位置上的索引条目不会立刻消失,而是等 autovacuum 清理死元组时一并处理。所以长事务卡住 autovacuum,不只是表留垃圾,索引同样会累积大量“指向已死元组”的条目,扫描时需要跳过更多无效项,B-Tree 层级也可能被撑高。

4. 核心机制:快照与长事务

4.1 快照是“时间的切片”

一个事务在开始执行第一条语句时(取决于隔离级别,见后文)会拿到一个快照,记录“哪些事务已提交、哪些还在运行”。此后它看到的数据都是这个切片的视角。只要这个事务不结束,它对应的切片就一直有效,数据库就不能假设“没人再看旧数据了”。

4.2 长事务把快照固定在原地

autovacuum 在清理前会计算一个“最老的活跃事务边界”,凡是可能被比它更老的事务看到的数据版本都不能删。如果有一个事务跑了 30 分钟,这个边界就被钉死在 30 分钟前。哪怕中间真实发生了几万次更新,那些旧版本都只能堆着。这就是“长事务拖垮 autovacuum”的根因。

先看一段可以复现的实验脚本(使用 psql 执行,属于完整可运行示例之一):

-- 完整示例 1:复现长事务阻止清理(需两个会话配合)-- 会话 ACREATETABLEt_long(idintprimarykey,vtext);INSERTINTOt_longSELECTg,repeat('x',100)FROMgenerate_series(1,100000)g;BEGIN;SELECTcount(*)FROMt_long;-- 此处故意不 COMMIT,保持事务打开-- 会话 B:在 A 未提交的情况下大量更新UPDATEt_longSETv=v||'1'WHEREid%10=0;UPDATEt_longSETv=v||'2'WHEREid%10=0;-- 在任意会话查看死元组与表大小SELECTrelname,n_dead_tup,n_live_tup,pg_size_pretty(pg_total_relation_size(relid))AStotalFROMpg_stat_user_tablesWHERErelname='t_long';-- 关闭会话 A 的事务后,再手动触发一次清理-- COMMIT; 或 ROLLBACK;VACUUM(VERBOSE,ANALYZE)t_long;SELECTrelname,n_dead_tup,n_live_tup,pg_size_pretty(pg_total_relation_size(relid))AStotalFROMpg_stat_user_tablesWHERErelname='t_long';

预期结果:会话 A 事务未结束时,n_dead_tup会持续增长;执行VACUUM后如果长事务仍在,死元组数量下降有限,表物理大小基本不变。会话 A 结束并再次VACUUM后,死元组才被大量回收。容易改错的地方是:很多人以为VACUUM一定会把磁盘空间还给操作系统,其实普通VACUUM只是把空间标记为可复用,要真正缩小文件通常需要VACUUM FULL或pg_repack,且VACUUM FULL会持排他锁。

4.3 事务快照与隔离级别的对应关系

PostgreSQL 默认隔离级别是“读已提交”,每条语句开始时取新快照;而“可重复读”是事务开始时取一次快照,整个事务都用它。这意味着在可重复读下,即使事务只读,也会长时间固定边界。

隔离级别快照获取时机对旧版本的影响
读已提交每条语句开始时相对温和,但长事务内的每条语句仍可能延长
可重复读事务开始时一次长事务危害更明显
串行化事务开始时一次,并检测冲突危害同可重复读,额外有冲突重试

5. 完整过程:一次更新从产生到被清理

5.1 时间线推演

现在让一次“更新订单状态”的请求完整走一遍:

T1 事务 A 开始,拿到快照 T2 事务 A 执行 UPDATE,旧版本 xmax = A,新版本 xmin = A T3 事务 A 未提交,去做下游调用(可能耗时数十秒) T4 事务 B 读取该行,根据快照判断:A 未提交,所以读到旧版本 T5 事务 C 再更新同一行,产生第二个新版本 T6 后台 autovacuum 启动,计算最老边界 = 事务 A T7 由于 A 仍活着,T2/T5 产生的旧版本都不能删,dead tuple 累积 T8 事务 A 最终提交,边界前移 T9 下一次 autovacuum 才能回收这些旧版本

5.2 死元组不是立刻可回收的

很多人以为“提交后旧版本就消失了”,实际是提交只是让边界有机会前移,真正的物理回收要等 autovacuum 下一次运行。默认触发条件是死元组超过阈值(例如autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * 表行数),超过阈值才会被调度。热点小表如果按默认 20% 比例触发,可能一天只清一次。

5.3 Spring 事务为什么容易制造长事务

在 Spring 里,@Transactional的边界通常比你想的更大。常见错误模式有:在事务方法里调用远程接口、发 MQ、下载文件、做复杂计算。这些操作可能耗时几百毫秒到几十秒,而事务会一直开着。还有一种更隐蔽的情况:事务方法内抛出的异常被 catch 后没有继续抛出,导致事务没有回滚;或者通过this调用同类方法,代理失效,@Transactional根本没生效。

// 完整示例 2:Spring Boot 里典型的长事务反例与修正(简化但可编译的 Spring 代码结构)importorg.springframework.stereotype.Service;importorg.springframework.transaction.annotation.Transactional;importorg.springframework.jdbc.core.JdbcTemplate;@ServicepublicclassOrderService{privatefinalJdbcTemplatejdbc;privatefinalRemoteClientremoteClient;publicOrderService(JdbcTemplatejdbc,RemoteClientremoteClient){this.jdbc=jdbc;this.remoteClient=remoteClient;}// 反例:事务里包含远程调用,容易变成长事务@TransactionalpublicvoidexportBad(longorderId){Stringstatus=jdbc.queryForObject("select status from orders where id = ?",String.class,orderId);// 远程调用可能耗时几秒到几十秒,事务一直开着Stringresult=remoteClient.reconcile(status);jdbc.update("update orders set reconcile_result = ? where id = ?",result,orderId);}// 正例:先短事务读,事务外做远程调用,再短事务写publicvoidexportGood(longorderId){Stringstatus=readStatus(orderId);Stringresult=remoteClient.reconcile(status);updateResult(orderId,result);}@Transactional(readOnly=true)publicStringreadStatus(longorderId){returnjdbc.queryForObject("select status from orders where id = ?",String.class,orderId);}@TransactionalpublicvoidupdateResult(longorderId,Stringresult){jdbc.update("update orders set reconcile_result = ? where id = ?",result,orderId);}}

exportGood把远程调用放到事务外,读和写各自用短事务完成。预期效果是数据库层面几乎看不到长时间运行的活跃事务。适用场景:任何包含外部 IO 的业务流。容易改错的地方是:拆分后要保证业务一致,比如用状态机或补偿逻辑处理“读成功但写失败”的情况,不能简单地拆完就不管幂等与重试。

6. autovacuum 的判定逻辑与失败条件

6.1 autovacuum 做了什么

autovacuum 是 PostgreSQL 内置的后台清理进程,主要做三件事:回收死元组、更新统计信息(配合 autoanalyze)、防止事务 ID 回卷。它不是“定时任务”,而是按死元组比例触发。每个表都有一个 autovacuum 工作进程被调度去处理。

6.2 长事务如何让它“白跑”

即使 autovacuum 被调度起来,它也会先计算最老活跃事务边界。只要边界比死元组的产生时间还早,死元组就不能被删除,autovacuum 只能把这次任务标记为“部分完成”。如果长事务一直存在,每次清理都是白跑,日志里可能看到skipped: oldest xmin is far in the past之类的提示。

6.3 复制槽与备份也会钉住边界

除了长事务,物理复制槽、逻辑复制槽、未完成的pg_basebackup也可能持有旧边界。排查时不要只盯着idle in transaction,还要看pg_replication_slots的active和xmin字段。

现象可能原因排查手段
表大小持续上涨死元组堆积pg_stat_user_tables的n_dead_tup
索引变大变慢索引指向死元组查看索引大小与膨胀估算
idle in transaction很多应用未提交/未回滚pg_stat_activity
复制槽卡住清理复制槽持有 xminpg_replication_slots
autovacuum 跑但没效果最老边界被钉住查看长事务与日志

7. 表与索引膨胀的量化与验证

7.1 怎么判断已经膨胀

除了看大小,最直接的指标是死元组占比。可以查询每个表的活跃/死亡元组数量,再结合表的实际行数估算膨胀率。PostgreSQL 没有内置的“膨胀率”视图,但可以用官方扩展pgstattuple才能拿到物理层面的精确数据。

-- 完整示例 3:膨胀排查 SQL(可直接执行)-- 1. 找出死元组最多的表SELECTrelname,n_live_tup,n_dead_tup,round(n_dead_tup::numeric/nullif(n_live_tup+n_dead_tup,0)*100,2)ASdead_pct,pg_size_pretty(pg_total_relation_size(relid))AStotalFROMpg_stat_user_tablesORDERBYn_dead_tupDESCLIMIT10;-- 2. 找出最老的长事务SELECTpid,now()-xact_startASxact_age,now()-state_changeASidle_age,state,wait_event_type,left(query,80)ASquery_headFROMpg_stat_activityWHERExact_startISNOTNULLORDERBYxact_startASCLIMIT10;-- 3. 查看复制槽是否持有旧 xminSELECTslot_name,active,xmin,catalog_xmin,restart_lsnFROMpg_replication_slots;

预期结果:如果dead_pct长期高于 20%,且第一条查询里有几十万的n_dead_tup,同时第二条能查到持续时间很长的idle in transaction,基本可以确认是长事务导致的膨胀。容易改错的地方:pg_stat_user_tables的计数是估算值,不代表物理精确值;要精确测量需安装pgstattuple扩展执行pgstattuple()。

7.2 一个数字例子

假设订单表有 200 万行,每行有效数据 200 字节,理论数据量约 400MB。每天更新其中 20% 的行,每次更新产生一个旧版本。如果每天 autovacuum 都因长事务清理失败,一个月后堆积的旧版本可能达到数千万行,物理文件膨胀到十几 GB,索引也从几百 MB 涨到数 GB。查询时即使走索引,命中缓存的比例也下降,因为扫描要跳过大量死条目。

7.3 空间不会自动归还

要注意两个层次:死元组被回收后,空间被标记为“可复用”,但文件本身不缩小;只有VACUUM FULL或pg_repack才真正重建文件并释放磁盘。生产上通常优先接受“复用”,只有在膨胀严重、且维护窗口允许时才做重建。

8. 设计取舍:为什么不清得更激进

PostgreSQL 不是不能更激进地清理,而是有取舍。激进清理需要更频繁地扫描表,带来额外 IO 和 CPU;还要保证正在执行的事务不会被误删数据版本,所以必须留下保守边界。MVCC 的设计目标是用“写放大 + 空间膨胀”换取“读写不阻塞、无长锁”,这在大多数 OLTP 场景下是划算的。

另一种取舍是隔离级别。读已提交对旧版本更友好,可重复读语义更强但对长事务更敏感。选择哪种隔离级别,要根据业务对一致性的真实要求来决定,而不是无脑选最高。

方案优点代价
读已提交快照更短,膨胀更轻同事务内两次读可能不同
可重复读事务内一致快照 длин,长事务危害大
定期 VACUUM FULL真正回收磁盘排他锁,业务影响大
pg_repack在线重建需要额外空间与工具

(上表修正:可重复读行的“代价”应为“快照更长,长事务危害大”。)

9. 工程使用:Spring + PostgreSQL 的落地建议

9.1 事务要短,外部调用要移出事务

第一条铁律:事务里只做数据库操作,远程调用、消息、文件、大计算一律放到事务外。可以用读-计算-写三段式拆分,配合重试与幂等。

9.2 设置事务超时

Spring 的@Transactional(timeout = 3)可以在超过阈值时回滚,但不能替代把外部调用移出事务,因为超时只在数据库调用点检查。更稳妥的是在连接池层面设置idle_in_transaction_session_timeout。

-- 在 PostgreSQL 层设置空闲事务超时(示例,5 分钟)ALTERSYSTEMSETidle_in_transaction_session_timeout='5min';SELECTpg_reload_conf();-- 也建议设置语句超时为兜底ALTERSYSTEMSETstatement_timeout='30s';SELECTpg_reload_conf();

参数含义:idle_in_transaction_session_timeout会在事务空闲超过指定时间后断开连接,直接解决“忘记提交”的泄漏;statement_timeout防止单条语句跑太久。适用场景:绝大多数联机业务。容易改错的地方:把值设得太小会误杀正常的长查询或批处理任务,需要按业务类型区分连接池或加白名单。

9.3 连接池与事务边界

HikariCP 的maximumPoolSize不是越大越好,连接越多,长事务泄漏的可能越大。建议配合leakDetectionThreshold观察连接持有时间,把异常持有连接的代码路径暴露出来。同时注意spring.datasource.hikari.auto-commit与 Spring 事务管理器的配合。

# application.yml 片段spring:datasource:hikari:maximum-pool-size:20leak-detection-threshold:20000connection-timeout:3000

leak-detection-threshold单位是毫秒,超过该时间未归还连接会打印告警堆栈,帮助定位长事务来源。它不是强制回收,只是告警。

10. 常见误区

  • 误区一:提交了就没有旧版本。实际上旧版本要等 autovacuum 回收,提交只是解除了边界限制。
  • 误区二:VACUUM会缩小文件。普通VACUUM只标记空间可复用。
  • 误区三:只看 CPU 和慢查询,不看事务年龄。长事务可能完全不占 CPU。
  • 误区四:idle in transaction无害。它正是最危险的状态。
  • 误区五:把autovacuum关掉来省资源。这会加速膨胀并最终触发事务 ID 回卷风险。

11. 生产实践建议

  1. 监控“最老事务年龄”和“死元组占比”,而不是只看磁盘使用率。
  2. 应用层强制事务 timeout,并在数据库层设置空闲事务超时。
  3. 远程调用、消息发送、文件操作一律移出事务。
  4. 对大表单独调整 autovacuum 参数,例如更低的autovacuum_vacuum_scale_factor。
  5. 定期用pg_repack处理严重膨胀的表,避开业务高峰。
  6. 检查复制槽,避免长期未消费的逻辑复制槽持有 xmin。
-- 针对热点大表调优 autovacuum(示例)ALTERTABLEordersSET(autovacuum_vacuum_scale_factor=0.02,autovacuum_vacuum_threshold=1000,autovacuum_vacuum_cost_delay=2);

效果:让热点表更早触发清理、减少每次扫描成本。边界:更频繁清理会增加 IO,需要在低峰期观察对延迟的影响。

12. 排障清单

1. 查最老事务:pg_stat_activity.xact_start 排序 2. 查空闲事务:state = 'idle in transaction' 3. 查死元组:pg_stat_user_tables.n_dead_tup 4. 查复制槽:pg_replication_slots 5. 查 autovacuum 日志:是否边被钉住 6. 确认膨胀:pgstattuple 或 pg_repack 评估 7. 处置:结束泄漏事务 -> 手动 VACUUM -> 必要时重建 8. 复盘:应用事务边界与超时配置

13. 面试/复盘问题

  1. MVCC 下更新一行为什么不覆盖原数据?
  2. xmin/xmax如何影响可见性?
  3. 为什么长事务会阻止 autovacuum 回收死元组?
  4. 读已提交和可重复读在快照上的差别是什么?
  5. 为什么普通VACUUM不缩小文件大小?
  6. Spring 里哪些写法容易制造长事务?

14. 总结

把整篇内容收回一张链路:请求进入 -> 事务拿快照 -> 更新产生新版本 -> 旧版本变死元组 -> autovacuum 尝试清理 -> 若存在长事务则清理失败 -> 表与索引膨胀。核心判断只有一条:只要还有一个事务可能看到旧版本,数据库就不能删它。因此治理长事务的本质,是治理应用的事务边界。

决策清单:事务内只做数据库操作;外部 IO 移出事务;设置合理的空闲事务超时和语句超时;监控最老事务年龄与死元组占比;对严重膨胀用pg_repack而不是硬扛。做到这些,磁盘和索引就不会被“忘记提交”悄悄拖垮。

15. 参考资料

  • PostgreSQL 官方文档:Concurrency Control(MVCC)
  • PostgreSQL 官方文档:Routine Vacuuming 与 autovacuum 参数
  • PostgreSQL 官方文档:pg_stat_activity、pg_stat_user_tables、pg_replication_slots
  • PostgreSQL 官方文档:pgstattuple 扩展
  • Spring Framework 官方文档:Transaction Management
  • HikariCP 官方文档:Configuration(leakDetectionThreshold、maximumPoolSize)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询