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

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

Стратегия бэкапов PostgreSQL: RPO и RTO в числах, дамп против физической копии и archive_timeout

опубликовано
читать
≈ 11 мин
автор
среда и версия
PostgreSQL 18.6 в контейнере postgres:18 (--memory 2g, 20 ядер хоста); pgbench -s 60 (905 МБ) и база multi из четырёх таблиц по 1,5 млн строк (906 МБ); опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Бэкап есть, а на вопрос «сколько данных мы потеряем, если база упадёт сейчас, и через сколько она вернётся» нет ответа. Первое число называется RPO (recovery point objective): сколько времени назад отстаёт самая свежая точка, которую можно восстановить. Второе — RTO (recovery time objective): сколько проходит от решения восстанавливаться до момента, когда база принимает записи. Оба определяет механика бэкапа, а не слово в регламенте: RPO — как часто новые данные попадают в копию, RTO — сколько байт надо вернуть на место и сколько WAL проиграть. Механизм журнала описан в статье про WAL, раскладка файлов копии — в статье про каталог данных. Реплика не заменяет бэкап: ошибочный DELETE реплицируется так же быстро, как и корректный, это разобрано в статье про потоковую репликацию. Опыт идёт на базах около 900 МБ: объём больше на этой машине не измерялся.

DBA / 01

RPO и RTO на временной шкале: базовая копия, архив WAL, потеря и возврат

Временная шкала восстановления из базовой копии и архива WAL Слева направо: базовая копия pg_basebackup на LSN 1/36000028 в 00:24:46 (937 МБ за 1,96 с), 23 архивных сегмента с 36 по 4C общим объёмом 368 МБ, последний архивный сегмент закончился на LSN 1/4D000000 в 00:25:19.56, момент сравнения в 00:25:19.66, когда на primary 35 407 строк истории, и момент 00:25:22.63, когда восстановленный кластер принял запись и в нём 35 314 строк. Отрезок между последним архивом и сравнением равен RPO: 93 транзакции и 739 800 байт WAL. Отрезок от запуска копии до готовности равен RTO: 2,9 с, из них 1,74 с проигрывание WAL. pg_basebackup 1/36000028 937 МБ за 1,96 с сегмент …4C 1/4D000000 в архиве 00:25:19.56 primary 35 407 строк сравнение 00:25:19.66 копия готова 35 314 строк запись с 00:25:22.63 23 сегмента, 368 МБ архив WAL с 36 по 4C RPO: 93 транзакции 739 800 Б не в архиве RTO: 2,9 с до записи из них redo 1,74 с RPO — до сбоя, потеряно; RTO — после сбоя, ждём
RPO лежит слева от сбоя: всё, что primary записал после закрытия последнего архивного сегмента, в копию не попало. RTO лежит справа: запуск копии, проигрывание 23 сегментов и выход из режима восстановления.

Стенд

Один контейнер с включённой архивацией в локальный каталог и archive_timeout=30s. Каталоги /archive и /backup — анонимные тома, они удалятся вместе с контейнером. Дамп, копия и восстановленные кластеры лежат рядом с primary, поэтому время не включает копирование по сети:

S=dba-backup-strategy-rpo-rto
docker run -d --name $S --memory 2g -v /archive -v /backup -e POSTGRES_PASSWORD=pg postgres:18 \
  -c archive_mode=on -c 'archive_command=test ! -f /archive/%f && cp %p /archive/%f' -c archive_timeout=30s
docker exec -u root $S chown postgres /archive /backup
docker exec $S pgbench -U postgres -i -s 60 postgres
docker exec $S psql -U postgres -c 'create database multi'
for i in 1 2 3 4; do docker exec $S psql -U postgres -d multi -qc "create table t$i as
  select g as id, md5(g::text)||md5((g+1)::text)||md5((g+2)::text) as payload from generate_series(1,1500000) g;
  alter table t$i add primary key (id)"; done

Команда архивации взята из документации, которая сама называет её примером, а не рекомендацией: cp не делает fsync, для продакшена нужен инструмент, который его делает. Заливка данных при включённой архивации гонит гигабайты WAL в /archive: в первой попытке каталог вырос до 4,4 ГБ. Перед заливкой поставь archive_command='/bin/true' и верни команду после неё. Размеры баз:

postgres|905 MB
multi|906 MB

Опыт 1: логический дамп и параллельность

pg_dump -Fd пишет каталог файлов, по одному на таблицу; -j N берёт N таблиц одновременно с общим снимком, и работает только в формате directory (документация). Дамп и pg_restore выполняются от пользователя postgres, потому что docker exec без -u запускает их от root, файлы получают владельца root, и пользователь postgres потом не может их ни прочитать, ни удалить (в опыте это закончилось Permission denied). Время — встроенный time bash:

docker exec -u postgres $S bash -c 'TIMEFORMAT="real %3Rs"
  time pg_dump -Fd -j 1 -f /backup/dump1 multi; du -sh /backup/dump1
  time pg_dump -Fd -j 4 -f /backup/dump4 multi; du -sh /backup/dump4'

Вывод (каждая команда выполнена дважды, второй прогон: 9,399 с при -j 1 и 2,591 с при -j 4):

real 9.543s
145M	/backup/dump1
real 2.568s
145M	/backup/dump4

Восстановление в чистый кластер: initdb, запуск на порту 5433, createdb multi, затем pg_restore -j N -d multi /backup/dump4:

== pg_restore -j 1
real 6.835s
== pg_restore -j 4
real 4.088s

Дамп с -j 4 быстрее в 3,7 раза, восстановление — в 1,7 раза. Четыре таблицы одного размера идеально делятся между четырьмя работниками; pg_restore кроме COPY строит индексы, и его ускорение меньше. Если в базе одна таблица, параллельность не поможет: на pgbench -s 60 почти всё место занимает pgbench_accounts, и -j 1 и -j 4 дали 1,91 и 1,79 с на дамп, а восстановление с -j 4 не стало быстрее (6,78 с при -j 1, 7,18 с при -j 4, одиночные прогоны). Тот же дамп весит всего 17 МБ при базе 905 МБ: филлер pgbench состоит из пробелов и сжимается почти в ноль. Размер дампа зависит от содержимого, а не от размера базы, и планировать по нему хранилище нельзя.

Итог: дамп плюс восстановление multi — 2,568 + 4,088 = 6,7 с с -j 4 и 9,543 + 6,835 = 16,4 с с -j 1. RPO у дампа равен периоду между дампами: записи, сделанные после начала дампа, в нём не будет. Эта оценка следует из определения, ей не нужен опыт. Дамп читается и более новым сервером (документация говорит об этом прямо), и из способов в таблице ниже только он годится для переноса на новую мажорную версию.

Опыт 2: физическая копия и проигрывание WAL

pg_basebackup -X stream копирует каталог данных и сам забирает WAL, который нужен для согласованности. Контейнер уже архивирует WAL, поэтому после копии на primary идёт нагрузка pgbench -T 20, а сегменты складываются в /archive. Затем копия запускается как восстановление: restore_command берёт сегменты из архива, recovery.signal включает режим восстановления. На primary перед запуском копии записано число строк:

docker exec -u postgres $S pg_basebackup -D /backup/base -X stream -c fast -P -v
docker exec $S pgbench -U postgres -c 4 -j 4 -T 20 postgres
docker exec $S psql -U postgres -Atc "select now(), count(*) from pgbench_history"
docker exec -u postgres $S bash -c "echo \"restore_command = 'cp /archive/%f %p'\" >> /backup/base/postgresql.auto.conf
  touch /backup/base/recovery.signal; chmod 700 /backup/base
  pg_ctl -D /backup/base -o '-p 5433 -c archive_mode=off' -l /backup/base.log -w start"

-c archive_mode=off нужен, чтобы копия не стала писать в тот же /archive. pg_basebackup занял 1,96 с на 937 МБ (START WAL LOCATION: 1/36000028). Вывод primary и первой проверки копии:

2026-10-28 00:25:19.660738+00|35407
waiting for server to start.... done
server started
real 0.109s
1647
t

