awesome-everything EN
↑ Обратно к восхождению

Очереди, потоки, события

Change-Data Capture: стриминг write-ahead log без переполнения диска

Суть CDC читает лог репликации базы, поэтому каждый закоммиченный insert/update/delete становится событием — задержка меньше, чем у polling, без изменений в приложении, но слот репликации может растить WAL, пока primary не встанет.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 16 min

Звонок в 3 ночи: «Primary база только на чтение». Не падение — Postgres исчерпал диск и сам перестал принимать записи, чтобы себя защитить. Виновато не приложение. Шесть часов назад воркер Kafka Connect словил OOM, коннектор Debezium умер, и его слот репликации тихо удержал каждый сегмент WAL с тех пор. На нагруженной OLTP-машине, генерирующей ~30 ГБ WAL в час, объём данных за ночь дошёл с 40% до полного. Фикс был в одну строку — дропнуть мёртвый слот — но никто не знал, что слот существует, пока диск не кончился.

Читаем лог вместо опроса таблицы

Любая реляционная база уже пишет надёжную упорядоченную запись каждого закоммиченного изменения: write-ahead log (WAL в Postgres, binlog в MySQL). Он существует для восстановления после сбоев и репликации. Change-Data Capture перехватывает этот лог для второй цели — вместо опроса таблицы («есть строки новее моего курсора?») или двойной записи приложения и в базу, и в шину сообщений, CDC-коннектор читает лог репликации и эмитит по одному событию на каждое изменение строки.

Механизм в Postgres: ты создаёшь logical replication slot на основе output-плагина логического декодирования (pgoutput, поставляется с Postgres 10). Слот — это серверная закладка: он записывает LSN (log sequence number), который подтвердил потребитель, и сервер обещает не утилизировать WAL дальше этой точки. Коннектор вроде Debezium открывает репликационное соединение, декодирует WAL в структурированные события изменений и шлёт каждое — обычно в Kafka — как insert, update или delete с образами строки before и after.

Выигрыш реальный. Polling раз в 5 минут — это до 5 минут задержки, а строка, вставленная-и-удалённая между опросами, теряется целиком. Лог-based CDC доставляет изменения в миллисекундах от коммита, ловит каждое промежуточное состояние и добавляет нулевую нагрузку запросами на твои таблицы, потому что читает лог, а не строки. И ему вообще не нужны изменения в приложении — приложение продолжает делать обычные INSERT/UPDATE/DELETE.

Слот — это заряженное ружьё, направленное в твой диск

Вот часть, которая превращает фичу в аварию в 3 ночи. Обещание слота — «я не утилизирую WAL, который ты не подтвердил» — безусловно. Если потребитель застрял, отстаёт или умер, сервер хранит каждый сегмент WAL с restart_lsn слота — навсегда, пока новые записи валят сверху ещё больше. WAL не мелочь: нагруженная OLTP-система может генерировать 20–50 ГБ WAL в час, поэтому мёртвый слот способен довести здоровый диск от комфортного до полного меньше чем за два часа.

Коварны именно тихие отказы:

  • Потребитель упал — падение коннектора или OOM Kafka Connect останавливает подтверждения; WAL растёт до disk-full → Postgres отвергает все записи → полная авария.
  • Долгая транзакция — логическое декодирование не может уйти дальше открытой транзакции, поэтому restart_lsn заморожен и WAL накапливается, хотя коннектор выглядит здоровым.
  • База с низким трафиком — парадоксально, простаивающая БД тоже пухнет: без коммитов слот никогда не двигает подтверждённую позицию, поэтому Debezium эмитит периодические heartbeats, чтобы её подтолкнуть.
ПодходЗадержкаПравка приложенияГлавный риск
Опрос таблицы по курсоруДо интервала опроса (минуты)Запрос + логика курсораТеряет изменения внутри интервала; нагрузка запросами
Двойная запись приложения (БД + шина)СразуДа — каждый путь записиНет атомарности: одна запись прошла, другая нет
Outbox + polling-relayИнтервал опроса (часто доли секунды)Да — запись в таблицу outboxНагрузка опроса relay; рост таблицы outbox
Лог-based CDC (Debezium)МиллисекундыНикакихСлот держит WAL → disk-full останавливает primary

Сеньорская защита двухслойная: алертить на лаг слота (pg_replication_slots, следить за дистанцией в байтах между confirmed_flush_lsn и текущей позицией WAL), чтобы действовать до давления на диск, и ограничить ущерб через max_slot_wal_keep_size, чтобы Postgres инвалидировал убегающий слот, а не умер. У ограничения свой трейдофф — инвалидированный слот значит, что коннектору придётся переснапшотить — но переснапшот лучше аварии primary.

Почему это работает

CDC и паттерн outbox — не соперники, они компонуются. Outbox решает атомарность — пишешь бизнес-строку и строку события в одной транзакции, чтобы они коммитились вместе. Но всё равно нужно что-то, что вытолкнет строки outbox наружу. Polling-relay — простой вариант; нацелить Debezium на таблицу outbox — лог-based (у Debezium даже есть outbox event router). Так что «CDC vs outbox» — неверная ось: реальный выбор в том, захватывать ли доменные таблицы напрямую или захватывать специально сделанную таблицу outbox, форму которой ты контролируешь.

