数据库内核月报

数据库内核月报 - 2020 / 04

PostgreSQL · 源码分析 · 回放分析(一)

Author: 烛远

基本原理

在数据库的运行过程中,难免会遇到各种非预期的问题,例如:


在这些情况下,我们不希望我们的数据异常甚至丢失,有的情况下我们不能进行修复,例如火灾(这类问题依赖于备份存储介质的方式解决,需要异地容灾),但有的情况下我们可以进行解决,例如断电、崩溃。我们希望当数据库重新启动时,能够恢复其崩溃的那一瞬间的状态,能够恢复出“一致的”、“完整的”数据。

由于内存是易失性的,当数据库发生断电、崩溃等情况时,存储在内存中的数据会丢失,因此不能寄希望于存储在内存中的数据,我们希望找到一种方式,能够帮助数据库系统完成崩溃恢复,同时不那么影响性能。

  REDO UNDO
未提交事务 不允许未提交的数据写入 允许未提交的数据写入(Steal)
已提交事务 已提交的数据可以延迟写入 已提交的数据必须写入(Force)
优点 可以延迟数据写入,减弱随机写 可以直接inplace修改,减少膨胀

表1 REDO和UNDO的对比

WAL(Write-Ahead Logging,预写式日志),就是完成这一工作的重要方式,数据库在执行事务的过程中,会将对数据的操作过程记录在WAL中,当数据库发生崩溃的时候,能够使用这个操作记录,将数据库恢复到崩溃前的状态。日志有几种记录方式,一是记录REDO,二是UNDO,还有一种是REDO/UNDO日志,REDO允许我们重新进行对数据的修改,UNDO允许我们撤销对数据的修改,REDO/UNDO日志是以上两种日志的结合。

除了WAL以外,还有Shadow Pagging的技术,是System R和sqlite所使用到的技术,看上去有点像COW(Copy On Write,写时复制)技术;此外还存在WBL(Write-Behind Logging,结合NVM所产生的技术)等技术出现。

数据库的基本组件

图1 数据库基本组件的联系,I/O是围绕着缓冲区管理器进行的《数据库系统实现》

在数据库系统的内部,存在一个叫做 日志管理器 的基本组件,当数据库在正常运行的时候,事务管理器将对数据的操作发送到日志管理器中,日志管理器会将日志顺序写入到缓冲区管理器中,缓冲区管理器将日志刷入到磁盘中,事务管理器只有在确认这条事务的最后一条日志被刷入到磁盘后,才会向客户端返回事务提交的信息。

    当崩溃发生时,在重启的时候,恢复管理器就会开始工作,它会读取事务的状态,将已经提交的数据重新回放,将已经放弃或者中断的事务进行回滚,将数据库内不一致的数据恢复到一致的状态。在恢复的时候,恢复管理器有一套算法逻辑在其中,决定如何进行回放,大名鼎鼎的ARIES就是这方面的一个算法。

ARIES的算法,是IBM提出的一整套关于日志记录和恢复处理的算法,后续的数据库管理系统都多少参考了该算法。


可以预见的是,如果数据库长时间运行了很久,突然崩溃了,在重启的时候可能需要从数天前开始进行恢复,需要花费数个小时甚至上天的时间。这时候需要使用到检查点技术,将脏数据刷入到磁盘中,记录检查点刷下的最旧的数据页的,可以保证我们在恢复的时候从相对较新的位置开始。同时让我们可以清理掉旧的日志文件(或者复用),让日志不会无限制地增长。

日志所提供的功能不仅于崩溃恢复,它还能提供复制(包括主备复制、外部订阅复制等)、主备状态同步、按时间点还原等功能。

实现简述

在记录日志时


在写XLog、写数据页面的时候,都只写入到缓冲区中,而不等待写入到磁盘中,以提供很快的写入速度,只在事务提交时会进行等待(当打开同步提交时)。

LSN检查仅存在于共享缓冲区管理器中,不存在于临时表使用的本地缓冲区管理器中,因此,对临时表的操作不能被 WAL记录。

