Подписчик перестал получать строки, в логе одна и та же ошибка раз в несколько секунд, а на издателе растёт pg_wal. Логическая репликация отправляет не страницы и не байты WAL, а строки: издатель читает журнал, собирает из него изменения каждой транзакции и отдаёт их после COMMIT (большую транзакцию, превысившую logical_decoding_work_mem, подписка PostgreSQL 18 по умолчанию получает частями ещё до конца: streaming = parallel), а на подписчике воркер применяет их обычными INSERT, UPDATE, DELETE. Отсюда все её особенности: нужен идентификатор строки для UPDATE и DELETE, схема и sequence едут отдельно, а любая ошибка применения останавливает всю очередь на одной транзакции. Слот при этом держит WAL и горизонт каталогов так же, как слоты из статьи про горизонт xmin. Опыт идёт на таблице в 100 000 строк: механизм виден на этом объёме, больших объёмов статья не измеряет.
Путь изменения: от WAL до apply worker
Физическая реплика применяет WAL байт в байт. Логическая сначала переводит его в строки. В DecodeCommit (decode.c, тег REL_18_6) видно, когда плагин вообще получает транзакцию:
/*
* Send the final commit record if the transaction data is already
* decoded, otherwise, process the entire transaction.
*/
...
else
{
ReorderBufferCommit(ctx->reorder, xid, buf->origptr, buf->endptr,
commit_time, origin_id, origin_lsn);
}
Пропущены строки 725–731, ветка двухфазных транзакций. reorderbuffer собирает изменения транзакции и отдаёт их плагину, когда в WAL встретилась запись COMMIT (или раньше, частями, если транзакция не помещается в logical_decoding_work_mem, а подписка разрешает streaming); для разбора записей по каталогу декодеру нужен снимок, который строит snapbuild, поэтому слот держит catalog_xmin. Дальше выходной плагин (pgoutput для подписок, test_decoding для просмотра) форматирует строки, а на подписчике их принимает apply worker.
Путь транзакции 777 от WAL до конфликта на подписчике
COMMIT. Конфликт insert_exists откатывает её, воркер перезапускается и повторяет ту же транзакцию, а LSN из сообщения об ошибке подходит для SKIP.Опыт 1: публикация, начальная копия и replica identity
docker network create dba-logical-replication-decoding-net
docker run -d --name dba-logical-replication-decoding-pub --network dba-logical-replication-decoding-net \
--memory 1g -e POSTGRES_PASSWORD=pg postgres:18 -c wal_level=logical
docker run -d --name dba-logical-replication-decoding-sub --network dba-logical-replication-decoding-net \
--memory 1g -e POSTGRES_PASSWORD=pg postgres:18
На обоих узлах создана база app (CREATE DATABASE app) и таблицы; на издателе в orders вставлено 100 000 строк, events не имеет первичного ключа. Подписка заводит слот и копирует таблицы сама:
-- издатель
CREATE TABLE orders (id serial PRIMARY KEY, email text, amount int);
CREATE TABLE events (id int, note text);
INSERT INTO orders (email, amount) SELECT 'u' || i || '@example.com', i FROM generate_series(1, 100000) i;
INSERT INTO events VALUES (1, 'first');
CREATE PUBLICATION pub_orders FOR TABLE orders, events;
-- подписчик: те же CREATE TABLE, затем
CREATE SUBSCRIPTION sub_orders
CONNECTION 'host=dba-logical-replication-decoding-pub user=postgres password=pg dbname=app'
PUBLICATION pub_orders;
SELECT srrelid::regclass, srsubstate FROM pg_subscription_rel ORDER BY 1;
Первый запрос, сразу после CREATE SUBSCRIPTION, и второй, через три секунды:
srrelid | srsubstate
---------+------------
orders | i
events | i
srrelid | srsubstate | srsublsn
---------+------------+-----------
orders | r | 0/2AB6838
events | r | 0/2AB6838
Коды из документации: i — инициализация, d — данные копируются, f — копия закончена, s — синхронизирована, r — готова, обычная репликация. На подписчике count(*) равен 100000, в pg_stat_subscription виден один воркер типа apply. Другие состояния (d, f, s) на таком объёме не наблюдались: копия 100 000 строк заканчивается быстрее, чем их можно поймать.
Теперь UPDATE и DELETE в таблице без первичного ключа:
UPDATE events SET note = 'x' WHERE id = 1;
DELETE FROM events WHERE id = 1;
INSERT INTO events VALUES (2, 'second');
ERROR: cannot update table "events" because it does not have a replica identity and publishes updates
HINT: To enable updating the table, set REPLICA IDENTITY using ALTER TABLE.
ERROR: cannot delete from table "events" because it does not have a replica identity and publishes deletes
HINT: To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.
INSERT 0 1
Ошибку даёт сам издатель, до отправки чего-либо: INSERT проходит, потому что ключ ему не нужен. После ALTER TABLE events REPLICA IDENTITY FULL тот же UPDATE проходит (relreplident стал f, у orders остался d — первичный ключ), и на подписчике строка 1 имеет note = x. FULL пишет в WAL всю старую строку, а не ключ, и подписчик ищет её полным сравнением столбцов: для настоящей таблицы вместо него нужен уникальный индекс (REPLICA IDENTITY USING INDEX).
Опыт 2: что не реплицируется
Sequence и DDL, как говорит документация, едут отдельно. Строка вставлена на издателе, затем сравнены sequence:
-- издатель: INSERT INTO orders (email, amount) VALUES ('new@example.com', 5) RETURNING id;
SELECT last_value, is_called FROM orders_id_seq; -- на обоих узлах
last_value | is_called -- издатель
------------+-----------
100001 | t
last_value | is_called -- подписчик
------------+-----------
1 | f
Строка с id = 100001 на подписчике есть, а его sequence стоит в начале: после переключения на подписчика первый же nextval вернёт 1 и столкнётся с существующим ключом. Перед переключением sequence переносят вручную setval.
Теперь на издателе ALTER TABLE orders ADD COLUMN status text DEFAULT 'new' и вставка новой строки. Лог подписчика:
ERROR: logical replication target relation "public.orders" is missing replicated column: "status"
CONTEXT: processing remote data for replication origin "pg_16400" during message type "INSERT" in transaction 776, finished at 0/2B21928
LOG: background worker "logical replication apply worker" (PID 130) exited with exit code 1
LOG: logical replication apply worker for subscription "sub_orders" has started
Сообщение рождается в logicalrep_report_missing_or_gen_attrs. Воркер падает, стартует снова и падает на той же транзакции: в pg_stat_subscription_stats apply_error_count дошёл до 3, а строк на подписчике осталось 100 001. После ALTER TABLE orders ADD COLUMN status text на подписчике очередь разобралась сама. Но старые строки разошлись: у new@example.com на издателе status = new (значение по умолчанию записано при ADD COLUMN), на подписчике NULL, потому что репликация передаёт только новые изменения. Столбец добавляют сначала на подписчике, затем на издателе, а старые значения догоняют отдельным UPDATE.
Опыт 3: конфликт уникальности и его разбор
На подписчике вручную вставлена строка с ключом, который издатель выдаст следующим, затем статистика сброшена и вставка на издателе:
-- подписчик
SELECT pg_stat_reset_subscription_stats(oid) FROM pg_subscription;
INSERT INTO orders VALUES (100003, 'local@example.com', 1, 'local');
-- издатель
INSERT INTO orders (email, amount) VALUES ('clash@example.com', 9) RETURNING id; -- 100003
Лог подписчика, первая ошибка:
ERROR: conflict detected on relation "public.orders": conflict=insert_exists
DETAIL: Key already exists in unique index "orders_pkey", modified in transaction 784.
Key (id)=(100003); existing local row (100003, local@example.com, 1, local); remote row (100003, clash@example.com, 9, new).
CONTEXT: processing remote data for replication origin "pg_16400" during message type "INSERT" for replication target relation "public.orders" in transaction 777, finished at 0/2B21A28
В PostgreSQL 18 журнал называет вид конфликта, обе строки и транзакцию, а примечания к выпуску добавляют новые столбцы в pg_stat_subscription_stats. В контейнере их семь: confl_insert_exists, confl_update_origin_differs, confl_update_exists, confl_update_missing, confl_delete_origin_differs, confl_delete_missing, confl_multiple_unique_conflicts. Через десять секунд после сброса:
subname | apply_error_count | sync_error_count | confl_insert_exists
sub_orders | 4 | 0 | 4
Оба счётчика растут вместе с перезапусками воркера, пока конфликт не снят: так видно, что подписчик застрял, не читая лог. Конфликт разбирают двумя способами. Первый — исправить данные: для второго такого же конфликта (ключ 100004) на подписчике выполнен DELETE FROM orders WHERE id = 100004, и через несколько секунд там появилась строка clash2@example.com, а счётчики остановились на 8. Второй — пропустить транзакцию целиком по LSN из finished at:
ALTER SUBSCRIPTION sub_orders SKIP (lsn = '0/2B21A28');
LOG: logical replication completed skipping transaction at LSN 0/2B21A28
Сверка делается в maybe_start_skipping_changes:
if (likely(XLogRecPtrIsInvalid(MySubscription->skiplsn) ||
MySubscription->skiplsn != finish_lsn))
return;
/* Start skipping all changes of this transaction */
skip_xact_finish_lsn = finish_lsn;
ereport(LOG,
errmsg("logical replication starts skipping transaction at LSN %X/%X",
LSN_FORMAT_ARGS(skip_xact_finish_lsn)));
Пропускается вся транзакция, а не одна строка, и после этого subskiplsn снова 0/0. Расхождение остаётся: на подписчике 100003 содержит local@example.com, на издателе clash@example.com. Пропуск убирает блокировку очереди, но не чинит данные, их приходится сверять вручную. LSN для SKIP берётся из finished at, а документация CREATE SUBSCRIPTION предупреждает: если ошибка случилась в параллельном apply worker (большая транзакция при streaming = parallel), этого LSN в логе может не быть.
Опыт 4: что декодер достаёт из WAL
Слот с плагином test_decoding показывает, что получил бы подписчик, ничего не применяя. Вариант peek не двигает позицию слота:
SELECT pg_create_logical_replication_slot('peek_slot', 'test_decoding');
BEGIN;
INSERT INTO events VALUES (3, 'third');
UPDATE orders SET amount = amount + 1 WHERE id = 1;
DELETE FROM events WHERE id = 3;
COMMIT;
SELECT lsn, xid, data FROM pg_logical_slot_peek_changes('peek_slot', NULL, NULL);
0/2B253B8|778|BEGIN 778
0/2B253B8|778|table public.events: INSERT: id[integer]:3 note[text]:'third'
0/2B25438|778|table public.orders: UPDATE: id[integer]:1 email[text]:'u1@example.com' amount[integer]:2 status[text]:'new'
0/2B254E8|778|table public.events: DELETE: id[integer]:3 note[text]:'third'
0/2B25560|778|COMMIT 778
Три изменения идут одной транзакцией 778, между BEGIN и COMMIT. UPDATE несёт новую строку целиком: ключ не менялся, поэтому старый ключ не передан. DELETE в events содержит всю старую строку, потому что у таблицы REPLICA IDENTITY FULL. Повторный peek вернул те же 5 строк. Незавершённая транзакция слоту не видна: peek внутри открытой транзакции с INSERT дал 0 строк, после COMMIT — 3 (BEGIN, INSERT, COMMIT).
Цена такого слота — удержанный WAL: после UPDATE всех строк orders неактивный peek_slot держал 23 МБ (pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)), и число растёт, пока слот не потребят или не удалят. Слот держит ещё и горизонт каталогов:
SELECT slot_name, restart_lsn, confirmed_flush_lsn, catalog_xmin FROM pg_replication_slots WHERE slot_name = 'peek_slot';
peek_slot|0/2B25380|0/2B253B8|778
Забытый слот — тот же держатель, что в статье про xmin: пока catalog_xmin не сдвинется, VACUUM не убирает из системных каталогов версии строк, удалённые транзакциями начиная с 778.
Что из этого следует
- Первичный ключ или индекс для replica identity нужны до публикации. Таблица без них принимает
INSERT, но отвергаетUPDATEиDELETEна издателе, и приложение узнаёт об этом по ошибке в бою. - Схему меняй в порядке «подписчик, затем издатель». Обратный порядок остановил очередь на первой же строке с новым столбцом и держал её, пока схему не выровняли.
- Sequence переноси вручную перед переключением. Данные строк реплицируются, значения sequence нет, и у подписчика они остаются в начале.
- Следи за
apply_error_countиconfl_*, а не за отсутствием алертов на лаг. Застрявший воркер не отстаёт, он стоит, и счётчики растут на единицу при каждом перезапуске. SKIPпропускает транзакцию целиком. Прежде чем его применять, прочитайDETAILиз лога и реши, какая из двух строк верная.- Слот, который никто не читает, — это удержанный WAL. Проверяй
activeи объёмrestart_lsnдля каждого слота, включая служебные.
Чек-лист
- Для каждой публикуемой таблицы выполни
SELECT relname, relreplident FROM pg_classи убедись, что у таблицы сUPDATE/DELETEнеnи неdбез ключа. - Запиши порядок DDL для подписчика и издателя в процедуру выкладки: сначала подписчик.
- Добавь в мониторинг
apply_error_count,sync_error_countиconfl_*изpg_stat_subscription_stats, а такжеwal_statusи объём удержанного WAL по каждому слоту. - Перед переключением на подписчика сверь sequence с издателем через
setval. - Убери стенд вместе с данными:
docker rm -f -v dba-logical-replication-decoding-pub dba-logical-replication-decoding-sub && docker network rm dba-logical-replication-decoding-net.