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

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

Базовый бэкап, архив WAL и восстановление на момент времени: backup_label, timeline 2 и pg_combinebackup

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 в контейнере postgres:18 (--memory 2g); pgbench -s 30, база 456 МБ, 90 секунд нагрузки на 4 клиента; архив WAL в общем томе Docker; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Таблицу удалили в 00:32:39, а полный бэкап сделан за четыре минуты до этого: чтобы вернуть таблицу, мало копии каталога данных, нужен ещё непрерывный поток WAL от момента копирования и точка, на которой его остановить. Копия работающего каталога сама по себе несогласована, и согласует её тот же REDO из статьи про восстановление после сбоя: backup_label говорит, с какой позиции журнала его начинать. Остановка на нужной позиции создаёт новую timeline, как при failover. Ниже полный цикл на базе 456 МБ (pgbench -s 30): базовый бэкап, архив WAL, DROP TABLE, восстановление в новом контейнере и инкрементальные бэкапы PostgreSQL 17+ с pg_combinebackup.

DBA / 01

Базовый бэкап, цепочка WAL и ветвление timeline в точке DROP

От базового бэкапа до ветвления timeline 2 Базовый бэкап стартует с позиции 0/1A000028 на timeline 1. Архив WAL содержит сегменты с 1A по 41. Цель восстановления 00:32:38 лежит перед коммитом DROP TABLE на позиции 0/413D65B0 в 00:32:39,55. Восстановление останавливается перед этим коммитом, ветвится на timeline 2 и записывает файл 00000002.history. Сегменты timeline 1 после точки ветвления в восстановлении не применяются. timeline 1 pg_basebackup START WAL LOCATION 0/1A000028 архив WAL: сегменты 1A … 41, 40 файлов по 16 МБ цель 00:32:38 COMMIT DROP TABLE 0/413D65B0, 00:32:39,55 сегменты 42, 43 в архиве, не применены timeline 2 promote 00000002.history: 1 0/413D65B0 before 2026-10-28 00:32:39.554065+00
Восстановление стартует с REDO-позиции из backup_label, читает 40 сегментов из архива и останавливается перед коммитом DROP TABLE. Точка ветвления записана в 00000002.history; хвост timeline 1 остаётся в архиве нетронутым.

Стенд

Источник и восстановление делят один том: в нём каталоги wal (архив) и backup. Архив пишет обычный cp в archive_command; summarize_wal нужен инкрементальным бэкапам из третьего опыта. После старта каталоги создаются и отдаются пользователю postgres:

S=dba-basebackup-wal-archive-pitr
docker volume create $S-vol
docker run -d --name $S-src --memory 2g -v $S-vol:/shared -e POSTGRES_PASSWORD=pg postgres:18 \
  -c wal_level=replica -c archive_mode=on -c summarize_wal=on -c archive_timeout=60 \
  -c 'archive_command=test ! -f /shared/wal/%f && cp %p /shared/wal/%f'
docker exec -u root $S-src sh -c 'mkdir -p /shared/wal /shared/backup && chown -R postgres:postgres /shared'
docker exec $S-src pgbench -U postgres -i -s 30 -q postgres

test ! -f отказывается перезаписывать уже заархивированный сегмент: молчаливая подмена файла в архиве хуже, чем ошибка. Первые секунды команда падает, пока каталога нет: pg_stat_archiver.failed_count показал 8, последняя неудача в 00:28:44, сразу после старта, и сегмент …01 ушёл в архив повторной попыткой.

Опыт 1: pg_basebackup и backup_label

docker exec $S-src pg_basebackup -U postgres -D /shared/backup/full -c fast --manifest-checksums=CRC32C
docker exec $S-src cat /shared/backup/full/backup_label

вывод (pg_basebackup занял 1,87 с, каталог 488 МБ):

START WAL LOCATION: 0/1A000028 (file 00000001000000000000001A)
CHECKPOINT LOCATION: 0/1A000080
BACKUP METHOD: streamed
BACKUP FROM: primary
START TIME: 2026-10-28 00:28:53 UTC
LABEL: pg_basebackup base backup
START TIMELINE: 1