XLog:Transaction log,事务的日志,通常指的是记录时的在内存中的事务日志,WAL指的是持久化后的日志 LSN:Log sequence number,日志序列号,这是WAL日志唯一的、全局的标识 bgwriter:PostgreSQL负责将脏页面刷入磁盘的进程 walwriter:PostgreSQL负责将WAL刷入磁盘的进程


在崩溃恢复时

在回放的过程中,checkpointer会持续地做检查点,让数据页面向前更新,这样万一又重启了,能更快地恢复。

checkpointer:PostgreSQL中的检查点进程

日志内容

PostgreSQL的WAL是REDO类型的。我们看一下PostgreSQL的日志的格式和包含的信息。

PG社区还在实现Zheap的特性,这是PG的新的日志格式,是一种REDO/UNDO日志,届时将能够很好地解决PG数据库的膨胀问题,我们将在后续的文章中介绍这一特性。

WAL文件

PostgreSQL的WAL文件存放于数据目录下的pg_wal目录里,ls一下可以看到以下文件:

-rw------- 1 postgres users 1073741824 Apr 17 08:41 000000010000000000000001
-rw------- 1 postgres users 1073741824 Apr 14 11:09 000000010000000000000002
-rw------- 1 postgres users 1073741824 Apr 14 11:09 000000010000000000000003
drwx------ 2 postgres users       4096 Apr 14 11:09 archive_status #和备份有关,表示日志文件的备份状态,这里不做介绍

可以看到这里每个WAL文件大小为1GB(这和我们configure、initdb时的参数有关),命名为一串16进制的串,这个串和时间线以及LSN紧密相关,每个WAL文件都包含了特定时间线内,从某个LSN开始到某个LSN结束的WAL日志。根据一个特定的LSN,可以知道对应的WAL日志的文件名,以及在文件中所处的位置。
PostgreSQL WAL文件
事务日志与WAL段文件 《PostgreSQL指南:内幕探索》

WAL日志

使用pg_waldump工具我们可以看到PostgreSQL的日志,每一条日志可以理解为一次对数据库的操作记录:

rmgr: Standby     len (rec/tot):     42/    42, tx:        699, lsn: 0/410E21B8, prev 0/410E2180, desc: LOCK xid 699 db 13933 rel 221196
rmgr: Heap        len (rec/tot):     59/    59, tx:        699, lsn: 0/410E21E8, prev 0/410E21B8, desc: INSERT off 4, blkref #0: rel 1663/13933/221196 blk 0
rmgr: Transaction len (rec/tot):     38/    38, tx:        699, lsn: 0/410E2228, prev 0/410E21E8, desc: COMMIT 2020-04-17 08:38:04.881890 UTC


这是一条id为699的事务所产生的三条日志,做了锁表、插入数据、提交的操作,让我们对照着SQL看一下这条日志是怎么生成的:

postgres=# begin;
BEGIN      --开启一个新的事务,此时不会分配事务ID,也不会生成WAL
postgres=# lock table t;
LOCK TABLE --锁住表t,生成事务ID 699,生成锁表的日志0/410E21B8
					 --锁住了(db:13933, rel:221196)的表(我们后续会聊这条日志如何在热备模式下发挥作用)
postgres=# insert into t select 1; 
INSERT 0 1 --向表t插入一条数据,生产插入数据的日志0/410E21E8
					 --向表(1663,13933,221196),BlockNumber为0的page,offset为4的tupe的位置,写入了一条数据,该页面的LSN会被更新为这条日志的LSN
postgres=# end;
COMMIT     --提交,生成提交日志0/410E2228(数据库会等待这条日志刷盘再返回给客户端,这是保证持久化的关键,当然得设置同步提交为on)
					 --这条日志包含了事务的提交状态,以及提交的时间(我们后续会聊这个时间如何在时间点还原下发挥作用)


