Вторая статья цикла «CDC Pipeline в домашней лаборатории». В первой мы сделали Telegram-бота для парсинга банковских скриншотов. Теперь подготовим PostgreSQL к тому, чтобы Debezium мог захватывать изменения в реальном времени.


Зачем я это пишу

Честно: в первую очередь это мои заметки. На работе я поддерживаю CDC-пайплайны, но разбираться в чужой конфигурации — не то же самое, что понимать систему изнутри. Поэтому строю с нуля дома.

Во вторую очередь — портфолио. Если буду менять работу, эти статьи можно приложить к резюме: «вот, смотрите, я не просто кнопки нажимаю, а понимаю что делаю».

И в третью — мотивация не бросить. Если хотя бы десять человек прочитают и кому-то пригодится, значит не зря потратил выходные.

Так что если вы тоже настраиваете CDC для себя или учитесь — добро пожаловать, разберёмся вместе.


Зачем эта статья (технически)

У меня есть PostgreSQL с таблицей транзакций. Budget Parser из первой статьи уже пишет туда данные. Следующий шаг — научить Debezium читать изменения из WAL и отправлять их в Kafka.

Но просто так Debezium к PostgreSQL не подключится. Нужно:

  • Включить wal_level = logical

  • Создать publication для нужных таблиц

  • Настроить пользователя с правами на репликацию

  • Открыть сетевой доступ

Эта статья — пошаговая настройка всего перечисленного.


Почему не polling?

Можно не заморачиваться с CDC, а просто опрашивать таблицу по updated_at:

SELECT * FROM transactions WHERE updated_at > :last_check;

Проблемы:

  • Нагрузка на базу — постоянные запросы, даже когда ничего не изменилось

  • DELETE невидим — запись исчезла, как узнать какая?

  • Нужен индекс на updated_at + колонка в каждой таблице

CDC работает иначе: PostgreSQL сам сообщает об изменениях через механизм логической репликации. Никаких лишних запросов, все операции (INSERT/UPDATE/DELETE) видны.


Что такое WAL и зачем он нужен

WAL (Write-Ahead Log) — журнал, куда PostgreSQL записывает все изменения до того, как применит их к данным. Если сервер упадёт — при старте он прочитает WAL и восстановит консистентное состояние.

Но WAL можно использовать не только для recovery. Его можно декодировать и получать информацию об изменениях в читаемом формате.

Три уровня wal_level

Уровень

Что записывается

Для чего

minimal

Только для crash recovery

Максимальная производительность

replica

+ данные для физической репликации

Standby-серверы, read replicas

logical

+ информация о конкретных изменениях в строках

CDC, Debezium

Ключевое отличие:

  • replica: WAL содержит «Изменён блок X на странице Y» — физический уровень

  • logical: WAL содержит «INSERT в таблицу users: id=1, name='Иван'» — логический уровень с данными

Именно logical нужен для CDC. Debezium подключается к PostgreSQL, читает WAL и преобразует изменения в события для Kafka.

🔍 Как WAL гарантирует сохранность данных

Почему сначала WAL, а потом данные?

  • Запись в WAL — последовательная. Диск просто дописывает в конец файла. Это быстро.

  • Запись страницы данных — случайная. Надо найти нужное место, перезаписать. Это в 10-100 раз медленнее.

Если сервер упадёт между этими операциями — при старте PostgreSQL прочитает WAL и «доиграет» незаписанные изменения. Это называется recovery:

database system was not properly shut down; automatic recovery in progress

redo starts at 0/40D04D0

redo done at 0/40D14E0

LSN (Log Sequence Number) — адрес записи в WAL, как номер страницы и строки в книге:

LSN = 0/19B3A58

      │ └── смещение внутри сегмента

      └──── номер сегмента

Debezium использует LSN чтобы знать «где я остановился». При рестарте он продолжит с сохранённой позиции — ничего не потеряется.

Влияние на производительность

Переход с replica на logical увеличивает объём WAL. В моём home lab с парой сотен транзакций в месяц это незаметно. В production с миллионами записей — стоит замерить.


Практика: настройка PostgreSQL

Инфраструктура

Использую LXC-контейнер в Proxmox:

  • ID: 302

  • Hostname: pg-source

  • IP: 192.168.0.151

  • RAM: 2GB

  • Disk: 16GB

Команда создания контейнера (для Proxmox)
pct create 302 local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst \

  --hostname pg-source \

  --memory 2048 \

  --cores 2 \

  --rootfs local-lvm:16 \

  --net0 name=eth0,bridge=vmbr0,ip=192.168.0.151/24,gw=192.168.0.1 \

  --nameserver 8.8.8.8 \

  --unprivileged 1 \

  --features nesting=1

pct start 302

pct enter 302

Шаг 1: Установка PostgreSQL 16

apt update && apt upgrade -y

apt install -y postgresql-16 postgresql-contrib-16

Шаг 2: Включаем logical replication

Редактируем /etc/postgresql/16/main/postgresql.conf:

cat >> /etc/postgresql/16/main/postgresql.conf << 'EOF'

# === CDC / Logical Replication ===

wal_level = logical          # Записывать логические изменения

max_replication_slots = 4    # Количество слотов репликации

max_wal_senders = 4          # Параллельные подключения для репликации

EOF

Что означают параметры:

Параметр

Зачем

wal_level = logical

Включает запись логических изменений в WAL

max_replication_slots

Сколько «подписчиков» может читать WAL. Слот — это закладка: PostgreSQL не удалит WAL-сегменты, пока слот их не прочитает

max_wal_senders

Сколько клиентов могут одновременно читать WAL

Важно: нужен именно restart, не reload:

systemctl restart postgresql

В чём разница:

  • reload — PostgreSQL перечитывает конфиг (pg_hba.conf, некоторые параметры), но процесс не перезапускается

  • restart — полный перезапуск процесса, применяются все параметры

wal_level — параметр, который читается только при старте. Поэтому reload его не изменит.

Проверяем:

sudo -u postgres psql -c "SHOW wal_level;"

wal_level

-----------

 logical

Шаг 3: Создаём базу и таблицу

sudo -u postgres psql

CREATE DATABASE budget_db;

\c budget_db

-- Схема для изоляции

CREATE SCHEMA finance;

-- Таблица транзакций

CREATE TABLE finance.parsed_transactions (

    id SERIAL PRIMARY KEY,

    transaction_date DATE,

    merchant VARCHAR(255),

    amount NUMERIC(12,2) NOT NULL,

    transaction_type VARCHAR(20) DEFAULT 'expense',

    category VARCHAR(100),

    balance_after NUMERIC(12,2),

    source_image_hash VARCHAR(64),

    is_verified BOOLEAN DEFAULT FALSE,

    verified_at TIMESTAMP,

    original_amount NUMERIC(12,2),

    original_merchant VARCHAR(255),

    parsed_at TIMESTAMP DEFAULT NOW()

);

-- Индексы

CREATE INDEX idx_parsed_tx_date ON finance.parsed_transactions(transaction_date);

CREATE INDEX idx_parsed_tx_verified ON finance.parsed_transactions(is_verified);

COMMENT ON TABLE finance.parsed_transactions 

    IS 'Транзакции из банковских скриншотов (Budget Parser)';

Почему одна таблица?

Для изучения CDC достаточно одной таблицы — все при��ципы (INSERT/UPDATE/DELETE, replication slot, publication) работают одинаково. В будущем добавлю таблицы категорий и бюджетов — это позволит посмотреть, как CDC обрабатывает связанные данные.

Шаг 4: Создаём пользователей

Принцип минимальных привилегий: каждый делает только своё.

-- Пользователь для Debezium (CDC) — только чтение + репликация

CREATE USER debezium WITH REPLICATION PASSWORD 'debezium_pass';

