Data retention и очистка
Правила, сколько данные живут в БД и Redis, когда удаляются и как. Без retention БД растёт до терабайт, запросы деградируют, бэкапы перестают влезать в окно, а пользовательские данные висят дольше, чем нужно.
Reference по pgx, индексам, миграциям — в
db-pgx. Про outbox — в
../patterns/outbox. Про Redis TTL —
в caching.
Содержание
- Что считается retention
- Soft-delete:
deleted_at - Hard delete: физическое удаление
- TTL для технических таблиц
- Outbox cleanup
- Audit log retention
- Archival (холодные данные)
- Redis retention
- Kafka retention
- S3 / MinIO retention
- Backup, RPO и RTO
- Cleanup-механизмы
- Bloat и VACUUM
- Что не делать
- См. также
Что считается retention
Retention — политика «данные этого типа живут столько-то, потом удаляются или архивируются». Определяется для каждой таблицы, не глобально.
Критерии:
- Нужен ли пользователю доступ к этим данным через год? Нет → retention < 1 год.
- Нужно ли это для аналитики / отчётов? Да → archival, не hard delete.
- Есть ли регуляторное требование хранить? Отдельный разговор с legal. Handbook не регулирует.
Default:
- Бизнес-сущности (user, review, place, photo) — soft delete, хранятся долго (до hard delete по запросу пользователя).
- Технические таблицы (outbox, sessions, rate-limit counters, audit log) — TTL и hard delete.
Soft-delete: deleted_at
Для сущностей, которые нельзя физически удалить из-за FK или бизнес- логики (user, review, place):
ALTER TABLE reviews
ADD COLUMN deleted_at TIMESTAMPTZ;
CREATE INDEX idx_reviews_active
ON reviews (place_id, created_at DESC)
WHERE deleted_at IS NULL;Правила:
- Все read-запросы «живых» строк —
WHERE deleted_at IS NULL. - Partial index по тому же предикату — без него индекс разросётся на удалённые строки, seq-scan неминуем.
UPDATE ... SET deleted_at = NOW()вместоDELETE.- Дубликат проверка: уникальные constraints нужно делать условными
или с учётом
deleted_at— иначе нельзя создать новую запись с тем же ключом после soft-delete.
-- уникальность email только среди активных
CREATE UNIQUE INDEX uq_users_email_active
ON users (email)
WHERE deleted_at IS NULL;Hard delete soft-deleted строк — отдельным job’ом (см. §Cleanup-механизмы), например, через 90 дней после deleted_at.
Hard delete: физическое удаление
Два сценария:
- По запросу пользователя («удалить мой аккаунт»). Триггерит
каскад: soft-delete → dispatch event
user.delete_requested→ downstream сервисы реагируют (review.anonymize,media.purge) → через N дней финальный hard-delete самой записи. - Технические данные по TTL. Sessions, outbox acked rows, rate- limit counters, audit logs старше окна.
Hard delete — только через CronJob, не из application кода:
- Application не должен инициировать массовые DELETE (риск длинных tx, lock escalation).
- CronJob гоняется в off-peak окно, батчами.
- Retention окно — явно задано, в infra-репо как параметр задачи.
Общий паттерн батч-delete — на стороне приложения (не pg_sleep
внутри SQL):
const batchSize = 5000
for {
tag, err := pool.Exec(ctx, `
WITH to_delete AS (
SELECT id
FROM reviews
WHERE deleted_at < NOW() - INTERVAL '90 days'
ORDER BY deleted_at
LIMIT $1
)
DELETE FROM reviews
WHERE id IN (SELECT id FROM to_delete)`, batchSize)
if err != nil {
return fmt.Errorf("batch delete: %w", err)
}
deleted := tag.RowsAffected()
metrics.CleanupDeleted.WithLabelValues("reviews").Add(float64(deleted))
if deleted < batchSize {
return nil // больше нечего чистить
}
// Backoff по фактическому давлению на WAL, не по фиксированному sleep.
if err := waitForReplicationLag(ctx, pool, 50*time.Millisecond); err != nil {
return err
}
}Правила батч-cleanup’а:
- Батч 1000–10000, не больше. На большом батче lock на строках держится долго, autovacuum отстаёт, WAL растёт.
- Backoff — по метрике, не по
pg_sleep.pg_sleep(0.1)удерживает serverside-соединение, мешает autovacuum и не масштабируется с нагрузкой. Правильный сигнал —pg_last_wal_receive_lsn()на реплике минусpg_current_wal_insert_lsn()на primary: ждать, пока лаг не опустится ниже целевого (например, 8 МБ). Альтернатива — throttling через rate limiter в Go (напр.golang.org/x/time/rate, 10–50 батчей в секунду). - Никаких VACUUM FULL из cleanup-кода (см. §Bloat и VACUUM).
- Метрика
cleanup_rows_deleted_total{table}— обязательна; она же — SLI для health’а cleanup’а. Если за сутки = 0 — cleanup не запускается.
Гарантии при каскадном hard-delete
Cascade hard-delete по запросу пользователя — это распределённая
операция: user-service, review, media, notification должны удалить
свои куски данных синхронно, с retry и compensation. Используется
Saga-паттерн (см. ../patterns/saga).
Инициатор — user-service — создаёт saga user_hard_delete(user_id):
- Soft-mark в user-service:
UPDATE users SET status = 'deletion_pending' WHERE id = $1. - Publish
user.deletion_requested.v1через outbox (partition key =user_id). - Consumer’ы в review / media / notification обрабатывают
идемпотентно:
- Записывают в локальный
deletion_log(saga_id, service, user_id, status, created_at, completed_at). - Удаляют или анонимизируют данные в своей БД в одной
транзакции с записью в
deletion_log. - Publish
<service>.deletion_completed.v1.
- Записывают в локальный
- Saga-orchestrator (в user-service) ждёт completion от всех сервисов. Timeout — 24 часа.
- По completion от всех: user-service делает финальный hard-delete
usersrow + публикуетuser.deletion_finalized.v1.
Orphaned data handling:
- Если один consumer не отчитался за 24 часа — saga переходит в
failed, alert on-call, ручной разбор. Никакого авто-force-complete: данные могут быть недоступны из-за сетевой проблемы, а не потому что они уже удалены. - Таблица
deletion_logв каждом сервисе — источник истины: содержит все частично удалённые сущности, по ней можно повторить операцию вручную. - Еженедельный reconciliation job: выбирает застрявшие записи
deletion_logи алертит:
SELECT saga_id, user_id, service, created_at, NOW() - created_at AS age
FROM deletion_log
WHERE status = 'pending'
AND created_at < NOW() - INTERVAL '7 days'
ORDER BY age DESC;Запрещённые паттерны:
- Fire-and-forget
Publishбез outbox — сообщение может потеряться при падении инициатора между commit’ом БД и публикацией. - Прямой HTTP-вызов
DELETEна downstream-сервис для cascade — нет retry, нет compensation, нет истории. - Полагаться только на Postgres FK
ON DELETE CASCADE— невозможно в распределённой системе, где у каждого сервиса своя БД.
TTL для технических таблиц
| Таблица | TTL | Rationale |
|---|---|---|
outbox (в БД сервиса) (acked) | 7 дней | Окно для replay событий при инциденте |
sessions (device + JWT rfr) | 30 дней / TTL refresh-token | Не переживает токен |
rate_limit_violations | 90 дней | Аналитика злоупотреблений |
audit_log | 180 дней | Compliance-независимо: для расследований |
audit_log — login/auth события | 1 год | Security-важные факты дольше |
idempotency_keys (если в Postgres) | 24 часа | Окно retry клиента |
failed_logins | 30 дней | После — счётчик уже не релевантен |
upload_sessions (unfinished) | 1 день | Резерв места в storage, dangling |
webhook_deliveries | 30 дней | История доставок для диагностики |
TTL — в days/hours, не в абстрактных «месяцах» (месяцы разной длины усложняют SQL).
Outbox cleanup
Подробно — ../patterns/outbox.
Кратко:
DELETE FROM outbox
WHERE offset_acked IS NOT NULL
AND created_at < NOW() - INTERVAL '7 days';Правила:
- Никогда не удаляй unacked-строки (
offset_acked IS NULL) — это потеря события. - Минимум 3 дня retention, рекомендуем 7. Если в пятницу случился инцидент и разбираемся в понедельник — нужны payload’ы событий.
- CronJob в infra-репо, раз в сутки, в off-peak (ночь).
Audit log retention
Отдельная таблица <service>.audit_log, которая пишется в той же
транзакции, что и бизнес-запись. Структура:
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
actor_id BIGINT, -- кто (user_id или NULL для system)
entity_type VARCHAR(64) NOT NULL, -- "review", "user"
entity_id BIGINT NOT NULL,
action VARCHAR(64) NOT NULL, -- "create", "update", "delete", "ban"
payload JSONB, -- diff или snapshot
correlation_id VARCHAR(32),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_entity ON audit_log (entity_type, entity_id, created_at DESC);
CREATE INDEX idx_audit_created ON audit_log (created_at);Retention:
- 180 дней для обычных операций (create, update).
- 1 год для security-событий (login, ban, password change, role
change). Фильтруется по
actionв WHERE cleanup-job’а. - Никогда не удаляй записи, связанные с delete-запросами пользователя (нужны для compliance-доказательства).
Cleanup — отдельный job с WHERE-условием, не массовый:
DELETE FROM audit_log
WHERE created_at < NOW() - INTERVAL '180 days'
AND action NOT IN ('login_success', 'login_failed', 'password_change',
'role_change', 'ban', 'unban', 'delete_requested');Для security-событий — отдельный job с INTERVAL '1 year' и
противоположным IN-списком.
Archival (холодные данные)
Когда данные нельзя удалить (нужны для отчётов или user-request), но они реже читаются, переноси их в отдельную таблицу:
CREATE TABLE reviews_archive (LIKE reviews INCLUDING ALL);
-- периодически перенос
INSERT INTO reviews_archive
SELECT * FROM reviews
WHERE created_at < NOW() - INTERVAL '2 years';
DELETE FROM reviews
WHERE created_at < NOW() - INTERVAL '2 years';Архивная таблица не индексируется агрессивно (меньше write-нагрузки на индексы), не попадает в hot-path запросов, может храниться на более дешёвом storage (tablespace на slow disk).
Для запросов по архивным данным — либо отдельный endpoint (который
идёт в архив), либо UNION ALL при редкой потребности. В handbook
этот путь не стандарт — применяем только когда горячая таблица
реально деградирует.
Redis retention
- TTL — всегда. Бесконечных ключей не бывает. См.
caching. - Eviction policy —
allkeys-lru(по умолчанию для кэша). - Мониторинг:
redis_db0_keys,redis_memory_used_bytes,redis_evicted_keys_total. Если eviction’ы постоянно > 0 — кэш переполнен, увеличь память или сократи TTL.
Отдельно: dedup-ключи consumer’ов — TTL 24 часа, хранят
Message.UUID. Растут пропорционально RPS; на 10k msg/s × 86400s =
860M ключей в сутки — следи за memory.
Kafka retention
- Бизнес-топики (
kazmaps.<service>.<entity>.<action>) — retention 7 дней. Меньше — риск потерять replay-окно; больше — лишние затраты на диск. - DLQ-топики (
<topic>.dlq) — retention 30 дней. Disapoisoned сообщения нужны дольше — их разбирают вручную. - Connector-internal / offset topics — стандартные настройки broker’а, не трогаем.
Retention — политика топика, настраивается при создании:
kafka-topics.sh --create --topic kazmaps.review.review \
--partitions 4 --replication-factor 3 \
--config retention.ms=604800000 # 7 daysИзменение retention существующего топика — через kafka-configs.sh --alter. Обычно это задача Kafka-owner’а, не backend-инженера.
S3 / MinIO retention
- User uploads (фото, аватарки) — хранятся до soft-delete владельца + 30 дней, затем hard delete.
- Processing artifacts (временные файлы media-pipeline) — TTL 24 часа через S3 lifecycle policy.
- Логи / traces — не в S3, живут в Loki/Tempo со своими retention.
S3 lifecycle rules живут в infra-репо (IaC), не настраиваются из application кода.
Backup, RPO и RTO
Retention отвечает на вопрос «когда удаляем», backup — «как
восстановимся, когда удалим или сломаем по ошибке». Без явных RPO/RTO
любые другие правила этого раздела бессмысленны: данные, которые мы
хранили 180 дней, пропадут за один неудачный DROP TABLE без способа
их вернуть.
Целевые значения
Каждый сервис фиксирует для своей БД:
| Параметр | Что значит | Целевое значение (default) |
|---|---|---|
| RPO (Recovery Point Objective) | Сколько свежих данных допустимо потерять при полном отказе primary | ≤ 5 минут |
| RTO (Recovery Time Objective) | Сколько времени от начала восстановления до возвращения сервиса в prod | ≤ 1 час |
| Backup retention | Сколько дней храним backup’ы назад | 30 дней ежедневных + 6 месяцев еженедельных |
| Restore drill frequency | Как часто реально проверяем, что backup восстанавливается | 1 раз в квартал, per service |
Дефолт подходит для бизнес-сервисов среднего критичности. Для
сервисов с жёсткими требованиями (платежи, auth) RPO / RTO
пересматриваются в сторону уменьшения и фиксируются в README
сервиса (см. service-readme).
Что бэкапится
- Postgres per-service — WAL-archive + базовые бэкапы (WAL-G / pgBackRest в infra). Это даёт point-in-time recovery в пределах retention-окна: можно подняться на любой момент времени, не только на момент очередного дампа.
- S3 / MinIO — bucket versioning + object-lock (для юзер-uploads) плюс периодический cross-region replication. Retention versions = 30 дней.
- Redis — не бэкапится. Redis — ускоритель (cache, rate-limit, dedup), potere его данных = временная деградация (fail-open по дефолту), не потеря persistent state. Любые «важные» данные мимо Postgres в Redis — антипаттерн.
- Kafka — не бэкапится как отдельная система. Первичный источник —
Postgres outbox (7 дней retention, см.
../patterns/outbox) + retention топиков 7 дней (см. Kafka retention). При потере Kafka-брокера сообщения восстанавливаются replay’ем из outbox за окно retention.
Правила
- PITR обязателен. Ежедневный
pg_dumpв одиночку не даёт RPO ≤ 5 мин — потеря между дампами составит до 24 часов. Стек должен включать WAL-shipping. - Бэкапы хранятся вне того же кластера. Бэкап в тот же
Postgres-инстанс, который он бэкапит, — не бэкап. Храни в
отдельном bucket’е (желательно в другом регионе), с политикой
immutable (
object-lock), чтобы случайныйrm -rfне унёс бэкапы вместе с primary. - Восстановление тестируется, не предполагается. Раз в квартал — DR drill: поднимаем staging-БД из бэкапа, а не из дампа продакшена, прогоняем smoke-тесты, замеряем фактический RTO. Результат — метрика, не «ожидаемое время», а измеренное.
- Backup ≠ архив. Бэкап — страховка от аварии (минуты-часы recovery). Архив — долговременное хранение неактивных данных (см. §Archival). Разные процессы, разные SLO, разные storage-классы.
- Шифрование at-rest. Бэкапы, содержащие PII (у нас — практически все, кроме lookup-таблиц), шифруются на storage-уровне (SSE / server-side encryption bucket) + на application-уровне (GPG / KMS при экспорте). Ключи шифрования — в secret manager, не в том же bucket’е, что бэкапы.
Метрики и alert’ы
backup_last_success_timestamp_seconds{service}— gauge с epoch времени последнего успешного бэкапа. Alert:time() - backup_last_success_timestamp_seconds > 26h→ page (для daily backup).wal_archive_lag_seconds{service}— gauge, отставание WAL-shipping. Alert:> 300(5 минут) → page, RPO под угрозой.backup_size_bytes{service}— gauge, для capacity-planning.restore_drill_last_success_timestamp_seconds{service}— gauge после успешного квартального drill’а. Alert:time() - ... > 100*86400(100 дней без drill’а) → ticket.
Конкретные query’и и dashboards — в infra-репо, handbook фиксирует только что меряется и какой alert.
Процедура восстановления
Детальные runbook’и — в каждом сервис-репо, не в handbook (конкретные имена buckets, команды WAL-G, кого звать из infra — контекст сервиса). Шаблон:
- Инцидент-commander объявляет DR: сообщает каналу, замораживает deploy’и.
- Решение: PITR на point X vs promote standby-реплики vs full restore. Определяется по природе сбоя (логическая ошибка → PITR до момента ошибки; железный отказ → promote; катастрофа всего региона → full restore).
- Восстановление идёт на новый инстанс / namespace, не затирая текущий (даже если он «сломан» — его снэпшот нужен для post-mortem’а).
- Применяются миграции, если были накат’аны между backup’ом и моментом восстановления.
- Smoke-тесты:
/readyz, ключевые endpoint’ы, критичные фоновые задачи (outbox forwarder — см.slo-and-budget). - Cutover: DNS / service mesh переводит трафик на восстановленный инстанс. Старый инстанс остаётся в read-only режиме до post-mortem’а.
- Post-mortem: что именно потеряли (разница между RPO-target и фактическим окном потери), сколько заняло (разница между RTO-target и фактическим), что изменить в инфре или коде.
Cleanup-механизмы
Сравнение:
| Механизм | Когда использовать | Плюсы | Минусы |
|---|---|---|---|
| Kubernetes CronJob | Default для периодической очистки. Outbox, sessions, audit logs | Изолированный lifecycle, отдельные логи, retry через k8s | Требует инфра-setup, отдельный образ или share-образ сервиса |
pg_cron extension | Когда Postgres managed instance его предоставляет | Всё в БД, без k8s-зависимости | Зависит от Postgres-плагина, usage-лимиты |
Application CronJob (goroutine + time.Ticker) | Нет. Антипаттерн | — | Привязка к application lifecycle, не масштабируется, неочевидно |
| Triggers | Нет для retention | — | Бизнес-логика в БД (см. db-pgx) |
Default — Kubernetes CronJob. В infra-репо:
apiVersion: batch/v1
kind: CronJob
metadata:
name: review-outbox-cleanup
spec:
schedule: "0 3 * * *" # каждую ночь в 3:00
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
containers:
- name: cleanup
image: postgres:16-alpine
command:
- psql
- "$(DATABASE_URL)"
- -c
- |
DELETE FROM outbox
WHERE offset_acked IS NOT NULL
AND created_at < NOW() - INTERVAL '7 days';
envFrom:
- secretRef: { name: review-db-secret }
restartPolicy: OnFailureДля сложного cleanup (несколько таблиц, бизнес-логика) — отдельный CLI-команда в сервис-репо:
cmd/cleanup/main.goОна вызывается из CronJob как <service>-binary cleanup --older-than 7d --table outbox. Преимущество — код cleanup проходит те же тесты,
что и сервис.
Bloat и VACUUM
После массового DELETE Postgres не возвращает место на диск — taba остаются как tombstones, потом autovacuum их очищает. Правила:
-
VACUUM ANALYZE— достаточно в 90% случаев. Autovacuum справляется. -
VACUUM FULL— блокирует таблицу (exclusive lock). Только в maintenance-окне. После массового cleanup (например, first-time очистка старых data) можно один раз. -
Autovacuum tuning per-table — для hot-таблиц с высоким update/delete rate (
outbox,sessions):ALTER TABLE outbox SET ( autovacuum_vacuum_scale_factor = 0.05, -- vacuum при 5% dead tuples autovacuum_vacuum_cost_limit = 2000 -- чаще/активнее );
См. ../troubleshooting/db-slow-query.
Что не делать
- Хардкодить retention в application-коде.
if time.Since(x) > 30*24*time.Hour { delete(x) }— cleanup должен быть отделён в CronJob/cleanup-команду с параметром. - Удалять unacked outbox. Потеря события. См.
../patterns/outbox. - Запускать массовый DELETE в одной транзакции. Больше 10k строк в одном DELETE держит lock десятки секунд, растёт WAL. Батчи по 1000–10000.
- Использовать
TRUNCATEв prod. Быстро, но:- Не вызывает триггеры (пропустятся audit-записи, если есть).
- Сбрасывает sequence’ы, если не с
CONTINUE IDENTITY. - Требует exclusive lock — блокирует чтение.
- Удалять без audit-записи. Security-событие «данные удалены» всегда логируется в audit.
- Хранить PII без retention. Email, phone, паспорт — мягкий/жёсткий delete по пользовательскому запросу + в audit log только hash/mask, не plaintext.
- Полагаться на Postgres streaming replication для «бесконечного архива». Replica не защищает от логических ошибок. Для архива — отдельный instance / backup, а не реплика.
- Удалять производственный данные вручную через psql. Только
CronJob или dedicated команда. Ручной
DELETEне проходит код- ревью.
См. также
db-pgx— soft-delete черезdeleted_at, partial indexes, что не делать в миграциях.../patterns/outbox— retention outbox, cleanup-cron.caching— Redis TTL, eviction policies.logging— маскирование PII в audit-записях.security— что можно / нельзя оставлять надолго в логах и таблицах.../troubleshooting/db-slow-query— bloat и как с ним бороться.../how-to/add-migration— как добавить retention-колонки (deleted_at) в существующую таблицу.