在上面产生了三条不同类型的日志,有Standby,Heap,Transaction三种类型,这里的类型指的是资源管理器的类型。在PostgreSQL中,对数据不同的操作被进行了分类,例如对序列号的操作、对BTree索引的操作,每一类操作类型会使用对应的资源管理器进行管理,包括进行记录和回放。

下图展示了在PostgreSQL 10中所包含的资源管理器的类型,共计有22种(在最新的PostgreSQL 12中,资源管理器的类型未增加),涉及到了堆元组操作、索引操作、序列号操作等。

PostgreSQL的资源管理器
PostgreSQL 10的资源管理器 《PostgreSQL指南:内幕探索》

记录流程

在数据库的运行过程中,很多操作需要记录WAL日志,一个标准的记录流程是这样的:

  1. 对需要修改的页面进行PIN和LOCK操作
  2. START_CRIT_SECTION() 开启临界区,此时不允许任何错误,若发生错误,直接报PANIC错误
  3. 将需要的修改应用到页面上
  4. 将页面标记为脏,这必须发生在WAL日志插入前
  5. 如果该表需要进行插入WAL记录的操作,初始化一条XLOG并插入,然后设置页面的LSN
  6. END_CRIT_SECTION() 结束临界区。
  7. 对需要修改的页面进行UNPIN和UNLOCK操作

buffer和page的区别在于buffer是内存中的,page是在存储中的,buffer中有块区域叫做frame(页框), page会被读取到frame中以供读写 PIN buffer表示从磁盘中置换入page到frame中,并且不能被置换出去 LOCK > buffer表示锁定住buffer,使其他进程无法读写frame(page)


我们可以结合插入数据的代码看一下插入数据是WAL是如何记录的:

调用顺序:PostgresMain->exec_simple_query->PortalRun->PortalRunMulti->ProcessQuery->
    	standard_ExecutorRun->ExecutePlan->ExecModifyTable->ExecInsert->
    	heapam_tuple_insert->heap_insert

heap_insert(Relation relation, HeapTuple tup, CommandId cid,
			int options, BulkInsertState bistate)
{
    // 获取将要插入的heaptup
	heaptup = heap_prepare_insert(relation, tup, xid, cid, options);

    // 读取buffer,在内部会自动PIN buffer,LOCK buffer
	buffer = RelationGetBufferForTuple(relation, heaptup->t_len,
									   InvalidBuffer, options, bistate,
									   &vmbuffer, NULL);

	// 开始临界区
	START_CRIT_SECTION();

    // 插入数据
	RelationPutHeapTuple(relation, buffer, heaptup,
						 (options & HEAP_INSERT_SPECULATIVE) != 0);

	// 将页面标记为脏页
	MarkBufferDirty(buffer);

	// 开始记录WAL日志,RelationNeedsWAL,如果是临时表,就不需要WAL日志了
	if (!(options & HEAP_INSERT_SKIP_WAL) && RelationNeedsWAL(relation))
	{
        // info信息,标记记录为XLOG_HEAP_INSERT类型的,将来将会使用heap_xlog_insert回放
        // 如果是新页,还会标记这个为XLOG_HEAP_INIT_PAGE,就表示回放时需要先初始化新页
		uint8		info = XLOG_HEAP_INSERT;
		if (ItemPointerGetOffsetNumber(&(heaptup->t_self)) == FirstOffsetNumber &&
			PageGetMaxOffsetNumber(page) == FirstOffsetNumber)
		{
			info |= XLOG_HEAP_INIT_PAGE;
			bufflags |= REGBUF_WILL_INIT;
		}

        // 初始化一条XLog记录,并插入
		XLogBeginInsert();
		XLogRegisterData((char *) &xlrec, SizeOfHeapInsert);
        ...
        // 这是一条RM_HEAP_ID类型的日志,将来回放的时候,将会根据这个ID使用heap_redo进行回放
		recptr = XLogInsert(RM_HEAP_ID, info);

        // 设置页面的LSN,值得注意的是这里的LSN用的是EndRecPtr,为什么要在最后设置?
		PageSetLSN(page, recptr);
	}

    //结束临界区
	END_CRIT_SECTION();

    //UNLOCK buffer,UNPIN buffer,之后buffer可以被其他事务使用,或者置换出去
	UnlockReleaseBuffer(buffer);
	if (vmbuffer != InvalidBuffer)
		ReleaseBuffer(vmbuffer);
}

上述代码是一个典型的插入数据、写WAL的一个流程,但关于这个流程还是有不少疑问:

  1. 先修改buffer里的数据,再写WAL,会不会导致数据落盘而写WAL不成功
    回到前面的 缓冲区管理器能够写出一个脏页面 的前提,这个是数据库需要确保不能发生的。需要这个前提的原因在于,PostgreSQL的日志类型时REDO的,数据只能往前回放,无法向后恢复,因此数据页面不能比WAL“新”
  2. 为什么将buffer标记为脏要发生在WAL日志插入前 如果在WAL日志插入后将buffer标记为脏,有可能做检查点时,使用了新的LSN,但是由于该页不是脏页导致跳过刷脏,导致该页数据在磁盘中的是旧的,但是检查点已经超前了,后续崩溃恢复时,该页面就会存在这条WAL日志未回放的情况
  3. 为什么要使用EndRecPtr,可以使用RecPtr吗
    不只是页面的LSN,包括检查点的LSN、刷数据的LSN(flushPtr)等也是使用的EndRecPtr,以刷数据的LSN为例,使用EndRecPtr就能表示已经刷完了到哪个LSN结束的WAL日志对应的数据,要是使用RecPtr就很费解了;检查点的LSN使用EndRecPtr,就能方便地在下次回放时,找到下一条需要回放的日志的LSN。在页面的LSN中,使用就EndRecPtr可以和上述逻辑维持一致了;而且RecPtr在影响完页面后,对这个页面来说已经不重要了,我们关心的是下一条影响这个页面的WAL记录


另外,这里仅仅展示了最简单的插入数据的流程,生成的WAL日志也比较简单,有一些比较复杂的对数据库的修改,比如涉及到索引的分裂,需要创建一个新页面,再写入新key,这需要至少记录两个WAL(涉及到连续分裂会更多),当回放处于这两个WAL日志之间时,数据库处于一个“中间状态”,这就需要一些技巧来隐藏这种状态。

恢复流程

数据库从崩溃中重启,从控制文件中,获知上一次没有正常停库,进入崩溃恢复状态,从控制文件中读取到上一次检查点的位置,从检查点开始进行严格的串行回放。

  1. 读取到新的日志,解析日志头部,根据日志的类型,将日志交由对应资源管理器回放
  2. 解析该WAL日志,根据具体的操作类型,交由具体的函数进行回放
  3. 解析WAL日志内容
  4. XLogReadBufferForRedo,读取需要修改的页面,进行PIN和LOCK操作,并根据LSN确认是否需要REDO
  5. 如果需要REDO,则将日志应用到页面上,更新页面的LSN,标记页面为脏页
  6. 对需要修改的页面进行UNPIN和UNLOCK操作,其他进程可以使用该页面,bgwriter可以向下刷该页面


我们可以结合插入数据的代码看一下redo是如何工作的:

调用顺序:StartupXLOG->heap_redo->heap_xlog_insert

heap_xlog_insert(XLogReaderState *record)
{
	// 如果xl_info中存在XLOG_HEAP_INIT_PAGE,则说明需要初始化页
	if (XLogRecGetInfo(record) & XLOG_HEAP_INIT_PAGE)
	{
		buffer = XLogInitBufferForRedo(record, 0);
		page = BufferGetPage(buffer);
		PageInit(page, BufferGetPageSize(buffer), 0);
		action = BLK_NEEDS_REDO;
	}
	else
        // action是根据page LSN和record LSN计算得到的
        // 如果page LSN<record LSN,说明页面比较旧,需要进行redo
		action = XLogReadBufferForRedo(record, 0, &buffer);
	if (action == BLK_NEEDS_REDO)
	{
		...

        // 构建htup (HeapTuple),这个就是新插入的数据
        htup = &tbuf.hdr;
		...

        // 向page中插入这条htup
		if (PageAddItem(page, (Item) htup, newlen, xlrec->offnum,
						true, true) == InvalidOffsetNumber)
			elog(PANIC, "failed to add tuple");

		// 将该page的LSN设置为这条记录的LSN
		PageSetLSN(page, lsn);

		if (xlrec->flags & XLH_INSERT_ALL_VISIBLE_CLEARED)
			PageClearAllVisible(page);

		// 将该buffer标记为脏
		MarkBufferDirty(buffer);
	}

    // UNLOCK buffer,UNPIN buffer
	if (BufferIsValid(buffer))
		UnlockReleaseBuffer(buffer);
}


