HA PostgreSQL: Patroni, репликация и PITR-бэкапы

Высокая доступность PostgreSQL: streaming-репликация (sync/async), автоматический failover через Patroni поверх etcd/Consul, PITR-бэкапы через WAL archiving (pgBackRest/wal-g), replication slots, split-brain и fencing — с трейдофами по RPO/RTO

Одиночный PostgreSQL — это единая точка отказа: упал процесс, диск или вся ВМ, и приложение стоит, а свежие данные под вопросом. HA-конфигурация решает две разные задачи: пережить отказ узла без ручного вмешательства (failover) и восстановиться на любой момент времени после логической ошибки (PITR). Это не одно и то же — реплика не спасёт от DELETE без WHERE, а бэкап не даст автопереключение за секунды.

Это первая статья серии «PostgreSQL в проде» — про эксплуатацию самого сервера. Клиентская сторона (как приложение читает с реплик, reconnect, пулы в коде) разобрана отдельно в «Надёжная работа с PostgreSQL из Go, Java и Rust»; здесь — про то, что стоит за кулисами со стороны СУБД. Все числа ниже — из живого стенда postgres-ops/ha-patroni в публичном репозитории digital-cookbook: 3 узла PostgreSQL 18.4 под управлением Patroni 4.1.4, кластер из 3 узлов etcd 3.6.13 как хранилище состояния, HAProxy 3.0 перед ними; PITR вынесен на отдельные single-node инстансы с pgBackRest 2.58.0 и wal-g 3.0.8.

Ретрофутуристская схема HA-кластера PostgreSQL: три сервера-башни, один с короной лидера, две реплики принимают поток перфоленты-WAL; в центре — треугольник из трёх узлов-кворума etcd с ключом лидера, сбоку архивный барабан бэкапа со стрелкой обратной перемотки времени

В статье

Репликация: async против sync

Физическая streaming-репликация в PostgreSQL — это трансляция того же самого потока WAL (write-ahead log), которым база защищает себя от сбоев, на другой узел. Лидер (primary) пишет журнал, реплики (standby) его получают по сети и проигрывают у себя, повторяя каждое изменение. Механика самого журнала разобрана в статье «WAL: как устроен и как повышает надёжность»; здесь важно одно свойство — момент, в который лидер считает транзакцию завершённой.

При асинхронной репликации (по умолчанию) COMMIT возвращается сразу после того, как запись попала в локальный WAL лидера. Реплика получит её чуть позже, с задержкой. Это дёшево по латентности, но если лидер погибнет в момент между локальным коммитом и отправкой на реплику — последние транзакции пропадут вместе с ним. Это ненулевой RPO (Recovery Point Objective — сколько данных мы готовы потерять).

При синхронной репликации лидер не завершает COMMIT, пока хотя бы одна реплика не подтвердит, что запись у неё на диске. RPO = 0: подтверждённая транзакция гарантированно есть минимум на двух узлах. Плата — сетевой round-trip до реплики в критическом пути каждой записи.

На стенде эта плата измерима. Один и тот же нагрузочный тест (pgbench, 4 клиента × 500 коммитов одиночных INSERT) через HAProxy на лидера:

АСИНХРОННАЯ:  latency average =  7.24 ms   tps = 553
СИНХРОННАЯ:   latency average = 10.94 ms   tps = 366

Синхронность стоит +51% к латентности записи (7.24 → 10.94 мс) и −34% к пропускной способности (553 → 366 tps). Это не абстрактный «оверхед», а конкретная цена гарантии, что ни одна подтверждённая транзакция не потеряется при падении лидера.

Важный нюанс — Patroni не делает синхронной все реплики. При включённом synchronous_mode он держит ровно одну sync-реплику, а остальные оставляет асинхронными:

 application_name | sync_state
------------------+------------
 patroni2         | sync        ← лидер ждёт подтверждения именно от неё
 patroni3         | async

