В прошлом уроке мы разбирали physical replication — клонирование на уровне байтов 8 KiB страниц. Это надёжно и работает «всё или ничего», но имеет ограничения:
- Версии Postgres должны совпадать → невозможен rolling major upgrade без downtime.
- Реплицируется вся БД → невозможно поднять replica только для пары таблиц.
- Архитектура должна совпадать → миграция с x86 на ARM ферму = пересборка.
- Replica всегда read-only → невозможен multi-master или гибрид «replica для аналитики + локальные write».
В Postgres 10 появилось решение этих ограничений — logical replication. Идея: вместо передачи байт страниц декодировать WAL на primary в логические изменения («строка с id=42 в orders получила status=‘paid’») и отправлять эти changes на subscriber, где они применяются как обычные INSERT/UPDATE/DELETE.
Logical decoding: WAL → row changes
В основе logical replication лежит механизм logical decoding. Это процесс, который читает WAL и преобразует физические записи (а именно — записи об изменениях heap, с информацией о tuple) в семантические события: «в таблицу X добавлена строка с этими значениями», «в таблице Y строка с такими-то primary key обновлена в такие-то значения».
Для этого нужно:
wal_level = logicalна primary. Это поднимает на уровень WAL такие записи, чтобы по ним можно было восстановить полные данные кортежа. На уровнеreplica(для physical) этого мало.- REPLICA IDENTITY на таблицах. Это говорит Postgres, какие колонки писать в WAL, чтобы можно было идентифицировать строку при UPDATE/DELETE. По умолчанию — primary key. Если PK нет — нужно сделать
ALTER TABLE ... REPLICA IDENTITY FULL(пишет в WAL все старые значения колонок). - Logical replication slot — slot с типом
logical(vsphysicalиз прошлого урока). Хранит позицию decoding и состояние плагина. - Output plugin — для встроенной logical replication это
pgoutput. Для CDC-pipelines в Kafka —wal2json,decoderbufs,pgoutput+ Debezium.
Publication и Subscription
Архитектура logical replication построена вокруг двух объектов:
- Publication на publisher (старший сервер): набор таблиц + список операций (INSERT/UPDATE/DELETE/TRUNCATE), которые отдаются «вовне».
- Subscription на subscriber: подключение к publisher + ссылка на publication. Подписавшись, subscriber получает initial copy и затем стримит inkremental changes.
На publisher — logical slot + publication, перечисляющая 'что я отдаю'. На subscriber — subscription, перечисляющая 'что я хочу'. WAL декодируется в pgoutput-формат и применяется как обычные INSERT/UPDATE/DELETE на subscriber.
Минимальный пример настройки (на реальной БД):
-- на publisher
ALTER SYSTEM SET wal_level = 'logical'; -- requires restart
CREATE PUBLICATION orders_pub FOR TABLE orders, order_items;
-- на subscriber
CREATE TABLE orders (...); -- DDL руками!
CREATE TABLE order_items (...);
CREATE SUBSCRIPTION orders_sub
CONNECTION 'host=publisher dbname=app user=replicator'
PUBLICATION orders_pub;
CREATE SUBSCRIPTION делает три вещи: создаёт logical slot на publisher, запускает tablesync workers для initial COPY каждой таблицы, и затем запускает apply worker для incremental changes.
Синтаксис создания publication на реальной БД. В pglite logical replication не работает (нет walsender), но синтаксис разбирается. CREATE PUBLICATION — DDL операция, делается на publisher.
Просмотр published tables — какие таблицы попадают в publication. Полезно при отладке: 'почему изменение в таблице X не реплицируется?' Часто ответ — она просто не в publication.
Что НЕ реплицируется
Главное, что нужно знать заранее, — logical replication не передаёт всё:
- DDL (CREATE/ALTER/DROP TABLE, индексы, типы). Если на publisher добавил колонку, на subscriber её нет — следующий INSERT с лишней колонкой просто упадёт с ошибкой. Сначала ALTER на subscriber (тогда лишняя колонка примет NULL), потом на publisher.
- Sequences. Значение
serial/identityколонок реплицируется, но сам sequence не движется на subscriber. После switchover придётся вручнуюsetval()каждого sequence. - Large objects (LO). Реплицируются только если внутри обычной таблицы как bytea.
lo_*API — нет. - TRUNCATE реплицируется с Postgres 11+, но требует
publish_via_partition_rootдля партиционированных таблиц.
Это и есть главная боль logical-репликации в продакшене: рассогласование схем. Тулинг типа pglogical_create_subscriber или Bucardo раньше делал это автоматически; в современном Postgres большая часть схемы — работа человека или скриптов миграций.
Когда использовать logical вместо physical
Несколько ясных сценариев:
- Major version upgrade без downtime. Поднимаем Postgres 16 рядом с 14, делаем 14 publisher, 16 subscriber, ждём catch-up, переключаем трафик. Это единственный способ обновить мажорную версию без часа downtime на dump/restore.
- Cross-cloud / cross-region replication. Часто вместе с network compression или через managed CDC-сервисы.
- Selective replication. Хочу реплицировать только 5 таблиц из 200 — physical не умеет, logical делает легко.
- Multi-source consolidation. Десять источников (
shard_001..shard_010), один analytics-target — собирает все subscriptions. Physical не поддерживает (replica может быть только от одного primary). - CDC в Kafka/event-bus. Использует тот же logical decoding с другим output plugin (Debezium, wal2json).
- Active-active / multi-master. Через extensions (pglogical, BDR, EDB Postgres Distributed). С нативной logical replication multi-master не делается из-за conflict resolution.
Ограничения и подводные камни
- Sequences не реплицируются → после failover нужны
setval(). Иначе следующий INSERT попытается использовать существующий ID и упадёт на unique violation. - Conflict handling = stop. Если на subscriber UPDATE строки, которой нет (например, DELETE на subscriber сделан вручную), apply worker остановится с ERROR. Subscription стопится до ручного вмешательства (
ALTER SUBSCRIPTION ... DISABLE, разрешить конфликт,ENABLE). - Replica identity для UPDATE/DELETE. Если у таблицы нет PK и не выставлен
REPLICA IDENTITY FULL, UPDATE/DELETE не реплицируются с ошибкой «cannot update table without replica identity». - Initial copy блокирует другие операции на subscription. Большая таблица в initial copy = долгий tablesync.
- WAL bloat на publisher. Logical slot держит WAL до confirmation. Если subscriber отстаёт — WAL копится. Это тот же риск, что и с physical slot.
REPLICA IDENTITY и почему он важен
При UPDATE на subscriber Postgres должен найти строку для обновления. Чтобы это сделать, в WAL должна быть информация, идентифицирующая строку: либо PK, либо unique constraint, либо все old values.
Уровни:
- DEFAULT — пишется в WAL только PK. Требует PK на таблице.
- USING INDEX
<name>— какой-то конкретный unique index. Заменяет PK, если нет. - FULL — все old values. Безопасно, но дорого: каждый UPDATE/DELETE пишет полные дополнительные данные в WAL. Используется для таблиц без PK.
- NOTHING — ничего. UPDATE/DELETE не реплицируются (только INSERT попадает в pgoutput).
Production-правило: у всех реплицируемых таблиц должен быть PK. Если PK нет — добавь, или поставь REPLICA IDENTITY FULL (с осознанием overhead).
Проверка REPLICA IDENTITY для таблиц текущей БД. На production важно убедиться, что у всех реплицируемых таблиц есть PK или выставлен FULL. Колонка relreplident: 'd'=default(PK), 'i'=USING INDEX, 'f'=FULL, 'n'=NOTHING.
Conflict resolution: что произойдёт, если apply упал
На subscriber могут возникать конфликты, нехарактерные для physical replication:
- Конфликт по PK. Subscriber’у пришёл INSERT с id=42, но на subscriber id=42 уже есть. Apply падает с unique violation. Subscription остановилась.
- Не нашлась строка для UPDATE/DELETE. Например, на subscriber кто-то вручную сделал DELETE этой строки. Apply падает.
- Type mismatch. На publisher колонка
integer, на subscriber тоже integer но с разным значением — обычно ок. Но если на publisher тип расширен доbigint, а на subscriber всё ещёintegerи значение не помещается — падает.
Когда subscription остановилась, в pg_stat_subscription будет старый last_msg_receipt_time, в логах — текст ошибки. Разрешение конфликта обычно — руками: либо подправить subscriber-данные, либо ALTER SUBSCRIPTION ... SKIP (lsn = '0/...') — пропустить проблемную транзакцию.
В PG15+ появилось disable_on_error = true опция для subscription — апплай при ошибке автоматически отключается, чтобы не зацикливаться на retry. Это рекомендуемая настройка для production.
Параллельные apply workers
В PG16+ logical replication умеет применять транзакции параллельно (если их можно безопасно расщепить). Параметр max_parallel_apply_workers_per_subscription (default 2). До этого apply был строго последовательным — bottleneck на одной CPU. На write-heavy сценариях параллельный apply увеличивает throughput в 2-4 раза.
Row filters и column lists (PG15+)
С PG15 publication поддерживает row filter и column list:
CREATE PUBLICATION p FOR TABLE orders WHERE (status = 'active')— реплицировать только активные заказы.CREATE PUBLICATION p FOR TABLE orders (id, customer_id, status)— реплицировать только эти колонки (sensitive колонки не покидают publisher).
Это даёт качественно новые сценарии:
- Compliance. На analytics-кластер не идут email и phone — только агрегаты и FK.
- Multi-tenant shard offload. Каждый shard публикует свои строки в единый analytics-cluster, отфильтрованные по tenant_id.
- Subset replication для микросервисов. Сервис «orders» получает только колонки/строки, которые ему нужны, без широкого доступа к таблице.
Ограничения: row filter обязан быть immutable и не ссылаться на колонки, не входящие в REPLICA IDENTITY. Column list для UPDATE/DELETE требует REPLICA IDENTITY содержащую все колонки фильтра.
Logical vs physical: краткое сравнение
| Свойство | Physical | Logical |
|---|---|---|
| Что передаётся | Байты страниц через WAL | Декодированные tuple changes |
| Major version | Должна совпадать | Может отличаться (старшая → младшая или равная) |
| Гранулярность | Вся БД | Per-table |
| Subscriber writeable | Нет (read-only) | Да (это обычная БД с extra apply worker) |
| DDL | Реплицируется | Нет, делается вручную |
| Sequences | Реплицируются | Не реплицируются |
| Conflict | Не бывает (replica only reads) | Может быть, останавливает apply |
| Initial sync | pg_basebackup | tablesync worker per table |
| Use case | HA, read scaling | Migration, selective copy, CDC |
Чек-лист
- Logical replication = WAL декодируется в tuple changes, передаётся как pgoutput-сообщения, применяется как обычные DML на subscriber.
- Требует
wal_level = logicalна publisher иREPLICA IDENTITY(обычно PK) на всех реплицируемых таблицах. - Publication = «что отдаём» (per-table, опционально per-operation). Subscription = «что подписаны» + apply worker.
- Initial copy через tablesync, потом incremental stream через apply worker.
- Не реплицируются: DDL, sequences, large objects. Это делается отдельно (миграции, ручной setval, отдельные тулзы).
- REPLICA IDENTITY: DEFAULT (PK) / USING INDEX / FULL (все old values, дорого) / NOTHING (только INSERT).
- Use cases: major upgrade без downtime, selective replication, multi-source consolidation, CDC в Kafka.
- Logical slot удерживает WAL так же, как physical — мониторь
restart_lsn.