这是一条插入数据的WAL日志的回放流程,我们可以看到,记录WAL日志的代码和回放部分的代码是高度一致的,这也该过程被叫做回放的原因。

在崩溃恢复的过程中,数据库已经看不到具体的SQL语句了,只有一条条操作记录,恢复管理器只负责机械地将这些记录应用到数据上,将数据库还原到崩溃前的状态。

部分写问题

现在的磁盘/文件系统大多是4KB对齐的(部分老的磁盘甚至是512字节的扇区),这样就只能保证4KB的原子读写。这就导致了当写入一个较大页面时,会在文件系统、磁盘驱动里被拆分为几次I/O,当写入到一半时,就会发生部分写问题,导致数据页面或者WAL文件损坏。

MySQL也存在类似的问题,它采用了一个叫做double write buffer技术解决了这个问题,但也带来了额外的开销。

PostgreSQL有自己的一套解决的方法:


FullPageWrite(FPW)的原理是,当做了checkpoint后,如果某个数据页面是第一次被修改,那么就会记录完整的数据页面到WAL文件中,当恢复时,就能够获取完整数据页面重新进行修复,因此哪怕数据页面被写坏了,也能够修复出来。当然这也会带来写放大的开销,尤其是当checkpoint十分频繁时,写放大会十分地严重。

该特性需要手动开启,如果数据页大小大于文件系统所提供的原子写粒度的话,就不需要这个特性了。


当WAL也出现错误时,又不巧碰上了崩溃恢复,需要这段WAL日志,很不幸就不能进行恢复了,数据库会及时地崩溃并告诉你无能为力。

但是WAL日志是预分配且一直是顺序写入的,因此也最多由于部分写会丢失尾部的部分WAL日志,且这部分WAL文件没落盘成功,数据库也不会返回事务成功(当同步提交为on时),因此WAL文件遇到部分写问题也没啥影响,直接丢弃这段不完整的WAL日志就行了。

至于更加麻烦的磁盘静默错误和内存错误的话,就很难在数据库层面解决了,一般会通过冗余校验的方式进行解决,例如磁盘的RAID技术(部分RAID级别),ECC内存等。

总结

本文简单描述了数据库崩溃恢复的基本原理,以及PostgreSQL是如何记录日志、进行崩溃恢复的。

本文严重参考了PG源码中的src/backend/access/transam/README,README的原理部分讲的十分清晰,以至于该文在这部分的原理只做了翻译,以及结合源码进行了分析,该README中还包含更多的细节,如果对这部分原理感兴趣,强烈建议去阅读这篇文档。

在下一篇文章中,我将会详细描述在热备的情况下备库如何进行恢复,以及如何做到按时间点还原(PITR),这部分README没有进行描述,希望能将这部分原理清晰地带给大家。

参考

《Intro to Database Systems》CMU Database Group
《数据库系统实现》机械工业出版社
https://github.com/postgres/postgres/blob/master/src/backend/access/transam/README
https://www.pgcon.org/2012/schedule/track/Hacking/408.en.html
https://www.enterprisedb.com/blog/zheap-storage-engine-provide-better-control-over-bloat
http://www.vldb.org/pvldb/vol10/p337-arulraj.pdf
https://chenhuajun.github.io/2017/09/02/PostgreSQL如何保障数据的一致性.html