Это осознанный баланс. Если бы лидер ждал все реплики, то падение любой из них останавливало бы запись — синхронность превратилась бы в единую точку отказа наоборот. Одна sync-реплика даёт RPO = 0, а Patroni автоматически переназначает эту роль при сбоях, так что кластер не встаёт из-за гибели одной ноды. В голом PostgreSQL то же выражается через synchronous_standby_names = 'ANY 1 (...)' — кворумная синхронная репликация.

Replication slots: страховка и её обратная сторона

Реплика может отстать: сеть моргнула, узел перезагрузился, проигрывание WAL не поспевает за потоком. Проблема в том, что лидер по умолчанию удаляет отработанные сегменты WAL — и если реплика попросит сегмент, который лидер уже стёр, репликация рвётся, реплику приходится пересобирать с нуля.

Replication slot решает это: лидер помнит, до какого места (restart_lsn) дошла каждая реплика, и не удаляет WAL новее этой точки. Patroni создаёт physical slot на каждую реплику автоматически:

 slot_name | slot_type | active | retained_wal
-----------+-----------+--------+--------------
 patroni1  | physical  | t      | 0 bytes
 patroni3  | physical  | t      | 0 bytes

Пока реплика активна и не отстаёт, retained_wal близок к нулю — лидер спокойно чистит журнал. Но у слота есть обратная сторона, о которой забывают до первого инцидента: если реплика отвалилась, а слот остался, restart_lsn застывает, и лидер вынужден хранить весь WAL с этого момента. retained_wal растёт, пока не переполнит диск лидера — и тогда встанет уже вся запись, а не только отставшая реплика.

Поэтому за неактивными слотами (active = false с растущим retained_wal) следят и вовремя удаляют осиротевшие. В самом PostgreSQL тот же риск прикрывает параметр max_slot_wal_keep_size — потолок, после которого слот инвалидируется ради спасения диска: лучше потерять одну отставшую реплику, чем весь кластер.

Автоматический failover через Patroni

Ручной failover — это гонка. Дежурный инженер должен заметить смерть лидера, выбрать самую свежую реплику, промоутить её (pg_promote), переконфигурировать остальные реплики на нового лидера и переключить трафик приложения. Ночью, под давлением, с риском промоутить не ту реплику или, хуже, получить две «главные» базы одновременно. Автоматизация этого и есть задача Patroni.

Patroni — это агент, работающий рядом с каждым экземпляром PostgreSQL. Он не сам решает, кто лидер, — он использует внешнее распределённое хранилище (DCS — Distributed Configuration Store), в нашем стенде это кластер из трёх узлов etcd. Лидерство выражается через lease (аренду) ключа в etcd с ограниченным сроком жизни (ttl). Лидер обязан периодически продлевать аренду; пока он это делает — он primary. Если лидер умер и перестал продлевать, аренда истекает, и живые реплики через тот же etcd договариваются о новом лидере: самая свежая по WAL промоутится, остальные перенастраиваются на неё.

Топология стенда:

HAProxy маршрутизирует по роли; лидерство хранится в кворуме etcdHAProxy:5000 запись → лидер · :5001 чтение → репликиpatroni1Leader (primary)patroni2Replica (sync)patroni3Replica (async)поток WAL →etcd ×3ключ лидера (lease/ttl)

Проверим, что failover действительно автоматический. Убиваем лидера (docker compose kill patroni1) и замеряем RTO (Recovery Time Objective — сколько кластер недоступен на запись) как время до первой успешной записи через HAProxy:

до сбоя:      patroni1 Leader (TL 1) · patroni2 Replica · patroni3 Replica
убили patroni1:  RTO = 32.6 c
после:        patroni2 Leader (TL 2) · patroni3 Replica

Новый лидер поднялся сам, HAProxy переключил на него запись — вмешательства человека не было. Обратите внимание на RTO = 32.6 c: это не «мгновенно». Основной вклад даёт ttl = 30 — срок аренды лидера в etcd. Пока старая аренда не истекла, новый лидер не выбирается: живые узлы обязаны убедиться, что прежний лидер точно потерял лидерство, прежде чем промоутить замену. Уменьшение ttl и loop_wait ускоряет failover, но повышает риск ложных срабатываний при коротких сетевых задержках — кластер начнёт «переключаться» на ровном месте. Это осознанный трейдоф между скоростью реакции и стабильностью, а не значение, которое нужно бездумно занижать.

