АйТи Фреш
Главная / Статьи / Серверы и хостинг
Серверы и хостинг

«recovery ended before configured recovery target was reached»: почему PITR в PostgreSQL 18 не доезжает до заданного времени

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~27 мин чтения
«recovery ended before configured recovery target was reached»: почему PITR в PostgreSQL 18 не доезжает до заданного времени
Иллюстрация к статье ««recovery ended before configured recovery target was reached»: почему PITR в PostgreSQL 18 не доезжает до заданного времени».

Статья для тех, кто держит PostgreSQL на своём сервере и хотя бы раз пробовал восстановиться «на 03:05, за несколько минут до аварии». Базовая копия развёрнута, доступные сегменты WAL прочитаны, а сервер вместо запуска пишет FATAL и останавливается. Я разберу, что PostgreSQL 18 считает достижением цели по времени, почему одной записи WAL до 03:05 недостаточно, как отличить паузу в транзакциях от недоставленного сегмента и что можно делать с уже начатым восстановлением. Покажу это на согласованном разборе выезда, логах, командах pg_waldump и безопасной конфигурации.

Ситуация: копия есть, WAL читается, а сервер не запускается

Вечер пятницы, звонок: «Ночью выкатили обновление, пропал справочник контрагентов, нужно вернуть состояние на 03:05». Действуем по знакомой схеме: разворачиваем базовую копию в отдельный каталог данных, создаём пустой файл recovery.signal, задаём restore_command и recovery_target_time, затем запускаем экземпляр. PostgreSQL несколько минут читает архив, но вместо паузы в заданной точке оставляет в журнале такую последовательность:

LOG:  redo done at 4B/E7A81358 system usage: CPU: user: 41.22 s, system: 8.71 s, elapsed: 173.05 s
LOG:  last completed transaction was at log time 2026-06-11 02:41:07.412183+03
FATAL:  recovery ended before configured recovery target was reached
LOG:  startup process (PID 21884) exited with exit code 1
LOG:  aborting startup due to startup process failure

Читать эту ошибку как однозначное «архив повреждён» нельзя. Проверка в PerformWalRecovery() устроена проще: если было запрошено архивное восстановление с целью, чтение доступного WAL завершилось, а внутренний признак reachedRecoveryTarget не выставлен, сервер выдаёт FATAL. Из одной этой строки невозможно понять, отсутствует ли очередной сегмент, ошибается ли restore_command, выбрана ли неверная линия времени или в прочитанном потоке ещё не встретилась подходящая транзакционная запись.

Строка last completed transaction was at log time тоже не ставит диагноз в одиночку. Она показывает время последней применённой записи завершения транзакции — COMMIT либо ABORT. Если это время раньше цели, возможны как минимум два объяснения: база действительно не завершала транзакций позже либо нужный хвост WAL ещё не попал в архив. Эти варианты требуют разных действий, поэтому после чтения лога я всегда проверяю архив и живой источник.

Наличие базовой копии и набора файлов с правдоподобными именами не гарантирует достижимость произвольного времени. Для временной цели PostgreSQL должен прочитать запись завершения транзакции на границе, с которой можно сравнить указанную метку. В то же время время изменения файла сегмента на диске ничего не говорит о времени последней транзакции внутри: сегмент могли поздно скопировать, повторно доставить или закрыть после длительного простоя.

Не делайте вывод «ночью не было COMMIT», пока не проверили текущий сегмент `pg_wal` на источнике и работу архивации. Одинаковый FATAL возникает и при спокойной базе, и при недоставленном хвосте WAL.
Памятка: Ситуация: копия есть, WAL читается, а сервер не запускается — схема
Памятка: Ситуация: копия есть, WAL читается, а сервер не запускается. Открыть схему в полном размере

Как PostgreSQL 18 определяет достижение цели по времени

Восстановление последовательно читает записи WAL. До применения очередной записи вызывается recoveryStopsBefore(), после применения — recoveryStopsAfter(). В PostgreSQL 18 временная цель проверяется в recoveryStopsBefore(): сначала код отбрасывает записи, не относящиеся к менеджеру ресурсов Transaction, а затем рассматривает обычные COMMIT и ABORT, а также COMMIT PREPARED и ABORT PREPARED.

Контрольная точка, принудительное переключение сегмента, полностраничный образ, запись Standby и большинство других записей WAL временную цель не отмечают. У некоторых записей, например у именованной точки восстановления, есть собственная метка времени, но recovery_target_time всё равно не использует её как границу. Именованная точка обрабатывается отдельно, когда задан recovery_target_name.

Сравнение времени в ветке REL_18_STABLE выглядит так:

if (recoveryTargetInclusive)
    stopsHere = (recordXtime > recoveryTargetTime);
else
    stopsHere = (recordXtime >= recoveryTargetTime);

При recovery_target_inclusive = on, а это значение по умолчанию, PostgreSQL останавливается перед первой записью COMMIT или ABORT со временем строго позже цели. Транзакции, завершившиеся точно в целевой момент, должны попасть в результат, поэтому для обнаружения конца такой группы нужна следующая, более поздняя запись. При off восстановление останавливается перед первой записью с временем, равным цели или превышающим её.

Отсюда возникает непривычное следствие: чтобы получить состояние на 03:05, серверу обычно требуется прочитать немного «будущего» — хотя бы одну запись завершения транзакции после 03:05. Эта пограничная запись не применяется при временной цели: функция принимает решение до её воспроизведения. Если доступный WAL заканчивается на транзакции 02:41, сервер пока не может доказать, где относительно транзакций находится 03:05.

Это не означает, что временной снимок «не существует» в философском смысле. Состояние данных между 02:41 и 03:05 могло не меняться. Проблема техническая: в имеющемся потоке нет маркера, на котором алгоритм способен зафиксировать достижение цели. Как только появится и будет доставлена запись COMMIT или ABORT после цели, тот же recovery_target_time может стать достижимым.

Цели по LSN, xid и имени работают иначе. При recovery_target_lsn сравнивается позиция текущей записи WAL; inclusive определяет остановку до или после граничной записи. Для recovery_target_xid ищется запись завершения именно указанной транзакции, причём сравнение идёт по равенству, потому что xid выдаются при начале транзакций, а завершаются транзакции не обязательно в том же порядке. recovery_target_name останавливает восстановление после первой точки с совпавшим именем.

Практический вопрос звучит не «есть ли файл WAL после 03:05», а «прочитал ли сервер COMMIT или ABORT после 03:05». Это разные проверки.
«recovery ended before configured recovery target was reached»: почему PITR в PostgreSQL 18 не доезжает до заданного времени — схема
Схема к статье. Открыть схему в полном размере

Разбор выезда: мебельная фабрика «Дубрава», 75 РМ

Условный клиент — мебельная фабрика «Дубрава», 75 РМ, собственный сервер Dell PowerEdge R640 с двумя Intel Xeon Silver 4210, 128 ГБ оперативной памяти и NVMe под данные. Операционная система — Debian 12, база работает на PostgreSQL 18 из репозитория PGDG. Размер кластера — 640 ГБ. Архивирование осталось от прежнего подрядчика: archive_command копирует завершённые сегменты на отдельный том, базовая копия снимается ночью.

11 июня в 03:20 ночной регламент запустил миграцию схемы, а в 03:24 разрушительная транзакция завершилась COMMIT. Утром задача была простой по формулировке: поднять копию на 03:05, выгрузить справочник и вернуть данные в рабочую базу. Я развернул базовую копию от 01:00 в отдельный каталог и задал параметры восстановления:

restore_command = 'cp -- "/srv/pgarchive/%f" "%p"'
recovery_target_time = '2026-06-11 03:05:00+03'
recovery_target_inclusive = on
recovery_target_action = 'pause'
recovery_target_timeline = 'current'

Значение current в этом конкретном случае выбрано осознанно: требовалась та же линия времени, которая была текущей при создании базовой копии. Это не универсальная настройка для любого PITR. Если нужное состояние находится на дочерней линии после предыдущего восстановления, придётся указать её номер либо использовать latest после проверки файлов истории.

Первый запуск закончился приведённым выше FATAL. Последняя применённая запись завершения транзакции имела время 02:41:07.412183. В архивном каталоге находилось 1 247 сегментов, последний доступный файл назывался 000000010000004B000000E7, а его время изменения было 03:57. На первый взгляд казалось, что архив перекрывает цель, но время файла отражало операцию с файлом, а не содержимое WAL.

Я проверил хвост архивного потока утилитой той же основной версии, что и сервер. На машине с несколькими клиентскими версиями сначала обязательно смотрю результат pg_waldump --version, чтобы случайно не разбирать WAL PostgreSQL 18 другой версией программы.

pg_waldump --version
pg_waldump -p /srv/pgarchive -r Transaction 000000010000004B000000E0 000000010000004B000000E7 | tail -n 5

Последняя транзакционная запись действительно оказалась в позиции 4B/E7A81358 и имела время 02:41:07.412183+03. После неё в доступных сегментах были записи других менеджеров ресурсов, но ни одного COMMIT или ABORT. Важная поправка к распространённой диагностике: отсутствие дыр между файлами E0 и E7 ещё не доказывает, что E7 — актуальный конец потока.

Рабочий сервер фабрики оставался доступен, хотя данные на нём уже были испорчены миграцией. Проверка показала, что COMMIT 03:24 находился в текущем, ещё не завершённом сегменте в pg_wal. Архиватор передаёт archive_command только завершённые сегменты, поэтому запись существовала на источнике, но восстановительная машина её не видела. Я принудительно переключил WAL и проверил состояние архиватора:

SELECT pg_switch_wal();

SELECT archived_count,
       last_archived_wal,
       last_archived_time,
       failed_count,
       last_failed_wal,
       last_failed_time
FROM pg_stat_archiver;

После появления следующего сегмента в /srv/pgarchive я повторно запустил тот же каталог восстановления с прежней целью 03:05. PostgreSQL прочитал запись COMMIT 03:24, остановился перед ней и перешёл в паузу. Разрушительная транзакция не была применена как завершённая, справочник оказался на месте. На инцидент ушло 55 минут, из них около 40 минут заняли развёртывание 640 ГБ и воспроизведение WAL.

Здесь принципиальна логическая связность кейса. Если бы COMMIT 03:24 уже находился в доступном архиве при первой попытке, цель 03:05 с inclusive = on была бы достигнута, а описанного FATAL не возникло бы. Причиной оказался не «полный архив без подходящего коммита», а недоставленный текущий сегмент на фоне низкой активности.

Если в доступном WAL уже есть COMMIT разрушительной транзакции позже целевого времени, PostgreSQL должен использовать его как границу. FATAL при таких вводных заставляет проверять выбранную линию времени, фактическую конфигурацию и доступность именно этого сегмента.

Диагностика за пять минут: лог, архиватор и pg_waldump

Когда восстановление остановилось, я сначала сохраняю полный журнал запуска. Нужны сообщения о начале targeted recovery, строка redo done at, время последней завершённой транзакции и ошибки restore_command. Формулировка сообщения о начале зависит от выбранного типа цели, поэтому искать лучше не одну дословную строку, а соседние записи процесса startup.

Следом сверяю конфигурацию. Параметры восстановления могут находиться в postgresql.conf, подключаемых через include файлах и postgresql.auto.conf. Последний читается после основного файла и переопределяет совпадающие значения. Дополнительно параметры можно передать серверу через командную строку, и тогда они имеют ещё больший приоритет. На остановленном экземпляре полезно проверить сами файлы и сохранённую команду запуска, а на работающем — поля setting, source, sourcefile и sourceline представления pg_settings.

SELECT name, setting, source, sourcefile, sourceline
FROM pg_settings
WHERE name IN (
    'restore_command',
    'recovery_target',
    'recovery_target_lsn',
    'recovery_target_name',
    'recovery_target_time',
    'recovery_target_xid',
    'recovery_target_inclusive',
    'recovery_target_action',
    'recovery_target_timeline'
)
ORDER BY name;

Потом проверяю источник. Если он жив, вызываю pg_switch_wal() и наблюдаю pg_stat_archiver. Поле last_archived_wal полезно, но документация отдельно предупреждает: WAL обычно архивируются по порядку, однако это не гарантируется во всех обстоятельствах. Поэтому один номер последнего файла не доказывает наличие каждого предыдущего сегмента. Я сопоставляю журнал архиватора, ошибки копирования и фактический список файлов.

Для базовой копии, созданной pg_basebackup с манифестом, запускаю pg_verifybackup. Проверка WAL специфична для версии, поэтому использую pg_verifybackup PostgreSQL 18. Параметр -w задаёт отдельный каталог WAL. Полноценная проверка WAL поддерживается для копии в обычном формате; для tar-копии документация требует --no-parse-wal, поэтому сам архив таким запуском уже не проверяется.

pg_verifybackup -w /srv/pgarchive /srv/basebackup

Наконец, разбираю интересующий диапазон pg_waldump. Позиционные аргументы — начальный и конечный сегменты. -p задаёт каталог поиска, -r Transaction оставляет записи менеджера Transaction, а -z выводит статистику вместо отдельных записей. Опция -n ограничивает число записей от начала чтения; она не означает «последние записи», поэтому для хвоста обычного вывода я использую tail -n.

pg_waldump -p /srv/pgarchive -r Transaction 000000010000004B000000D0 000000010000004B000000E7

pg_waldump -p /srv/pgarchive -r Transaction 000000010000004B000000E7 | tail -n 50

pg_waldump -p /srv/pgarchive -z 000000010000004B000000E7

Если известен предполагаемый LSN, можно начать чтение с него через -s. Если архив хранит сегменты сжатыми, сначала работаю с их распакованными копиями. Утилита также не читает файлы с суффиксом .partial; бездумно переименовывать единственный экземпляр такого файла нельзя — для исследования делайте копию. Документация предупреждает и о возможных неверных результатах при чтении WAL работающего кластера, поэтому безопаснее анализировать архив либо снимок файлов.

pg_waldump -p /srv/pgarchive -r Transaction -s 4B/E7000000 000000010000004B000000E7

Если транзакционная запись после цели присутствует, а сервер её не прочитал, я проверяю выбранную timeline, права процесса PostgreSQL, кавычки и коды возврата restore_command, имя файла и системный идентификатор копии. Если записи нет и источник утрачен, временная цель в данном наборе WAL действительно недостижима. Если источник жив, сначала пытаюсь доставить текущий сегмент, не меняя целевое время.

`pg_waldump -n 50` показывает первые 50 записей от выбранного начала, а не последние 50 записей сегмента. Для хвоста задайте близкий стартовый LSN или передайте обычный вывод в `tail -n 50`.
Порядок действий: Диагностика за пять минут: лог, архиватор и pg_waldump — схема
Порядок действий: Диагностика за пять минут: лог, архиватор и pg_waldump. Открыть схему в полном размере

Что делать после диагноза: варианты по убыванию точности

Первый и лучший вариант при недоставленном текущем сегменте — не менять цель, а получить недостающий WAL. Если рабочий сервер доступен, pg_switch_wal() заставит перейти к новому сегменту и позволит архивировать завершённый. Нужно дождаться успешной архивации, а не ограничиваться тем, что функция вернула LSN. После доставки файла тот же каталог восстановления можно запустить снова: PostgreSQL продолжит примерно от сохранённой точки и остановится на первом подходящем COMMIT или ABORT.

Второй вариант — именованная точка, если она была создана до опасной операции. В конфигурации оставляют только recovery_target_name, а остальные типы цели убирают. Восстановление остановится после первой встреченной точки с таким именем.

recovery_target_name = 'before_migration_20260611'
recovery_target_action = 'pause'
recovery_target_timeline = 'current'

Третий вариант — точный LSN. В pg_waldump выбирают позицию известной хорошей записи и осознанно задают inclusive. При on запись, начинающаяся в указанном LSN, будет применена, после чего восстановление остановится. При off сервер остановится перед граничной записью.

recovery_target_lsn = '4B/E7A81358'
recovery_target_inclusive = on
recovery_target_action = 'pause'
recovery_target_timeline = 'current'

Однако менять цель на уже пройденный LSN в каталоге после неудачного восстановления нельзя: PostgreSQL умеет продолжать replay вперёд, но не откатывает уже применённые записи WAL назад. Если журнал показывает, что нужная более ранняя позиция пройдена, базовую копию придётся развернуть заново. Это важнее удобства: попытка продолжить из текущего каталога не вернёт прежнее состояние.

Четвёртый вариант — recovery_target_xid, когда xid нужной транзакции известен из надёжного журнала. Этот способ точен, но нельзя выбирать цель по правилу «последний меньший xid»: номера выдаются при старте транзакций, а завершаются они в другом порядке. PostgreSQL ищет равенство с конкретным xid.

recovery_target_xid = '1042765'
recovery_target_inclusive = on
recovery_target_action = 'pause'

Пятый вариант — скорректировать время. Он подходит, только если в доступном WAL имеется более поздняя транзакционная запись, на которой новая цель будет обнаружена. Сдвиг времени к 02:41:07.500000+03 бессмысленен, если архив всё равно заканчивается на COMMIT 02:41:07.412183+03. Сначала нужен последующий COMMIT или ABORT.

Убрать цель совсем технически можно: targeted recovery проиграет весь доступный WAL. Но это допустимо лишь после понимания его содержимого. Если в хвосте находится разрушительная миграция, сервер воспроизведёт и её. Параметр recovery_target = 'immediate' означает другое: остановку при первом достижении согласованного состояния; для восстановления из онлайн-копии это соответствует окончанию создания базовой копии.

recovery_target = 'immediate'
recovery_target_action = 'pause'

Для первой успешной остановки я использую recovery_target_action = 'pause'. Это значение по умолчанию. При включённом hot_standby согласованную восстановленную базу можно проверить запросами на чтение. Если точка подходит, pg_wal_replay_resume() завершит восстановление. Если нужна более поздняя цель, сервер штатно останавливают, меняют цель вперёд и запускают снова. При выключенном hot_standby действие pause ведёт себя как shutdown.

SELECT pg_is_in_recovery();
SELECT pg_is_wal_replay_paused();
SELECT pg_last_wal_replay_lsn();
SELECT pg_wal_replay_resume();

Действие promote завершает recovery сразу после достижения цели и создаёт новую линию времени. После этого попробовать более раннюю точку в том же каталоге нельзя. Для базы объёмом 640 ГБ ошибочное повышение означает ещё одно развёртывание копии и потерю тех самых 40 минут, которые в аварии особенно дороги.

После FATAL разрешено продолжать восстановление вперёд, например после доставки следующего сегмента. Вернуться к уже пройденному времени или LSN в том же каталоге нельзя.

Как сократить ночные провалы между транзакциями

Если база подолгу простаивает, я добавляю контролируемую служебную транзакцию. Она не превращает архив в непрерывную шкалу времени, но ограничивает расстояние между записями COMMIT. При запуске раз в минуту следующая успешно выполненная и заархивированная транзакция обычно даёт временной цели границу с минутной гранулярностью плюс задержка архивации.

Служебную таблицу создаю отдельно от прикладной схемы. Начальная вставка сделана повторяемой, чтобы повторный запуск установочного скрипта не падал на первичном ключе.

CREATE SCHEMA IF NOT EXISTS ops;

CREATE TABLE IF NOT EXISTS ops.pitr_heartbeat (
    id      smallint PRIMARY KEY,
    beat_at timestamptz NOT NULL
);

INSERT INTO ops.pitr_heartbeat (id, beat_at)
VALUES (1, clock_timestamp())
ON CONFLICT (id)
DO UPDATE SET beat_at = EXCLUDED.beat_at;

