Шов CDC: эстафета в kafka-cookbook
Меню Brew меняется само по себе: бариста правит цену эспрессо, маркетинг
публикует новую статью в блог, у клиента меняется телефон. И каждое такое
изменение должно доехать до соседнего мира — до kafka-cookbook, где тот же
самый кофейный Brew живёт уже как поток событий в Kafka, кормит Elasticsearch и
строит поиск по меню. Вопрос финала всего курса один: как отдать наш поток
изменений Kafka-курсу, не переписывая ничего на той стороне?
Ответ — Change Data Capture. Мы не пишем своего relay (это был путь 09-03), не
заводим триггеров. Мы настраиваем у Postgres логическую репликацию ровно на
нужных таблицах, а Debezium из kafka-cookbook подключается к ней и читает поток
сам. Postgres становится источником истины, а WAL — нашей шиной событий. Этот
юнит собирает шов и доказывает, что он держит нагрузку.
CDC вместо relay: WAL — уже журнал изменений
В 09-03 мы вручную клали событие строкой в outbox и вычитывали его relay'ем.
CDC заходит с другой стороны: изменение уже записано — в WAL, журнал
упреждающей записи, которым Postgres и так гарантирует durability. Логическое
декодирование разбирает WAL обратно в логические INSERT/UPDATE/DELETE по
таблицам — и отдаёт их потребителю. Своего кода доставки писать не нужно вообще:
relay'ем работает сам Postgres + декодер на стороне потребителя.
Чтобы поток был адресным, а не «весь сервер целиком», CDC опирается на две
настройки: PUBLICATION (какие таблицы стримим) и REPLICA IDENTITY (сколько
данных о старой строке писать в WAL на UPDATE/DELETE). Обе мы выставляем
явно.
Три источника и их REPLICA IDENTITY FULL
В CDC-эстафету едут три базовые таблицы: drinks (меню), articles (блог),
customers (справочник клиентов). На них в схеме Brew уже стоит REPLICA IDENTITY FULL — и это не косметика.
По умолчанию (REPLICA IDENTITY DEFAULT) на UPDATE/DELETE Postgres пишет в
WAL только первичный ключ старой строки — этого хватает, чтобы физическая
реплика нашла строку. Но Debezium на той стороне строит из потока полноценные
события «было → стало», и для UPDATE/DELETE ему нужен before-image —
старое состояние строки целиком. С DEFAULT он увидит в before-image один id
и не сможет восстановить, что именно изменилось. REPLICA IDENTITY FULL говорит
Postgres писать в WAL всю старую строку — тогда before-image содержит все
столбцы.
REPLICA IDENTITY DEFAULT | REPLICA IDENTITY FULL | |
|---|---|---|
| В WAL на UPDATE/DELETE | только PK старой строки | вся старая строка |
| before-image для Debezium | один id | все столбцы (у drinks — 9) |
| Хватает физической реплике | да | да |
| Хватает CDC «было → стало» | нет | да |
| Цена в WAL | минимальная | растёт на горячих/широких строках |
| В нашей схеме Brew | — | drinks, articles, customers |
PUBLICATION: явный список вместо автосоздания
Поток адресуют публикацией:
CREATE PUBLICATION dbz_publication FOR TABLE drinks, articles, customers;Мы перечисляем таблицы руками, а не включаем publication.autocreate на
стороне Debezium. Так в одном месте видно ровно то, что уходит в поток: три
таблицы, ни больше ни меньше. Убрать таблицу из стрима — это ALTER PUBLICATION dbz_publication DROP TABLE <name>, добавить — ADD TABLE. Никакой магии «само
подцепит всё, что нашлось».
Доказательство шва через test_decoding
Собрать конфигурацию мало — надо показать, что before-image действительно несёт
всю строку. Для этого демо создаёт временный логический слот репликации с
выходным плагином test_decoding, делает один UPDATE напитка, вычитывает
изменения слота через pg_logical_slot_get_changes и считает, сколько столбцов
попало в сегмент old-key (это и есть before-image в формате test_decoding).
Под REPLICA IDENTITY FULL там оказываются все 9 столбцов drinks, а не один
id. Слот после проверки сразу сносится.
test_decoding — это отладочный плагин, который печатает изменения текстом;
Debezium в бою использует свой декодер, а не этот. Нам он нужен ровно чтобы
глазами увидеть before-image и убедиться, что FULL работает.
Шов целиком: от UPDATE до Kafka
Собрав части, видно весь путь одного изменения — от записи в Brew до Debezium на
стороне kafka-cookbook, без единой строки нашего кода доставки:
Сквозной шов: один UPDATE в Brew доезжает до kafka-cookbook
Postgres (этот курс)
UPDATE drinks
│ изменение записано
▼
WAL — журнал упреждающей записи (durability пишет его и так)
│ logical decoding разбирает WAL обратно в INSERT/UPDATE/DELETE
│ REPLICA IDENTITY FULL → before-image несёт всю старую строку
▼
PUBLICATION dbz_publication (drinks, articles, customers)
│ через логический слот репликации
▼
kafka-cookbook (следующий курс)
Debezium → Kafka → Elasticsearch
db/init.sql байт-совместим — схему на той стороне не переписываютСвоего relay (как в 09-03) тут нет: relay'ем работает сам Postgres + декодер
Debezium. Наша задача — отдать корректный поток, и две настройки (PUBLICATION +
REPLICA IDENTITY FULL) её решают.
Что показывает наш код
cmd/demo/main.go — это raw-pgx escape-hatch: урок про конфигурацию репликации
(PUBLICATION, REPLICA IDENTITY, слоты) и системные функции декодирования —
это DDL и pg_*-вызовы, не SQL уровня sqlc, поэтому здесь нет ни query.sql, ни
internal/db/. Демо последовательно: накатывает схему Brew, применяет артефакт
эстафеты db/init.sql, показывает REPLICA IDENTITY трёх источников, печатает
опубликованные таблицы и доказывает before-image через test_decoding. Артефакт
db/init.sql — тот самый файл, что байт-совместим с kafka-cookbook и уезжает на
её сторону; повторное применение идемпотентно (CREATE TABLE IF NOT EXISTS,
повторный ALTER ... REPLICA IDENTITY FULL, DO-блок на публикацию).
Запуск
docker compose up -d
make lecture L=10-use-cases/10-05-the-cdc-seam-handoff T=db-reset
make lecture L=10-use-cases/10-05-the-cdc-seam-handoffT=run — режим по умолчанию, его можно не писать. Изнутри каталога юнита короче:
make db-reset, затем make run. А make test гоняет асмерченный
integration-тест: он проверяет, что публикация покрывает ровно три таблицы, что
на всех стоит REPLICA IDENTITY FULL и что before-image содержит 9 столбцов
(без поднятой песочницы тест делает t.Skip). Юнит требует wal_level=logical
— он уже выставлен в корневом docker-compose.yml курса.
1) Базовые таблицы на месте, REPLICA IDENTITY FULL на CDC-источниках:
articles replica identity: full
customers replica identity: full
drinks replica identity: full
2) Публикация для Debezium (явный список таблиц):
CREATE PUBLICATION dbz_publication FOR TABLE drinks, articles, customers
публикует таблицы: articles, customers, drinks
3) Проверяем шов логическим декодированием (test_decoding):
UPDATE drinks #1 → перехвачено изменений в слоте: 3
before-image (old-key) содержит столбцов: 9 → REPLICA IDENTITY FULL работает
(без FULL Debezium увидел бы в before-image только id; здесь — всю строку)
4) Эстафета: db/init.sql байт-совместим с kafka-cookbook — Debezium
читает наши таблицы без переписывания схемы. Дальше — Kafka-курс.Все три источника несут full. Публикация стримит ровно drinks, articles,
customers. А ключевая строка — третья: один UPDATE drinks оставил в слоте три
изменения (BEGIN/UPDATE/COMMIT), и before-image у UPDATE содержит 9
столбцов — всю строку drinks целиком. Это и есть доказательство, что
REPLICA IDENTITY FULL делает свою работу: Debezium получит полное «было», а не
огрызок из одного id.
Заборчик
- Невычитываемый слот держит WAL. Логический слот репликации, который никто не
вычитывает, не даёт Postgres удалять сегменты журнала, пока их не подтвердил
самый отстающий потребитель — и диск медленно заполняется. Наше демо честно сносит
слот в конце, но в проде застрявший слот (умерший Debezium, отключённый consumer) —
реальный путь к
No space left on device. За слотами надо следить (pg_replication_slots) и подчищать мёртвые — это территория твоего DBA. test_decoding— не то, чем читает Debezium. Это отладочный плагин: печатает изменения текстом для глаз, у Debezium свой декодер. Мы взяли его только чтобы увидеть before-image.REPLICA IDENTITY FULL— это компромисс: за полный before-image платишь WAL. КаждыйUPDATE/DELETEтеперь пишет в журнал всю старую строку, а не один PK — на горячей таблице с широкими строками это заметный рост объёма WAL и нагрузки на репликацию. На наших трёх справочниках (меню, блог, клиенты) запись редкая, цена копеечная; на high-churn таблице это решение надо взвешивать.- Сквозной пайплайн
Debezium → Kafka → sinksмы здесь не запускаем. Это уже сторонаkafka-cookbook: следующий курс. Наша задача — отдать корректный поток, и она выполнена.
Что забрать с собой
CDC — это способ отдать поток изменений наружу, не написав ни строки доставки:
WAL уже журнал изменений, логическое декодирование разбирает его обратно в
INSERT/UPDATE/DELETE, а адресуют поток PUBLICATION (какие таблицы) и
REPLICA IDENTITY (сколько старой строки писать в WAL). REPLICA IDENTITY FULL
кладёт в before-image всю строку — без него потребитель не восстановит
UPDATE/DELETE, но и WAL растёт. Это альтернатива transactional outbox из
09-03: там событие писали руками в outbox, здесь источником служит сам журнал
БД.
И здесь же закрывается весь курс. Главным героем у нас всё время был SQL:
sqlc-юниты держали запросы в центре, а escape-hatch'и (как этот) выходили на
уровень DDL, MVCC и системных функций ровно тогда, когда sqlc мешал увидеть суть.
Финальный кадр — правило байт-совместимости схемы Brew, которое мы держали с первого
модуля: db/init.sql этого юнита дословно совпадает по именам и типам колонок
с init.sql на стороне kafka-cookbook (защищено тестом TestInitSQL_ByteCompatTokens),
поэтому Debezium читает наши drinks/articles/customers без переписывания
схемы. Переименуй здесь хоть одну колонку базовой таблицы — и эстафета рвётся.
Дальше — соседний курс kafka-cookbook (github.com/dsbasko/kafka-cookbook). Он
подхватывает ровно этот поток: Debezium слушает нашу dbz_publication, кладёт
изменения в Kafka, а оттуда sink'и едут в Elasticsearch и строят поиск по тому же
кофейному Brew. Один мир, одна модель данных, два курса — Postgres отдал
эстафету, Kafka её принимает. Это был последний юнит. Спасибо, что дошёл до
конца.