Маршрутизация трафика устроена просто и надёжно: HAProxy проверяет здоровье не портом PostgreSQL, а HTTP-эндпоинтом Patroni REST API. На запрос GET /leader отвечает 200 только текущий лидер:

listen primary
    bind *:5000
    option httpchk GET /leader
    http-check expect status 200
    server patroni1 patroni1:5432 check port 8008
    server patroni2 patroni2:5432 check port 8008
    server patroni3 patroni3:5432 check port 8008

После failover бывший лидер начинает отвечать на /leader кодом не-200, новый — кодом 200, и HAProxy автоматически перенаправляет запись. Симметричный listen-блок на :5001 с проверкой GET /replica раздаёт чтение по репликам.

Split-brain и почему узлов нечётное число

Самый опасный сценарий HA — split-brain: две базы одновременно считают себя лидером и принимают запись. При сетевом разделе старый лидер может не знать, что его уже сместили, и продолжать писать; после восстановления сети получаются две расходящиеся истории данных, слить которые автоматически невозможно.

Patroni защищается от этого через тот же etcd. Лидер — это единственный держатель lease-ключа, а ключ в кворумном хранилище один. Наглядное доказательство — что происходит с убитым лидером, когда он возвращается:

вернули patroni1:  patroni1 Replica (TL 2) · patroni2 Leader (TL 2) · patroni3 Replica (TL 2)

Бывший лидер patroni1 вошёл в кластер репликой, а не вторым лидером. Ключевая деталь — timeline: новый лидер стартовал на timeline 2 (TL 2), а старый лидер жил на TL 1. Вернувшись, patroni1 по данным etcd видит, что лидерство ушло, и через pg_rewind откатывает свою расходящуюся часть истории, догоняя timeline 2 как обычная реплика. Двух «главных» баз не возникает ни на секунду.

Отсюда же требование нечётного числа узлов DCS. Кворум — это большинство: из 3 узлов etcd работоспособны минимум 2. При сетевом разделе только та часть кластера, что видит большинство etcd, может держать лидерство; меньшая часть теряет право писать (её лидер не может продлить аренду) и демоутится. Чётное число узлов (например 2) даёт патовую ситуацию при разделе 1:1 — ни одна половина не имеет большинства, и кластер встаёт целиком. Три узла etcd переживают потерю одного; пять — двух. Дополнительный слой защиты — watchdog: если Patroni на бывшем лидере по какой-то причине не смог демоутить PostgreSQL, аппаратный или софтверный watchdog принудительно перезагрузит узел, гарантируя, что старый лидер замолчит.

PITR: восстановление на момент

Репликация и failover бесполезны против логической ошибки. DELETE без WHERE или DROP TABLE мгновенно реплицируется на все standby — они честно повторят разрушение. От этого спасает не реплика, а point-in-time recovery: способность откатить базу на конкретный момент до ошибочной операции.

PITR строится из двух частей: периодический базовый бэкап (полная копия кластера) плюс непрерывный архив WAL (archive_command складывает каждый заполненный сегмент журнала в хранилище). Восстановление — это развернуть базовый бэкап и проиграть архивный WAL до заданного recovery_target_time. Всё, что случилось после этой точки, включая ошибочный DELETE, просто не проигрывается.

На стенде это показано двумя независимыми инструментами. Сценарий одинаков: базовый бэкап, затем 1000 «хороших» строк с фиксацией момента T1, затем 500 «ошибочных» строк уже после T1, затем восстановление на T1.

pgBackRest — декларативный, с инкрементальными бэкапами и встроенной верификацией. Восстановление одной командой:

pgbackrest --stanza=demo --type=time --target="$T1" --delta restore

wal-g — легковесный Go-бинарник, ориентированный на облачные хранилища (S3/GCS). Восстановление — штатный recovery PostgreSQL по restore_command:

wal-g backup-fetch $PGDATA LATEST
# в postgresql.conf: restore_command = 'wal-g wal-fetch %f %p', recovery_target_time = '$T1'