Первые три поля: с какой позиции начинать REDO, где лежит чекпоинт-запись, на какой timeline. Позиция REDO 0/1A000028 меньше позиции чекпоинта 0/1A000080: REDO-позиция — это начало чекпоинта, а запись о его завершении приходит позже. Файл собирает build_backup_content (тег REL_18_6, строки 59–66):

	appendStringInfo(result, "CHECKPOINT LOCATION: %X/%X\n",
					 LSN_FORMAT_ARGS(state->checkpointloc));
	appendStringInfoString(result, "BACKUP METHOD: streamed\n");
	appendStringInfo(result, "BACKUP FROM: %s\n",
					 state->started_in_recovery ? "standby" : "primary");
	appendStringInfo(result, "START TIME: %s\n", startstrbuf);
	appendStringInfo(result, "LABEL: %s\n", state->name);
	appendStringInfo(result, "START TIMELINE: %u\n", state->starttli);

Пока backup_label лежит в каталоге, сервер при старте пишет в журнал starting backup recovery и берёт REDO-позицию из файла, а не из pg_control (видно в журналах ниже). Поэтому копировать каталог cp-ом без pg_backup_start нельзя, а с PostgreSQL 15 эксклюзивного режима нет: эксклюзивный режим, где файл создавался в самом работающем каталоге и мог остаться после падения, удалён, функции стали pg_backup_start() и pg_backup_stop(). pg_basebackup вызывает их сам и кладёт файл только в копию.

Вторая половина бэкапа — WAL от начала до конца копирования. Про него говорит файл .backup, который архив получил рядом с сегментом …1A:

START WAL LOCATION: 0/1A000028 (file 00000001000000000000001A)
STOP WAL LOCATION: 0/1A000120 (file 00000001000000000000001A)
START TIME: 2026-10-28 00:28:53 UTC
STOP TIME: 2026-10-28 00:28:53 UTC
STOP TIMELINE: 1

(показаны пять строк из десяти). Бэкап согласован после того, как применён WAL до 0/1A000120, то есть в пределах одного сегмента. pg_basebackup -X stream положил этот сегмент в pg_wal копии сам, поэтому такой бэкап поднимается и без архива; архив нужен, чтобы идти дальше.

Дальше pgbench -c 4 -T 90: 111 331 транзакция, 1 236 tps. Каждый заполненный сегмент pgarch отдаёт archive_command; в коде pgarch_archiveXlog это один вызов (строки 601–603):

		/* Archive the file! */
		ret = ArchiveCallbacks->archive_file_cb(archive_module_state,
												xlog, pathname);

Пока команда возвращает не ноль, архиватор повторяет тот же сегмент: так прошли те 8 неудач из стенда. На источнике в pg_wal/archive_status лежат 000000010000000000000041.done … 43.done, pg_stat_archiver показывает archived_count 70 и последний сегмент …43. archive_timeout=60 по документации принудительно переключает сегмент через минуту после предыдущего, поэтому потеря при отказе диска источника ограничена минутой записи, а не заполнением 16 МБ.

Опыт 2: DROP TABLE и восстановление на момент времени

Таблица payments на 200 000 строк, 30 секунд нагрузки pgbench, затем ошибка:

SELECT clock_timestamp();  -- 2026-10-28 00:32:39.504568+00
DROP TABLE payments;
SELECT clock_timestamp();  -- 2026-10-28 00:32:39.609316+00

Ещё 15 секунд нагрузки и pg_switch_wal(), чтобы последний сегмент ушёл в архив (архив к этому моменту около 1 ГБ). Восстановление идёт в новом контейнере из копии full и архива; цель — 00:32:38, за 1,5 секунды до DROP:

docker run -d --name $S-rst --memory 2g -v $S-vol:/shared --entrypoint sleep postgres:18 infinity
docker exec $S-rst bash -c 'cp -a /shared/backup/full /var/lib/postgresql/restore \
  && chown -R postgres:postgres /var/lib/postgresql/restore && chmod 700 /var/lib/postgresql/restore'
docker exec -u postgres $S-rst bash -c "touch /var/lib/postgresql/restore/recovery.signal
  pg_ctl -D /var/lib/postgresql/restore -w -l /var/lib/postgresql/restore.log -o \"-c restore_command='cp /shared/wal/%f %p' \
  -c recovery_target_time='2026-10-28 00:32:38 UTC' -c recovery_target_action=promote\" start"

recovery.signal включает архивное восстановление, restore_command читает сегменты, recovery_target_action = 'promote' превращает сервер в primary, когда цель достигнута (без этого он остановился бы на паузе). Из журнала сервера (без меток времени и PID, часть строк опущена):

LOG:  starting backup recovery with redo LSN 0/1A000028, checkpoint LSN 0/1A000080, on timeline ID 1
LOG:  starting point-in-time recovery to 2026-10-28 00:32:38+00
LOG:  redo starts at 0/1A000028
LOG:  completed backup recovery with redo LSN 0/1A000028 and end LSN 0/1A000120
LOG:  consistent recovery state reached at 0/1A000120
LOG:  redo done at 0/413D65B0 system usage: CPU: user: 0.89 s, system: 1.39 s, elapsed: 6.68 s
LOG:  last completed transaction was at log time 2026-10-28 00:32:36.396926+00
cp: cannot stat '/shared/wal/00000002.history': No such file or directory
LOG:  selected new timeline ID: 2
LOG:  archive recovery complete
LOG:  database system is ready to accept connections

Состояние consistent достигнуто на 0/1A000120, это позиция STOP из файла .backup. REDO прошёл 40 сегментов за 6,68 секунды. Строки cp: cannot stat … 00000002.history — не ошибка: перед выбором новой timeline сервер проверяет, не занят ли её номер. Результат:

docker exec -u postgres $S-rst psql -Atq -c "select count(*) from payments" -c "select pg_is_in_recovery()"
docker exec -u postgres $S-rst pg_controldata /var/lib/postgresql/restore | grep -E "TimeLineID|REDO location"
docker exec $S-rst cat /var/lib/postgresql/restore/pg_wal/00000002.history

вывод:

200000
f
Latest checkpoint's REDO location:    0/413D65B0
Latest checkpoint's TimeLineID:       2
Latest checkpoint's PrevTimeLineID:   1
1	0/413D65B0	before 2026-10-28 00:32:39.554065+00

Таблица на месте, все 200 000 строк. Файл истории — одна строка (после неё в файле идёт ещё пустая): «timeline 1 закончилась на 0/413D65B0, перед событием 00:32:39.554065». Это время не из clock_timestamp() в консоли, а из записи коммита; её видно в WAL:

pg_waldump -p /shared/wal -s 0/413D65B0 -n 1 000000010000000000000041 | cut -c1-187

вывод:

rmgr: Transaction len (rec/tot):   1105/  1105, tx:     122825, lsn: 0/413D65B0, prev 0/413D6578, desc: COMMIT 2026-10-28 00:32:39.554065 UTC; rels: base/5/16415 base/5/16418 base/5/16419

Восстановление остановилось на самой записи COMMIT транзакции DROP TABLE и не применило её. Цель по времени работает с точностью до коммита: сервер применяет записи, пока время коммита не превысит цель, и останавливается на первом, который превысил. Если цель попадает между двумя коммитами, она «округляется» вниз до предыдущего. Новая timeline нужна, чтобы WAL нового primary не смешался с сегментами …42, …43 старой: у них другой номер timeline в имени (000000020000000000000041 против 000000010000000000000041).

Опыт 3: инкрементальный бэкап и pg_combinebackup

С PostgreSQL 17 pg_basebackup --incremental копирует только блоки, изменённые после предыдущего бэкапа. Узнать, какие, помогает summarize_wal = on: процесс-суммаризатор пишет в pg_wal/summaries по файлу на диапазон LSN (на источнике 10 файлов, хранятся wal_summary_keep_time = 10 дней). Первый инкремент сделан сразу после 90 секунд нагрузки, от манифеста полного бэкапа; второй — после 10 секунд лёгкой нагрузки (pgbench -N -c 1 -R 100, 832 транзакции), от манифеста первого:

docker exec $S-src pg_basebackup -U postgres -D /shared/backup/incr1 -c fast --incremental=/shared/backup/full/backup_manifest
docker exec $S-src pg_basebackup -U postgres -D /shared/backup/incr2 -c fast --incremental=/shared/backup/incr1/backup_manifest
БэкапРазмер каталогаВремя
full488 МБ1,87 с
incr1 (после 111 тыс. транзакций)443 МБ9,95 с
incr2 (после 832 транзакций)30 МБ2,93 с

Первый инкремент почти не меньше полного: случайные UPDATE pgbench за 90 секунд задели файл pgbench_accounts так, что в инкременте 367 МБ из 403 МБ. Инкремент хранит блоки целиком (8 КБ), а не изменённые строки, поэтому выигрыш зависит от доли тронутых блоков, а не от числа транзакций. Второй: 825 блоков, 6 766 592 байта. В заголовке файла INCREMENTAL.16399 всё это видно:

head -c 12 /shared/backup/incr2/base/5/INCREMENTAL.16399 | od -An -tx4

вывод:

 d3ae1f0d 00000339 0000c2fc

Магическое число, число блоков 0x339 = 825 и длина файла в блоках на момент бэкапа 0xc2fc = 49 916. 825 блоков × 8 192 = 6 758 400 байт плюс заголовок, выровненный до одной страницы 8 192, даёт ровно 6 766 592 байта файла. Восстановить такой файл можно только с предками. pg_combinebackup делает это функцией reconstruct_from_incremental_file: сначала блоки из самого свежего инкремента (строки 150–156):

	for (i = 0; i < latest_source->num_blocks; ++i)
	{
		BlockNumber b = latest_source->relative_block_numbers[i];

		Assert(b < block_length);
		sourcemap[b] = latest_source;
		offsetmap[b] = latest_source->header_length + (i * BLCKSZ);

Блоки, которых нет в свежем инкременте, функция ищет, идя назад по предыдущим бэкапам: если в предыдущем есть полный файл, берёт блоки из него, иначе читает его INCREMENTAL.*. Склейка и проверка:

docker exec $S-rst bash -c 'cd /shared/backup && time pg_combinebackup full incr1 incr2 -o combined'
docker exec -u postgres $S-rst pg_verifybackup -n /shared/backup/combined

вывод: real 0m1.714s, каталог combined 494 МБ, backup successfully verified. Запуск combined без архива и без recovery.signal проходит восстановление по backup_label второго инкремента:

LOG:  starting backup recovery with redo LSN 0/3A000028, checkpoint LSN 0/3A000080, on timeline ID 1
LOG:  completed backup recovery with redo LSN 0/3A000028 and end LSN 0/3A000120
LOG:  database system is ready to accept connections

Контрольный запрос select sum(abalance), (select count(*) from pgbench_history) from pgbench_accounts на источнике перед вторым инкрементом дал 918131|832, на восстановленном combined — те же 918131|832.

Что из этого следует

  • Бэкап состоит из двух частей: копия каталога с backup_label и непрерывная цепочка WAL от START WAL LOCATION. Потерянный или перезаписанный сегмент в архиве обрывает цепочку, и всё, что после него, восстановить нельзя.
  • Цель по времени — это коммит, а не момент: проверяй результат по pg_waldump и файлу .history, а не по часам консоли. Выбирай цель с запасом в несколько секунд до ошибки.
  • После promote сервер на timeline 2. Если нужно вернуться и выбрать другую цель, у старого архива есть ветка timeline 1: recovery_target_timeline позволяет выбрать ветку явно.
  • Инкрементальный бэкап экономит место, когда меняется малая доля блоков (30 МБ против 488 МБ), и не экономит при случайных обновлениях по всей таблице (443 МБ). Все звенья цепочки нужны вместе: потеря incr1 делает incr2 нерабочим.
  • Бэкап, который не восстанавливали, не проверен: pg_verifybackup сверяет файлы по манифесту, но только запуск показывает, что WAL доходит до цели.

Чек-лист

  1. archive_command не перезаписывает файлы и возвращает ненулевой код при любой ошибке; pg_stat_archiver.failed_count и возраст last_archived_time под алертом.
  2. После каждого бэкапа проверь backup_label и наличие всех сегментов от START WAL LOCATION.
  3. Восстановление на момент времени репетируй в отдельном контейнере; в отчёте фиксируй цель, pg_controldata и файл .history.
  4. Для инкрементальных бэкапов держи summarize_wal = on заранее и не удаляй звенья цепочки.
  5. Контейнеры и тома после опыта убирай: docker rm -f -v $S-src $S-rst; docker volume rm $S-vol.

Источники

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