• 硬件错误,例如突然断电、磁盘错误、有人拔了你的内存条 :P
  • 软件问题,例如操作系统崩溃、数据库内部存在bug等等
  • 操作错误,例如误删数据、插入了不符合预期的数据、应用程序异常等等
  • … …

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

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

表1 REDO和UNDO的对比

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

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

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

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

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

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

实现简述

在记录日志时

  • 每个数据页面 (堆或索引) 都标有影响页面的最新XLog记录的LSN
  • 在缓冲区管理器能够写出一个脏页面之前,它必须确保XLog已经被刷新到磁盘,至少达到页面的LSN

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

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

  • 从检查点开始,回放WAL日志,如果数据页面的LSN小于WAL记录的LSN,则说明数据页面比较旧,需要进行回放,反之则不需要回放,就会跳过回放过程。

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

checkpointer:PostgreSQL中的检查点进程

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

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

  1. -rw------- 1 postgres users 1073741824 Apr 17 08:41 000000010000000000000001
  2. -rw------- 1 postgres users 1073741824 Apr 14 11:09 000000010000000000000002
  3. -rw------- 1 postgres users 1073741824 Apr 14 11:09 000000010000000000000003
  4. 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的日志,每一条日志可以理解为一次对数据库的操作记录:

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

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

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

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


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是如何记录的:

上述代码是一个典型的插入数据、写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记录

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

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

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

  1. 调用顺序:StartupXLOG->heap_redo->heap_xlog_insert
  2. heap_xlog_insert(XLogReaderState *record)
  3. // 如果xl_info中存在XLOG_HEAP_INIT_PAGE,则说明需要初始化页
  4. if (XLogRecGetInfo(record) & XLOG_HEAP_INIT_PAGE)
  5. {
  6. buffer = XLogInitBufferForRedo(record, 0);
  7. page = BufferGetPage(buffer);
  8. PageInit(page, BufferGetPageSize(buffer), 0);
  9. action = BLK_NEEDS_REDO;
  10. }
  11. else
  12. // action是根据page LSN和record LSN计算得到的
  13. action = XLogReadBufferForRedo(record, 0, &buffer);
  14. if (action == BLK_NEEDS_REDO)
  15. {
  16. ...
  17. // 构建htup (HeapTuple),这个就是新插入的数据
  18. htup = &tbuf.hdr;
  19. ...
  20. // 向page中插入这条htup
  21. if (PageAddItem(page, (Item) htup, newlen, xlrec->offnum,
  22. true, true) == InvalidOffsetNumber)
  23. elog(PANIC, "failed to add tuple");
  24. // 将该page的LSN设置为这条记录的LSN
  25. PageSetLSN(page, lsn);
  26. if (xlrec->flags & XLH_INSERT_ALL_VISIBLE_CLEARED)
  27. PageClearAllVisible(page);
  28. // 将该buffer标记为脏
  29. MarkBufferDirty(buffer);
  30. }
  31. // UNLOCK buffer,UNPIN buffer
  32. if (BufferIsValid(buffer))
  33. UnlockReleaseBuffer(buffer);
  34. }

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

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

部分写问题

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

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

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

  • 当数据页面损坏时,有一个叫做FullPageWrite(FPW)的特性来保证数据的完整性
  • 数据文件可以打开checksum用于校验,由于较为影响性能,所以需要在初始化数据库时手动指定开启
  • 每个WAL页面,都包含magic,来检查页面的有效性

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没有进行描述,希望能将这部分原理清晰地带给大家。

参考