Одиночный 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.
В статье
- Репликация: async против sync
- Replication slots: страховка и её обратная сторона
- Автоматический failover через Patroni
- Split-brain и почему узлов нечётное число
- PITR: восстановление на момент
- Health-check кластера из кода
- Карта решений: RPO и RTO
Репликация: 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 промоутится, остальные перенастраиваются на неё.
Топология стенда:
Проверим, что 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 октября).
Источники
- PostgreSQL Documentation — 27.2. Log-Shipping Standby Servers, 27.4. Synchronous Replication, 26.3. Continuous Archiving and Point-in-Time Recovery
- Patroni Documentation — DCS, leader lease,
synchronous_mode, REST API - pgBackRest · wal-g
- Стенд:
postgres-ops/ha-patroni(PostgreSQL 18.4, Patroni 4.1.4, etcd 3.6.13, HAProxy 3.0, pgBackRest 2.58.0, wal-g 3.0.8)
Комментарии