Таблицу удалили в 00:32:39, а полный бэкап сделан за четыре минуты до этого: чтобы вернуть таблицу, мало копии каталога данных, нужен ещё непрерывный поток WAL от момента копирования и точка, на которой его остановить. Копия работающего каталога сама по себе несогласована, и согласует её тот же REDO из статьи про восстановление после сбоя: backup_label говорит, с какой позиции журнала его начинать. Остановка на нужной позиции создаёт новую timeline, как при failover. Ниже полный цикл на базе 456 МБ (pgbench -s 30): базовый бэкап, архив WAL, DROP TABLE, восстановление в новом контейнере и инкрементальные бэкапы PostgreSQL 17+ с pg_combinebackup.
Базовый бэкап, цепочка WAL и ветвление timeline в точке DROP
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
| Бэкап | Размер каталога | Время |
|---|---|---|
| full | 488 МБ | 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 доходит до цели.
Чек-лист
archive_commandне перезаписывает файлы и возвращает ненулевой код при любой ошибке;pg_stat_archiver.failed_countи возрастlast_archived_timeпод алертом.- После каждого бэкапа проверь
backup_labelи наличие всех сегментов отSTART WAL LOCATION. - Восстановление на момент времени репетируй в отдельном контейнере; в отчёте фиксируй цель,
pg_controldataи файл.history. - Для инкрементальных бэкапов держи
summarize_wal = onзаранее и не удаляй звенья цепочки. - Контейнеры и тома после опыта убирай:
docker rm -f -v $S-src $S-rst; docker volume rm $S-vol.