Skip to Content
ConventionsData retention

Data retention и очистка

Правила, сколько данные живут в БД и Redis, когда удаляются и как. Без retention БД растёт до терабайт, запросы деградируют, бэкапы перестают влезать в окно, а пользовательские данные висят дольше, чем нужно.

Reference по pgx, индексам, миграциям — в db-pgx. Про outbox — в ../patterns/outbox. Про Redis TTL — в caching.

Содержание

Что считается 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: физическое удаление

Два сценария:

  1. По запросу пользователя («удалить мой аккаунт»). Триггерит каскад: soft-delete → dispatch event user.delete_requested → downstream сервисы реагируют (review.anonymize, media.purge) → через N дней финальный hard-delete самой записи.
  2. Технические данные по 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):

  1. Soft-mark в user-service: UPDATE users SET status = 'deletion_pending' WHERE id = $1.
  2. Publish user.deletion_requested.v1 через outbox (partition key = user_id).
  3. Consumer’ы в review / media / notification обрабатывают идемпотентно:
    • Записывают в локальный deletion_log(saga_id, service, user_id, status, created_at, completed_at).
    • Удаляют или анонимизируют данные в своей БД в одной транзакции с записью в deletion_log.
    • Publish <service>.deletion_completed.v1.
  4. Saga-orchestrator (в user-service) ждёт completion от всех сервисов. Timeout — 24 часа.
  5. По completion от всех: user-service делает финальный hard-delete users row + публикует 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 для технических таблиц

ТаблицаTTLRationale
outbox (в БД сервиса) (acked)7 днейОкно для replay событий при инциденте
sessions (device + JWT rfr)30 дней / TTL refresh-tokenНе переживает токен
rate_limit_violations90 днейАналитика злоупотреблений
audit_log180 днейCompliance-независимо: для расследований
audit_log — login/auth события1 годSecurity-важные факты дольше
idempotency_keys (если в Postgres)24 часаОкно retry клиента
failed_logins30 днейПосле — счётчик уже не релевантен
upload_sessions (unfinished)1 деньРезерв места в storage, dangling
webhook_deliveries30 днейИстория доставок для диагностики

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 policyallkeys-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 — контекст сервиса). Шаблон:

  1. Инцидент-commander объявляет DR: сообщает каналу, замораживает deploy’и.
  2. Решение: PITR на point X vs promote standby-реплики vs full restore. Определяется по природе сбоя (логическая ошибка → PITR до момента ошибки; железный отказ → promote; катастрофа всего региона → full restore).
  3. Восстановление идёт на новый инстанс / namespace, не затирая текущий (даже если он «сломан» — его снэпшот нужен для post-mortem’а).
  4. Применяются миграции, если были накат’аны между backup’ом и моментом восстановления.
  5. Smoke-тесты: /readyz, ключевые endpoint’ы, критичные фоновые задачи (outbox forwarder — см. slo-and-budget).
  6. Cutover: DNS / service mesh переводит трафик на восстановленный инстанс. Старый инстанс остаётся в read-only режиме до post-mortem’а.
  7. Post-mortem: что именно потеряли (разница между RPO-target и фактическим окном потери), сколько заняло (разница между RTO-target и фактическим), что изменить в инфре или коде.

Cleanup-механизмы

Сравнение:

МеханизмКогда использоватьПлюсыМинусы
Kubernetes CronJobDefault для периодической очистки. 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. Быстро, но:
    1. Не вызывает триггеры (пропустятся audit-записи, если есть).
    2. Сбрасывает sequence’ы, если не с CONTINUE IDENTITY.
    3. Требует 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) в существующую таблицу.
Last updated on