《》
前面的文档中,我们讨论了很多关于XLOG问题:XLOG如何组织?如何写入log buffer?log buffer如何落盘?现在我们来阐述XLOG的用处:当数据库意外发生宕机后,如何依据XLOG来做数据恢复?在《数据库重启恢复概述》中,我们阐述过数据库几种基于日志的恢复策略,其中提到undo\redo策略是现在应用最广泛的策略。而PostgreSQL也使用的这种策略。
依据undo\redo策略,我们在数据库重启恢复时,需要对已提交事务中未落盘的数据进行redo,对未提交事务中已落盘的数据进行undo。而PostgreSQL对于undo\redo策略的实现非常简单,可以归纳为两点:
可见,PostgreSQL在故障恢复时并不做undo操作,对于元组的undo是留在查询的可见性判断时进行的,所以PostgreSQL会牺牲一定的查询性能,换来无比简单的故障恢复流程。本文只讨论PostgreSQL在故障恢复时如何依据XLOG实现redo,对于CLOG及可见性的判断会在后面的文章中阐述。
在概述中我们提到PostgreSQL故障恢复时,会redo日志文件中的所有XLOG。诚然,如果一个数据库存放了从诞生之日起至今所有的XLOG,当发生故障时,然后将全部的XLOG都redo一次,一定可以恢复数据库中的数据。但是这样做既不现实也没必要。不现实是因为数据库每天都会面对大量的增删查改操作,产生大量的XLOG,所以如果想要保存是个数据库今年甚至几十年的所有XLOG需要多大的空间?没必要是因为数据库发生故障通常只有部分还没来得及落盘的数据会丢失,需要通过XLOG来恢复,而大部分的数据已经持久化到磁盘,没必要恢复,所以根本不需要将所有XLOG都redo一次。那么这里就牵扯到几个问题:
问题1:如何判断一条XLOG对应的数据是否落盘?
这个问题,其实在前面的文档中已经回答过了。每一条XLOG都对应一个LSN,在PostgreSQL中,将XLOG的物理偏移作为LSN。PostgreSQL中插入流程如下:
将元组写入数据页
为这个insert操作产生一条XLOG
将XLOG写入log buffer,返回该XLOG的LSN
将步骤3返回的LSN写入步骤1的数据页,作为这个页面的page LSN
那么在故障恢复时,通过对比XLOG的log LSN与页面的page LSN,就知道这个XLOG对应的数据是否已经落盘。如果log LSN <= page LSN则说明XLOG对应的数据已经落盘,该XLOG无需在页面中执行redo。否则就说明XLOG对应的数据没有落盘,XLOG需要进行redo操作。
问题2:是不是存在某个点,在这个点之前的所有XLOG都不需要做redo?
当然存在。一个感性的认识,一个数据库运行了两天,那么在第二天的时候,第一天的数据怎么都该落盘了,那么在故障恢复时这部分数据都不需要用XLOG来恢复,而这部分XLOG理所当然就不需要了,可以删了。这个特性可以极大的减少日志的体量,提高磁盘空间的利用率。
而这个点,就叫做redopoint,在故障恢复时,只需要从redopoint开始遍历XLOG直到日志文件结尾,从而大大缩短故障恢复的时间。
问题3:既然redopoint这么好,那么如何确定redopoint呢?
redopoint的特性是在redopoint之前所有XLOG对应的数据都已经落盘。那么与其去找redopoint不如构建redopoint。怎么构建呢?思路非常简单,假设log buffer当前的情况如图1所示:
当前日志写入的位置为Insert->CurrBytePos,那么只要在这个时候将数据页面中的所有数据都落盘,那么在落盘完成之后,Insert->CurrBytePos之前的所有XLOG对应的数据都落盘了,Insert->CurrBytePos自然成为了redopoint。数据页落盘的时其他的事务可以正常的开始或提交。这就是PostgreSQL实现checkpoint的核心思想。
下面,我们来看看PostgreSQL是如何实现checkpoint的。在PostgreSQL中有以下场景会触发checkpoint:
PostgreSQL有一个专门的后台进程用于周期性执行checkpoint,然而其他情况触发checkpoint后,也是通过向后台进程发信号的方式将checkpoint交给后台进程来完成。比如超级用户在执行了CHECKPOINT操作时,PostgreSQL会调用RequestCheckpoint,在该函数中会通过kill给后台进程发送信号(关键代码:checkpointer.c line1012),从而使后台进程执行checkpoint。执行checkpoint的后台进程如图所示:
所以,我们在调试checkpoint流程时需要附加这个进程。
实现checkpoint创建的函数为CreateCheckPoint,该函数代码比较长,所以我们只分析其关键流程。在分析流程之前,我们先来看几个重要的结构体。checkpoint的相关信息被保存在名为pg_control的文件中。与该文件对应的是一个全局变量:
/*
* We maintain an image of pg_control in shared memory.
*/
static ControlFileData *ControlFile = NULL;
ControlFileData有非常多的成员,这里我们只说明一些重要的成员:
typedef struct ControlFileData
{
/*
* System status data
*/
DBState state; /* 数据库状态 */
XLogRecPtr checkPoint; /* 最近一次chekpoint的位置 */
XLogRecPtr prevCheckPoint; /* 上一次chekpoint的位置 */
} ControlFileData;
state
表示数据库的状态,重启时可以通过state来判断之前数据库是否正常关闭,如果没有正常关闭,则进入恢复流程。
checkPoint
checkpoint的位置。checkpoint实际是一个CheckPoint结构体,其中存放了redopoint。在checkpoint流程的最后,这个结构体中的数据会被作为一条XLOG写入日志文件并落盘。 而这条XLOG的LSN就会被记录在ControlFileData的checkPoint成员中。所以在恢复时,通过checkPoint就可以获取到CheckPoint结构体中的数据,从而得到redopoint。
prevCheckPoint
在checkpoint的流程中也可能出现系统故障,所以checkPoint对应的数据不一定正确,所以PostgreSQL使用prevCheckPoint来存放上一次chekpoint的位置,如果最近一次的chekpoint不靠谱,那么就使用从上一次的checkpoint开始恢复。
现在我们来看看CheckPoint结构体:
typedef struct CheckPoint
{
XLogRecPtr redo; /* next RecPtr available when we began to
* create CheckPoint (i.e. REDO start point) */
...
} CheckPoint;
在这个结构体中,我也只需要关注第一个成员redo,这就是redopoint。在恢复时总是从redo处开始遍历XLOG,直到日志文件结束。
现在,我们来看看CreateCheckPoint的主要流程及对应的关键代码:
在CheckPointGuts中,通过调用CheckPointBuffers对数据页面进行落盘,CheckPointBuffers遍历所有的数据页面,然后调用SyncOneBuffer将脏页进行落盘。
调用顺序:CheckPointGuts > CheckPointBuffers > BufferSync
关键代码:BufferSync中line1957的while循环
在执行完第2步后,redopoint之前的日志对应的数据都落盘了,所以这个redopoint就正式生效了,那么就可以写入log buffer中。
这里注意一下这个ProcLastRecPtr。ProcLastRecPtr是进程私有的一个变量,在执行XLogInsert之后,日志的开始位会被存放在ProcLastRecPtr中,结束位置会被存放在XactLastRecEnd中。
至此,checkpoint的创建流程完毕。
现在,我们来看看当数据库发生故障重启后,是如何通过redo XLOG来恢复数据的。重启恢复的主要函数是StartupXLOG,这个函数非常的长,所以我们依然只分析其关键流程:
RecPtr在前面被初始化为checkpoint的位置
这里将RecPtr与redopoint进行比较,如果redopoint比较小,则定位redopoint对应的XLOG。ReadRecord是一个非常重要的函数,原型如下:
static XLogRecord *
ReadRecord(XLogReaderState *xlogreader, XLogRecPtr RecPtr, int emode,
bool fetching_ckpt)
该函数用于获取RecPtr对应的XLOG,然后将这条XLOG的结束为位置存放在xlogreader的EndRecPtr成员中。如果给RecPtr传InvalidXLogRecPtr,那么ReadRecord就会获取xlogreader->EndRecPtr对应的XLOG,同理获取到XLOG后,会将这条XLOG的结束位置存放在EndRecPtr中。
所以给RecPtr传InvalidXLogRecPtr通常是为了获取下一条XLOG。
RmgrTable设计的非常精妙,是使用C语言实现类似C++多态性的一种方式,我们来看看RmgrTable的定义:
typedef struct RmgrData
{
const char *rm_name;
void (*rm_redo) (XLogReaderState *record);
void (*rm_desc) (StringInfo buf, XLogReaderState *record);
const char *(*rm_identify) (uint8 info);
void (*rm_startup) (void);
void (*rm_cleanup) (void);
} RmgrData;
const RmgrData RmgrTable[RM_MAX_ID + 1] = {
#include "access/rmgrlist.h"
};
对,你没看错,这里用一个#include来初始化RmgrTable。access/rmgrlist.h文件的内容如下:
PG_RMGR(RM_XLOG_ID, "XLOG", xlog_redo, xlog_desc, xlog_identify, NULL, NULL)
PG_RMGR(RM_XACT_ID, "Transaction", xact_redo, xact_desc, xact_identify, NULL, NULL)
PG_RMGR(RM_SMGR_ID, "Storage", smgr_redo, smgr_desc, smgr_identify, NULL, NULL)
PG_RMGR(RM_CLOG_ID, "CLOG", clog_redo, clog_desc, clog_identify, NULL, NULL)
PG_RMGR(RM_DBASE_ID, "Database", dbase_redo, dbase_desc, dbase_identify, NULL, NULL)
PG_RMGR(RM_TBLSPC_ID, "Tablespace", tblspc_redo, tblspc_desc, tblspc_identify, NULL, NULL)
PG_RMGR(RM_MULTIXACT_ID, "MultiXact", multixact_redo, multixact_desc, multixact_identify, NULL, NULL)
PG_RMGR(RM_RELMAP_ID, "RelMap", relmap_redo, relmap_desc, relmap_identify, NULL, NULL)
PG_RMGR(RM_STANDBY_ID, "Standby", standby_redo, standby_desc, standby_identify, NULL, NULL)
PG_RMGR(RM_HEAP2_ID, "Heap2", heap2_redo, heap2_desc, heap2_identify, NULL, NULL)
PG_RMGR(RM_HEAP_ID, "Heap", heap_redo, heap_desc, heap_identify, NULL, NULL)
PG_RMGR(RM_BTREE_ID, "Btree", btree_redo, btree_desc, btree_identify, NULL, NULL)
PG_RMGR(RM_HASH_ID, "Hash", hash_redo, hash_desc, hash_identify, NULL, NULL)
PG_RMGR(RM_GIN_ID, "Gin", gin_redo, gin_desc, gin_identify, gin_xlog_startup, gin_xlog_cleanup)
PG_RMGR(RM_GIST_ID, "Gist", gist_redo, gist_desc, gist_identify, gist_xlog_startup, gist_xlog_cleanup)
PG_RMGR(RM_SEQ_ID, "Sequence", seq_redo, seq_desc, seq_identify, NULL, NULL)
PG_RMGR(RM_SPGIST_ID, "SPGist", spg_redo, spg_desc, spg_identify, spg_xlog_startup, spg_xlog_cleanup)
PG_RMGR(RM_BRIN_ID, "BRIN", brin_redo, brin_desc, brin_identify, NULL, NULL)
PG_RMGR(RM_COMMIT_TS_ID, "CommitTs", commit_ts_redo, commit_ts_desc, commit_ts_identify, NULL, NULL)
PG_RMGR(RM_REPLORIGIN_ID, "ReplicationOrigin", replorigin_redo, replorigin_desc, replorigin_identify, NULL, NULL)
PG_RMGR(RM_GENERIC_ID, "Generic", generic_redo, generic_desc, generic_identify, NULL, NULL)
PG_RMGR(RM_LOGICALMSG_ID, "LogicalMessage", logicalmsg_redo, logicalmsg_desc, logicalmsg_identify, NULL, NULL)
PG_RMGR的定义如下:
#define PG_RMGR(symname,name,redo,desc,identify,startup,cleanup) \
{ name, redo, desc, identify, startup, cleanup },
所以展开后代码如下:
const RmgrData RmgrTable[RM_MAX_ID + 1] = {
{RM_XLOG_ID, "XLOG", xlog_redo, xlog_desc, xlog_identify, NULL, NULL},
{RM_XACT_ID, "Transaction", xact_redo, xact_desc, xact_identify, NULL, NULL},
...
{RM_HEAP_ID, "Heap", heap_redo, heap_desc, heap_identify, NULL, NULL},
...
};
所以,对于insert来说,恢复时会调用RM_HEAP_ID对应的rm_redo,也就是heap_redo,然后调用heap_xlog_insert来重做insert操作。而在heap_xlog_insert中会调用XLogReadBufferForRedo来比较XLOG的log LSN和页面的page LSN。如果log LSN <= page LSN则返回BLK_DONE表示该XLOG不需要在页面中进行redo。
现在,我们用一个用例来验证一下,前面所说的无脑redo。我们设计如下用例:
## 建表
drop table if exists test;
create table test(a varchar);
## 插入一条记录
insert into test values('Completely destroy information stored without your knowledge or approval: Internet history, Web pages and pictures from sites visited on the Internet, unwanted cookies, chatroom conversations, deleted e-mail messages, temporary files, the Windows swap file, the Recycle Bin, previously deleted files, valuable corporate trade secrets, Business plans, personal files, photos or confidential letters, etc.East-Tec Eraser 2005 offers full support for popular browsers (Internet Explorer, Netscape Navigator, America Online, MSN Explorer, Opera), for Peer2Peer applications (Kazaa, Kazaa Lite, iMesh, Napster, Morpheus, Direct Connect, Limewire, Shareaza, etc.), and for other popular programs such as Windows Media Player, RealPlayer, Yahoo Messenger, ICQ, etc. Eraser has an intuitive interface and wizards that guide you through all the necessary steps needed to protect your privacy and sensitive information.Other features include support for custom privacy needs, user-defined erasure methods, command-line parameters, integration with Windows Explorer, and password protection.Direct Connect, Limewire, Shareaza, etc.), and for other popular programs such as Windows Media Player');
## 做一次CHECKPOINT
CHECKPOINT;
## 开启事务
begin;
## 插入多次,直到XLOG跨块
insert into test values('Completely destroy information stored without your knowledge or approval: Internet history, Web pages and pictures from sites visited on the Internet, unwanted cookies, chatroom conversations, deleted e-mail messages, temporary files, the Windows swap file, the Recycle Bin, previously deleted files, valuable corporate trade secrets, Business plans, personal files, photos or confidential letters, etc.East-Tec Eraser 2005 offers full support for popular browsers (Internet Explorer, Netscape Navigator, America Online, MSN Explorer, Opera), for Peer2Peer applications (Kazaa, Kazaa Lite, iMesh, Napster, Morpheus, Direct Connect, Limewire, Shareaza, etc.), and for other popular programs such as Windows Media Player, RealPlayer, Yahoo Messenger, ICQ, etc. Eraser has an intuitive interface and wizards that guide you through all the necessary steps needed to protect your privacy and sensitive information.Other features include support for custom privacy needs, user-defined erasure methods, command-line parameters, integration with Windows Explorer, and password protection.Direct Connect, Limewire, Shareaza, etc.), and for other popular programs such as Windows Media Player');
然后我们按照如下步骤操作:
建表并插入一条记录
手动做一次CHECKPOINT
如此可以确保第一条记录插入成功,且相应的数据和XLOG已经落盘。
开启一个新的是事务
打开两个VS,附加当前的用户进程和后台日志进程
执行多次插入操作,直到XLOG写满一个buffer page
此时TEST表中有7条元组。
等待后台日志进程将XLOG落盘
step5会触发后台日志进程将XLOG进行落盘,注意此时事务并没有提交。
XLogWrite成功后,立即kill掉PostgreSQL的主进程
kill掉这个进程会使PostgreSQL的所有其他进程都被kill掉。此时TEST表中的情况是,有1条正常提交且落盘的元组,以及6条未提交且未落盘的元组,但这6条元组对应的XLOG都已经落盘。按照常规的思路,由于6条元组对应的事务没有提交,那么他本身就不应该出现在数据库中。所以在数据库重启后,其实没有必要去redo这6条元组。现在我们来看看PostgreSQL重启后会怎么做?
重启PostgreSQL,观察redo流程
a. 重启后,首先获取重启前数据库的状态是DB_IN_PRODUCTION,说明数据库在重启前正在正常工作,所以重启时需要进行数据恢复。
b.从redopoint开始遍历XLOG,然后调用对应函数执行redo
比较page LSN和log LSN,判断XLOG应该做何种操作:
这里action值为BLK_RESTORED表示该XLOG是一个备份区块的XLOG,因为这是CHECKPOINT之后的第一次insert。而在此之后其他insert对应的action都是BLK_NEEDS_REDO,表示这条XLOG需要在页面中进行redo。
所以在PostgreSQL中redo操作并不关心事务是否提交。
c.redo次数
在前面的用例中,checkpoint之后,我们实际上执行了6次insert。但实际上只会redo 5条数据。这是什么原因呢?在后台日志进程将日志落盘时,log buffer应该如下图所示:
其中page0已经写满,而page1只写了一部分,第6次insert产生的XLOG横跨了page0~page1。在《Log Buffer》中我们讲过,后台日志进程只会将写满的page落盘,也就是只会将page0落盘,而page1不会落盘,则就造成了第6次insert的XLOG不全,在ReadRecord中会用各种办法判断一条XLOG是否完整,是否正确,如果发现不完整或者不正确的XLOG则直接结束redo循环。
查询
结束恢复后,我们来做一次全表遍历查询,看看test表中元组的情况。
lines的值为6,表示现在TEST表中有6条元组(CHECKPOINT之前插入的1条以及重启恢复的5条)。
而在可见性判断时,只有第一条可见,其余5条均不可见。
所以最终只查询出1条元组
还记得之前在《XLOG 2.0》中讲到的备份区块么?为了解决Partial Write带来的问题。对于checkpoint 之后,页面的第一次修改,会在 XLOG 中记录页面的全部数据。那么如何判断对于某个页面的修改时checkpoint 之后的第一次修改呢?在前面讲解Checkpoint流程时,我们跳过了一个步骤,在获取redopoint之后,会将这个redopoint记录在XLogCtl->Insert.RedoRecPtr中,XLogCtl->Insert.RedoRecPtr的修改流程如下:
加锁
在修改XLogCtl->Insert.RedoRecPtr之前会先调用WALInsertLockAcquireExclusive进行加锁。在《PostgreSQL重启恢复—Log Buffer》中我们讲过WALInsertLockAcquire,该函数会对当前进程对应的WALInsertLock加锁,而WALInsertLockAcquireExclusive会遍历WALInsertLocks数据,将所有的WALInsertLock加锁。
获取redopoint
修改XLogCtl->Insert.RedoRecPtr
RedoRecPtr是一个进程私有的变量,由于访问XLogCtl->Insert.RedoRecPtr需要加锁,为了提升并发性每个进程都有一个私有的RedoRecPtr用于缓存XLogCtl->Insert.RedoRecPtr,对于RedoRecPtr的读写不需要加锁。后面我们将看到RedoRecPtr的具体应用。
解锁
好了,有了RedoRecPtr之后,我们来看看PostgreSQL是如何使用RedoRecPtr来判断页面是否需要全备份。其实判断规则非常简单,在XLogRecordAssemble函数的line527开始会进行是否全备份的判断。首先取出页面的page LSN,比较page LSN与RedoRecPtr的大小,如果page LSN <= RedoRecPtr,则说明该页面需要全备份。
注意
由于对于RedoRecPtr的修改是发生在CheckPointGuts之前的,所以当满足page LSN <= RedoRecPtr时,check point并不一定完成了。但这并不影响页全备份,因为checkpoint完成后,异常恢复时,是从RedoRecPtr开始遍历日志,所以必须保证RedoRecPtr之后页面的第一次修改要将整个页面写入XLOG。
从上面的代码中,我们使用RedoRecPtr与page LSN相比较,然而RedoRecPtr是本地进程私有的变量,其中的值并不一定等于全局的XLogCtl->Insert.RedoRecPtr,而访问XLogCtl->Insert.RedoRecPtr需要加锁。为了提高并发性,XLogInsertRecord中我们会对本地RedoRecPtr进行校验。XLogInsertRecord用于将XLOG写入log buffer,而这个操作本身就需要加锁!。所以在这个函数里面校验RedoRecPtr非常合适,具体步骤如下:
加锁
获取全局Insert->RedoRecPtr,并判断其是否与本地RedoRecPtr相等,如果不相等则更新本地RedoRecPtr
比较RedoRecPtr与page LSN,如果page LSN <= RedoRecPtr,则返回InvalidXLogRecPtr,从而利用外层循环重新执行XLogRecordAssemble
至此,我们阐述了如何判断是否需要备份区块,同时也解决了《XLOG 2.0》遗留的那个“诡异”流程。