В таблице из 500 000 строк SELECT count(*) падает с invalid page in block 3000, а запрос по id = 1 работает: порча сидит в одной странице, и сервер останавливает только тех, кто её читает. Контрольные суммы страниц эту страницу отсекли, но что делать дальше, сумма не говорит. Ответ зависит от того, что лежит рядом: проверенный бэкап с WAL-архивом, реплика или ничего. Ниже порча одной страницы и все три пути: восстановление с замером, pg_amcheck, выгрузка уцелевших строк по ctid и zero_damaged_pages, который стирает данные безвозвратно. Все порчи сделаны на копии внутри собственного контейнера.
Дерево решений при битой странице
ctid теряет только содержимое испорченной страницы, а zero_damaged_pages нужен лишь затем, чтобы таблица снова читалась целиком.Стенд
Контрольные суммы страниц включены (SHOW data_checksums вернул on), расширения pageinspect и amcheck установлены:
C=dba-restore-drills-and-corruption
docker run -d --name $C --memory 1g -e POSTGRES_PASSWORD=pg postgres:18
docker exec -i $C psql -U postgres <<'SQL'
CREATE EXTENSION pageinspect; CREATE EXTENSION amcheck;
CREATE TABLE orders(id int PRIMARY KEY, customer int, note text);
INSERT INTO orders SELECT g, g % 1000, repeat('x', 60) FROM generate_series(1, 500000) g;
VACUUM (ANALYZE) orders;
SQL
Файл таблицы base/5/16440, 6173 страницы, вместе с индексом 59 МБ. В странице 3000 лежит 81 строка с id от 243001 до 243081, до неё 243 000 строк, после неё 256 919.
Опыт 1: одна испорченная страница
Сервер остановлен, восемь байт со смещением 5000 внутри страницы 3000 заменены на 0xFF, сервер запущен. Файл правит одноразовый контейнер на том же томе:
docker stop $C
docker run --rm --volumes-from $C --user postgres postgres:18 sh -c '
f=/var/lib/postgresql/18/docker/base/5/16440
printf "\377\377\377\377\377\377\377\377" | dd of=$f bs=1 seek=$((3000*8192+5000)) conv=notrunc
od -A d -t x1 -j $((3000*8192+4996)) -N 16 $f'
docker start $C
conv=notrunc не даёт dd обрезать файл. od показывает восемь ff среди 78 (это буква x из note): 78 78 78 78 ff ff ff ff ff ff ff ff 78 78 78 78. Запросы по таблице:
SELECT count(*) FROM orders;
SELECT * FROM orders WHERE id = 243001;
SELECT * FROM orders WHERE id = 1;
вывод: первые два запроса падают, третий возвращает строку.
ERROR: invalid page in block 3000 of relation "base/5/16440"
CONTEXT: parallel worker
ERROR: invalid page in block 3000 of relation "base/5/16440"
В журнале сервера перед ошибкой стоит LOG: page verification failed, calculated checksum 35920 but expected 15570. Счётчик сброшен pg_stat_reset() перед опытом; после двух упавших запросов pg_stat_database показывает checksum_failures = 2 и checksum_last_failure = 2026-10-28 00:40:22.944263+00.
Ошибка относится к странице, а не к таблице: запрос, который до блока 3000 не дошёл, отрабатывает.
Опыт 2: что видит pg_amcheck
pg_amcheck обходит кучу (verify_heapam) и B-tree индексы (устройство индекса). Ключ --heapallindexed сверяет каждую строку таблицы с индексом, --parent-check проверяет связи между родительскими и дочерними страницами:
docker exec $C pg_amcheck -U postgres -d postgres -t orders -i orders_pkey --heapallindexed --parent-check
вывод, код возврата 2:
heap table "postgres.public.orders":
ERROR: invalid page in block 3000 of relation "base/5/16440"
btree index "postgres.public.orders_pkey":
ERROR: invalid page in block 3000 of relation "base/5/16440"
Индекс ругается на ту же страницу таблицы: --heapallindexed читает кучу. Сам по себе индекс чист: pg_amcheck -i orders_pkey --parent-check --rootdescend завершился с кодом 0 и без вывода. Чтобы pg_amcheck проверил то, что за суммой, сумму игнорируют (PGOPTIONS='-c ignore_checksum_failure=on'). Вывод — WARNING: ignoring checksum failure in block 3000 of relation "base/5/16440" и код 0: без суммы структура страницы оказалась в порядке. Восемь байт 0xFF попали в текст note одной строки, а pg_amcheck значения столбцов не проверяет, их ловит контрольная сумма. Чтобы увидеть, что находит сам amcheck, в копии правятся ещё два байта: в строке (3500,1) значение id меняется с 283501 на 1004397 (байт 0x04 на 0x0f), а в странице 500 индекса ключ шестого элемента с 0x2c525 на 0xfc525. Прогон с игнорированием суммы, затем второй прогон после возврата байта в индексе (0x02):
btree index "postgres.public.orders_pkey":
ERROR: high key invariant violated for index "orders_pkey"
DETAIL: Index tid=(500,6) points to heap tid=(2241,20) page lsn=0/39CAE38.
btree index "postgres.public.orders_pkey":
ERROR: heap tuple (3500,1) from table "orders" lacks matching index tuple within index "orders_pkey"
--heapallindexed собирает Bloom-фильтр из записей индекса и проверяет в нём каждую строку таблицы. Цитата REL_18_6, verify_nbtree.c, строки 2798–2807: строка кучи без пары в индексе поднимает именно это сообщение.
/* Probe Bloom filter -- tuple should be present */
if (bloom_lacks_element(state->filter, (unsigned char *) norm,
IndexTupleSize(norm)))
ereport(ERROR,
(errcode(ERRCODE_DATA_CORRUPTED),
errmsg("heap tuple (%u,%u) from table \"%s\" lacks matching index tuple within index \"%s\"",
ItemPointerGetBlockNumber(&(itup->t_tid)),
ItemPointerGetOffsetNumber(&(itup->t_tid)),
RelationGetRelationName(state->heaprel),
RelationGetRelationName(state->rel)),
Оба байта затем возвращены, остаётся одна испорченная страница 3000. Прогон только по куче (--no-dependent-indexes) с игнорированием суммы при изменённом id вернул код 0: расхождение видно лишь при сверке с индексом.
Опыт 3: ignore_checksum_failure и pageinspect
ignore_checksum_failure = on не чинит страницу, а разрешает её прочитать: сервер пишет WARNING и отдаёт то, что лежит на диске (документация). Страницу видно через pageinspect, который иначе падает так же:
SELECT * FROM page_header(get_raw_page('orders', 3000));
SET ignore_checksum_failure = on;
SELECT lower, upper, special, pagesize, checksum FROM page_header(get_raw_page('orders', 3000));
SELECT lp, lp_off, lp_len, encode(substr(t_data, 1, 8), 'hex') AS hdr, t_ctid
FROM heap_page_items(get_raw_page('orders', 3000)) WHERE lp_off <= 5007 AND lp_off + lp_len > 4990;
вывод: первый запрос — ERROR: invalid page in block 3000 of relation "base/5/16440", остальные после WARNING: ignoring checksum failure in block 3000 of relation "base/5/16440"
lower | upper | special | pagesize | checksum
-------+-------+---------+----------+----------
348 | 416 | 8192 | 8192 | 15570
lp | lp_off | lp_len | hdr | t_ctid
----+--------+--------+------------------+-----------
34 | 4928 | 93 | 5ab5030022000000 | (3000,34)
Заголовок цел (lower и upper те же, что у здоровой страницы, сумма 15570 совпадает с «expected» из журнала), а байты 5000–5007 лежат в строке lp = 34. Дальше видно, чем опасен параметр:
SELECT count(*) AS total, count(*) FILTER (WHERE note <> repeat('x', 60)) AS bad_note FROM orders;
вывод: total = 500000, bad_note = 1. Запрос прочёл все строки без ошибки, а в одной испорчен текст (SELECT note по ней падает с invalid byte sequence for encoding "UTF8": 0xff). После такого чтения страница осталась в shared_buffers: запрос в новой сессии без параметра вернул 500000 и снова упал только после рестарта сервера. Проверка «ошибка пропала» без рестарта ничего не доказывает.
Опыт 4: выгрузка строк по ctid и zero_damaged_pages
Кого можно спасти, показывает ctid (адрес строки в странице). Диапазоны до и после испорченного блока читает TID Range Scan, который испорченную страницу не открывает:
COPY (SELECT * FROM orders WHERE ctid < '(3000,0)') TO '/tmp/salvage_a.csv' CSV;
COPY (SELECT * FROM orders WHERE ctid >= '(3001,0)') TO '/tmp/salvage_b.csv' CSV;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM orders WHERE ctid >= '(3001,0)';
вывод:
COPY 243000
COPY 256919
Aggregate (actual rows=1.00 loops=1)
Buffers: shared hit=3172
-> Tid Range Scan on orders (actual rows=256919.00 loops=1)
TID Cond: (ctid >= '(3001,0)'::tid)
Buffers: shared hit=3172
3172 буфера — ровно блоки с 3001 по 6172. Выгрузка загружена в orders_salvage; потерю считает сравнение с рядом id: SELECT min(g), max(g), count(*) FROM generate_series(1, 500000) g LEFT JOIN orders_salvage s ON s.id = g WHERE s.id IS NULL вернул 243001 | 243081 | 81. Это работает при плотной нумерации; у других таблиц потерю сверяют с внешним источником. Страница при этом не пуста: heap_page_items под ignore_checksum_failure отдаёт id и customer всех 81 строк (два int4 лежат в первых 8 байтах t_data), и ключи совпали со списком, снятым до порчи. Ключи нужны, чтобы искать утраченные строки в других системах.
Теперь способ вернуть таблице читаемость. Код bufmgr.c (REL_18_6, строки 1821–1829) показывает, что оба параметра — это флаги чтения, а не действия над файлом:
if (zero_damaged_pages)
flags |= READ_BUFFERS_ZERO_ON_ERROR;
/*
* For the same reason as with zero_damaged_pages we need to use this
* backend's ignore_checksum_failure value.
*/
if (ignore_checksum_failure)
flags |= READ_BUFFERS_IGNORE_CHECKSUM_FAILURES;
ignore_checksum_failure отдаёт страницу как есть; zero_damaged_pages заменяет нечитаемую страницу нулями в памяти. Опыт после рестарта сервера, чтобы в буфере не было испорченной страницы:
SET max_parallel_workers_per_gather = 0;
SET zero_damaged_pages = on;
SELECT count(*) FROM orders;
VACUUM (DISABLE_PAGE_SKIPPING) orders;
вывод:
WARNING: invalid page in block 3000 of relation "base/5/16440"; zeroing out page
count
--------
499919
VACUUM прошёл все 6173 страницы и снова написал то же WARNING, но после рестарта сервера запросы упали с прежним invalid page in block 3000: нулевая страница на диск не попала. На диск результат ложится при перезаписи таблицы:
SET zero_damaged_pages = on; VACUUM FULL VERBOSE orders; повторил WARNING ... zeroing out page и написал INFO: "public.orders": found 0 removable, 499919 nonremovable row versions in 6173 pages.
После рестарта: count(*) 499 919, строк с id BETWEEN 243001 AND 243081 — 0, новый файл base/5/16458 на 6172 страницы, pg_amcheck с --heapallindexed --parent-check без вывода и с кодом 0. Строки страницы стёрты навсегда: сумма customer упала с 249 750 000 до 249 746 679, ровно на 1+2+…+81 = 3321. Шаг необратим, если выгрузка не сделана раньше.
Опыт 5: бэкап замечает порчу, учение занимает полсекунды
Страницу 100 нового файла сначала испортили так же и вернули прежние байты (xxxxxxxx): pg_amcheck снова прошёл с кодом 0. Затем снят базовый бэкап (что внутри):
docker exec -u postgres $C bash -c 'time pg_basebackup -U postgres -D /tmp/bk2 -c fast'
docker exec -u postgres $C pg_verifybackup /tmp/bk2
вывод: real 0m1.483s, backup successfully verified. Затем в страницу 100 снова записаны восемь 0xFF, и pg_basebackup повторён в другой каталог:
WARNING: checksum verification failed in file "./base/5/16458", block 100: calculated 7A90 but expected F6BA
pg_basebackup: error: checksum error occurred
Код возврата 1: pg_basebackup по умолчанию проверяет суммы страниц и не заканчивает бэкап с порчей. Учение: поднять /tmp/bk2 на другом порту без внешнего адреса, выполнить контрольный запрос, прогнать pg_amcheck, остановить.
Контрольный запрос SELECT count(*), sum(customer), md5(string_agg(id::text, ',' ORDER BY id)) FROM orders вернул 499919|249746679|8170c828283943b1672ffac130283767 на рабочей базе до порчи и то же на восстановленном бэкапе; pg_amcheck на нём вернул код 0. Запуск, запрос, проверка и остановка заняли real 0m0.516s на 59 МБ.
Что из этого следует
- Порядок решения: бэкап, затем реплика, затем выгрузка. Восстановление не теряет строк, выгрузка теряет содержимое испорченных страниц,
zero_damaged_pagesстирает остаток. У реплики первым делом запусти ту же командуpg_amcheck. ignore_checksum_failureиzero_damaged_pagesне лечат. Первый отдаёт страницу как есть и оставляет её в буфере, второй стирает её, и на диске это закрепляет толькоVACUUM FULL(обычныйVACUUMв опыте не закрепил). Включай их в одной сессии суперпользователя, после выгрузки всего читаемого.pg_amcheckне заменяет контрольные суммы: порчу в значениях столбцов он не видел, а при битой сумме остановился на первой странице. Прогоняй его и на копии с игнорированием суммы, и на восстановленном бэкапе.
Регламент учений
- Раз в месяц и после каждого изменения способа бэкапа восстанавливай последний бэкап в отдельный контур. Контрольные запросы:
count(*), сумма значимого столбца иmd5по ключам важных таблиц. - После запуска копии прогони
pg_amcheck --heapallindexed --parent-check. Ненулевой код возврата останавливает приёмку. - Запиши время от команды восстановления до успешного контрольного запроса: это измеренный RTO из стратегии бэкапов, а не расчётный.
- Учение ведут двое: восстанавливает один, результат подписывает другой. В журнал: дата, версия бэкапа, время, расхождения.
Чек-лист
SHOW data_checksums=onвезде;checksum_failuresизpg_stat_databaseпод алертом, любое ненулевое значение — инцидент.pg_basebackupоставляй с проверкой сумм (по умолчанию) и после каждого бэкапа запускайpg_verifybackup.- При
invalid pageне включайzero_damaged_pagesпервым: сначала остановка записи, копия каталога, затем бэкап или реплика. - Выгрузку по
ctidделай доzero_damaged_pagesи сверяй потерю с независимым источником. - Контейнер с данными опыта убери вместе с томом:
docker rm -f -v dba-restore-drills-and-corruption.