Очереди, потоки, события
Change-Data Capture: стриминг write-ahead log без переполнения диска
Звонок в 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 Создать logical replication slot + publication; записать текущий LSN
- 2 Снапшот: прочитать существующие строки, чтобы засеять начальное состояние (блокирующий или инкрементальный чанками)
- 3 Переключиться на стриминг: читать WAL с LSN, зафиксированного в начале снапшота
- 4 Декодировать каждое изменение в событие insert/update/delete с образами before/after
- 5 Подтвердить потреблённый LSN обратно в слот, чтобы Postgres мог утилизировать старый WAL
- 01Объясни коллеге, почему logical replication slot может уронить primary базу, и что бы ты выставил до выкатки CDC.
- 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 станет самым низколатентным и наименее инвазивным способом разослать изменения базы по остальной системе.