Назад к блогу

WalShadow: репликация Postgres → ClickHouse из физического WAL

WalShadow: репликация Postgres → ClickHouse из физического WAL

ClickHouse представила WalShadow — открытый движок для потоковой репликации данных из PostgreSQL в ClickHouse, читающий физический WAL напрямую, в обход логического декодирования и промежуточных форматов вроде Kafka или JSON. Такой подход обещает задержки в пределах секунды и профиль нагрузки на источник, сопоставимый с обычной физической репликой. Разбираем, как устроен конвейер декодирования и вставки, и какие ограничения признают сами авторы.

ClickHouse выпустила WalShadow — открытый движок, который переносит данные из Postgres в ClickHouse напрямую из физического WAL. Код доступен на GitHub, а для ClickHouse Managed Postgres запущено приватное превью. Движок не использует логическую репликацию: он читает тот же поток WAL, что и обычные физические реплики Postgres, декодирует его вне исходной базы и пишет нативные блоки ClickHouse.

Ниже — что именно изменилось, как это устроено, откуда взялась мотивация и какие ограничения авторы признают сами.

Что именно изменилось

WalShadow поддерживает полный жизненный цикл репликации: начальную загрузку, непрерывную репликацию, эволюцию схемы, восстановление после перезапуска и плановые переключения источника. Требуется PostgreSQL 16 или новее, причём мажорная версия теневого Postgres WalShadow должна совпадать с мажором источника. На стороне источника поддерживаются PostgreSQL 16, 17, 18 и 19; демон отклоняет непроверенные мажорные версии. Назначение использует протокол ClickHouse Native, а сопоставление типа time требует поддержки Time64 в ClickHouse.

После установки доступны две команды. walshadow-stream управляет потоком репликации, а init выполняет первичную настройку: записывает конфигурационные блоки [source], [ch] и выбранные [table.*], а флаг --all-tables выбирает все таблицы. Конфигурация задаётся в TOML-файле через --ch-config, при этом загрузчик дополнительно сливает соседний каталог, полученный заменой .toml на .d (например, ch-config.d/*.toml). Управление на лету идёт через walshadow-stream ctl с подкомандами add, remove, pause, resume, source, dest, reload, apply и status. Режим начальной загрузки таблицы задаётся полем initial_load со значениями none, copy, base_backup, object_store.

Как устроено чтение физического WAL

WalShadow не просит исходную базу декодировать изменения в логические события. Он обрабатывает WAL вне источника в четыре этапа:

  1. Фильтрует записи каталога и воспроизводит их в отдельный shadow-инстанс Postgres только со схемой — так поддерживается актуальный каталог таблиц, колонок и типов по мере эволюции схемы.
  2. Распределяет heap-записи по пулу декодеров на Rust, не пропуская их через shadow-инстанс.
  3. Батчер группирует декодированные строки по таблицам в готовые ClickHouse-native блоки без промежуточных преобразований формата.
  4. Отдельный пул вставщиков параллельно пишет несколько блоков в ClickHouse, позволяя декодированию и вставке масштабироваться независимо.

Такой путь не требует output plugin логического декодирования, Kafka, JSON-сериализации или отдельного шага нормализации. Поскольку блоки обрабатываются параллельно, они могут прийти в ClickHouse не по порядку; к каждой строке прикрепляется позиция WAL источника (_lsn), что позволяет ClickHouse сохранять последнюю версию для каждого ключа. Операции, требующие строгого порядка (изменения схемы и truncations), вводят барьеры, которые ждут, пока все предшествующие данные станут durable, прежде чем примениться.

Источнику нужно лишь отдавать физический WAL — это даёт профиль нагрузки, похожий на физический standby, и доставляет изменения в ClickHouse в пределах секунды.

Managed shadow PostgreSQL

Рядом с источником WalShadow поднимает и сопровождает отдельный инстанс PostgreSQL — managed shadow. Он хранит каталог и значения, нужные для репликации. Его нельзя продвигать, локально vacuum-ить или писать в его каталоги. Соединение репликации shadow с WalShadow требует живого потока для захвата границ каталога; архивные файлы служат лишь запасным путём восстановления и не эквивалентны режиму запуска. При старте WalShadow проверяет режим recovery, каталог данных, команду restore и конфигурацию bridge, прежде чем принять shadow, и падает при несоответствии, а не перезапускает PostgreSQL.

Для каждой базы создаётся своё соединение с каталогом shadow и свой набор bridge-воркеров. WalShadow повышает max_worker_processes у shadow, чтобы их разместить. В режиме значений shadow хранит не только состояние каталога, но и требуемые данные, поэтому его потеря требует свежего bootstrap.

Destination-таблицы в ClickHouse

По умолчанию WalShadow создаёт destination-таблицу с движком ReplacingMergeTree(_lsn, _is_deleted). Это движок ClickHouse, который при слиянии частей оставляет для каждого ключа сортировки только одну строку — последнюю по указанной колонке версии; параметры _lsn и _is_deleted задают, по какой колонке определяется версия и как учитывается признак удаления. ORDER BY строится по row key источника: это колонки первичного ключа или индекса, выбранного через REPLICA IDENTITY USING INDEX, а при REPLICA IDENTITY FULL без первичного ключа — все сортируемые колонки ClickHouse. В последнем случае включается allow_nullable_key, а первая колонка сортировки становится PRIMARY KEY. ReplacingMergeTree обеспечивает сходимость после обновлений, удалений и повторов демона.

Колонка _lsn типа UInt64 хранит позицию коммита источника и версию строки, а _is_deleted типа Bool — маркер удаления. При soft_delete=true tombstone сохраняются как последние версии; при is_deleted = false маркер удаляется, а строки DELETE источника отбрасываются. Когда обновление меняет row key, WalShadow дополнительно пишет delete-маркер для старого ключа с версией на единицу меньше новой версии строки.

Маршрутизация и конфигурация

Маршрутизация задаётся тремя слоями: connection URL, TOML-конфиг и таблицы конфигурации на стороне источника. URL принимаются в формах postgres://... и clickhouse://... или clickhouses://..., а учётные данные можно держать в переменных окружения WALSHADOW_PG_URL и WALSHADOW_CH_URL. Приоритет значений:

1. explicit CLI flag
2. optional source PostgreSQL config row
3. merged TOML files

Connection URL дают значения по умолчанию ниже значений конфиг-файла. Таблицы на источнике включаются через [runtime_config] schema и установку sql/runtime_config_install.sql; config_table задаёт replicate и initial_load, config_namespace — значения по умолчанию для namespace, config_column — отдельные колонки. Строки config_table и config_namespace применяются к таблицам в своей базе данных и не имеют колонки database. Итоговую конфигурацию с маскированными паролями показывает walshadow-stream ctl show.

Начальная загрузка

Режим copy читает таблицу кусками по heap-блокам: по одному COPY (SELECT … WHERE ctid >= … AND ctid < …) на кусок. Размер куска задаёт [bootstrap] copy_chunk_blocks в 8-килобайтных страницах, по умолчанию 131072 (1 GiB); таблица меньше одного куска читается одним неквалифицированным COPY. backfills.toml записывает следующий блок только после того, как строки предыдущего куска подтверждены как долговечные, поэтому перезапуск повторяет не более одного куска, а повторные строки несут тот же _lsn = S и схлопываются.

Курсор привязан к filenode. VACUUM FULL, CLUSTER, TRUNCATE и переписывающий ALTER TABLE перемещают строки, и смена filenode между кусками перезапускает таблицу, а не продолжает в страницы с другими строками. Для предикатов нужен TID range scan, поэтому enable_tidscan на источнике оставляют включённым.

Другие режимы: base_backup переносит backup размером с кластер даже для одной таблицы; object_store требует полный wal-g backup и непрерывный архивный WAL до точки выборки; initial_load = "none" никогда не восстанавливает строки, существовавшие до выборки и не получившие более поздних изменений.

Предыстория: логическая репликация и её ограничения

До WalShadow для репликации Postgres → ClickHouse применялись CDC-инструменты на основе логической репликации — в частности, PeerDB и Debezium. PeerDB подключается к Postgres, запускает процесс логической репликации, а события создания, редактирования и удаления строк преобразуются в запросы к ClickHouse. Процесс состоит из двух этапов: Initial Copy переносит существующие данные через S3, а CDC переносит новые изменения и может работать неограниченно долго.

Ограничения проявлялись на практике. Пока идёт Initial Copy, WAL перестаёт чиститься и место на диске стремительно заканчивается — пришлось патчить PeerDB, чтобы в интерфейсе отображались отдельные партиции. Конструкция ALTER TABLE x DELETE, генерируемая clickhouse-sqlalchemy, приводила к массе мёрджей изменённых частей таблиц: нагрузка на CPU упиралась в потолок, диск выжирался, кластер переходил в read only с ошибками Cannot reserve 1.00 MiB, not enough space. Debezium также использует логическую репликацию.

Отдельная проблема — replication slots. Это механизм Postgres, который удерживает сегменты WAL до тех пор, пока зарегистрированные потребители репликации их не обработают. Если потребитель не успевает или неактивен, слот удерживает всё больше сегментов; объём удерживаемого WAL вычисляется как разница между restart LSN слота и текущим LSN базы. Задержки растут, когда активный потребитель не успевает обрабатывать изменения. Крупные и длительные транзакции заставляют логическое декодирование сбрасывать состояние на диск, что увеличивает нагрузку на I/O и замедляет репликацию.

Почему выбрали физический WAL

Авторы решают проблему дорогой и медленной репликации через логическое декодирование. Потребляя физический WAL напрямую, WalShadow устраняет необходимость в слотах логической репликации, снижает операционные накладные расходы и потребление ресурсов на источнике. Также заявлено, что физический WAL позволяет поддерживать сложные изменения схемы (ADD COLUMN, RENAME COLUMN, DROP COLUMN, CREATE TABLE), которые трудно обрабатывать через обычные конвейеры логического декодирования.

При этом сам WalShadow требует wal_level = logical и usable replica identity для каждой реплицируемой таблицы.

Что меняет на практике

Часть настроек применяется на лету: правила для таблиц и колонок, имена метаданных-колонок для конкретной таблицы, order_by и primary_key (в момент создания таблицы), назначения namespace и политика удаления, состояние паузы, размеры батчей, таймаут сброса, сжатие и число повторов, а также эндпоинты источника и ClickHouse. Требуют рестарта: replicate_all, схема runtime-config, кластерные имена [system_columns], режим soft-delete и настройки буферизации TOAST, размеры worker-pool и лимиты памяти, а также выбор backup и shadow bootstrap.

Изменения применяются командой walshadow-stream ctl reload (то же делает SIGHUP) либо атомарно через ctl apply с TOML на stdin. Control пишет 50-api.toml в каталог фрагментов конфигурации (переопределяется --control-fragment); невалидный объединённый конфиг отклоняется, а предыдущий фрагмент восстанавливается. Добавление или удаление базы данных требует рестарта, потому что shadow регистрирует bridge workers при старте, а reload лишь сообщает о базах, которые не удалось отследить. Учётные данные архива и bootstrap-конфигурацию следует держать в TOML, а не в таблицах источника.

Failover и switchover

Failover требует, чтобы target был потоковым стендбаем текущего primary, мог остановить запись до promotion, мог проиграть WAL до замороженной головы источника, разделял system ID и линию timeline с источником, имел доступный WAL и, при использовании слотов, физический слот, покрывающий позицию возобновления.

Порядок pause → repoint → promote важен потому, что pause ограничивает вывод ClickHouse тем WAL, которым target уже владеет, а repoint до promotion позволяет WalShadow проверить replay на том самом сервере, который станет primary. Promotion gate не даёт строкам из заброшенной ветки источника попасть в ClickHouse.

При незапланированном promotion, пока WalShadow ещё читает старый primary, транзакции или строки могут оказаться за точкой ветвления, и WalShadow fails closed вместо компенсации ClickHouse. В таком случае нужно перестроиться с известного baseline или восстановить старый запланированный путь. До promotion можно откатить repoint на старый primary и возобновиться, так как состояние потомков ещё не зафиксировано. После начала promotion нельзя использовать --ignore-cursor для форсирования прогресса — нужно смотреть crossing_blocked_on, crossing_detail, timeline источника, покрытие слотом и состояние подтверждения ClickHouse.

Изменения схемы источника

Автоматически поддерживаются: создание таблицы-назначения при совпадении с активной областью, добавление колонки, добавление колонки со значением по умолчанию, переименование колонки-назначения (если конфиг не закрепляет другое имя), удаление автоматически отображённой колонки-назначения, изменение типа колонки-назначения (если конфиг не задаёт тип), снятие NOT NULL (делает производный тип Nullable), TRUNCATE и DROP TABLE по политике.

Ручной миграции требуют переименование таблиц, перемещение схемы, смена replica identity и переход на unlogged-хранилище — их нужно согласовывать с явной миграцией назначения и маппинга. ClickHouse может отклонить смену типа ключевой колонки, включая колонки replica identity, используемые в ключах сортировки назначения, а также отказывается удалять или переименовывать колонки ключа сортировки. При REPLICA IDENTITY FULL и отсутствии первичного ключа это применяется ко всем колонкам, используемым для сортировки. Отклонение останавливает репликацию до переименований, добавлений и удалений колонок из этого события схемы.

Порядок ручной миграции при отклонении смены типа ключа: приостановить WalShadow, дождаться сходимости drain и emitter_ack в ctl status, изменить или заменить колонку назначения в ClickHouse, добавить переопределение типа при расхождении автоматического маппинга, возобновить WalShadow.

Цифры бенчмарка

ClickHouse приводит два ключевых показателя. По пропускной способности: исходный Postgres выдерживал примерно 290 000 вставляемых строк в секунду, WalShadow реплицировал 289 000 строк в секунду, PeerDB — около 120 000 строк в секунду (примерно 40% скорости источника). По задержке commit-to-visible: WalShadow достиг примерно 200 мс, PeerDB — около 10 секунд (примерно в 50 раз ниже). Нативная физическая реплика Postgres дала базовый уровень около 50 мс.

Бенчмарк воспроизводил непрерывный поток изменений из одной таблицы; Postgres, ClickHouse и каждый инструмент репликации работали в одном регионе на инстансах c8i.2xlarge с 8 vCPU каждый.

Ограничения и открытые вопросы

Значения вне собственного набора кодеков WalShadow (массивы, hstore, перечисления, диапазоны, домены, типы расширений) конвертируются shadow PostgreSQL по одному запросу на пакет вставки. Значение, которое shadow PostgreSQL не может конвертировать, останавливает пакет и называет столбец и строку вместо подстановки. Многомерный массив не помещается в стандартное однослойное отображение массива — столбец нужно отобразить на соответствующее вложенное представление массива массивов. Для больших значений режим clickhouse по умолчанию использует постоянные зеркала TOAST-чанков, и отсутствие требуемых зеркальных таблиц останавливает репликацию.

WalShadow не является HA-системой: он потребляет решения о переключении PostgreSQL, но не принимает их, и не обеспечивает выбор лидера, изоляцию старого первичного узла, синхронную долговечность, перемещение DNS или оркестрацию повышения. При добавлении или удалении базы данных требуется перезапуск. replicate_all именует каждое назначение по имени исходной таблицы, поэтому две базы с одинаковым именем таблицы претендуют на одну таблицу ClickHouse — первая заявка выигрывает, вторая логирует ошибку и остаётся нереплицированной.

При сбоях ClickHouse WalShadow повторяет ограниченные сбои с переподключением и задержкой; когда бюджет повторов исчерпан, демон завершается и полагается на перезапуск супервизором процесса.

Источники

Похожее