На Debian 12 системный файл /etc/cron.d/pg-pitr-heartbeat содержит пять полей времени, имя пользователя и команду. Пять звёздочек означают запуск каждую минуту. Ключи psql здесь существуют: -X запрещает чтение пользовательского файла запуска, -q уменьшает служебный вывод, -A включает невыровненный формат, -t убирает заголовки и итоговую строку, а -v ON_ERROR_STOP=1 заставляет завершиться с ошибкой при ошибке SQL.

* * * * * postgres /usr/bin/psql -X -qAt -v ON_ERROR_STOP=1 -d proddb -c 'UPDATE ops.pitr_heartbeat SET beat_at = clock_timestamp() WHERE id = 1;'

Я не перенаправляю ошибки в /dev/null: молча сломавшийся heartbeat создаёт ложную уверенность. Вывод задания нужно отправлять в настроенную почту cron, системный журнал или мониторинг. Отдельно проверяю, что локальная аутентификация разрешает системному пользователю postgres подключаться к proddb и что UPDATE действительно меняет одну строку.

Фиксированной цифры накладных расходов для такого обновления нет. Объём зависит от full_page_writes, частоты контрольных точек, HOT-обновлений, состояния страницы, wal_compression и работы autovacuum. Поэтому обещание «ровно несколько мегабайт в сутки» было бы гаданием. Я измеряю разницу по статистике WAL на тестовом интервале и проверяю рост самой таблицы.

SELECT wal_records, wal_fpi, wal_bytes, stats_reset
FROM pg_stat_wal;

SELECT n_tup_upd, n_tup_hot_upd, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
WHERE schemaname = 'ops' AND relname = 'pitr_heartbeat';

Heartbeat решает только задачу временной границы. Его COMMIT ещё должен попасть на восстановительный узел. При обычном файловом архивировании текущий сегмент передаётся лишь после завершения, поэтому без archive_timeout, ручного переключения или потоковой доставки минутные COMMIT могут оставаться на рабочем сервере значительно дольше минуты.

Для плановых работ я предпочитаю именованную точку. Перед миграцией вызываю pg_create_restore_point() на рабочем сервере и сохраняю возвращённый LSN в журнале выкладки.

SELECT pg_create_restore_point('before_migration_20260611');

Функция пишет в WAL специальную запись точки восстановления. Это не checkpoint и не restartpoint. При recovery_target_name PostgreSQL остановится после первой точки с совпавшим именем, поэтому имена нельзя повторять. Я включаю дату и номер задачи либо другой уникальный идентификатор, а успешное создание точки делаю обязательным условием продолжения выкладки.

Наконец, задаю разумный archive_timeout. Документация PostgreSQL предупреждает, что слишком короткое значение раздувает архив: досрочно закрытый сегмент всё равно занимает полный размер сегмента. В качестве общего ориентира документация называет интервал порядка минуты, но конкретное значение я выбираю по допустимому RPO, скорости доставки и объёму хранилища. Переключение происходит после истечения интервала с прошлого переключения при наличии активности, включая checkpoint; при полном отсутствии активности одного таймера недостаточно.

archive_timeout = '1min'
Пульс раз в минуту не гарантирует RPO в одну минуту без доставки WAL. Гранулярность транзакционных меток и задержка архивации — две отдельные величины.

Грабли: часовые пояса, линии времени и recovery.signal

Первая ловушка — часовой пояс в recovery_target_time. Значение принимает формат timestamp with time zone, но сокращение зоны нельзя использовать, если соответствующий набор timezone_abbreviations не был настроен раньше в конфигурации. Надёжные формы — числовое смещение 2026-06-11 03:05:00+03 либо полное имя IANA 2026-06-11 03:05:00 Europe/Moscow.

Внутреннее представление timestamptz хранит момент времени в UTC, а при выводе преобразуется в настроенную зону. Для серверных сообщений важен также log_timezone. Поэтому я сопоставляю не голые часы из разных инструментов, а время с явным смещением. Трёхчасовая ошибка между UTC и московским временем способна привести либо к преждевременной остановке, либо к применению опасной транзакции.

Вторая ловушка — цель до окончания базовой копии. Документация прямо требует, чтобы точка остановки находилась после момента завершения pg_backup_stop(). Пока онлайн-копия создавалась, набор файлов сам по себе не представлял согласованное состояние; WAL нужен, чтобы довести его до конца резервного копирования. Если копия завершилась в 03:40, восстановиться с неё на 03:05 нельзя — нужна более ранняя базовая копия.

Третья ловушка — линия времени. recovery_target_timeline = 'latest' используется по умолчанию и выбирает последнюю подходящую timeline из архива. current означает линию, которая была текущей при создании базовой копии. После предыдущих PITR в архиве могут присутствовать дочерние линии и файлы .history; выбирать значение нужно по истории ветвления, а не по правилу «для повторного восстановления всегда current».

Если timeline задаётся числом, PostgreSQL принимает десятичный идентификатор. Для шестнадцатеричной записи нужен префикс 0x: например, timeline из имени 00000011000000A10000004F можно задать как 0x11, что соответствует десятичному значению 17. Само имя WAL-файла нельзя копировать целиком в параметр.

recovery_target_timeline = '0x11'

Четвёртая ловушка — несколько типов цели одновременно. Разрешён максимум один из параметров recovery_target, recovery_target_lsn, recovery_target_name, recovery_target_time и recovery_target_xid. Сочетание, например, времени и LSN вызывает ошибку конфигурации. При этом recovery_target_inclusive, recovery_target_action и recovery_target_timeline не являются отдельными типами цели и могут сопровождать выбранный параметр.

Пятая ловушка — recovery_target_action = 'shutdown'. После достижения цели сервер выключается, но recovery.signal не удаляется. Следующий запуск снова закончится немедленным выключением, если не изменить конфигурацию или не удалить сигнал. При promote восстановление завершается, и файл удаляет сам PostgreSQL.

Шестая и самая дорогая ошибка — убрать цель лишь ради запуска. Без цели PostgreSQL проиграет весь доступный архив, включая операцию, от которой выполняется восстановление. После завершения recovery появится новая линия времени, а возврат назад потребует повторного развёртывания базовой копии.

Седьмая ловушка — считать файл recovery.signal командой, которую можно переносить между каталогами без проверки. Он должен быть пустым обычным файлом непосредственно в каталоге данных восстанавливаемого кластера. Если рядом присутствует standby.signal, режим standby имеет приоритет. Перед каждым запуском я проверяю оба файла и убеждаюсь, что работаю именно с копией, а не с каталогом рабочего сервера.

Самая безопасная первая попытка — отдельный каталог данных, явно выбранная timeline и действие `pause`. Повышение выполняют только после проверки данных.
Порядок действий: Грабли: часовые пояса, линии времени и recovery.signal — схема
Порядок действий: Грабли: часовые пояса, линии времени и recovery.signal. Открыть схему в полном размере

Рабочий регламент, который я оставляю администратору

До инцидента я проверяю не только факт создания копий, но и возможность их восстановить. Если резервная копия содержит манифест, запускаю pg_verifybackup подходящей версии, затем выполняю тестовый PITR на изолированном сервере. Документация подчёркивает, что даже успешная проверка манифеста не заменяет пробного запуска и проверки данных.

Перед плановой миграцией создаю уникальную точку через pg_create_restore_point(), записываю её имя и возвращённый LSN в журнал работ, затем убеждаюсь, что содержащий запись сегмент доставлен в архив. Для аварийной цели по времени фиксирую зону со смещением, выбранную timeline и ожидаемую первую транзакцию после цели.

При FATAL не удаляю цель. Сначала сохраняю логи, проверяю источники настроек и анализирую последний доступный WAL. Если источник жив, переключаю сегмент и жду архивирования. Если нужная запись уже есть в архиве, проверяю restore_command, timeline и совместимость утилит. Если нужная более ранняя позиция уже применена, не пытаюсь «отмотать» каталог, а заново разворачиваю базовую копию.

