Когда пользователь на сервере-publisher логической репликации загружает данные через COPY, PostgreSQL вставляет строки компактно, много строк в одной вставке, задействуя механизм heap_multi_insert(). Это экономит потребление WAL, уменьшает количество взятий локов и пр. На подписчике же apply worker вставляет каждую такую строку отдельно через heap_insert() — по одной WAL-записи на кортеж. Результат — медленный catch-up и лишний дисковый I/O.

В этой статье я расскажу об эксперименте, который позволяет задействовать multi_insert механизм на сторону подписчика логической репликации, какие решения за этим стоят и что показали бенчмарки.

Разве логическая репликация медленная?

Код: github.com/danolivo/pgdev/tree/lr-multi-insert
Материалы и скрипты бенчмарков: github.com/danolivo/conf/tree/main/2026c-LR-Multi-Insert

Проблема

Логическая репликация в PostgreSQL передаёт изменения как последовательность отдельных сообщений INSERT. Даже если на паблишере данные были загружены одним COPY на сотню миллионов строк, walsender разбирает WAL, декодирует мульти-инсерт записи обратно в отдельные изменения и отправляет их по одному. Apply worker на подписчике принимает каждое сообщение и вызывает ExecSimpleRelationInsert()table_tuple_insert()heap_insert(). На каждый кортеж генерируется отдельная WAL-запись и много чего ещё.

Вот как выглядит схема репликации данных:

Паблишер:
  COPY 100M строк
    → heap_multi_insert()    — ~100 WAL-записей на 100k кортежей
    → WAL (компактный)
    → walsender: DecodeMultiInsert() → ReorderBufferQueueChange()
    → pgoutput: одно сообщение INSERT на каждый кортеж
    → по сети: INSERT INSERT INSERT INSERT ...

Подписчик:
  walreceiver принимает N отдельных INSERT-сообщений
    → apply_handle_insert() для каждого
    → heap_insert()          — 1 WAL-запись на кортеж

В наших экспериментах на 100 миллионах строк паблишер при COPY генерирует 13 ГБ WAL, а подписчик на те же данные — около 18 ГБ. Подписчик пишет больше WAL, чем паблишер, и при этом тратит на catch-up около 46 минут.

Ситуация может стать ещё хуже в случае каскадной репликации, архивирования WAL или бэкапирования подписчика.

Идея

Не трогать wire-протокол. Не менять pgoutput. Батчить на стороне подписчика.

Apply worker по-прежнему получает отдельные INSERT-сообщения, но вместо немедленной вставки каждого кортежа накапливает их в буфере и периодически сбрасывает пачкой через table_multi_insert(). Одна WAL-запись на пачку вместо одной на кортеж.

Почему не добавить MULTI_INSERT в протокол? Причин несколько. Протокол логической репликации отражает SQL-операции, а heap_multi_insert() — внутренняя деталь реализации COPY. Новый тип сообщения потребует инкремент версии протокола, fallback-логику и ломает API всех output-плагинов — LogicalDecodeChangeCB принимает одно изменение за раз. Кроме того, конфликты при батчевой вставке обрабатывать сложнее: нужно откатить всю пачку и перезапустить поштучно. Это стоит сделать, но позже и отдельным коммитом.

Как воспользоваться

Фича управляется опцией подписки multi_insert:

CREATE SUBSCRIPTION mysub
  CONNECTION 'host=publisher port=5432 dbname=mydb'
  PUBLICATION mypub
  WITH (multi_insert = on, streaming = off);

Ограничение текущей версии: multi_insert несовместим с streaming != off. Streaming-apply имеет свою дисциплину транзакций и контекстов памяти, которую пилотная реализация не покрывает. Для ручного управления слотом:

-- На паблишере (replication connection):
CREATE_REPLICATION_SLOT mysub_slot LOGICAL pgoutput;

-- На подписчике:
CREATE SUBSCRIPTION mysub
  CONNECTION 'host=publisher port=5432 dbname=mydb'
  PUBLICATION mypub
  WITH (
    create_slot = false,
    slot_name = 'mysub_slot',
    multi_insert = on,
    streaming = off
  );

Опция multi_insert — это чисто подписочная настройка, никаких изменений протокола или паблишера.

Реализация: ApplyMIBuffer

Ядро реализации — структура ApplyMIBuffer, которая живёт в ApplyContext apply worker’а. Один буфер на одну таблицу в каждый момент времени.

Что внутри:

  • Собственный Relation, открытый через table_open(relid, RowExclusiveLock) — отвязан от цикла logicalrep_rel_open/close, который обнуляет запись в map’е внутри транзакции.

  • Персистентный EState + ResultRelInfo с индексами, открытыми один раз при инициализации через ExecOpenIndices.

  • Долгоживущий receiveslot (TTSOpsVirtual) — каждый входящий INSERT десериализуется в один и тот же слот, экономя MakeSingleTupleTableSlot + ExecDropSingleTupleTableSlot на каждое сообщение.

  • Массив slots[] фиксированного размера: 10 000 элементов TupleTableSlot*, аллоцируемых лениво и переиспользуемых между сбросами через ExecClearTuple.

Два режима сброса

Буфер сбрасывается в двух режимах:

Промежуточный сброс (intermediate flush) — срабатывает при достижении лимита ёмкости (nslots >= 10000 или cum_bytes >= 8 МБ). Вызывает table_multi_insert(), обходит индексы, очищает слоты через ExecClearTuple и продолжает накопление. Буфер остаётся живым.

Финальный сброс (final flush) — при коммите, смене таблицы или любом не-INSERT событии. Выполняет ту же работу, но затем уничтожает буфер и обнуляет указатель.

Разделение на два режима — ключевая оптимизация. Ранняя версия с единственным режимом «сброс-и-уничтожение» вынуждала пересоздавать слоты при каждом достижении лимита внутри одной транзакции. По данным flamegraph-анализа, ResourceOwnerForget на pin’ах tupdesc’ов занимал около 53% CPU apply worker’а. После перехода на двухрежимную схему с переиспользованием слотов эта доля упала примерно до 6%.

Обработка конфликтов

Если таблица имеет immediate UNIQUE/PK индекс, сброс буфера выполняется внутри BeginInternalSubTransaction. При ERRCODE_UNIQUE_VIOLATION субтранзакция откатывается, батчинг отключается до конца текущей транзакции применения, а накопленные кортежи перепроигрываются через стандартный ExecSimpleRelationInsert, который доходит до обычного пути CheckAndReportConflict / disable_on_error / ALTER SUBSCRIPTION SKIP. Любая другая ошибка пробрасывается наверх.

Какие таблицы подходят

Функция apply_mi_relation_is_safe() проверяет таблицу при первом INSERT. Батчинг недоступен для таблиц с: триггерами (BEFORE/INSTEAD OF), RLS, CHECK-ограничениями, хранимыми generated-столбцами, exclusion-ограничениями, отложенными UNIQUE/PK, невалидными или неготовыми индексами, а также для не-plain таблиц (секционированные, foreign).

Это строже, чем ограничения COPY. COPY работает через executor и валидирует CHECK/RLS в рантайме. Apply worker с table_multi_insert() обходит executor, поэтому ограничения, которые COPY проверяет динамически, здесь отсекаются статически. Если таблица не проходит проверку, apply worker молча переключается на поштучную вставку.

Бенчмарки

Стенд

Два узла в GCP на разных континентах:

  • Паблишер: europe-west1 (Бельгия), e2-standard-4

  • Подписчик: us-west1 (Орегон), e2-standard-4

  • RTT: ~140 мс

  • PostgreSQL: собранный из ветки lr-multi-insert

  • Параметры: wal_level = logical, checkpoint_timeout = 1h, autovacuum отключен

  • Нагрузка: COPY N строк на паблишере, замер времени catch-up на подписчике

Тестовая таблица:

bench_copy (
  id       bigint PRIMARY KEY,
  val      double precision,
  payload  text,
  ts       timestamptz
)

Три размера: 1M, 10M, 100M строк. Для каждого размера два прогона: multi_insert = true и multi_insert = false.

Результаты: таблица с PRIMARY KEY

Строки

Catch-up, off

Catch-up, on

Ускорение

Sub WAL, off

Sub WAL, on

Экономия WAL

1M

19.9 с

10.5 с

1.9×

186 МБ

134 МБ

28%

10M

223.0 с

139.2 с

1.6×

2 753 МБ

2 223 МБ

19%

100M

2 776 с

1 306 с

2.1×

18 ГБ

13 ГБ

28%

Время catch-up сокращается в 1.6–2.1 раза. WAL на подписчике уменьшается на 19–28%. Время COPY на паблишере не зависит от настройки multi_insert — оптимизация чисто на стороне подписчика.

При 100M строк: паблишер генерирует 13 ГБ WAL при COPY. Без батчинга подписчик пишет около 18 ГБ (усиление ~38%). С батчингом — 13 ГБ, что совпадает с паблишером.

Результаты: таблица без PRIMARY KEY

Отдельная серия экспериментов на таблице без PK показывает ещё более выраженный эффект:

Строки

Catch-up, off

Catch-up, on

Ускорение

Sub WAL, off

Sub WAL, on

Экономия WAL

1M

14.0 с

13.4 с

1.05×

123 МБ

70 МБ

43%

10M

125.8 с

77.7 с

1.6×

1 232 МБ

701 МБ

43%

100M

1 626 с

921 с

1.8×

12 ГБ

7 ГБ

~43%

Без PK нет per-tuple index insertion, и эффект батчинга раскрывается полностью: WAL подписчика практически совпадает с WAL паблишера (для 10M строк: паблишер 697 МБ, подписчик 701 МБ). Экономия WAL стабильно держится на уровне 43%.

Что показали flamegraph’ы

Профилирование apply worker’а через flamegraph’ы позволяет увидеть, куда уходит CPU и что именно меняет батчинг.

Таблица с индексами

Без батчинга ExecSimpleRelationInsert занимает около 38% CPU apply worker’а. Сюда входят: heap_insert (~37% суммарно), ExecOpenIndices/ExecCloseIndices на каждый кортеж, create_edata_for_relation, FreeExecutorState, TargetPrivilegesCheck, LockRelationOid. Всё это per-tuple setup/teardown, которое суммарно потребляет около 25% CPU.

С батчингом весь этот каркас исчезает из горячего пути. heap_multi_insert занимает около 10% вместо 37% у heap_insert — сокращение примерно в 4 раза при том же количестве кортежей.

Доминирующей стоимостью становится ExecInsertIndexTuples — 43% вместо 17%. Абсолютная стоимость на кортеж не изменилась — просто всё остальное стало дешевле. Это указывает направление для следующего этапа: батчевое обновление индексов.

Таблица без индексов

Без индексов картина ещё чище. heap_insert + каркас — 47% CPU в vanilla. С батчингом heap_multi_insert — 22%. XLogInsert падает примерно в 5 раз (с 5.8% до 1.1%), что напрямую отражает устранение WAL-усиления.

Новым потолком становится парсинг текстового протокола: slot_store_data занимает 43% CPU. Следующий рычаг для этого типа нагрузки — бинарный протокол (CREATE SUBSCRIPTION ... WITH (binary = true)), а не дальнейшая оптимизация батчинга.

Ограничения текущей версии

Streaming несовместим. multi_insert требует streaming = off. Streaming-apply имеет собственную дисциплину транзакций и контекстов памяти; совмещение — задача для следующего релиза.

Конфликт отключает батчинг до конца транзакции. Unique violation при сбросе буфера переводит apply worker на поштучный путь для всей оставшейся транзакции. Стандартный механизм disable_on_error / SKIP при этом работает, но батчинг уже не возобновляется.

Parallel apply workers исключены. Батчинг не работает внутри параллельных apply worker’ов.

Per-transaction disable. Если хотя бы одна таблица не проходит eligibility check, батчинг отключается для всей оставшейся транзакции — даже для других, подходящих таблиц.

Будущее: батчинг на стороне паблишера

Текущая реализация — subscriber-side only. Следующий шаг — publisher-side batching с новым сообщением MULTI_INSERT в протоколе логической репликации.

Идея: walsender при логическом декодировании группирует последовательные INSERT’ы для одной таблицы в одно сообщение, применяет колоночное сжатие (похожие значения в одном столбце хорошо сжимаются) и отправляет одну компактную пачку вместо тысяч отдельных сообщений. На стороне подписчика пачка распаковывается и подаётся в table_multi_insert().

Это ортогональный выигрыш к subscriber-side батчингу: снижает нагрузку на сеть (критично для кросс-регионального и мульти-облачного сценариев), но не влияет на стоимость index insertion. Оба направления дополняют друг друга.

Заключение

Батчевая вставка в apply worker’е логической репликации — относительно локальное изменение (основная логика живёт в worker.c), которое даёт измеримый результат: примерно двукратное ускорение catch-up и сокращение WAL на подписчике на 19–43% в зависимости от наличия индексов. Фича opt-in, не требует изменений протокола или паблишера, и молча откатывается на поштучный путь для таблиц, которые не подходят для батчинга.


Ссылки:

Комментарии (0)