#pg内核 #事务
概述
两阶段提交主要用于分布式数据库中,分布式数据库本质上是将数据分布到不同的主机上,以实现大数据量的存储。例如greenplum数据库就是基于pg的分布式分析型数据库。分布式数据库主要有两个关注点:分布式执行计划,分布式事务。而分布式事务就是本章要将的两阶段提交。
两阶段提交主要解决两个难点:
- 读操作需要保证在不同的主机上读到一致的数据。
- 需要保证事务在不同主机上的原子性,不能出现某个主机上写数据成功,另一个主机上写数据失败的情况。
两阶段提交就是针对写操作,将事务的提交分为两个阶段,当数据写入不同的主机时,要么全部提交,要么全部回滚。
两阶段提交遵循XA协议,XA协议是一种分布式事务协议,由Tuxedo提出。XA中主要可以分为两部分:事务管理器和本地资源管理器。 其中本地资源管理器由本地数据库实现,事务管理器作为全局的调度者,负责各个本地资源的提交和回滚。
- 2PC: 两阶段提交,是XA协议用于在全局事务中协调多个资源的机制,遵循OSI/DTP 标准。包含两个阶段:准备阶段和提交阶段
- 3PC: 三阶段提交,是两阶段提交的改进版,包含三个阶段:canCommit阶段、PreCommit阶段和DoCommit阶段。相比于两阶段提交,它的改进点是:
1. 同时在协调者和参与者中引入超时机制
2. 引入了一个准备阶段,保证最后提交阶段之前各参与节点的状态是一致的。
pg中是通过max_prepared_transactions参数来控制两阶段提交,当为0时关闭两阶段提交,非0时打开两阶段提交。
正常的事务,在事务commit之前,事务的数据时不会落盘的,一旦这时数据库异常或事务异常,数据将会丢失,但是两阶段提交执行后,即使没有提交,事务相关数据也会刷入磁盘,这时候即使重启了数据库,之前2PC执行的相关事务数据仍然存在。
2PC的使用示例
- 打开2PC的开关
配置max_prepared_transactions为非0值,如100 - 执行2PC SQL命令
CREATE TABLE tb1(id int);
BEGIN;
PREPARE TRANSACTION 'P1'; --创建两阶段提交事务
INSERT INTO tb1 VALUES(1); --插入一行数据
SELECT * FROM tb1; --查询表的内容
SELECT * FROM pg_prepared_xacts; --查询两阶段提交事务内容
- 重启数据库
- 查看2PC并提交
SELECT * FROM pg_prepared_xacts; --查询两阶段提交事务内容
COMMIT PREPARED 'P1'; --提交两阶段提交事务
- 回滚2PC
ROLLBACK PREPARED 'P1';
2PC执行流程
2PC的执行流程主要分为两个阶段: PREPARE阶段,COMMIT/ROLLBACK阶段
引入一个协调者coordinator来协调各个数据库的准备和提交,有了解greenplum数据库的应该就知道,greenplum中的master其实就是扮演者coordinator的角色,各个segment就是对应着各个数据库。
准备阶段
- coordinator向所有参与的数据库发出预处理请求prepare,并开始等待各参与者的响应。
- 各参与者执行本地事务,执行完成后并不会真正提交,也不会释放资源,而是先向coordinator报告结果,是否可以commit。
- coordinator接收到所有的参与者的反馈后,决定是否继续完成commit。
整个准备阶段只要有任一参与者没有正确响应,就会导致事务abort,而coordinator需要记录相关的日志信息。
提交/回滚阶段
- coordinator向所有参与者发出commit/rollback请求
- 各参与者收到commit/rollback请求之后,正式的执行本地事务的commit操作并记录日志,然后释放相关的资源。最后回复coordinator执行结果
- coordinator收到所有参与者的回复之后,记录日志并释放资源。
两阶段提交相关结构
GlobalTransactionData
全局事务数据结构体,保存每个事务相关的数据,数据库启动后,会初始化max_prepared_transactions个GlobalTransactionData结构,并以链表的形式存储到TwoPhaseStateData结构体的freeGxacts中
该结构体主要记录了三部分内容:
- 两阶段提交相关的信息,比如指向的下一个GlobalTransaction地址,进程信息,事务开始时间等
- XLOG信息,事务ID信息及用户信息
- 标志位,比如是否记录到PGPROC中,是否落盘,数据是否是从WAL日志中获得等
typedef struct GlobalTransactionData
{
GlobalTransaction next; /* TwophaseStateData的freeGxacts链表 */
int pgprocno; /* PGPROC,在initPRoc时会初始化 */
BackendId dummyBackendId; /* dummy进程,用来代表一个backend */
TimestampTz prepared_at; /* prepared事务开始的时间 */
XLogRecPtr prepare_start_lsn; /* prepared记录的XLOG日志的起始位置 */
XLogRecPtr prepare_end_lsn; /* prepared记录的XLOG日志的结束位置 */
TransactionId xid; /* 全局事务ID */
Oid owner; /* 启动这个事务的用户 */
BackendId locking_backend; /* 当前事务事务中运行的事务ID */
bool valid; /* 当前进程是在PGPROC中记录 */
bool ondisk; /* Prepare状态数据是否落盘 */
bool inredo; /* 数据内容是否是通过回放WAL日志获得 */
char gid[GIDSIZE]; /* prepared transaction指定的gid */
} GlobalTransactionData;
TwoPhaseStateData
数据库在启动时初始化的一个全局变量,记录了两阶段提交事务需要用到的GlobalTransactionData结构的使用情况。比如空闲链表,已使用的个数,已使用的GlobalTransactionData的地址。
typedef struct TwoPhaseStateData
{
/* Head of linked list of free GlobalTransactionData structs */
GlobalTransaction freeGXacts; //空闲链表,存储的是可用的GlobalTransactionData结构
/* Number of valid prepXacts entries. */
int numPrepXacts; //当前的有效的2PC事务的个数
/* There are max_prepared_xacts items in this array */
GlobalTransaction prepXacts[FLEXIBLE_ARRAY_MEMBER]; //当时使用的2PC的GlobalTransactionData的地址
} TwoPhaseStateData;
共享内存中存储格式
数据库启动时会初始化一个全局共享变量TwoPhaseState和max_prepared_transactions个GlobalTransactionData结构体,所有的GlobalTransactionData是以链表的形式保存,TwoPhaseState的成员freeGXacts指向链表的头部。当启动一个两阶段提交事务时,就会从freeGxacts的链表中取下一个空闲的GlobalTransactionData用来保存当前的2PC的事务信息,同时TwoPhaseState中的numprepXacts计数加1,并会将GlobalTransactionData地址保存到prepXacts数组中。当两阶段提交事务提交后,会重置其使用的GlobalTransactionData结构体,并将其重新插入到freeGxacts链表中,同时numpreXacts计数减1,并清除prepXacts数组中对应的元素。
2PC的代码执行流程
参照上面的使用示例,接下来分析一下执行PREPARE命令,COMMIT命令和ROLLBACK命令后代码执行流程。
prepare阶段
执行PrePare transaction命令后,实际执行的就是执行PrepareTransaction函数
- PrepareTransaction
执行PrepareTransaction函数,进行Prepare相关操作。该函数执行完之后,相关数据就已经写入WAL日志且刷入磁盘中了,即使重启数据库数据也不会丢失,只不过事务状态是未提交的,对其他事务来时还是不可见的。需要注意的是,在Prepare后,这个2PC事务添加的锁也不会释放,即使重启数据库,重启后锁依然存在。
- 事务启动前的准备工作,跟正常的事务启动差不多
TransactionState s = CurrentTransactionState; //获取当前的事务
TransactionId xid = GetCurrentTransactionId(); //获取当前的事务ID,如果不存在就分配一个,也就是两阶段提交必然会分配事务ID
...
s->state = TRANS_PREPARE; //事务状态更新为prepare
prepared_at = GetCurrentTimestamp(); //获取当前的时间作为两阶段提交创建时间戳
BufmgrCommit();//啥也没干
- 从共享内存链表TwoPhaseState中获取一个空闲的GlobalTransactionData并初始化得到一个全局事务状态变量gxact
gxact = MarkAsPreparing(xid, prepareGID, prepared_at,
GetUserId(), MyDatabaseId); //获取到一个全局的GXact
- 启动两阶段提交事务,并进行相应处理,如将当前事务的record写入XLOG中,并由checkpointer控制是否刷盘(手动创建checkpoint会写pg_twophase目录)
StartPrepare(gxact);//初始化2PC文件头数据
AtPrepare_Notify();//已经执行LISTEN、UNLISTEN、NOTIFY的话不允许2PC
AtPrepare_Locks();//记录当前所有的持有的锁信息
AtPrepare_PredicateLocks();//记录所有的predicate lock信息
AtPrepare_PgStat();//记录2PC相关的统计信息
AtPrepare_MultiXact();//记录MultiXact信息
AtPrepare_RelationMap();//2PC 不允许任何relation Map修改
EndPrepare(gxact);//2PC真正执行的东西
- 2PC 事务执行结束后的清理,
标记当前的gxact为prepared,并标记事务状态为in_progress,添加到ProcGlobal共享内存中,该事务所在的proc被添加到全局的ProcGlobal中,该事务会被认为是in progress的,也就是说这个事务的修改并不会被其他事务看到。
PostPrepare_Locks(xid);//将我们持有的锁保存到PGPROC中
ProcArrayClearTransaction(MyProc);//清理事务
...
PostPrepare_Twophase();//彻底与进程解耦
s->fullTransactionId = InvalidFullTransactionId;
s->subTransactionId = InvalidSubTransactionId;
s->nestingLevel = 0;
s->gucNestLevel = 0;
s->childXids = NULL;
s->nChildXids = 0;
s->maxChildXids = 0;
XactTopFullTransactionId = InvalidFullTransactionId;
nParallelCurrentXids = 0;
s->state = TRANS_DEFAULT; //事务状态修改为default
RESUME_INTERRUPTS();
- MarkAsPreparing
- 检查是否开启2PC,检查gid是否有冲突
- 检查是否还有空闲2PC空间
- 从freeGxacts链表上取下一个空闲的gxact并进行初始化
/* fail immediately if feature is disabled */
//只有GUC参数max_prepared_transactions参数不为0才能使用两阶段提交
if (max_prepared_xacts == 0)
ereport(ERROR,
(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
errmsg("prepared transactions are disabled"),
errhint("Set max_prepared_transactions to a nonzero value.")));
LWLockAcquire(TwoPhaseStateLock, LW_EXCLUSIVE);//获取LWlock,因为要访问全局变量TwoPhaseState
//检查是否有GUD冲突
for (i = 0; i < TwoPhaseState->numPrepXacts; i++)
{
gxact = TwoPhaseState->prepXacts[i];
if (strcmp(gxact->gid, gid) == 0) //判断名字是否有冲突
{
ereport(ERROR,
(errcode(ERRCODE_DUPLICATE_OBJECT),
errmsg("transaction identifier \"%s\" is already in use",
gid)));
}
}
/* 从全局的空闲链表中获取一个空闲的GXACT */
if (TwoPhaseState->freeGXacts == NULL)
ereport(ERROR,
(errcode(ERRCODE_OUT_OF_MEMORY),
errmsg("maximum number of prepared transactions reached"),
errhint("Increase max_prepared_transactions (currently %d).",
max_prepared_xacts)));
gxact = TwoPhaseState->freeGXacts;//从freelist上取一个GXact
TwoPhaseState->freeGXacts = gxact->next; //全局的指向下一个
MarkAsPreparingGuts(gxact, xid, gid, prepared_at, owner, databaseid);//初始化gxact以及对应的proc
gxact->ondisk = false; //是否落盘,初始化为false
TwoPhaseState->prepXacts[TwoPhaseState->numPrepXacts++] = gxact;//将选择的GXact地址保存到数据中,方便遍历
LWLockRelease(TwoPhaseStateLock);//释放锁
return gxact;
- EndPrepare
该函数将完成两阶段提交的所有准备并写入到WAL日志。 - 注册数据到record中
- 确保WAL的registerbuffer有足够的空间
- 填充数据到record中
- 插入到WAL buffer中
- 将WAL buffer中数据刷入磁盘
- 标记该两阶段提交事务为有效,将当前进程信息加入到ProcGlobal中
RegisterTwoPhaseRecord(TWOPHASE_RM_END_ID, 0,
NULL, 0); //注册到record中,用于写入到2PL文件
XLogEnsureRecordSpace(0, records.num_chunks);//保证register_buffers有足够的空间
for (record = records.head; record != NULL; record = record->next)
XLogRegisterData(record->data, record->len);//填充数据
gxact->prepare_end_lsn = XLogInsert(RM_XACT_ID, XLOG_XACT_PREPARE);//插入XLOG数据
XLogFlush(gxact->prepare_end_lsn);//WAL日志刷盘
MarkAsPrepared(gxact, false);//标记为可用,并将进程加到ProcGlobal中
commit/rollback阶段
执行commit prepared ‘p1’,提交prepare事务,或执行rollback prepared 'p1’都是执行FinishPreparedTransaction函数实现
- 通过传入的gid从共享内存中的TwoPhaseState中找到gxact
- 从XLOG或pg_twophase中读取gxact对应的数据到buffer中
- 通过RecordTransactionCommitPrepared函数写commit标记到XLOG中,并标记当前事务状态为committed。
- 通过ProcArrayRemove从共享内存变量ProcGlobal中移除当前proc,这样对其他事务就可见了
- 发送IM消息到其他的cache,标记当前tuple已经被更新,需要重新读取填充。
- 提交成功,清理锁信息以及从TwoPhaseState中移除gxact
if (gxact->ondisk) //如果已经落盘到2PL文件中,则从pg_twophase中读取数据
buf = ReadTwoPhaseFile(xid, false);
else //从XLOG中读取数据
XlogReadTwoPhaseData(gxact->prepare_start_lsn, &buf, NULL);
latestXid = TransactionIdLatest(xid, hdr->nsubxacts, children); //获取最新的事务ID
if (isCommit)
RecordTransactionCommitPrepared(xid,hdr->nsubxacts, children,
hdr->ncommitrels, commitrels,hdr->ninvalmsgs, invalmsgs,
hdr->initfileinval, gid); //记录事务提交信息,会写XLOG
else
RecordTransactionAbortPrepared(xid,
hdr->nsubxacts, children,hdr->nabortrels, abortrels,
gid);//记录事务abort信息
ProcArrayRemove(proc, latestXid); //将proc信息从ProcArray中删除
DropRelationFiles(delrels, ndelrels, false); //删除标记为droped的文件
SendSharedInvalidMessages(invalmsgs, hdr->ninvalmsgs); //IM消息相关
PredicateLockTwoPhaseFinish(xid, isCommit); //释放2PC的锁
RemoveGXact(gxact); //从TwoPhase全局变量中删除
if (gxact->ondisk)
RemoveTwoPhaseFile(xid, true); //删除pg_twophase下的对应文件
2PC的checkpoint
checkpoint定时进行checkpoint定时将buffer中的内容刷入到磁盘,其中也会将2两阶段提交的WAL日志信息刷入到磁盘中。这里只会将WAL写入pg_twophase目录下。
正常情况下,两阶段提交事务的数据会在Prepare 命令执行完调用的Endprepare函数中奖事务相关数据写入WAL日志中并刷入磁盘。但是如果在Prepare transaction执行过程中,在执行Endprepare前,checkpointer就触发了,那么就会触发刷WAL日志数据数据到磁盘的。
如果PrePare transaction执行时没有跨过检查点,那么只需要将两阶段数据保存到WAL日志中即可,但是如果跨过了检查点,由于checkpointer会触发WAL日志的刷盘和清理,所以有可能删除2PC产生的WAL日志。因此需要将检查点之前的相关WAL日志保存到数据目录的pg_twophase目录下,文件名以事务ID的8位16进制值命名。
- CheckPointTwoPhase
- 申请2PC的锁
- 遍历每一个2PC事务
- 如果2PC中的end_lsn小于redo点,则将这部分WAL日志保存到pg_twophase目录下下
- 释放2PC的锁
- pg_twophase目录下文件sync
*/
LWLockAcquire(TwoPhaseStateLock, LW_SHARED);
//将所有的2PL相关WAL数据刷入磁盘
for (i = 0; i < TwoPhaseState->numPrepXacts; i++)
{
GlobalTransaction gxact = TwoPhaseState->prepXacts[i];
if ((gxact->valid || gxact->inredo) &&
!gxact->ondisk &&
gxact->prepare_end_lsn <= redo_horizon) //只会将LSN小于checkpoint的redo point时才会写入2PL文件,否则不用写,会刷入WAL日志中
{
char *buf;
int len;
XlogReadTwoPhaseData(gxact->prepare_start_lsn, &buf, &len); //从XLOG中读取2PL数据
RecreateTwoPhaseFile(gxact->xid, buf, len); //创建2PL文件,文件名以事务ID的16进制值命名
gxact->ondisk = true;
gxact->prepare_start_lsn = InvalidXLogRecPtr;
gxact->prepare_end_lsn = InvalidXLogRecPtr;
pfree(buf); //从XLOG读数据的时候分配的空间,这里要释放掉
serialized_xacts++;
}
}
LWLockRelease(TwoPhaseStateLock);
fsync_fname(TWOPHASE_DIR, true); //刷入磁盘
【参考】
- 《PostgreSQL数据库内核分析》
- 《Postgresql技术内幕-事务处理深度探索》
- pg14源码