PostgreSQL Data Page Corruption Repair
PostgreSQL is a very reliable database, but even the most reliable database will struggle when faced with unreliable hardware. This article introduces methods for dealing with data page corruption in PostgreSQL.
The Initial Problem
A statistics database running offline tasks in production encountered an error when business users ran SQL:
Seeing this error message, the first instinct is that it’s a relational data file corruption caused by hardware errors. The first step is to check and locate the specific problem.
Here, 16400 is the database’s oid, and 275852 is the table’s relfilenode, usually equal to OID.
After locating the problematic table, check the problematic page. The error indicates that the page with block number 18858877 has issues.
Through inspection, we found that this page cannot be accessed, but the pages before and after it can be accessed normally. Using errverbose can print the source code location where the error occurred. Searching PostgreSQL source code, we find this error message appears in only one location: https://github.com/postgres/postgres/blob/master/src/backend/storage/buffer/bufmgr.c. We can see that the error occurs when the page is loaded from disk to the memory shared buffer. PostgreSQL considers this an invalid page, so it reports an error and aborts the transaction.
Further examining the logic of the PageIsVerified function:
Next, we need to specifically locate the problem. The first step is to find the position of the problematic page on disk. This is actually two sub-problems: which file it’s in, and the offset address within the file. Here, the relation file’s relfilenode is 275852. In PostgreSQL, each relation file is split into 1GB segment files by default, named according to the rule relfilenode, relfilenode.1, relfilenode.2, ....
Therefore, we can calculate: the 18858877th page, each page 8KB, one segment file 1GB. The offset is 18858877 * 2^13 = 154491920384.
Thus, the problematic page is located within the 143rd segment at offset 0x386FA000.
This translates to the specific file ${PGDATA}/base/16400/275852.143.
Using a binary editor to open and navigate to the corresponding offset, we found that the page content has been zeroed out and has no salvage value. Fortunately, online databases have at least a primary-replica configuration. If it’s page corruption caused by bad blocks on the primary, the replica should still have the original data. Indeed, we can find the corresponding data on the replica:
Of course, if the page is normal, executing read operations on the replica won’t report errors. Therefore, you can directly retrieve the corrupted data by filtering through CTID.
So far, although the data has been recovered, we can breathe a sigh of relief. But the bad block problem on the primary still needs to be handled. This is relatively simple - just rebuild the table and extract the latest data from the replica. There are various methods: VACUUM FULL, pg_repack, or manually rebuilding and copying data.
However, I noticed a parameter I’d never seen before in the code that determines page validity: zero_damaged_pages. Looking up the documentation, I found this is a developer debugging parameter that allows PostgreSQL to ignore corrupted data pages, treating them as all-zero empty pages. It uses WARNING instead of ERROR. This aroused my interest. After all, sometimes for some rough statistical business, having SQL that ran for several hours interrupted due to one or two dirty records might be more frustrating than missing those few records. Can this parameter meet such requirements?
zero_damaged_pages(boolean)PostgreSQL normally reports an error and aborts the current transaction when it detects a corrupted page header. Setting
zero_damaged_pagestooncauses the system to instead report a warning and zero out the corrupted page in memory. However, this destroys data, meaning all rows on the corrupted page will be lost. But it does allow you to bypass the error and retrieve undamaged rows from uncorrupted pages in the table. This option is useful for recovering data when corruption is caused by software or hardware issues. Normally, you should only use this option when you’ve given up on recovering data from the corrupted pages. The zeroed pages are not forced to be written back to disk, so it’s recommended to rebuild the corrupted table or index before turning off this option again. This option is off by default and can only be modified by superusers.
After all, when the table is rebuilt, the original bad blocks are released. If the hardware itself doesn’t provide bad block identification and screening functionality, this becomes a time bomb that might cause problems again in the future. Unfortunately, the database on this machine is 14TB, using a 16TB SSD, and there are temporarily no machines of the same type available. We can only make do for now, so we need to research whether this parameter can allow queries to automatically skip bad pages when encountered.
The Makeshift Solution
As follows, set up a test cluster locally, configure primary-replica. Try to reproduce the problem and determine:
Connect to the primary, create a sample table and insert 555 records, occupying approximately three pages.
Now, let’s simulate bad block situation. First find the corresponding file for the test table in the primary.
We’ve already given the logic for PostgreSQL to determine whether a page is “normal”. Here we’ll modify the data page to make it “abnormal”. Bytes 12-16 of the page, which are the last four bytes of the first line here a0 03 c0 03, are pointers to the upper and lower bounds of free space within the page. Interpreted in little-endian, this means that within this page, free space starts at 0x03A0 and ends at 0x03C0. Logical free space ranges naturally need to satisfy upper bound ≤ lower bound. Here we’ll modify the upper bound 0x03A0 to 0x03D0, exceeding the lower bound 0x03C0, i.e., changing the fourth-to-last byte of the first line from A0 to D0.
Here, although the page on disk has been modified, the page is already cached in the memory shared buffer pool. Therefore, from the primary database, we can still normally see results from page 1. Next, restart the primary to clear its buffer. Unfortunately, when the database is shut down or a checkpoint is executed, pages in memory will be flushed back to disk, overwriting our previously edited results. Therefore, first shut down the database, re-execute the edit, then start.
We can see that the modified page 0 cannot be recognized by the database, but the unaffected page 1 can still be accessed normally.
Although queries on the primary fail due to page corruption, executing similar queries on the replica returns normal results:
Next, let’s turn on the zero_damaged_pages parameter. Now queries on the primary don’t error. Instead, there’s a warning, data on page 0 evaporated, and returned results start from page 1.
Page 0 has indeed been loaded into the memory buffer pool, and the data in the page has been zeroed out.
The zero_damaged_pages parameter needs to be configured at the instance level:
Here, by configuring zero_damaged_pages, the primary can continue to cope even when encountering bad blocks.
After garbage pages are loaded into memory and zeroed, if a checkpoint is executed, will this all-zero page be flushed back to disk to overwrite the original data? This is very important because dirty data is still data with salvage value. Causing permanent irreversible loss for temporary convenience is certainly unacceptable.
We can see that whether it’s checkpoints or restarts, this all-zero page in memory won’t forcibly replace the corrupted page on disk, leaving hope for recovery while ensuring online queries can continue. Excellent! This also matches the description in the documentation: “The zeroed pages are not forced to be written back to disk.”
A Subtle Problem
Just when I thought the experiment was complete and I could safely turn on this switch to cope temporarily, I suddenly remembered a subtle issue: the primary and replica read different data, which is quite awkward.
More awkwardly, the primary cannot see tuples from page 0, meaning the primary thinks records from page 0 don’t exist. Therefore, even with primary key constraints on the table, you can still insert records with the same primary key:
If we promote this replica to become the new primary, this problem still exists on the replica: one primary key can return two records! What a disaster…
Additionally, there’s an interesting question: how will VACUUM handle such zero pages?
VACUUM “fixed” this page? But unfortunately, VACUUM taking it upon itself to fix dirty data pages isn’t necessarily a good thing… Because when VACUUM completes the repair, this page is treated as a normal page and will be flushed back to disk during CHECKPOINT…, thereby overwriting the original dirty data. If this repair isn’t the result you wanted, data may be lost.
Summary
- Replication and backup are the best methods for dealing with hardware damage.
- When data page corruption occurs, you can find the corresponding physical page, compare it, and attempt repair.
- When page corruption prevents queries from proceeding, the parameter
zero_damaged_pagescan be used temporarily to skip errors. - The parameter
zero_damaged_pagesis extremely dangerous - When zeroing is enabled, corrupted pages are loaded into the memory buffer pool and zeroed, and won’t overwrite the original disk pages during checkpoints.
- Pages zeroed in memory will be attempted to be repaired by VACUUM, and repaired pages will be flushed back to disk by checkpoints, overwriting original pages.
- Content in zeroed pages is invisible to the database, so constraint violations may occur.