GRANT CONNECT ON DATABASE budget_db TO debezium;

GRANT USAGE ON SCHEMA finance TO debezium;

GRANT SELECT ON ALL TABLES IN SCHEMA finance TO debezium;

ALTER DEFAULT PRIVILEGES IN SCHEMA finance GRANT SELECT ON TABLES TO debezium;

-- Пользователь для Budget Parser — запись данных

CREATE USER parser WITH PASSWORD 'parser_pass';

GRANT CONNECT ON DATABASE budget_db TO parser;

GRANT USAGE ON SCHEMA finance TO parser;

GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA finance TO parser;

GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA finance TO parser;

ALTER DEFAULT PRIVILEGES IN SCHEMA finance 

    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO parser;

ALTER DEFAULT PRIVILEGES IN SCHEMA finance 

    GRANT USAGE, SELECT ON SEQUENCES TO parser;

Пользователь

Права

Назначение

debezium

REPLICATION + SELECT

Читает WAL и данные таблиц

parser

INSERT/UPDATE/DELETE

Budget Parser пишет транзакции

Шаг 5: Настраиваем сетевой доступ

PostgreSQL по умолчанию слушает только localhost. Debezium будет подключаться с другого хоста.

# Слушать на всех интерфейсах

echo "listen_addresses = '*'" >> /etc/postgresql/16/main/postgresql.conf

# Разрешить подключения из локальной сети

cat >> /etc/postgresql/16/main/pg_hba.conf << 'EOF'

# CDC Pipeline - local network access

host    budget_db       debezium        192.168.0.0/24          scram-sha-256

host    budget_db       parser          192.168.0.0/24          scram-sha-256

host    replication     debezium        192.168.0.0/24          scram-sha-256

EOF

systemctl restart postgresql

Что означают строки в pg_hba.conf:

Строка

Смысл

host budget_db debezium 192.168.0.0/24 scram-sha-256

debezium может подключаться к budget_db из сети 192.168.0.x с паролем

host replication debezium ...

debezium может использовать протокол репликации (для чтения WAL)

Проверяем:

psql -h 192.168.0.151 -U debezium -d budget_db -c "SELECT 1;"

Шаг 6: Создаём Publication

Publication говорит PostgreSQL: «Отслеживай изменения в этих таблицах».

sudo -u postgres psql -d budget_db

CREATE PUBLICATION finance_publication 

    FOR TABLE finance.parsed_transactions;

Проверяем:

SELECT * FROM pg_publication_tables;

      pubname         | schemaname |      tablename        

-----------------------+------------+-----------------------

 finance_publication   | finance    | parsed_transactions

Проверяем связку с Budget Parser

Теперь убедимся, что всё работает вместе. На контейнере с Budget Parser (192.168.0.150) в .env:

DB_HOST=192.168.0.151

DB_PORT=5432

DB_NAME=budget_db

DB_USER=parser

DB_PASSWORD=parser_pass

Смотрим текущие данные и позицию в WAL:

SELECT id, transaction_date, merchant, amount

FROM finance.parsed_transactions

ORDER BY parsed_at DESC;

id | transaction_date |         merchant          | amount

----+------------------+---------------------------+---------

  4 | 2026-01-08       | YM*DEEPHOST               | -313.95

  3 | 2026-01-08       | VITA apteka 26 Samara RUS | -575.00

  2 | 2026-01-08       | Красное&Белое             | -440.83

  1 | 2026-01-08       | SAMARA_SAMARSK_OBL_TPP    |  -37.00

SELECT pg_current_wal_lsn();

pg_current_wal_lsn

--------------------

 0/19B3A58

Отправляем ещё один скриншот боту... и проверяем снова:

SELECT id, transaction_date, merchant, amount

FROM finance.parsed_transactions

ORDER BY parsed_at DESC;

id | transaction_date |         merchant          | amount

----+------------------+---------------------------+---------

  5 | 2026-01-08       | SAMARA_SAMARSK_OBL_TPP    |  -37.00  -- новая!

  4 | 2026-01-08       | YM*DEEPHOST               | -313.95

  3 | 2026-01-08       | VITA apteka 26 Samara RUS | -575.00

  2 | 2026-01-08       | Красное&Белое             | -440.83

  1 | 2026-01-08       | SAMARA_SAMARSK_OBL_TPP    |  -37.00

SELECT pg_current_wal_lsn();

pg_current_wal_lsn

--------------------

 0/19B48D8

LSN изменился: 0/19B3A58 → 0/19B48D8. Каждая новая транзакция двигает позицию в WAL. Когда подключим Debezium — он начнёт читать с определённого LSN и не пропустит ни одного изменения.


Аутентификация: peer vs password

Момент, который может запутать:

# Работает без пароля (peer auth)

sudo -u postgres psql

# Требует пароль (сетевое подключение)

psql -h 192.168.0.151 -U debezium -d budget_db

Peer auth: PostgreSQL смотрит на системного пользователя. Если это postgres — пускает как суперюзера базы.

Password auth: При сетевом подключении нужен пароль. Это то, что мы настроили в pg_hba.conf.


Типичные грабли

Настройка прошла без серьёзных проблем. Но вот типичные грабли, о которых стоит знать заранее:

1. wal_level не применился

systemctl reload postgresql не меняет wal_level. Нужен именно restart.

2. pg_hba.conf: порядок строк важен

PostgreSQL читает правила сверху вниз и применяет первое совпавшее. Если выше есть host all all 0.0.0.0/0 reject — ваши правила не сработают.

3. Replication slot не удаляется

Когда подключите Debezium — он создаст replication slot. Если Debezium упадёт, слот останется. PostgreSQL не будет удалять WAL-сегменты, пока слот их не прочитает. Диск может переполниться.

-- Посмотреть слоты

SELECT slot_name, active FROM pg_replication_slots;

-- Удалить неактивный слот

SELECT pg_drop_replication_slot('debezium_slot');

4. Publication не включает новые таблицы

Если создали publication для конкретных таблиц — новые туда не попадут автоматически:

-- Добавить вручную

ALTER PUBLICATION finance_publication ADD TABLE finance.new_table;

-- Или сразу для всей схемы

CREATE PUBLICATION finance_publication FOR TABLES IN SCHEMA finance;

Полезные команды

# Проверить WAL-параметры

sudo -u postgres psql -c "SHOW wal_level; SHOW max_replication_slots;"

# Посмотреть публикации

sudo -u postgres psql -d budget_db -c "SELECT * FROM pg_publication_tables;"

# Replication slots (появится после Debezium)

sudo -u postgres psql -d budget_db -c "SELECT * FROM pg_replication_slots;"

# Текущая позиция в WAL

sudo -u postgres psql -c "SELECT pg_current_wal_lsn();"

# Статус PostgreSQL

systemctl status postgresql

Итог

PostgreSQL готов к CDC:

Что

Статус

wal_level = logical

Publication создана

Пользователь с REPLICATION

Сетевой доступ

Budget Parser подключён

Текущая архитектура:

Жёлтая зона — первая статья. С��няя — эта статья. В следующей подключим Debezium и Kafka.


Что дальше

В следующей статье поднимем Kafka и Debezium Connect. Debezium подключится к нашему PostgreSQL, создаст replication slot и начнёт захватывать изменения в реальном времени.

#

Статья

Статус

1

Budget Parser: Telegram + Claude Vision

✅ Опубликовано

2

PostgreSQL для CDC

✅ Эта статья

3

Kafka + Debezium

⏳ Следующая

4

HDFS + Hive

📋 Планируется

5

Мониторинг в Grafana

📋 Планируется


Ссылки


Расскажите про свой опыт с CDC: кто ещё настраивает это для себя или в проде? Какие грабли встречались? Интересно узнать, что я не один такой, кто возится с этим на домашнем сервере.