Результат в обоих случаях идентичен — ошибочные данные откачены, хорошие целы:

до восстановления:  good | 1000    OOPS | 500
после restore на T1: good | 1000            ← OOPS исчезли

Отдельный эксплуатационный сюжет 2026 года подсказывает, почему полезно владеть двумя инструментами, а не одним. pgBackRest в апреле был архивирован автором, затем возрождён коалицией спонсоров (актуальная линия — 2.59.x). Зависимость, статус которой может внезапно измениться, — повод держать в проде знание и о второй, независимой реализации того же механизма.

И главное правило PITR, которое не видно в командах: бэкап, который ни разу не восстанавливали, — это не бэкап, а предположение. Регулярный автоматический restore-тест (развернуть бэкап на отдельном узле, проверить контрольную сумму) — единственный способ узнать, что бэкапы рабочие, до того, как они понадобятся всерьёз.

Health-check кластера из кода

Эксплуатация HA — это ещё и наблюдаемость: знать лаг каждой реплики и текущую топологию, чтобы ловить проблемы до того, как они станут инцидентом. Два независимых источника истины удобно опрашивать из кода. Со стороны лидера pg_stat_replication даёт реальный лаг каждой реплики в байтах WAL; Patroni REST API /cluster — кто сейчас лидер и в каком состоянии узлы. Небольшая Go-утилита на pgx объединяет оба:

const replQuery = `
SELECT application_name, state, sync_state,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes
FROM pg_stat_replication ORDER BY application_name`
== реплики со стороны лидера (pg_stat_replication) ==
replica      state      sync    replay_lag_bytes
patroni1     streaming  async   0
patroni3     streaming  async   0

== топология кластера (Patroni REST /cluster) ==
patroni1  replica  streaming
patroni2  leader   running
patroni3  replica  streaming

replay_lag_bytes = 0 означает, что реплики полностью догнали лидера. Именно эта метрика — основа алерта «реплика отстала на N байт»: отстающую реплику стоит вывести из-под чтения (иначе приложение прочитает устаревшие данные), а хронически отстающую — расследовать. Опрос /cluster по HTTP ловит смену лидера, не открывая SQL-сессий. В проде оба источника периодически опрашиваются и уходят в метрики (khorost_tech.* через OpenTelemetry/Prometheus).

Карта решений: RPO и RTO

Две метрики задают всю инженерию HA. RPO (Recovery Point Objective) — сколько данных мы готовы потерять; RTO (Recovery Time Objective) — сколько готовы лежать. Разные механизмы двигают разные метрики, и путать их — источник неверных решений.

Механизм Что даёт Цена
Async-репликация RTO ~30 c (failover), RPO > 0 дёшево по латентности; теряет последние транзакции при гибели лидера
Sync-репликация RPO = 0 +51% latency, −34% tps (по стенду)
Автофейловер (Patroni) RTO без участия человека сложность кластера + DCS; RTO ≈ ttl
PITR (pgBackRest/wal-g) восстановление после логической ошибки не про доступность: restore занимает минуты-часы

Ключевой вывод — это не «или/или», а слои. Репликация с автофейловером закрывает отказ железа (узел, диск, сеть) с малым RTO. PITR закрывает логические ошибки (человек, кривой релиз, повреждение данных), против которых репликация бессильна, — но с большим RTO. Прод нуждается в обоих: реплика не заменит бэкап, а бэкап не заменит реплику. А выбор между async и sync — это прямой обмен измеримой латентности на гарантию нулевой потери, и решать его нужно по цене транзакции, а не по умолчанию.

Следующая статья серии переходит от самого сервера к слою перед ним: «Пулинг соединений PostgreSQL: PgBouncer и pgcat»готовится, с 30 сентября — почему соединение к PostgreSQL дорогое и как инфраструктурный пулер держит базу под тысячами клиентов. Дальше — обслуживание хранилища («Партиционирование, VACUUM и борьба с bloat»готовится, с 2 октября) и оптимизация запросов («Оптимизация запросов: EXPLAIN на практике»готовится, с 6 октября).

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии