PostgreSQL 的“读写不阻塞”,兑现条件写在 vacuum 的沉默里
PostgreSQL 的 MVCC 常被概括为一句话:读不阻塞写,写不阻塞读。这句话是对的,但它描述的只是并发控制的前半句。后半句是:每一个被 UPDATE 或 DELETE 留下的旧行版本,都不会自动消失,它变成“死元组”,等待一个叫 VACUUM 的后台过程来回收。如果 VACUUM 因为任何原因追不上死元组产生的速度,表就会膨胀——扫描变慢、缓存被污染、磁盘占用持续上涨,最终甚至触发事务 ID 回卷的只读保护。读写不阻塞的承诺,兑现条件不在事务引擎里,而在 vacuum 能不能持续地、不被阻挡地完成它的工作[reference:0]。
autovacuum 的 20%:一个为大表设计的阈值,在大表上恰好最脆弱
autovacuum 的触发条件是 autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples。默认值下,threshold 是 50,scale_factor 是 0.2。也就是说,一张表要积累 20% 的死元组,才会触发一次清理[reference:1]。
这个设计在小表上没有问题:一张 10 万行的表,2 万死元组触发清理,绝对量很小。但把它线性放大到大表上,性质就变了。一张 1 亿行的表,要等到大约 2000 万行变成死元组,autovacuum 才会启动[reference:2]。在这之前,表已经膨胀了 20%,扫描这些死元组带来的 I/O 和缓存开销,已经在持续消耗查询性能。
阈值不是“设错了”,而是按比例设阈值这个策略,在大表上产生了与表大小成正比的绝对延迟。一个 100 GB 的表在 20% 膨胀时,意味着 20 GB 的无效数据已经被读进缓存、被扫描、被跳过[reference:3]。调低单表的 scale_factor 到 0.01 或 0.02 是常见的补救,但这要求运维方主动识别哪些表是“大表”或“高更新表”,并逐表覆盖默认值。全局默认值本身,保护不了它最需要保护的那些表。
长事务:一个快照,冻结整个数据库的回收视野
即使 autovacuum 被触发了,它也不一定能回收死元组。VACUUM 能否删除一个死元组,取决于是否还有任何活跃事务的 MVCC 快照“可能看见”它。PostgreSQL 通过跟踪最老的活跃事务 ID(xmin)来做这个判断:所有比 xmin 更老的死元组才可以被安全回收。
这意味着一个长时间运行的事务——哪怕它什么都没做,只是 idle in transaction——会把它开始时的快照钉在原地,阻止 VACUUM 回收该快照之后产生的所有死元组。影响范围不是某一张表,而是整个数据库:xmin 是一个全局视界,它被卡住,所有表的清理都停滞[reference:5]。官方故障排查文档对此的措辞很直接:任何阻止 xmin 推进的因素——未结束事务、已准备事务、持有 WAL 的复制槽——都会使清理无法进行,不管 autovacuum 运行得多频繁,或把它调得多激进[reference:6]。
这就是 MVCC 实现方式的一个结构性代价:并发收益来自“多版本共存”,而多版本共存的代价是“回收必须等待所有可能看见旧版本的读者退场”。长事务不是 bug,它是 MVCC 在正确性上必须尊重的存在;但它的副作用是,一个事务的时长可以直接转化为整个数据库的膨胀压力。
复制反馈:备库的查询,变成主库的膨胀
流式复制把同一套边界扩展到了主备之间。当备库上启用了 hot_standby_feedback = on,备库会把它仍然需要的 xmin 反馈给主库,主库据此保留备库可能还需要看的死元组[reference:7]。这是设计使然:目的是避免备库上正在执行的查询因为主库回收了元组而冲突中断[reference:8]。
代价的流向很清晰:备库上一个长时间运行的报表查询,会通过 feedback 机制阻止主库上的 VACUUM 回收死元组。主库的写入量可能并不大,但表照样在膨胀,因为回收的闸门被一个来自另一台机器的快照需求按住了。Netdata 的运维指南把这种情况列为独立的膨胀成因:主库膨胀但写入量中等时,首先要检查的不是写入负载,而是备库的查询模式和 hot_standby_feedback 设置[reference:9]。
这意味着“读写不阻塞”的兑现条件进一步外移:它不仅要求主库上没有人开长事务,还要求备库上没有人跑长查询,或者至少要求部署方清楚,备库查询的稳定性是用主库的磁盘空间换来的。
膨胀不是“空间浪费”,是承诺成本的累积账单
把 autovacuum 的阈值延迟、长事务的视界冻结和复制反馈的跨节点阻塞放在一起,PostgreSQL 膨胀问题的轮廓就清楚了:MVCC 把并发控制的成本从“锁等待”转移到了“后台回收”,而后台回收的速率取决于一个由阈值、活跃快照和复制拓扑共同决定的、数据库自身无法完全控制的边界。
CMU 和 OtterTune 的研究把 MVCC 实现的固有代价归纳为写放大、表膨胀和昂贵的 vacuum,并指出这些不是偶发的运维故障,而是设计选择带来的、需要对特定负载进行有效管理才能维持性能的结构性特征[reference:10]。换句话说,膨胀不是“没调好”,它是 MVCC 这台机器在持续运转时必然产生的废料;vacuum 是处理废料的生产线,而这条生产线的产能,被上面那些边界条件约束着。
当膨胀积累到极端程度,代价会从“慢”变成“不可写”:事务 ID 回卷风险逼近时,PostgreSQL 会进入保护性只读模式,拒绝新的写入,直到一次防回卷 vacuum 完成[reference:11]。这时,最初那个“读写不阻塞”的承诺,就以“写被完全阻塞”的形式,兑现了它全部的账单。
边界在运维纪律里,不在事务引擎里
PostgreSQL 的 MVCC 没有做错什么。它选择了一条把并发成本后置的路径:让事务路径尽量短、尽量不互相等待,把版本清理的责任交给一个异步的、可被配置和监控的后台过程。这条路径在短事务、均匀写入、没有长查询的工作负载下运转良好,边界条件很容易被满足。
问题出现在负载形状滑向边界之外的时候:表大到 20% 的阈值变成不可接受的绝对延迟,事务长到快照冻结了整个数据库的回收视野,或者备库的查询模式悄悄改变了主库的回收节奏。在这些场景下,“读写不阻塞”不是降级为“稍微慢一点”,而是以表膨胀、扫描退化、直到只读保护的形式,把成本转移到另一个维度上。判断 PostgreSQL 的 MVCC 是否适合自己的负载,比看它的 TPS 数字更有用的,是先确认一件事:你的运维纪律——长事务超时、逐表阈值、复制反馈策略——是否足以让 vacuum 持续地、不被阻挡地追上死元组产生的速度。承诺的兑现条件,写在那些沉默的后台进程和配置项里,而不是写在事务引擎的并发控制代码里。
- 点赞
- 收藏
- 关注作者
评论(0)