Бэкап есть, а на вопрос «сколько данных мы потеряем, если база упадёт сейчас, и через сколько она вернётся» нет ответа. Первое число называется RPO (recovery point objective): сколько времени назад отстаёт самая свежая точка, которую можно восстановить. Второе — RTO (recovery time objective): сколько проходит от решения восстанавливаться до момента, когда база принимает записи. Оба определяет механика бэкапа, а не слово в регламенте: RPO — как часто новые данные попадают в копию, RTO — сколько байт надо вернуть на место и сколько WAL проиграть. Механизм журнала описан в статье про WAL, раскладка файлов копии — в статье про каталог данных. Реплика не заменяет бэкап: ошибочный DELETE реплицируется так же быстро, как и корректный, это разобрано в статье про потоковую репликацию. Опыт идёт на базах около 900 МБ: объём больше на этой машине не измерялся.
RPO и RTO на временной шкале: базовая копия, архив WAL, потеря и возврат
Стенд
Один контейнер с включённой архивацией в локальный каталог и 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 не включает копирование копии с удалённого хранилища и ожидание людей.
| Способ | RPO | RTO на стенде | Ограничения |
|---|---|---|---|
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. - Реплика и бэкап решают разное. Реплика отвечает за доступность; ошибочное удаление она применит, и только копия с точкой до ошибки его отменит.
Чек-лист
- Запиши текущие RPO и RTO своей базы как числа. Нет числа — нет стратегии.
- Раз в квартал восстанавливай копию в отдельный кластер по тому же рецепту, что и в боевой ситуации, и записывай время каждого шага.
- После восстановления сравни число строк в ключевых таблицах с боевой базой и найди разницу — это измеренный RPO.
- Для архива WAL следи за
pg_stat_archiver:failed_countне растёт,last_archived_timeне старшеarchive_timeoutплюс запас. - Убери стенд вместе с томами:
docker rm -f -v dba-backup-strategy-rpo-rto.