Сначала снапшот, потом стрим — и момент их встречи

У коннектора, стартующего на существующей базе, проблема холодного старта: WAL содержит только недавние изменения, а потребителю ниже по потоку нужно текущее состояние каждой строки. Поэтому Debezium делает snapshot then stream: читает всю таблицу (или отфильтрованный набор), чтобы засеять начальное состояние, затем переключается на чтение лога с LSN, зафиксированного в начале снапшота.

Снапшот — там, где кусают нагрузка и блокировки. Классический блокирующий снапшот на MySQL берёт глобальную read-блокировку (FLUSH TABLES WITH READ LOCK) ради консистентности, что может заморозить записи на большой таблице на неприятное окно; MySQL 8.0.17+ с snapshot.locking.mode=minimal сужает это. Инкрементальный снапшот Debezium (на сигналах, watermark-подход) — современный ответ: снапшотит чанками пока стриминг продолжается, его можно ставить на паузу и возобновлять, и он никогда не берёт долгую блокировку — ценой большего числа движущихся частей. Для многотерабайтной таблицы разница — между многочасовой заморозкой записей и фоновым тонким ручейком.

Удаления, порядок и exactly-once, которого нет

Ещё три острых края, кусающих в проде:

Захват удалений требует правильного REPLICA IDENTITY. DELETE в Postgres по умолчанию логирует только первичный ключ, поэтому образ before события — это просто ключ. Чтобы захватить полную строку до удаления, надо поставить REPLICA IDENTITY FULL на таблицу — из-за чего каждый update логирует всю старую строку, увеличивая объём WAL. Debezium также эмитит tombstone (запись с null-значением после удаления), чтобы compacted Kafka-топики могли совсем выкинуть ключ. Забудешь семантику tombstone — и compacted-топик хранит призраков.

Порядок — по ключу, а не глобальный. Debezium сохраняет порядок внутри таблицы/ключа, направляя каждый ключ в одну и ту же Kafka partition. Между partition глобального порядка нет — поэтому потребитель, джойнящий две таблицы, не может считать, что видит их изменения в порядке коммита.

Доставка at-least-once; проектируй под это. Закладка слот/LSN означает, что при падении коннектор возобновляется с последней подтверждённой позиции — но может повторно эмитнуть события, закоммиченные после неё. Debezium 2.x умеет exactly-once в Kafka через Kafka-транзакции, но в момент, когда твой потребитель читает и действует, ты снова при at-least-once end-to-end. Единственная безопасная позиция: потребители должны быть идемпотентны — дедуп по LSN/ключу события или сделать операцию ниже по потоку no-op при повторе.

Выбери лучший вариант

Нужно держать поисковый индекс свежим из часто пишущей Postgres-таблицы `orders`, без правок кода приложения и со свежестью меньше секунды. Выбери подход захвата.

Викторина

Воркер Kafka Connect коннектора Debezium ловит OOM и лежит часами. Что происходит с исходным Postgres?

Викторина

Почему потребитель CDC обязан быть идемпотентным?

Расставь шаги по порядку

Расставь, как коннектор Debezium начинает захват существующей таблицы Postgres:

  1. 1 Создать logical replication slot + publication; записать текущий LSN
  2. 2 Снапшот: прочитать существующие строки, чтобы засеять начальное состояние (блокирующий или инкрементальный чанками)
  3. 3 Переключиться на стриминг: читать WAL с LSN, зафиксированного в начале снапшота
  4. 4 Декодировать каждое изменение в событие insert/update/delete с образами before/after
  5. 5 Подтвердить потреблённый LSN обратно в слот, чтобы Postgres мог утилизировать старый WAL
Вспомните перед уходом
  1. 01
    Объясни коллеге, почему logical replication slot может уронить primary базу, и что бы ты выставил до выкатки CDC.
  2. 02
    Почему доставка CDC по сути at-least-once и как это меняет то, как ты пишешь потребителя?
Итог

Change-Data Capture превращает собственный write-ahead log базы в поток событий: вместо опроса таблицы или двойной записи из приложения коннектор вроде Debezium читает WAL через logical replication slot и эмитит каждый insert, update и delete в миллисекундах от коммита, без правок приложения и без нагрузки запросами на твои таблицы. Эта мощь привязана к одному опасному механизму — слот держит WAL, пока потребитель его не подтвердит, поэтому застрявший, отстающий или мёртвый потребитель (или долгая транзакция) растит WAL, пока диск не переполнится и primary не перестанет принимать записи — реальный класс аварий на нагруженных системах с десятками ГБ WAL в час. Управляешь этим алертингом на лаг слота и ограничением через max_slot_wal_keep_size. Старт — это snapshot-then-stream, где блокирующие снапшоты могут залочить большие таблицы, а инкрементальные меняют простоту на фоновую нагрузку без блокировок. Удаления требуют правильного REPLICA IDENTITY и обработки tombstone, порядок — по ключу, а не глобальный, и поскольку возобновление at-least-once, твои потребители должны быть идемпотентны. Наладь операционную дисциплину — и CDC станет самым низколатентным и наименее инвазивным способом разослать изменения базы по остальной системе.

Продолжить восхождение ↑Change data capture: тест с выбором ответа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.