Справочник DBA: базы данных изнутри читается по порядку

Статья 9.7 · раздел 9 «Бэкапы и восстановление» · 70 из 109

Учения восстановления и битые страницы: pg_amcheck, выгрузка по ctid и zero_damaged_pages

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнере postgres:18 (--memory 1g), data_checksums = on; таблица orders 500 000 строк, 6173 страницы, вместе с индексом 59 МБ; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

В таблице из 500 000 строк SELECT count(*) падает с invalid page in block 3000, а запрос по id = 1 работает: порча сидит в одной странице, и сервер останавливает только тех, кто её читает. Контрольные суммы страниц эту страницу отсекли, но что делать дальше, сумма не говорит. Ответ зависит от того, что лежит рядом: проверенный бэкап с WAL-архивом, реплика или ничего. Ниже порча одной страницы и все три пути: восстановление с замером, pg_amcheck, выгрузка уцелевших строк по ctid и zero_damaged_pages, который стирает данные безвозвратно. Все порчи сделаны на копии внутри собственного контейнера.

DBA / 01

Дерево решений при битой странице

Дерево решений при битой странице Ошибка invalid page in block 3000. Если есть проверенный бэкап, база восстанавливается в отдельный контур: старт, контрольный запрос и pg_amcheck заняли 0,52 с. Если бэкапа нет, но есть чистая реплика, на ней запускают pg_amcheck и переключаются. Если нет ничего, строки выгружают по диапазонам ctid: 499 919 из 500 000. Остаток правят zero_damaged_pages и VACUUM FULL ценой 81 строки с id от 243001 до 243081. invalid page in block 3000 checksum_failures растёт есть проверенный бэкап? pg_verifybackup проходит есть чистая реплика? pg_amcheck на ней без ошибок нет ни того, ни другого решает владелец данных да да нет нет восстановить в отдельный контур старт, запрос и pg_amcheck: 0,52 с на 59 МБ pg_amcheck на реплике → переключение та же команда, что на основной базе выгрузить строки по диапазонам ctid 499 919 из 500 000 строк zero_damaged_pages + VACUUM FULL остаток: 81 строка, id 243001–243081, потеряна навсегда
Порядок вопросов важен: восстановление и реплика не теряют строк, выгрузка по 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 не заменяет контрольные суммы: порчу в значениях столбцов он не видел, а при битой сумме остановился на первой странице. Прогоняй его и на копии с игнорированием суммы, и на восстановленном бэкапе.

Регламент учений

  1. Раз в месяц и после каждого изменения способа бэкапа восстанавливай последний бэкап в отдельный контур. Контрольные запросы: count(*), сумма значимого столбца и md5 по ключам важных таблиц.
  2. После запуска копии прогони pg_amcheck --heapallindexed --parent-check. Ненулевой код возврата останавливает приёмку.
  3. Запиши время от команды восстановления до успешного контрольного запроса: это измеренный RTO из стратегии бэкапов, а не расчётный.
  4. Учение ведут двое: восстанавливает один, результат подписывает другой. В журнал: дата, версия бэкапа, время, расхождения.

Чек-лист

  1. SHOW data_checksums = on везде; checksum_failures из pg_stat_database под алертом, любое ненулевое значение — инцидент.
  2. pg_basebackup оставляй с проверкой сумм (по умолчанию) и после каждого бэкапа запускай pg_verifybackup.
  3. При invalid page не включай zero_damaged_pages первым: сначала остановка записи, копия каталога, затем бэкап или реплика.
  4. Выгрузку по ctid делай до zero_damaged_pages и сверяй потерю с независимым источником.
  5. Контейнер с данными опыта убери вместе с томом: docker rm -f -v dba-restore-drills-and-corruption.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru