Справочник DBA: базы данных изнутри читается по порядку

Статья 8.3 · раздел 8 «Репликация и высокая доступность» · 57 из 109

Логическая репликация PostgreSQL: декодирование WAL, replica identity, DDL мимо репликации и конфликт на подписчике

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 в двух контейнерах postgres:18 (издатель с wal_level=logical и подписчик, по 1 ГБ памяти); таблица orders на 100 000 строк; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Подписчик перестал получать строки, в логе одна и та же ошибка раз в несколько секунд, а на издателе растёт 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.

DBA / 01

Путь транзакции 777 от WAL до конфликта на подписчике

Путь транзакции 777 от WAL до конфликта insert_existsСлева направо: WAL с записью COMMIT транзакции 777 на LSN 0/2B21A28; декодер, где reorderbuffer держит транзакцию до COMMIT, а snapbuild даёт снимок каталога; слот sub_orders с плагином pgoutput; apply worker подписчика; таблица orders на подписчике, где уже есть строка с id 100003. Apply worker получает ошибку insert_exists: транзакция откатывается, воркер перезапускается, счётчики apply_error_count и confl_insert_exists растут. Выход: ALTER SUBSCRIPTION SKIP с LSN 0/2B21A28 или исправление данных.WALCOMMIT xid 7770/2B21A28декодерreorderbuffer ждётCOMMIT, snapbuildслотsub_ordersплагин pgoutputapply workerINSERT id 100003ошибка, откатorders (sub)строка 100003уже есть: localconfl_insert_exists 4, apply_error_count 4SKIP (lsn = '0/2B21A28') или правка данныхВоркер перезапускается и повторяет ту же транзакцию, пока её не пропустят или не уберут помеху
Транзакция 777 доходит до подписчика целиком и после 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 для каждого слота, включая служебные.

Чек-лист

  1. Для каждой публикуемой таблицы выполни SELECT relname, relreplident FROM pg_class и убедись, что у таблицы с UPDATE/DELETE не n и не d без ключа.
  2. Запиши порядок DDL для подписчика и издателя в процедуру выкладки: сначала подписчик.
  3. Добавь в мониторинг apply_error_count, sync_error_count и confl_* из pg_stat_subscription_stats, а также wal_status и объём удержанного WAL по каждому слоту.
  4. Перед переключением на подписчика сверь sequence с издателем через setval.
  5. Убери стенд вместе с данными: docker rm -f -v dba-logical-replication-decoding-pub dba-logical-replication-decoding-sub && docker network rm dba-logical-replication-decoding-net.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru