对于 PG 的 full page write 和 MySQL 的 double write 机制一直有些困惑,如果没有这些机制,是不是在页断裂的情况下数据一定就有问题?如果应用 redo log 或者 wal 是幂等设计的,在刷脏页的过程中,无论怎么 crash,只要支持幂等,是否就无需担心页断裂的问题。
带着上述问题,结合 PG 的实现,大致有了答案,以下分析如果有不对的,欢迎指出。
为什么 PG 没有设置 full_page_writes,就可能会导致崩溃后无法恢复?
首先这里说的崩溃是指在刷脏页过程中产生的崩溃,并且导致了页断裂。什么是页断裂?简单来说就是 PG 的数据页只有部分被写入到了磁盘,页面的 crc32 检验值不正常。比如 PG 一个页面通常 8KB,而操作系统一次原子写入的页面可能为 4KB 或者更小的 512 B,这样就可能导致 1 个 8KB 的页面只有一部分写入成功,这种现象称之为页断裂。
产生页断裂后,为什么会影响后面的恢复过程?
PG 崩溃恢复时,以 checkpoint 检查点为起始点,在原始页面的基础上,应用 wal 日志。原始页面发生损坏,也就无法正常应用 wal 日志。因为应用 wal 日志时,会读取原始页面的数据,比如原始页面的的下一条记录的偏移值,原始页面发生损坏,读出来的数据已经不再可靠,基于损坏的原始页面恢复出来的数据,也不再可靠,即使强行做崩溃恢复,也可能会产生数据不一致。
看看官方文档的解释:
官方文档说【存储在 wal 中的行级变化数据不足以恢复这样一个页面】,如何理解这句话?来看一个 insert 产生的 wal 日志记录,里面包含两个重要信息。
第一个是这条 wal 记录属于哪个表空间文件以及哪个 page,对应的结构如下:
typedef struct RelFileNode
{
Oid spcNode; /* tablespace */
Oid dbNode; /* database */
Oid relNode; /* relation */
} RelFileNode;
typedef uint32 BlockNumber;
RelFileNode 表示表空间 Oid 、数据库 Oid 以及表 Oid。BlockNumber 表示该条记录所属的页面号。
第二个是这个记录的 insert 相关的记录头信息,如下:
/* This is what we need to know about insert */
typedef struct xl_heap_insert
{
OffsetNumber offnum; /* inserted tuple's offset */
uint8 flags;
/* xl_heap_header & TUPLE DATA in backup block 0 */
} xl_heap_insert;
offnum 表示该条记录在页面内的序号,即这个页面上的第几条记录,官方文档中说的行级变化数据就是指这个数据。通过这个数据不能找到该记录对应在页面内的物理位置,物理位置需要读取页面的其他内容。在页面损坏的情况下,从页面读取的任何其他数据都不再可靠,所以无法依赖不可靠的数据来应用 wal 日志。
举个例子:
对于一个损坏的页面,其下一条记录指向的位置为 8000,因为该页面已经损坏,crc32 校验不对,此时应用一条 insert wal 记录,不管其 offnum 是多少,需要从页面中读出下一条插入记录对应的页面物理位置,读出来的 8000 到底是不是有效的,无法确认。
full_page_writes 如何避免页断裂问题?
有了 full_page_writes 机制,每次 checkpoint 之后页面的首次更新,都会将页面完整的写入到 wal 日志中。如果在刷脏页时发生崩溃,并且在恢复时发现原始页面损坏,就可以通过 wal 里面的 full page 进行恢复。
然而 full_page_writes 也带来了不小的性能开销。