В первых двух уроках мы выяснили: WAL — это последовательный поток всех изменений в БД, разрезанный на 16 MiB сегменты, с LSN-адресацией. После crash мы умеем по WAL восстановить состояние.
Теперь сделаем следующий логический шаг: что, если этот WAL отправить на другой сервер? Если второй сервер будет всегда «находиться в recovery-режиме» и применять каждую новую WAL-запись по мере поступления, он будет физически идентичной копией primary. Это и есть physical streaming replication — старейший и самый надёжный механизм репликации в Postgres.
Свойства physical replication
Главное, что нужно понять: physical replication — это байт-в-байт копия на уровне страниц. Это не «отдельная БД, в которую льются те же запросы». Это та же физическая БД, страницы 8 KiB которой синхронизированы между primary и standby через WAL.
Следствия:
- Версия Postgres major должна совпадать. Replica 15 не возьмёт WAL от primary 14 (формат WAL меняется). Major upgrade primary = всех replicas пересоздавать.
- Все табличные пространства, базы, расширения, объекты — те же. Невозможно реплицировать только одну базу или одну таблицу. Всё или ничего.
- Архитектуры совместимы. x86_64 → arm64 в general не работает (endianness и alignment отличаются в WAL FPI). Все ноды кластера обычно на одной архитектуре.
- DDL реплицируется автоматически. Создал индекс на primary — он появится на replica без специальных команд, потому что
CREATE INDEXпишет WAL. - Replica всегда read-only. Никакие записи невозможны (попытка вызовет ERROR), включая VACUUM. VACUUM делается только на primary, и его эффект приезжает на replica через WAL.
Архитектура: walsender и walreceiver
Primary держит walsender-процесс для каждой replica. Replica запускает walreceiver, который подключается к walsender и получает WAL по TCP. На replica startup process применяет WAL к страницам.
На primary запускается walsender — отдельный backend-процесс для каждой подключённой replica. Он читает WAL по мере его записи и отправляет по TCP-соединению. На replica работает walreceiver, который принимает байты и пишет их в pg_wal/ standby. Параллельно startup process реплеит входящий WAL, применяя изменения к shared_buffers.
Это всё работает на стандартных Postgres processes — никаких внешних агентов, никакого Kafka. Только TCP-соединение и WAL.
Шаг 1: initial sync через pg_basebackup
Прежде чем стримить incremental WAL, replica должна получить базовый снимок данных. Это делается утилитой pg_basebackup — она подключается к primary по replication protocol и копирует весь $PGDATA побайтово:
# на будущей replica:
pg_basebackup \
-h primary.example.com \
-U replicator \
-D /var/lib/postgresql/15/data \
-P \
-X stream \
-R
Что делают флаги:
-D— куда положить копию.-P— progress reporting.-X stream— параллельно с копированием data стримить и WAL, чтобы получить консистентный снимок (без-Xbackup не self-contained: если на primary случится checkpoint, могут потеряться промежуточные изменения).-R— автоматически записатьstandby.signalиprimary_conninfoв postgresql.auto.conf. Послеpg_basebackupдостаточно запуститьpg_ctl start— replica подключится сама.
После pg_basebackup standby содержит данные на момент начала backup’а + WAL до конца backup’а. Запустившись, она применяет WAL до текущего LSN primary и переходит в режим «жду новых WAL-записей по TCP». Этот переход называется catch-up phase.
Hot standby: read-only нагрузка на replica
По умолчанию replica может быть в двух состояниях:
- Warm standby — replica применяет WAL, но не принимает соединения. Видна как доступная только в момент failover.
- Hot standby — replica применяет WAL и одновременно принимает read-only соединения. Это default в современных Postgres (
hot_standby = on).
Hot standby позволяет:
- Запускать аналитические запросы без нагрузки на primary.
- Делать
pg_dumpс replica, освобождая primary. - Запускать read-only веб-приложения через connection pooler с маршрутизацией.
Но есть нюансы:
- Recovery conflict. Если на primary VACUUM удалил dead tuples, чьи xmax относятся к завершённой транзакции, а на replica в этот момент работает долгий SELECT, использующий этот snapshot — replica должна либо приостановить replay, либо убить запрос. Поведение управляется
max_standby_streaming_delay. - Read-after-write. После INSERT на primary мгновенно сделать SELECT на replica может вернуть пусто — WAL ещё не прилетел. Это replication lag, и приложение должно либо терпеть, либо использовать
synchronous_commit = remote_apply, либо после write читать обратно с primary. - hot_standby_feedback. Replica может слать primary информацию о своих активных snapshot — тогда primary VACUUM не будет удалять нужные replica dead tuples. Это решает recovery conflict, но раздувает bloat на primary.
На реальной replica проверка состояния выглядит так. В pglite мы не в recovery, поэтому in_recovery вернёт false. На настоящей replica — true и pg_last_wal_replay_lsn покажет, докуда дотянулась replication.
Replication slots: защита WAL от удаления
Что произойдёт, если replica на час уйдёт в offline (network split, ребут, etc.), а на primary за это время прошло 100 GiB WAL и сработало 50 checkpoint’ов? Все «старые» WAL-сегменты будут удалены checkpoint’ом, потому что primary не знает, что replica их ещё ждёт. Когда replica вернётся — она спросит у primary WAL с LSN, которого больше нет. Repair единственный — пересоздать replica через pg_basebackup.
Решение — replication slot. Это объект на primary, который хранит restart_lsn — минимальный LSN, который ещё нужен какому-то consumer’у. Primary не удалит WAL-сегменты до этого LSN, даже если они старше checkpoint.
Список replication slots. В реальной БД каждая physical replica будет создавать здесь запись с restart_lsn — позицией WAL, до которой replica подтвердила приём. pglite не поддерживает реальные slots, но синтаксис верный.
Обратная сторона: если replica умерла навсегда и её slot не удалён вручную, WAL копится бесконечно. Это самая частая причина «вдруг закончилось место в pg_wal/». Мониторинг должен следить за pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) для каждого slot.
Async vs sync vs remote_apply
Параметр synchronous_commit на primary определяет, что должно случиться до того, как COMMIT вернёт OK клиенту:
off— COMMIT возвращает OK сразу, WAL ещё не на disk. Самое быстрое, но при crash можно потерять последние транзакции. Допустимо для аналитики.local— WAL flushed на disk primary. Default-альтернатива async-replication. Стандартный durability на одном узле.on(default) — то же, чтоlocal, плюс если есть synchronous standby — WAL должен быть записан на disk standby.remote_write— WAL записан вpg_wal/standby, но необязательно fsync’нут.remote_apply— WAL применён на standby (изменения видны в SELECT). Это даёт строгую read-after-write consistency, но добавляет latency: COMMIT ждёт network round-trip + apply.
«Synchronous standby» определяется параметром synchronous_standby_names. По умолчанию пуст — все replicas async. Можно указать 'FIRST 1 (replica_a, replica_b)' — ждать ответ от любой одной из двух (quorum-like в варианте ANY).
Trade-off: чем строже sync, тем дольше commit latency и тем сильнее доступность primary зависит от replica (если sync replica умерла и других нет — primary висит на commit). Обычная схема: 2-3 replicas, synchronous_standby_names = 'ANY 1 (replica_a, replica_b, replica_c)', чтобы не зависеть от одной конкретной.
Cascading replication: replica → replica → replica
С Postgres 9.2+ replica может сама быть источником для других replicas — это cascading replication. Полезно для:
- Снижения нагрузки на primary. Если у тебя 10 read-replicas, держать 10 walsender на primary — дорого по CPU и network. Сделай 2 «первого уровня», и от них уже стримь на остальные 8.
- Геораспределения. Primary в Москве, replica-A в Санкт-Петербурге как «региональный hub», от неё — replicas в Нижнем, Казани, Уфе. Меньше WAN-трафика.
Cascaded replica запускается так же, как обычная — pg_basebackup от primary (или от другой replica), primary_conninfo указывает на upstream replica. Нюансы:
- WAL приезжает с задержкой = lag(primary → mid) + lag(mid → cascaded). Считайте сумму.
- Если middle replica умерла, cascaded остаются без WAL. Failover тулинг должен уметь перенаправлять cascaded на новый upstream.
Replication lag: что мониторить
Замер lag — критическая практика. Три LSN на primary в pg_stat_replication:
sent_lsn— сколько WAL primary отправил по сети.write_lsn— сколько WAL standby записал в свойpg_wal/.flush_lsn— сколько WAL standby flush’нул на disk.replay_lsn— сколько WAL standby применил к страницам (видно через SELECT на standby).
Разница pg_current_wal_lsn() - replay_lsn — это byte lag. Перевод в секунды (через приближённую скорость WAL) — time lag. На production-мониторинге обычно alert при byte lag > N MiB или time lag > N seconds.
Состояние replication на реальной primary. В pglite вьюшка возвращает 0 строк (нет walsender), но синтаксис верный.
Большой replay_lag при маленьком flush_lag означает: WAL пришёл и записан, но recovery не успевает применить. Часто причина — конфликт с долгим SELECT (см. max_standby_streaming_delay). Большой sent_lag — network bottleneck или slow disk на standby. Это разные диагнозы.
Recovery conflict: SELECT vs replay
На hot standby есть фундаментальный конфликт между:
- read-only нагрузкой (долгие SELECT’ы с устаревшим snapshot),
- WAL replay (от primary приходят изменения, которые могут «удалить» строки, нужные SELECT).
Конкретный пример: на primary VACUUM удалил dead tuples, чьи xmax относятся к старой завершённой транзакции. На replica в этот момент работает 10-минутный аналитический SELECT, чей snapshot ещё видит эти tuples. Если replica применит WAL — SELECT упадёт с «canceling statement due to conflict with recovery». Если не применит — replication lag растёт.
Параметры управления:
max_standby_streaming_delay(default 30s) — сколько максимум ждать SELECT перед его убийством. На аналитических replica часто ставят-1(бесконечно) приhot_standby_feedback = on.hot_standby_feedback = on— replica шлёт primary информацию о своих active xmin. Primary VACUUM не удаляет нужные replica tuples. Но это раздувает bloat на primary.
Trade-off ясный: либо терпите recovery conflict и убиваете долгие SELECT, либо терпите bloat на primary и работаете на replica спокойно. Выбор зависит от того, что важнее для системы.
Failover: что значит «replica становится primary»
Promote — это команда pg_ctl promote или вызов pg_promote(). На replica она:
- Закрывает walreceiver.
- Применяет всё, что есть в локальном WAL.
- Создаёт новый timeline — увеличивает timeline ID на 1 (см. имя WAL-файла: первые 8 hex). Это нужно, чтобы будущие WAL-записи новой primary не пересекались с «параллельной вселенной» старой primary, если та когда-то вернётся.
- Снимает
standby.signalи открывается на запись.
После promote старая primary уже не вернётся как primary к новой — она должна быть либо пересоздана как replica новой (pg_rewind или pg_basebackup), либо изолирована. Иначе получишь split-brain: две БД с расходящимися timeline.
Production HA-tooling (Patroni, repmgr) автоматизирует выбор новой primary, обновление DNS/HAProxy, fencing старой primary. Postgres сам не делает автоматический failover — это задача внешнего оркестратора.
Чек-лист
- Physical streaming replication = байт-в-байт копия страниц через WAL-поток. Major version и архитектура должны совпадать.
- pg_basebackup — initial sync; флаги
-X stream -Rдают self-contained базу + автоконфиг replica. - walsender / walreceiver / startup — пара процессов на primary/standby + recovery-loop, applying WAL.
- Hot standby — replica принимает read-only соединения. Грозит recovery conflict;
hot_standby_feedbackрешает, но даёт bloat на primary. - Replication slot —
restart_lsnзащищает WAL от удаления для активной replica. Заброшенный slot = вечный ростpg_wal/. - synchronous_commit =
off/local/on/remote_write/remote_apply— trade-off latency vs durability vs read-after-write. - Failover = promote replica, новый timeline, старая primary исключается из кластера. Авто-failover — задача внешнего оркестратора (Patroni).