Удаление по расписанию: данные, которые нельзя хранить дольше срока
Часть данных в любой системе живёт не вечно по определению: временные файлы загрузки, сессии, черновики, старые логи, устаревшие метрики. Если полагаться на то, что кто-то однажды вспомнит и почистит вручную — не почистит, и диск заполнится в самый неудобный момент, а данные, которые давно должны были исчезнуть, останутся лежать годами. Разберём, как выстроить технически надёжный процесс автоматического удаления по расписанию: от определения сроков хранения до работы с зависимостями, которые легко сломать неосторожным DELETE.
Содержание
- Почему данные не должны храниться бессрочно
- Матрица данных: категория, срок, точка отсчёта
- Ручное удаление не работает: нужен автоматизированный процесс
- Технические механизмы удаления в разных хранилищах
- Зависимости: как не удалить то, на что ещё ссылаются
- Батчинг, блокировки и безопасный запуск на проде
- Тестирование политик удаления перед боевым запуском
Почему данные не должны храниться бессрочно
Первый инстинкт при проектировании системы — не удалять ничего, «мало ли пригодится». Это удобная позиция ровно до того момента, пока данные не начинают стоить денег и создавать риски.
У ограниченного срока хранения обычно есть одна из трёх причин, и полезно понимать, какая именно применима к конкретной категории данных:
- Данные технически устаревают. Логи запросов за позапрошлый год никто не откроет — они нужны только для расследования инцидента в разумном окне после события. То же с метриками высокой гранулярности: через месяц точки раз в секунду не нужны, достаточно агрегатов.
- Данные — временные по своей природе. Файлы во время загрузки, черновики импорта, промежуточные результаты обработки, кэш, сессии авторизации. У таких данных с самого начала есть срок годности, и хранить их дольше — не польза, а забытая уборка.
- Срок ограничен внутренней политикой компании. Часть данных компания сознательно решает не хранить дольше определённого периода — из соображений минимизации рисков, снижения объёма чувствительной информации или требований внутреннего регламента. Мы намеренно не приводим конкретных сроков хранения для юридических категорий данных (бухгалтерия, персональные данные, логи доступа) — это вопрос к юристу или комплаенс-специалисту. Наша тема — как технически реализовать удаление, когда срок уже определён.
Отдельно стоит проговорить экономику: каждый мегабайт данных, который не нужен, но лежит на диске, — это место в бэкапах, время на их снятие, нагрузка на индексы и больше поверхности для ошибки при следующей миграции. «Просто не удалять» не бесплатно — это заметно уже на масштабе в несколько сотен гигабайт лишних логов и временных файлов.
Матрица данных: категория, срок, точка отсчёта
Прежде чем писать первую строчку кода для автоматического удаления, нужен документ — не обязательно формальный, но явный список, где для каждой категории данных зафиксированы три вещи:
| Категория данных | Срок хранения | Точка отсчёта |
|---|---|---|
| Логи веб-сервера | 90 дней | дата записи лога |
| Файлы во время загрузки (multipart upload) | 24 часа | момент старта загрузки |
| Сессии пользователей | 30 дней неактивности | дата последнего запроса, не создания |
| Черновики без публикации | 180 дней | дата последнего редактирования |
| Экспорты и выгрузки для скачивания | 7 дней | момент генерации файла |
| Уведомления, прочитанные пользователем | 60 дней | дата прочтения |
Ключевой момент, который часто упускают: точка отсчёта не всегда совпадает с датой создания записи. Сессия могла быть создана месяц назад, но обновляться каждым запросом — если считать от created_at, вы удалите активную сессию активного пользователя. Правильная точка отсчёта здесь — last_seen_at или updated_at. То же с черновиками: если человек регулярно возвращается к недописанному тексту, срок отсчитывается от последнего изменения, а не от момента создания.
Для каждой категории стоит явно зафиксировать в коде или конфиге:
retention_policies:
- table: upload_sessions
ttl: 24h
anchor_column: created_at
condition: "status = 'pending'"
- table: user_sessions
ttl: 720h # 30 дней
anchor_column: last_activity_at
- table: draft_posts
ttl: 4320h # 180 дней
anchor_column: updated_at
condition: "published_at IS NULL"
Такой конфиг — не декоративная документация, а источник истины, из которого генерируются реальные задачи удаления. Когда политика меняется, вы правите одну строку, а не ищете хардкод INTERVAL '90 days' по скриптам.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверРучное удаление не работает: нужен автоматизированный процесс
«Раз в квартал зайдём и почистим логи» — план, который не выполняется. У человека, который должен это сделать, всегда есть более срочная задача; список того, что и когда чистить, живёт в чьей-то голове или в старом тикете; а когда кто-то наконец заходит чистить руками, он на автомате использует широкий DELETE FROM logs WHERE created_at < ... без транзакции, батчинга и проверки блокировок — и роняет продовую базу в рабочее время.
Практический подход — automated процесс, который:
- Запускается по расписанию без участия человека — cron, systemd timer, планировщик в оркестраторе (Kubernetes CronJob, Celery beat, Sidekiq-cron).
- Читает список политик из одного места — конфиг, таблицу в базе или код, а не полагается на память того, кто это писал.
- Логирует, что и сколько удалено. Если задача перестанет находить данные для удаления (например, из-за сломанного запроса), вы должны увидеть это в логах или алертах, а не через полгода, когда закончится диск.
- Имеет dry-run режим — прогнать логику и увидеть, что будет удалено, без реального удаления. Спасает при первом запуске новой политики и при отладке.
Пример systemd-таймера для скрипта очистки — на VPS это надёжнее голого cron, потому что даёт логи через journalctl и явный контроль повторных запусков:
# /etc/systemd/system/cleanup-expired-data.service
[Unit]
Description=Cleanup expired data by retention policy
[Service]
Type=oneshot
User=appuser
WorkingDirectory=/opt/app
ExecStart=/opt/app/venv/bin/python cleanup.py --config retention.yaml
# /etc/systemd/system/cleanup-expired-data.timer
[Unit]
Description=Run cleanup daily at 03:15
[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
[Install]
WantedBy=timers.target
Persistent=true важен: если сервер был выключен в момент запуска таймера, задача выполнится сразу после старта, а не будет пропущена до следующего дня. Если предпочитаете классический cron, у нас есть отдельный материал про настройку cron-задач на VPS с частыми граблями по таймзонам и параллельным запускам.
Технические механизмы удаления в разных хранилищах
Способ реализовать удаление по TTL сильно зависит от того, где лежат данные.
PostgreSQL: партиционирование по дате + DROP вместо DELETE. Для таблиц вроде логов или событий, где старые строки нужно удалять целиком, партиционирование по диапазону дат — самый дешёвый способ. Вместо DELETE FROM events WHERE created_at < now() - interval '90 days', который создаёт мёртвые кортежи и требует vacuum, вы просто открепляете и дропаете партицию:
ALTER TABLE events DETACH PARTITION events_2026_05;
DROP TABLE events_2026_05;
Это мгновенная операция без сканирования таблицы и без раздувания. Мы отдельно разбирали, почему PostgreSQL не отдаёт место сразу после удаления и что делать, если партиционирование не вариант и приходится удалять построчно.
MySQL: аналогично RANGE-партиционирование по дате с ALTER TABLE ... DROP PARTITION, либо встроенный планировщик событий:
CREATE EVENT cleanup_old_sessions
ON SCHEDULE EVERY 1 DAY STARTS '2026-08-01 03:00:00'
DO
DELETE FROM sessions WHERE last_activity_at < NOW() - INTERVAL 30 DAY
LIMIT 5000;
MongoDB: TTL-индекс — самый простой вариант, если подходит по семантике: удаление ровно через фиксированный интервал после значения поля.
db.sessions.createIndex(
{ "lastActivityAt": 1 },
{ expireAfterSeconds: 2592000 } // 30 дней
)
Фоновый процесс MongoDB сам проверяет индекс и удаляет просроченные документы — раз в 60 секунд по умолчанию, точность до минуты, а не до секунды.
Redis: EXPIRE / SETEX на уровне ключа — если данные и так живут в Redis как временные (кэш, сессии, rate-limit счётчики), TTL встроен изначально, отдельный планировщик не нужен.
Object storage (S3-совместимые, MinIO): lifecycle-правила на бакете — конфигурация, а не код, применяется на стороне хранилища:
<LifecycleConfiguration>
<Rule>
<ID>expire-temp-uploads</ID>
<Filter><Prefix>tmp/uploads/</Prefix></Filter>
<Status>Enabled</Status>
<Expiration><Days>1</Days></Expiration>
</Rule>
</LifecycleConfiguration>
Это снимает нагрузку с приложения: не нужен отдельный cron, который перебирает объекты — хранилище делает это само.
Файлы и логи на диске — logrotate с параметром maxage, если задача именно в ограничении срока хранения, а не только в ротации по размеру. Про построение регламента для логов — отдельный материал: что писать, сжимать и когда удалять.
Зависимости: как не удалить то, на что ещё ссылаются
Самая частая причина, по которой автоматизированное удаление внедряют осторожно, а не «просто ставят cron и забывают», — риск удалить данные, на которые всё ещё ссылаются активные записи.
Внешние ключи ловят не всё. Если настроен FOREIGN KEY ... ON DELETE RESTRICT, попытка удалить запись, на которую есть ссылки, вызовет ошибку — это хорошо, вы узнаете о проблеме сразу. Но при ON DELETE CASCADE удаление «протухшей» записи тихо утащит за собой всё связанное — включая то, что удалять не планировалось. Пример: таблица upload_sessions с TTL 24 часа, и на неё случайно навешан каскад с таблицы processed_files, которая должна жить годами. Формально сработает правильно — просроченная сессия удалится, — но заодно исчезнут файлы, ссылавшиеся на неё по внешнему ключу.
Проверяйте зависимости перед удалением, а не полагайтесь только на БД. Практичный паттерн — перед DELETE явно проверить наличие активных ссылок:
DELETE FROM draft_posts d
WHERE d.updated_at < now() - interval '180 days'
AND d.published_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM review_requests r
WHERE r.draft_id = d.id AND r.status = 'pending'
);
Это дороже одного broad DELETE, но исключает ситуацию, когда чистка задним числом ломает активный процесс просто потому, что срок формально истёк.
Мягкое удаление как промежуточный шаг. Для данных с неочевидными зависимостями разумно не удалять физически сразу, а сначала пометить deleted_at (soft delete), исключить из выборок приложения и только через отдельный, более редкий процесс — удалить окончательно. Это даёт запас времени на то, чтобы обнаружить сломанную зависимость до необратимого удаления. Важный нюанс: мягкое удаление без второго шага — не решение, а отложенная проблема, которая копится в базе, если про неё забыть.
Копии в бэкапах и репликах. Автоматическое удаление из основной базы не означает, что данные исчезли отовсюду. Они остаются в снапшотах бэкапов до истечения их собственного цикла ротации, в read-репликах, в поисковых индексах вроде Elasticsearch, если туда велась отдельная синхронизация. Тему доказуемого удаления «везде», а не только в проде, мы разбирали в материале про регламент удаления данных с учётом копий в бэкапах.
Батчинг, блокировки и безопасный запуск на проде
Даже когда политика и зависимости продуманы, сама механика удаления на живой базе требует аккуратности.
Не удаляйте миллион строк одной транзакцией. Широкий DELETE держит блокировки на затронутых страницах и может подвесить конкурентные запросы на секунды или минуты, особенно на горячей таблице. Практика — удалять батчами с паузой между итерациями:
BATCH_SIZE = 2000
while True:
deleted = cursor.execute("""
DELETE FROM sessions
WHERE ctid IN (
SELECT ctid FROM sessions
WHERE last_activity_at < now() - interval '30 days'
LIMIT %s
)
""", (BATCH_SIZE,))
conn.commit()
if deleted == 0:
break
time.sleep(0.2) # дать конкурентным запросам пройти
Такой подход дольше по общему времени выполнения, но не создаёт заметных провалов в отзывчивости базы — что важно, если удаление идёт в фоне, а не в объявленное окно обслуживания.
Идемпотентность. Задача удаления должна безопасно переживать повторный запуск: если она упала на середине и перезапустилась, это не должно приводить к ошибке или задваиванию эффекта. Условие WHERE last_activity_at < ... идемпотентно по своей природе — повторный прогон просто не найдёт уже удалённые строки.
Мониторинг самого факта выполнения. Задача, которая тихо перестала запускаться (упавший cron, сбой деплоя, изменившийся путь к скрипту), не даст ошибки — она просто не выполнится, и данные продолжат копиться. Дешёвый способ подстраховаться — heartbeat-проверка: скрипт делает HTTP-пинг на внешний сервис в конце успешного выполнения, а сервис бьёт тревогу, если пинга давно не было.
Тестирование политик удаления перед боевым запуском
Прежде чем включать автоматическое удаление на проде, стоит пройти три шага, которые снимают большую часть риска.
Dry-run с подсчётом, а не удалением. Первый прогон новой политики должен только посчитать и залогировать, что будет удалено:
SELECT count(*), min(created_at), max(created_at)
FROM upload_sessions
WHERE status = 'pending' AND created_at < now() - interval '24 hours';
Если цифра сильно отличается от ожидаемой — сигнал, что либо срок задан неверно, либо условие status = 'pending' ловит лишнее (например, зависшие сессии, которые на самом деле нужно чинить, а не удалять).
Прогон на копии продовых данных. Тестовая база с реальным объёмом и распределением данных покажет, сколько займёт первый большой прогон (он почти всегда самый тяжёлый — накопленный за месяцы «хвост») и не упрётся ли задача в таймаут или блокировку.
Постепенное включение по категориям. Не включайте все политики разом. Начните с наименее рискованной — например, временных файлов загрузки с TTL 24 часа. Убедитесь, что процесс стабильно отрабатывает несколько циклов, логи адекватны, алерты приходят при сбое — и только после этого добавляйте следующую категорию.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Что делать, если политику удаления нужно применить к уже накопленным за годы данным?
Первый запуск на большом объёме — отдельная задача, не такая, как штатный ежедневный прогон. Разбейте её на батчи меньшего размера, выполняйте в период низкой нагрузки и рассмотрите растянуть очистку исторических данных на несколько дней, а не выполнить одним прогоном.
Нужно ли уведомлять пользователя, что его данные удалены по истечении срока?
Зависит от типа данных и внутренней политики компании — для одних категорий уведомление уместно (например, «черновик будет удалён через 7 дней неактивности»), для других не требуется. Однозначного технического ответа здесь нет, это вопрос продукта и, при необходимости, юридической консультации.
Как быть, если два процесса ссылаются на данные с разным ожидаемым сроком хранения?
Срок должен определяться самой строгой из применимых политик, а не самой длинной. Если один потребитель ожидает 30 дней, а другой не имеет ограничений, зафиксируйте 30 дней как финальный срок и скорректируйте логику второго потребителя — например, агрегируйте нужные ему данные заранее, до удаления первичных записей.
Что если удаление по расписанию упадёт из-за ошибки и накопится большой хвост данных?
Для этого нужен мониторинг самого факта выполнения задачи, а не только логирование успешных прогонов — heartbeat-проверка или алерт по отсутствию свежих записей в логе удаления должны сработать раньше, чем закончится диск.
Стоит ли хранить лог самих операций удаления?
Да, отдельно от данных, которые удаляются — короткая запись вида «удалено N строк из таблицы X по политике Y в момент времени Z» пригодится и для отладки, и как подтверждение того, что процесс реально работает.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →