Перейти к содержанию

Data retention и очистка

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

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

Содержание

Что считается 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 review.reviews
    ADD COLUMN deleted_at TIMESTAMPTZ;

CREATE INDEX idx_reviews_active
    ON review.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 auth.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:

WITH to_delete AS (
    SELECT id
      FROM review.reviews
     WHERE deleted_at < NOW() - INTERVAL '90 days'
     ORDER BY deleted_at
     LIMIT 1000
)
DELETE FROM review.reviews
 WHERE id IN (SELECT id FROM to_delete);

Батч 1000–10000, между батчами pg_sleep(0.1), чтобы не забить WAL.

TTL для технических таблиц

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

TTL — в days/hours, не в абстрактных «месяцах» (месяцы разной длины усложняют SQL).

Outbox cleanup

Подробно — ../patterns/outbox.md. Кратко:

DELETE FROM review.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 review.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 review.audit_log (entity_type, entity_id, created_at DESC);
CREATE INDEX idx_audit_created ON review.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 review.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 review.reviews_archive (LIKE review.reviews INCLUDING ALL);

-- периодически перенос
INSERT INTO review.reviews_archive
    SELECT * FROM review.reviews
     WHERE created_at < NOW() - INTERVAL '2 years';

DELETE FROM review.reviews
 WHERE created_at < NOW() - INTERVAL '2 years';

Архивная таблица не индексируется агрессивно (меньше write-нагрузки на индексы), не попадает в hot-path запросов, может храниться на более дешёвом storage (tablespace на slow disk).

Для запросов по архивным данным — либо отдельный endpoint (который идёт в архив), либо UNION ALL при редкой потребности. В handbook этот путь не стандарт — применяем только когда горячая таблица реально деградирует.

Redis retention

  • TTL — всегда. Бесконечных ключей не бывает. См. caching.md.
  • 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.created \
    --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 кода.

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.md)

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 review.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 review.outbox SET (
    autovacuum_vacuum_scale_factor = 0.05,  -- vacuum при 5% dead tuples
    autovacuum_vacuum_cost_limit = 2000     -- чаще/активнее
);

См. ../troubleshooting/db-slow-query.md.

Что не делать

  • Хардкодить retention в application-коде. if time.Since(x) > 30*24*time.Hour { delete(x) } — cleanup должен быть отделён в CronJob/cleanup-команду с параметром.
  • Удалять unacked outbox. Потеря события. См. ../patterns/outbox.md.
  • Запускать массовый 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.md — soft-delete через deleted_at, partial indexes, что не делать в миграциях.
  • ../patterns/outbox.md — retention outbox, cleanup-cron.
  • caching.md — Redis TTL, eviction policies.
  • logging.md — маскирование PII в audit-записях.
  • security.md — что можно / нельзя оставлять надолго в логах и таблицах.
  • ../troubleshooting/db-slow-query.md — bloat и как с ним бороться.
  • ../how-to/add-migration.md — как добавить retention-колонки (deleted_at) в существующую таблицу.