После достижения цели оставляю replay на паузе, подключаюсь только для чтения и проверяю контрольные строки, число записей, состояние схемы и прикладные зависимости. Лишь после подтверждения владельца данных вызываю pg_wal_replay_resume() или выполняю управляемое повышение. В кейсе мебельной фабрики «Дубрава», 75 РМ, именно эта последовательность позволила не применить COMMIT 03:24 и вернуть справочник без повторного развёртывания 640 ГБ.

Главный вывод из этой ошибки простой: время файла WAL, наличие последовательных имён и даже поздние нетранзакционные записи не доказывают достижимость recovery_target_time. PostgreSQL 18 ищет подходящий COMMIT или ABORT, а администратор должен обеспечить и наличие такой записи, и её доставку. Когда это различие закреплено в регламенте, FATAL перестаёт быть загадкой и становится проверяемой развилкой из нескольких конкретных причин.

Тест восстановления ценнее отчёта «backup completed successfully»: только тест одновременно проверяет базовую копию, доступность WAL, конфигурацию, линию времени и понятность действий администратора.

Частые вопросы

Можно ли просто убрать recovery_target_time и запустить сервер?

Технически да, но тогда PostgreSQL проиграет весь доступный WAL, включая разрушительную операцию. После завершения recovery появится новая timeline, и для возврата к более ранней точке базовую копию придётся разворачивать заново. Сначала исследуйте хвост через pg_waldump и доставьте недостающий сегмент, если источник ещё доступен.

Почему checkpoint или переключение WAL после нужного времени не достигают временной цели?

В PostgreSQL 18 recovery_target_time проверяется перед записями завершения транзакций менеджера Transaction: COMMIT, ABORT и их варианты для prepared transactions. Checkpoint и переключение сегмента не проходят это сравнение. Переключение полезно для доставки текущего сегмента, но временной границей станет находящийся в нём последующий COMMIT или ABORT.

Как понять, докуда восстановление действительно дошло?

Строка redo done at показывает конечный LSN replay, а last completed transaction — время последнего применённого COMMIT или ABORT. Затем нужно проверить содержимое хвоста pg_waldump и доступность следующего сегмента. Одного времени последней транзакции недостаточно, чтобы отличить простой базы от неполного архива.

Что именно меняет recovery_target_inclusive?

Для recovery_target_time значение on включает транзакции, завершившиеся точно в целевой момент: остановка происходит перед первой транзакционной записью строго позже цели. При off остановка происходит перед первой записью с временем, равным цели или превышающим её. Для цели по LSN on применяет граничную запись, а off останавливается перед ней.

Можно ли после FATAL заменить время на более ранний LSN и продолжить в том же каталоге?

Нет, если этот LSN уже пройден. PostgreSQL способен продолжить воспроизведение вперёд после доставки WAL или изменения цели на более позднюю, но не откатывает применённые записи. Для более ранней точки нужно заново развернуть исходную базовую копию.

Нужно ли заново разворачивать копию после недоставленного сегмента?

Обычно нет. Если recovery завершилось FATAL до достижения цели и экземпляр не был повышен, можно доставить следующий корректный сегмент и повторно запустить тот же каталог. PostgreSQL продолжит примерно от сохранённой restartpoint. Копия нужна заново, если желаемая цель уже пройдена либо recovery успешно завершилось с повышением.

Гарантирует ли heartbeat раз в минуту восстановление на любую минуту?

Он создаёт регулярные COMMIT и тем самым ограничивает промежутки между транзакционными метками, но не гарантирует их немедленную доставку. Для близкого RPO нужны исправная архивация, разумный archive_timeout или потоковая передача. Ошибки heartbeat должны контролироваться мониторингом.

Какой recovery_target_timeline выбрать: current или latest?

Current продолжает линию, которая была текущей при создании базовой копии. Latest выбирает последнюю подходящую линию из архива и является значением по умолчанию. После прежних PITR нужно изучить history-файлы и определить, на какой ветке находится требуемое состояние; универсального выбора для всех повторных восстановлений нет.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи