一个20亿次的旧事务把我的数据库击溃了?!

Rishabh Agarwal 2025-03-10 10:31:42

 
 

一个20亿次的旧事务把我的数据库击溃了?!

 
 

 

要理解这种现象的成因,我们需要深入PostgreSQL事务的内部机制。了解这些机制后,我们才能讨论问题本身以及如何避免它。

 

一、元组、MVCC与事务ID

 

如果你之前接触过关系型数据库,应该已经知道:事务(Transaction)是一组数据库操作的逻辑单元(Unit of Work, UoW),这些操作要么全部提交,要么全部回滚。

 

用户对数据库的任何操作(显式或隐式)都发生在一个事务中。

 

为了实现隔离性(ACID中的I),PostgreSQL采用了**多版本并发控制(Multi-Version Concurrency Control, MVCC)**模型。顾名思义,该模型通过维护数据对象的多个版本来确保事务间的数据隔离性。

 

MVCC的原理同样适用于数据库表的行。PostgreSQL会为同一逻辑行维护多个版本,这些不同版本在PostgreSQL术语中被称为「元组(tuples)」

 

元组是行的某个具体状态;每次更新行都会为同一逻辑行生成一个新元组。

 

作为软件工程师,我们通常不会注意到这一点。但现在既然知道了元组的存在,不妨快速检查一下数据库中「存活元组」和「死亡元组」的数量。

 

简单来说,存活元组是当前或未来事务可能修改或访问的元组,其余均为死亡元组。

 

  •  
SELECT n_live_tup, n_dead_tup, relname FROM pg_stat_all_tables;

 

PostgreSQL为每个事务分配两个ID——虚拟事务ID(VXID)和事务ID(XID)。这些ID用于决定哪些元组对事务可见。

 

事务ID(XID)可以唯一标识所有PostgreSQL数据库中的事务,并用于判断元组的可见性。

 

每个元组的头部包含xmin和xmax字段。xmin是创建该元组的事务XID,xmax是更新或删除该元组的事务XID。如果某元组是行的最新状态,其xmax值为0,PostgreSQL会忽略它。

 

*可通过以下SQL查询查看元组的xmin和xmax值:

 

  •  
SELECT xmin, xmax, * FROM <table_name>;

 

注意:返回的数据仅对当前活动事务可见。

 

PostgreSQL结合xmin、xmax及其他信息,决定某个事务中应显示哪个版本的元组。

 

到目前为止都理解了吗?很好,我们继续!

 

二、XID的限制与「猝死」

 

PostgreSQL的事务ID(XID)是32位无符号整数,理论上有约40亿(2³²)个唯一XID。虽然这看起来很多,但对数据密集型应用来说,可能在几个月内就会耗尽这些XID。

 

XID按1递增分配。当达到2³²时,XID会从0重新开始循环!这个完整的循环周期称为一个「纪元(epoch)」

 

实际上,XID 0、1、2被保留用于特殊用途,因此循环实际从XID 3开始。

 

如前所述,PostgreSQL通过MVCC维护行的多个版本(元组),而这些元组包含xmin和xmax值。但XID循环后,新事务会分配到更小的XID,导致xmin较大的元组突然不可见!

 

此时,所有数据似乎都凭空消失了!这种现象被称为「猝死(sudden death)」

 

三、PostgreSQL如何应对「猝死」

 

理论上,PostgreSQL将2³²个XID分为两半,每半约20亿个。当前事务的XID(例如my_xid)到my_xid + 2³¹属于前一半,其余属于后一半。注意:加法运算基于模2³²。

 

来源:Wikibooks

 

分割点并非固定,而是随着当前XID动态移动。当前事务的前半部分XID属于「未来」,后半部分属于「过去」。

 

基于此,元组可见性逻辑如下:

 

  •  
  •  
  •  
  •  
if my_xid < 2³¹:  return tuples with (xmin < my_xid or xmin > my_xid + 2³¹)else:  return tuples with (xmin < my_xid and xmin > my_xid + 2³¹)

注意:实际实现更复杂,此处仅为简化讨论。

 

分割XID范围后,当前事务可用的未来XID约为20亿个。但剩下的20亿个旧XID对应的数据会如何?这些数据对当前事务不可见。当启动下一个事务时,循环边界向前移动一位,导致最旧的XID(第20亿个)从「过去」变为「未来」。

过去的XID被提升为未来可用

 

假设数据库中仍存在对应20亿次旧事务的元组,这些原本可见的元组会突然不可见,仿佛从未被修改过!这将导致严重的数据不一致。

 

为防止这种情况,PostgreSQL会检查是否存在xmin超过20亿的元组。一旦发现,数据库将立即进入只读模式,禁止写入,直到处理完这些元组。

 

但如何确保这些元组被及时处理?答案是「清理(Vacuuming)」

 

PostgreSQL通过「冻结(freezing)」机制解决这一问题,该机制是清理过程的一部分。

 

当某个元组被认为足够旧(无活跃事务会修改它)时,其xmin会被标记为特殊值,标识为「冻结」。冻结的元组在比较时被视为「远古数据」,始终可见。

 

清理可通过手动执行或自动清理守护进程(auto-vacuum)完成。无论哪种方式,必须确保20亿次以上的旧事务元组被冻结,否则可能导致灾难性后果。

 

你可能会问:「既然有后台进程自动处理,为何还需要担心?」

 

虽然后台进程能分担工作,但仍需合理配置自动清理阈值。若数据库负载高,需冻结的元组数量庞大,清理进程可能仅在阈值极高时触发。此时,进程可能无法及时冻结所有元组,最终导致数据库崩溃。

 

因此,深刻理解PostgreSQL的清理机制和自动清理配置至关重要。否则,20亿次旧交易真的会让你的数据库宕机!

 

 

作者丨Rishabh Agarwal   编译丨Rio
来源丨网址:https://medium.com/javarevisited/how-a-2-billion-old-transaction-can-bring-your-database-down-ddf4db0ceaa1
dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn
最新评论
访客 2024年04月08日

如果字段的最大可能长度超过255字节,那么长度值可能…

访客 2024年03月04日

只能说作者太用心了,优秀

访客 2024年02月23日

感谢详解

访客 2024年02月20日

一般干个7-8年(即30岁左右),能做到年入40w-50w;有…

访客 2023年08月20日

230721

活动预告