WAL Bloat и Heartbeat
WAL bloat - это одна из самых частых production-проблем при работе с Debezium и PostgreSQL. Когда replication slot не продвигается, WAL накапливается бесконечно, заполняя диск и приводя базу данных в read-only режим.
Critical: Без правильной защиты abandoned slot может заполнить диск за дни или даже часы на высоконагруженных системах. Это не теоретическая угроза - это происходит регулярно в production.
Проблема WAL Bloat
В уроке о replication slots мы изучили, как slots удерживают WAL. Теперь рассмотрим, почему это становится проблемой.
Сценарий: Low-Traffic Table
Механизм проблемы:
- Slot создается при первом запуске Debezium
restart_lsnпоказывает последнюю обработанную позицию- PostgreSQL не удаляет WAL сегменты после этой позиции
- Если таблица не меняется - slot не продвигается
- WAL накапливается от всех транзакций в БД, не только мониторируемых таблиц
Последствия
| Этап | Симптомы | Влияние |
|---|---|---|
| Warning | pg_wal занимает более 50% диска | Алерты мониторинга |
| Critical | Диск заполнен на более 90% | Производительность деградирует |
| Outage | Диск заполнен на 100% | PostgreSQL переходит в read-only |
| Recovery | Требуется ручное вмешательство | Downtime, потенциальная потеря данных |
Многослойная защита от WAL Bloat
Правильная защита требует нескольких уровней. Один слой может отказать - многослойность обеспечивает resilience.
Один слой может отказать - многослойность обеспечивает resilience
Layer 1: max_slot_wal_keep_size
Это обязательный параметр PostgreSQL для production CDC. Он устанавливает жесткий лимит WAL retention на каждый slot.
-- Установка лимита (требует перезапуска или reload)
ALTER SYSTEM SET max_slot_wal_keep_size = '10GB';
SELECT pg_reload_conf();
-- Проверка текущего значения
SHOW max_slot_wal_keep_size;
Как работает
| WAL Retention | wal_status | Поведение |
|---|---|---|
| меньше limit | reserved | WAL сохраняется, всё нормально |
| больше limit | unreserved | WAL может быть удален |
| WAL удален | lost | Slot инвалидирован, требуется resnapshot |
Выбор значения
-- Рекомендации по размеру
max_slot_wal_keep_size = '10GB' -- Небольшие БД (менее 100GB data)
max_slot_wal_keep_size = '50GB' -- Средние БД (100GB-1TB)
max_slot_wal_keep_size = '100GB' -- Большие БД (более 1TB)
Формула расчета:
Recommended limit =
(WAL generation rate per hour) x
(Maximum acceptable connector downtime in hours) x
1.5 (safety margin)
Example:
50 MB/hour x 24 hours x 1.5 = 1.8 GB
-> Set to 5GB for safety margin
Компромисс: Слишком маленький лимит - slot инвалидируется при кратких проблемах (придется делать полный resnapshot). Слишком большой - не защитит от disk exhaustion.
Aurora PostgreSQL
Для Aurora используйте DB Cluster Parameter Group:
Parameter: rds.logical_replication
Value: 1
Parameter: max_slot_wal_keep_size
Value: 10737418240 (в байтах = 10GB)
Layer 2: Heartbeat Configuration
Heartbeat заставляет slot продвигаться даже когда мониторируемые таблицы не меняются.
pg_logical_emit_message() - рекомендуемый подход для PostgreSQL 14+
pg_logical_emit_message() vs Heartbeat Table
PostgreSQL 14+ предоставляет функцию pg_logical_emit_message() - это рекомендуемый подход.
| Подход | pg_logical_emit_message() | Heartbeat Table |
|---|---|---|
| PostgreSQL версия | 14+ | Любая |
| Требует таблицу | Нет | Да |
| Нагрузка на БД | Минимальная | UPDATE каждые N секунд |
| Блокировки | Нет | Возможны на таблице |
| Настройка | Одна строка конфига | Создать таблицу + права |
Конфигурация с pg_logical_emit_message (рекомендуется)
{
"name": "inventory-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "debezium_user",
"database.password": "secret",
"database.dbname": "inventory",
"topic.prefix": "inventory",
"table.include.list": "public.orders,public.customers",
"plugin.name": "pgoutput",
"heartbeat.interval.ms": "10000",
"heartbeat.action.query": "SELECT pg_logical_emit_message(false, 'heartbeat', now()::varchar)"
}
}
Параметры heartbeat:
| Параметр | Значение | Описание |
|---|---|---|
heartbeat.interval.ms | 10000 | Интервал между heartbeat (10 сек) |
heartbeat.action.query | SQL запрос | Выполняется для продвижения slot |
heartbeat.topics.prefix | __debezium-heartbeat | Kafka topic для heartbeat events |
10 секунд - это рекомендуемое значение, оптимизированное для Aurora failover detection. Меньший интервал создает лишнюю нагрузку, больший - увеличивает время обнаружения проблем.
Конфигурация с heartbeat table (PostgreSQL 13 и ниже)
-- Создание heartbeat таблицы
CREATE TABLE IF NOT EXISTS public.heartbeat (
id INTEGER PRIMARY KEY,
ts TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Начальная запись
INSERT INTO public.heartbeat (id, ts)
VALUES (1, NOW())
ON CONFLICT (id) DO NOTHING;
-- Права для Debezium user
GRANT SELECT, UPDATE ON public.heartbeat TO debezium_user;
{
"heartbeat.interval.ms": "10000",
"heartbeat.action.query": "UPDATE public.heartbeat SET ts = NOW() WHERE id = 1",
"table.include.list": "public.orders,public.customers,public.heartbeat"
}
Layer 3: Мониторинг Slot Health
Comprehensive Monitoring Query
-- Полный мониторинг replication slots
SELECT
slot_name,
plugin,
database,
active,
restart_lsn,
confirmed_flush_lsn,
-- WAL retention metrics
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal,
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS retained_bytes,
-- Slot health status (PostgreSQL 14+)
wal_status,
safe_wal_size,
-- Health assessment
CASE
WHEN NOT active AND pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) > 1073741824
THEN 'CRITICAL: Inactive slot retaining more than 1GB'
WHEN wal_status = 'lost'
THEN 'CRITICAL: Slot invalidated'
WHEN wal_status != 'reserved'
THEN 'WARNING: WAL status degraded'
WHEN NOT active
THEN 'WARNING: Inactive slot'
ELSE 'OK'
END AS health_status
FROM pg_replication_slots
WHERE slot_type = 'logical'
ORDER BY retained_bytes DESC NULLS LAST;
Alert Thresholds
# Grafana alert rules
- name: Replication Slot WAL Retention
condition: retained_bytes more than 1073741824 # 1 GB
for: 5m
severity: warning
annotations:
summary: "Slot retaining more than 1GB WAL"
- name: Replication Slot WAL Critical
condition: retained_bytes more than 5368709120 # 5 GB
for: 10m
severity: critical
annotations:
summary: "URGENT: Slot retaining more than 5GB WAL"
- name: Slot Invalidated
condition: wal_status == 'lost'
for: 1m
severity: critical
annotations:
summary: "Slot invalidated - requires resnapshot"
wal_status Values
| Значение | Описание | Действие |
|---|---|---|
reserved | WAL зарезервирован, всё в порядке | Мониторить размер |
extended | WAL превысил max_wal_size, но slot держит | Следить внимательно |
unreserved | WAL может быть удален в любой момент | Немедленная проверка коннектора |
lost | WAL уже удален, slot невосстановим | Требуется полный resnapshot |
Layer 4: Operational Runbooks
Runbook: Обнаружен High WAL Retention
Триггер: Alert “Slot retaining more than 1GB WAL”
Шаги:
- Проверить статус коннектора:
curl -s http://localhost:8083/connectors/inventory-connector/status | jq '.connector.state, .tasks[0].state'
- Если RUNNING но lag растет:
# Проверить логи на ошибки
docker logs connect 2>&1 | tail -100 | grep -i error
- Если FAILED:
# Перезапустить task
curl -X POST http://localhost:8083/connectors/inventory-connector/tasks/0/restart
- Если коннектор не существует:
# Проверить список коннекторов
curl -s http://localhost:8083/connectors | jq
# Если slot orphaned - эскалировать к владельцу
Runbook: Slot Invalidated (wal_status = ‘lost’)
Триггер: Alert “Slot invalidated”
Это означает: WAL, необходимый для slot, был удален. Продолжение CDC невозможно без действий.
Шаги:
- Остановить коннектор:
curl -X PUT http://localhost:8083/connectors/inventory-connector/pause
- Удалить orphaned slot:
SELECT pg_drop_replication_slot('debezium_inventory');
- Перезапустить коннектор (выполнит initial snapshot):
curl -X PUT http://localhost:8083/connectors/inventory-connector/resume
- Мониторить snapshot progress:
# Проверять пока SnapshotCompleted = true
curl -s http://localhost:8083/connectors/inventory-connector/status | jq
- Post-mortem: Почему slot остался без присмотра?
Runbook: Подозрение на Abandoned Slot
Триггер: Inactive slot с растущим lag
-- Найти подозрительные slots
SELECT
slot_name,
active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots
WHERE slot_type = 'logical'
AND active = false
AND pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) > 104857600; -- more than 100 MB
Шаги:
-
НЕ удалять сразу! Slot может принадлежать временно остановленному коннектору
-
Найти владельца:
# Есть ли коннектор с таким slot_name?
curl -s http://localhost:8083/connectors | jq
-
Если коннектор существует и PAUSED:
- Связаться с командой-владельцем
- Выяснить причину паузы и ETA восстановления
-
Если коннектор не существует:
- Подтвердить с командой, что коннектор удален навсегда
- Документировать решение
- Удалить slot:
SELECT pg_drop_replication_slot('slot_name');
Lab: Configure Heartbeat and Verify Slot Advancement
Цель
Настроить heartbeat и убедиться, что slot продвигается даже при отсутствии изменений в мониторируемых таблицах.
Предварительные требования
- Запущенный lab environment (docker-compose up -d)
- Коннектор inventory-connector развернут
Шаги
1. Проверить текущую позицию slot:
docker exec -it postgres psql -U postgres -d inventory -c "
SELECT
slot_name,
restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS lag
FROM pg_replication_slots
WHERE slot_name LIKE 'debezium%';
"
2. Обновить коннектор с heartbeat:
curl -X PUT http://localhost:8083/connectors/inventory-connector/config \
-H "Content-Type: application/json" \
-d '{
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "postgres",
"database.password": "postgres",
"database.dbname": "inventory",
"topic.prefix": "inventory",
"table.include.list": "public.orders,public.customers",
"plugin.name": "pgoutput",
"heartbeat.interval.ms": "10000",
"heartbeat.action.query": "SELECT pg_logical_emit_message(false, '\''heartbeat'\'', now()::varchar)"
}'
3. Подождать 30 секунд (3 heartbeat cycles):
sleep 30
4. Проверить, что slot продвинулся:
docker exec -it postgres psql -U postgres -d inventory -c "
SELECT
slot_name,
restart_lsn,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS lag
FROM pg_replication_slots
WHERE slot_name LIKE 'debezium%';
"
5. Проверить heartbeat events в Kafka:
docker exec -it kafka kafka-console-consumer \
--bootstrap-server localhost:9092 \
--topic __debezium-heartbeat.inventory \
--from-beginning \
--max-messages 3 \
--property print.timestamp=true
Ожидаемый результат
restart_lsnдолжен обновиться (сравнить со Шагом 1)lagдолжен быть минимальным (менее 1 MB)- В Kafka topic видны heartbeat events каждые 10 секунд
Ключевые выводы
-
WAL bloat - реальная угроза: Без защиты slot заполнит диск, вызвав database outage
-
Многослойная защита обязательна:
- Layer 1:
max_slot_wal_keep_size(жесткий лимит) - Layer 2: Heartbeat (активное продвижение)
- Layer 3: Мониторинг (раннее обнаружение)
- Layer 4: Runbooks (готовность к реагированию)
- Layer 1:
-
pg_logical_emit_message() - рекомендуемый подход для PostgreSQL 14+
-
Heartbeat каждые 10 секунд - оптимально для production
-
Abandoned slot - не просто inactive: active=false может означать временную паузу, всегда проверяйте владельца
-
wal_status=‘lost’ означает point of no return - требуется полный resnapshot
Production Insight: Настройте мониторинг WAL retention ДО первого инцидента. Большинство production outages от WAL bloat происходят потому, что команда узнала о проблеме только когда диск заполнился.
Cost Analysis: heartbeats vs WAL bloat trade-off
Heartbeats не бесплатны — они генерируют события и пишут в WAL. Но цена правильно настроенных heartbeats на порядки меньше, чем риск инцидента WAL bloat. Разберём цифры для типичного production-pipeline.
Стоимость heartbeats
Heartbeat каждые 10 секунд:
- 6 heartbeats/min * 60 * 24 = 8 640 событий/день
- Размер события (pg_logical_emit_message): ~200 байт в WAL
- WAL overhead: ~1.7 MB/day на slot
- Kafka throughput: <1 KB/sec
Цена:
Kafka storage (gp3 EBS, 7d retention): ~$0.0001/month
Network: ~50 MB/month
CPU overhead: <0.01% Connect worker
Total: <$0.10/month per connector
Стоимость WAL bloat без защиты
Сценарий: connector упал, никто не заметил 24 часа.
WAL generation rate (типичная OLTP): 50-200 MB/hour
За сутки: 1.2-4.8 GB bloat
Если slot держит 10+ дней (отпуск, нет on-call):
500 MB - 50 GB на коннектор
Aurora I/O cost: ~$0.20/GB чтение
Storage cost: $0.10/GB-month
Реальный outage:
PostgreSQL диск полон -> read-only mode
Production app unavailable: $X 000-X 0000/hour (зависит от бизнеса)
Recovery time: 2-8 часов (nuke slot, full resnapshot)
Snapshot strategy: cost trade-offs
Initial snapshot большой таблицы — одна из самых дорогих операций CDC. Cost varies по strategy:
"snapshot.mode = initial" (default):
Один раз при первом запуске
Полное чтение таблицы 1 TB через JDBC
Cost:
- Read I/O Aurora: 1 TB * $0.20/GB = $200 one-time
- Network egress в Kafka: 1 TB * $0.01/GB = $10
- Connect compute: 4-12 часов на m5.large = $1-3
- Source DB load: 30-50% CPU 4-12 часов
Total: ~$215 + business impact от read load
"snapshot.mode = always":
При каждом restart коннектора (debug/dev only!)
Если коннектор перезапускается еженедельно -> $215/week = $11K/year
АНТИПАТТЕРН для prod
"snapshot.mode = never":
Пропустить snapshot полностью, начать с current LSN
Cost: 0
Risk: данные до подключения коннектора отсутствуют в Kafka
Use case: green-field, либо есть batch-backfill параллельно
"incremental snapshot" (Debezium 1.6+):
Chunked: таблица читается порциями (10K rows на chunk)
Можно остановить и продолжить
Можно ad-hoc snapshot одной таблицы без полного rebuild
Cost: тот же total I/O, но spread во времени
Profit: peak load на источнике в N раз меньше
Пример:
1 TB таблица, chunk = 100K rows = ~100 MB
10 000 chunks * 1 sec задержка между ними = 2.7 часа total
Peak DB load: <10% (vs 30-50% для full snapshot)
Compute cost для Connect cluster
Per Kafka Connect worker (m5.large, 8 GB RAM):
- On-demand: $0.096/hour = $70/month
- Reserved 1y: ~$45/month
- Savings Plans: ~$50/month
Sizing rule:
~1 task = 1 vCPU (для типичного CDC throughput)
~5K events/sec на task (зависит от размера события)
+50% headroom для spikes
Production minimum: 3 workers (HA + rolling restart)
Total baseline: ~$200/month
Network egress
Источник (Postgres) -> Connect: in-VPC, free
Connect -> Kafka brokers: в той же AZ free, cross-AZ $0.01/GB
Kafka -> consumers: cross-AZ или cross-region
Пример (10K events/sec, 1 KB avg, cross-AZ replication factor 3):
Throughput: 10 MB/sec = 864 GB/day
Replication между brokers: 864 * 2 = 1.7 TB/day cross-AZ
Egress fee: $17/day = $510/month
S3 / object storage (если применимо)
Sink connectors часто пишут в S3 (data lake, archive):
Storage: $0.023/GB-month (Standard) или $0.004/GB (Glacier)
PUT requests: $0.005/1000 -- может стать существенным при high throughput
Compaction overhead: ~10-20% storage если используется Iceberg/Delta
Optimization checklist
[+] Heartbeats каждые 10s -- защита от WAL bloat за <$1/month
[+] Incremental snapshot для tables >100 GB
[+] Reserved instances для Connect и MSK (~30% saving)
[+] Cross-AZ только если нужен HA (single-AZ для dev/staging)
[+] Topic compaction для CDC topics с tombstones
[+] Schema Registry: cache на consumer side, не для каждого event
[+] tasks.max = 1 для большинства connectors (parallelism через partitions, не tasks)
Rule of thumb: Heartbeat overhead менее $1/month, prevented outage cost = тысячи. Это самое asymmetric trade-off в CDC operations.