pg_ctl -w вернулся через 0,109 с, и запрос уже видит 1647 строк при 35 407 на primary, а pg_is_in_recovery() равно t. Сервер открывает соединения только на чтение, как только достигнуто согласованное состояние, но проигрывание продолжается. Проверка «сервер поднялся» не доказывает, что восстановление закончилось. Лог копии показывает, когда оно закончилось на самом деле:

00:25:19.754 LOG:  starting backup recovery with redo LSN 1/36000028, checkpoint LSN 1/36000080, on timeline ID 1
00:25:19.773 LOG:  database system is ready to accept read-only connections
00:25:21.507 LOG:  redo done at 1/4CFFFBA0 system usage: CPU: user: 0.32 s, system: 1.03 s, elapsed: 1.74 s
00:25:21.553 LOG:  archive recovery complete
00:25:22.625 LOG:  database system is ready to accept connections

От pg_ctl start (00:25:19.71) до приёма записи (00:25:22.625) прошло 2,9 с, из них 1,74 с redo. За это время проиграно 23 сегмента с ...36 по ...4C — 368 МБ WAL, около 211 МБ/с. Число строк в pgbench_history после выхода из восстановления:

35314

На primary было 35 407: не хватает 93 транзакций. Восстановление остановилось на 1/4CFFFBA0, а primary к моменту сравнения дошёл до 1/4D0B4578; pg_wal_lsn_diff между ними — 739 800 байт. Этот хвост лежал в сегменте ...4D, который ещё не был закрыт, значит, в архив не попал. Это и есть RPO архивации: данные после последнего закрытого сегмента. pg_stat_archiver в момент сравнения показывал 25|00000001000000010000004C|2026-10-28 00:25:19.56448+00.

Опыт 3: archive_timeout ограничивает RPO архива

