在计算机系统中,checksum 通常用于校验数据在传输或存取过程中是否发生错误。PostgreSQL 从 9.3 开始支持 checksum,以发现数据因磁盘、 I/O 损坏等原因造成的数据异常。本文介绍 PostgreSQL 中 checksum 的使用及其实现原理。
概述
PostgreSQL 从 9.3 开始支持数据页的 checksum,可以在执行 initdb
时指定 -k
或 --data-checksums
参数开启 checksum,但开启 checksum 可能会对系统性能有一定影响,官网描述如下:
Use checksums on data pages to help detect corruption by the I/O system that would otherwise be silent. Enabling checksums may incur a noticeable performance penalty. This option can only be set during initialization, and cannot be changed later. If set, checksums are calculated for all objects, in all databases.
启用 checksum 后,系统会对每个数据页计算 checksum,从存储读取数据时如果检测 checksum 失败,则会发生错误并终止当前正在执行的事务,该功能使得 PostgreSQL 自身拥有了检测 I/O 或硬件错误的能力。
Checksum 引入一个 GUC 参数 ignore_checksum_failure
,该参数若设置为 true
,checksum 校验失败后不会产生错误,而是给客户端发送一个警告。当然,checksum 失败意味着磁盘上的数据已经损坏,忽略此类错误可能导致数据损坏扩散甚至导致系统奔溃,此时宜尽早修复,因此,若开启 checksum,该参数建议设置为 false
。
实现原理
设置 checksum
数据页的 checksum 在从 Buffer pool 刷到存储时才设置,当页面再此读取至 Buffer pool 时进行检测。
PostgreSQL 中 Buffer 刷盘的逻辑集中在 FlushBuffer
中,其中设置 checksum 的逻辑如下:
/*
* Update page checksum if desired. Since we have only shared lock on the
* buffer, other processes might be updating hint bits in it, so we must
* copy the page to private storage if we do checksumming.
*/
bufToWrite = PageSetChecksumCopy((Page) bufBlock, buf->tag.blockNum);
比较有意思的是,其他进程可能会在只加 content-lock 共享锁的情况下并发修改 page 的 Hint Bits,从而导致 checksum 值发生变化,为确保 page 的内容及其 checksum 保持一致,PostgreSQL 采用了 先复制页,然后计算 checksum
的方式,如下:
/*
* We allocate the copy space once and use it over on each subsequent
* call. The point of palloc'ing here, rather than having a static char
* array, is first to ensure adequate alignment for the checksumming code
* and second to avoid wasting space in processes that never call this.
*/
if (pageCopy == NULL)
pageCopy = MemoryContextAlloc(TopMemoryContext, BLCKSZ);
memcpy(pageCopy, (char *) page, BLCKSZ);
((PageHeader) pageCopy)->pd_checksum = pg_checksum_page(pageCopy, blkno);
即先将数据页的内容拷贝一份,拷贝的数据自然不会被其他进程修改,然后基于该拷贝页计算并设置 checksum 值。
checksum 算法
数据页的 checksum 算法基于 FNV-1a hash 改造而来,其结果为 32 位无符号整型。由于 PageHeaderData
中 pd_checksum
是 16 位无符号整型,因此将其截取 16 位作为数据页的 checksum 值,如下:
/*
* Save pd_checksum and temporarily set it to zero, so that the checksum
* calculation isn't affected by the old checksum stored on the page.
* Restore it after, because actually updating the checksum is NOT part of
* the API of this function.
*/
save_checksum = cpage->phdr.pd_checksum;
cpage->phdr.pd_checksum = 0;
checksum = pg_checksum_block(cpage);
cpage->phdr.pd_checksum = save_checksum;
/* Mix in the block number to detect transposed pages */
checksum ^= blkno;
/*
* Reduce to a uint16 (to fit in the pd_checksum field) with an offset of
* one. That avoids checksums of zero, which seems like a good idea.
*/
return (checksum % 65535) + 1;
pg_checksum_block
函数计算数据页的 32 位 checksum 值,具体算法可以参考源码,在此不详述。
检测 checksum
PostgreSQL 会在页面从存储读入内存时检测其是否可用,调用函数为 PageIsVerified
,该函数不仅会检测正常初始化过的页(non-zero page
),还会检测 全零页(all-zero page)
。
为什么会出现
全零页
呢?
在特定场景下表中可能出现全零页
,比如有进程扩展了一个表,即在该表中添加了一个新页,但在 WAL 日志写入存储之前,进程崩溃了。此时新加的页可能已经在表文件中,下次重启时就会读取到。
对于 non-zero page
,检测其 checksum 是否一致以及 page header 信息是否正确,若 checksum 失败,但 header 信息正确,此时会根据 ignore_checksum_failure
值判断验证是否通过;对于 all-zero page
,如果为全零,则验证通过。
若验证失败,两种处理方式:
- 若读取数据的模式为
RBM_ZERO_ON_ERROR
且 GUC 参数zero_damaged_pages
为 true,则将该页全部置 0 - 报错,
invalid page
checksum 与 Hint bits
数据页写至存储时,如果写失败,可能会导致破碎的页(torn page),PostgreSQL 通过 full_page_writes
特性解决此类写失败导致数据不可用的问题。
Hint Bits 是数据页中用于标识事务状态的标记位,一般情况下,作为提示位,不是很重要。但如果使用了 checksum,Hint Bits 的变化会导致 checksum 值发生改变。设想如果一个页面发生部分写,恰好把某些 Hint Bits 写错,此页面可能并不影响正常使用,但 checksum 会抛出异常,此时应如何恢复呢?
在 checksum 的实现中,checkpoint 后,如果页面因更新 Hint Bits 第一次被标记为 dirty
,需要记录一个 Full Page Image
至 WAL 日志中,以应对以上提到的因 Hint Bits 更新丢失导致 checksum 失败的问题,具体实现可参考 MarkBufferDirtyHint
。对于已经是 dirty
的页,更新 Hint Bits 则不需要记录 WAL 日志,因为在 checkpoint 后,第一次将该页标记为 dirty
时已经写入了对应的 Full Page Image
。
可见,在启用 checksum 的情况下,checkpoint 后页面的第一次修改如果是更新 Hint Bits, 会写 Full Page Image
至 WAL 日志,这会导致 WAL 日志占用更多的存储空间。
关于 PostgreSQL checksum 和 Full Page Image
的关系,可以参考 stackoverflow 上这个问题。
查看 checksum
PostgreSQL 10 在 pageinspect
插件中添加了函数 page_checksum()
用来查看 page 的 checksum,当然使用 page_header()
也可以查看 page 的 checksum,如下:
postgres=# SELECT page_checksum(get_raw_page('pg_class', 0), 0);
page_checksum
---------------
17448
(1 row)
postgres=# SELECT * FROM page_header(get_raw_page('pg_class', 0));
lsn | checksum | flags | lower | upper | special | pagesize | version | prune_xid
------------+----------+-------+-------+-------+---------+----------+---------+-----------
0/78A1E918 | 17448 | 0 | 200 | 368 | 8192 | 8192 | 4 | 0
(1 row)
总结
Checksum 使 PostgreSQL 具备检测因硬件故障或传输导致数据不一致的能力,一旦发生异常,通常会报错并终止当前事务,用户可以尽早察觉数据异常并予以恢复。当然,开启 checksum 也会引入一些开销,体现在两个方面:
- 计算数据页的 checksum 会引入一些 CPU 开销,具体开销取决于 checksum 算法的效率
- checkpoint 后,若因更新 Hint Bits 将页面第一次置为
dirty
会写一条记录Full Page Image
的 WAL 日志,以用于恢复因更新 Hint Bits 产生的破碎页。
对于数据可用性要求较高的场景,通常建议将 full_page_writes
和 checksum 都打开,前者用于避免写失败导致的数据缺失,后者用于尽早发现因硬件或传输导致数据不一致的场景,一旦发现,可以利用 full_page_writes
和 checksum 记录在 WAL 日志中的 Full Page Image
进行数据恢复。