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_longSETvv||1WHEREid%100;UPDATEt_longSETvv||2WHEREid%100;-- 在任意会话查看死元组与表大小SELECTrelname,n_dead_tup,n_live_tup,pg_size_pretty(pg_total_relation_size(relid))AStotalFROMpg_stat_user_tablesWHERErelnamet_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_tablesWHERErelnamet_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根本没生效。// 完整示例 2Spring Boot 里典型的长事务反例与修正简化但可编译的 Spring 代码结构importorg.springframework.stereotype.Service;importorg.springframework.transaction.annotation.Transactional;importorg.springframework.jdbc.core.JdbcTemplate;ServicepublicclassOrderService{privatefinalJdbcTemplatejdbc;privatefinalRemoteClientremoteClient;publicOrderService(JdbcTemplatejdbc,RemoteClientremoteClient){this.jdbcjdbc;this.remoteClientremoteClient;}// 反例事务里包含远程调用容易变成长事务TransactionalpublicvoidexportBad(longorderId){Stringstatusjdbc.queryForObject(select status from orders where id ?,String.class,orderId);// 远程调用可能耗时几秒到几十秒事务一直开着StringresultremoteClient.reconcile(status);jdbc.update(update orders set reconcile_result ? where id ?,result,orderId);}// 正例先短事务读事务外做远程调用再短事务写publicvoidexportGood(longorderId){StringstatusreadStatus(orderId);StringresultremoteClient.reconcile(status);updateResult(orderId,result);}Transactional(readOnlytrue)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_slotsautovacuum 跑但没效果最老边界被钉住查看长事务与日志7. 表与索引膨胀的量化与验证7.1 怎么判断已经膨胀除了看大小最直接的指标是死元组占比。可以查询每个表的活跃/死亡元组数量再结合表的实际行数估算膨胀率。PostgreSQL 没有内置的“膨胀率”视图但可以用官方扩展pgstattuple才能拿到物理层面的精确数据。-- 完整示例 3膨胀排查 SQL可直接执行-- 1. 找出死元组最多的表SELECTrelname,n_live_tup,n_dead_tup,round(n_dead_tup::numeric/nullif(n_live_tupn_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_timeout5min;SELECTpg_reload_conf();-- 也建议设置语句超时为兜底ALTERSYSTEMSETstatement_timeout30s;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:3000leak-detection-threshold单位是毫秒超过该时间未归还连接会打印告警堆栈帮助定位长事务来源。它不是强制回收只是告警。10. 常见误区误区一提交了就没有旧版本。实际上旧版本要等 autovacuum 回收提交只是解除了边界限制。误区二VACUUM会缩小文件。普通VACUUM只标记空间可复用。误区三只看 CPU 和慢查询不看事务年龄。长事务可能完全不占 CPU。误区四idle in transaction无害。它正是最危险的状态。误区五把autovacuum关掉来省资源。这会加速膨胀并最终触发事务 ID 回卷风险。11. 生产实践建议监控“最老事务年龄”和“死元组占比”而不是只看磁盘使用率。应用层强制事务 timeout并在数据库层设置空闲事务超时。远程调用、消息发送、文件操作一律移出事务。对大表单独调整 autovacuum 参数例如更低的autovacuum_vacuum_scale_factor。定期用pg_repack处理严重膨胀的表避开业务高峰。检查复制槽避免长期未消费的逻辑复制槽持有 xmin。-- 针对热点大表调优 autovacuum示例ALTERTABLEordersSET(autovacuum_vacuum_scale_factor0.02,autovacuum_vacuum_threshold1000,autovacuum_vacuum_cost_delay2);效果让热点表更早触发清理、减少每次扫描成本。边界更频繁清理会增加 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. 面试/复盘问题MVCC 下更新一行为什么不覆盖原数据xmin/xmax如何影响可见性为什么长事务会阻止 autovacuum 回收死元组读已提交和可重复读在快照上的差别是什么为什么普通VACUUM不缩小文件大小Spring 里哪些写法容易制造长事务14. 总结把整篇内容收回一张链路请求进入 - 事务拿快照 - 更新产生新版本 - 旧版本变死元组 - autovacuum 尝试清理 - 若存在长事务则清理失败 - 表与索引膨胀。核心判断只有一条只要还有一个事务可能看到旧版本数据库就不能删它。因此治理长事务的本质是治理应用的事务边界。决策清单事务内只做数据库操作外部 IO 移出事务设置合理的空闲事务超时和语句超时监控最老事务年龄与死元组占比对严重膨胀用pg_repack而不是硬扛。做到这些磁盘和索引就不会被“忘记提交”悄悄拖垮。15. 参考资料PostgreSQL 官方文档Concurrency ControlMVCCPostgreSQL 官方文档Routine Vacuuming 与 autovacuum 参数PostgreSQL 官方文档pg_stat_activity、pg_stat_user_tables、pg_replication_slotsPostgreSQL 官方文档pgstattuple 扩展Spring Framework 官方文档Transaction ManagementHikariCP 官方文档ConfigurationleakDetectionThreshold、maximumPoolSize