Сегмент попадает в архив, когда он закрыт. Под нагрузкой он закрывается заполненным (16 МБ), а при слабой записи мог бы закрыться через часы. archive_timeout заставляет сервер переключить сегмент не реже заданного интервала. Условие в CheckArchiveTimeout (тег REL_18_6), которую вызывает процесс checkpointer:

		/*
		 * Switch segment only when "important" WAL has been logged since the
		 * last segment switch (last_switch_lsn points to end of segment
		 * switch occurred in).
		 */
		if (GetLastImportantRecPtr() > last_switch_lsn)
		{
			XLogRecPtr	switchpoint;

			/* mark switch as unimportant, avoids triggering checkpoints */
			switchpoint = RequestXLogSwitch(true);

Сегмент без новой работы не переключается: нужна «важная» запись после прошлого переключения (снимки RUNNING_XACTS к ним не относятся, это сказано в комментарии к функции). Проверка: на таблицу tick каждые 5 секунд приходится одна вставка, и каждые 10 секунд читается pg_stat_archiver после pg_stat_reset_shared('archiver'). Столбцы: время запроса, archived_count, последний сегмент, last_archived_time, failed_count; ниже каждая третья строка вывода:

00:25:51|1|00000001000000010000004D|00:25:49|0
00:26:21|2|00000001000000010000004E|00:26:19|0
00:26:51|3|00000001000000010000004F|00:26:49|0
00:27:21|4|000000010000000100000050|00:27:19|0

Сегменты уходят в архив ровно через 30 секунд один за другим: 00:25:49, 00:26:19, 00:26:49, 00:27:19, хотя записывается по одной строке. Последняя вставка закоммичена в 00:27:21.9; она оказалась в сегменте ...51 вместе с RUNNING_XACTS и записью SWITCH (pg_waldump), и сегмент ушёл в архив в 00:27:49. Дальше запись прекратилась. За следующие 48 секунд (по 00:28:37) archived_count остался равным 5. На диске каждый такой сегмент занимает 16 777 216 байт, как и полный (ls -l). Документация описывает это как сознательную цену: закрытые досрочно файлы той же длины, что и полные (archive_timeout). При постоянной записи и интервале 30 секунд это 2880 сегментов в сутки по 16 МБ, то есть 46 080 МБ в архиве, если не сжимать их в archive_command (арифметика, не измерение).

Способ, RPO, RTO, ограничения

Числа ниже взяты с одной машины и баз 905–906 МБ; RTO не включает копирование копии с удалённого хранилища и ожидание людей.

СпособRPORTO на стендеОграничения
pg_dump -Fd -j 4 + pg_restore -j 4период между дампами2,568 с дамп + 4,088 с восстановлениечитается новыми версиями; параллельность только по таблицам; не позволяет откат на момент
то же, -j 1период между дампами9,543 с + 6,835 содна большая таблица занимает одного работника
pg_basebackup без архивапериод между копиямикопия 1,96 с; запуск копии без архива не измерялсяфайлы каталога данных: только той же мажорной версии
pg_basebackup + архив WAL, archive_timeout=30sне больше интервала переключения: 30 с в опыте 3; 93 транзакции при 20-секундной нагрузке2,9 с на 23 сегмента (368 МБ)16 МБ на сегмент даже при малой записи; архив нужен целиком от копии до точки

Копия — это файлы каталога данных, поэтому сервер другой мажорной версии её не откроет. Проверка: в свежей копии PG_VERSION содержит 18; после замены на 17 запуск сервера 18.6 заканчивается так:

FATAL:  database files are incompatible with server
DETAIL:  The data directory was initialized by PostgreSQL version 17, which is not compatible with this version 18.6 (Debian 18.6-1.pgdg13+2).

Это проверка заголовка, а не настоящая копия 17-й версии, но поведение сервера при несовпадении версий она показывает.

Что меняется при росте базы

RPO дампа и копии от размера не зависит: он задан расписанием. RPO архива задан интервалом переключения и временем, которое archive_command тратит на сегмент, а не размером базы. RTO состоит из слагаемых, и два из них растут: копирование файлов (на стенде 937 МБ за 1,96 с) и проигрывание WAL, накопленного с момента копии (368 МБ за 1,74 с). Логическое восстановление добавляет построение индексов. Время должно вырасти примерно пропорционально, но коэффициент у каждой базы свой: он зависит от диска, числа таблиц, доли индексов и содержимого, как показал дамп в 17 МБ против 145 МБ. Пропорция верна на объёме 900 МБ, на других объёмах она не измерена, поэтому перенеси её на свою базу только после учения на копии нужного размера.

Формулы для планирования: RPO ≈ min(archive_timeout, время заполнения сегмента) + время archive_command, RTO ≈ обнаружение + возврат файлов + проигрывание WAL + проверка. Обнаружение и проверка людьми на стенде не измерялись.

Правило 3-2-1 (три копии, на двух разных носителях, одна вне площадки) защищает от других отказов: потери диска, ошибки администратора, пожара. Стенд хранит всё в одном контейнере и ничего из этого не проверяет.

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

  • Назови RPO и RTO числом и проверь стендом. «Бэкапы ежедневно» — это RPO сутки. Если нужно меньше, нужен архив WAL, и его RPO ограничен archive_timeout, а не расписанием.
  • Не проверяй восстановление по pg_ctl -w. Сервер принимает запросы на чтение задолго до конца redo: в опыте 1647 строк против 35 314 итоговых. Конец восстановления — pg_is_in_recovery() = f и строка ready to accept connections.
  • Дамп параллелится по таблицам. Если одна таблица занимает почти весь объём, -j ничего не даёт: измерено на pgbench.
  • Архив с archive_timeout стоит места. Каждый досрочный сегмент — 16 МБ; сожми его в archive_command и мониторь failed_count в pg_stat_archiver.
  • Реплика и бэкап решают разное. Реплика отвечает за доступность; ошибочное удаление она применит, и только копия с точкой до ошибки его отменит.

Чек-лист

  1. Запиши текущие RPO и RTO своей базы как числа. Нет числа — нет стратегии.
  2. Раз в квартал восстанавливай копию в отдельный кластер по тому же рецепту, что и в боевой ситуации, и записывай время каждого шага.
  3. После восстановления сравни число строк в ключевых таблицах с боевой базой и найди разницу — это измеренный RPO.
  4. Для архива WAL следи за pg_stat_archiver: failed_count не растёт, last_archived_time не старше archive_timeout плюс запас.
  5. Убери стенд вместе с томами: docker rm -f -v dba-backup-strategy-rpo-rto.

Источники

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