Data retention и очистка¶
Правила, сколько данные живут в БД и Redis, когда удаляются и как. Без retention БД растёт до терабайт, запросы деградируют, бэкапы перестают влезать в окно, а пользовательские данные висят дольше, чем нужно.
Reference по pgx, индексам, миграциям — в
db-pgx.md. Про outbox — в
../patterns/outbox.md. Про Redis TTL —
в caching.md.
Содержание¶
- Что считается retention
- Soft-delete:
deleted_at - Hard delete: физическое удаление
- TTL для технических таблиц
- Outbox cleanup
- Audit log retention
- Archival (холодные данные)
- Redis retention
- Kafka retention
- S3 / MinIO retention
- 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 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: физическое удаление¶
Два сценария:
- По запросу пользователя («удалить мой аккаунт»). Триггерит
каскад: 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:
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 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.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-команда в сервис-репо:
Она вызывается из 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) в существующую таблицу.