← назад к разделу

Файлы — аватары, документы, чеки, экспорты — рано или поздно появляются в каждом сервисе, и класть их «пока в базу» проще всего: транзакция, бэкап и доступ из коробки. Это даже правильный ответ — до определённой границы. Эта статья — про то, где граница и что меняется за ней.

Два способа и их природа

В PostgreSQL файл — это bytea-колонка рядом с метаданными (large objects — отдельный механизм со своими ограничениями, в новых системах почти не нужен). Файл живёт в транзакции: атомарно создаётся и удаляется вместе со строкой, попадает в тот же бэкап, доступен через тот же пул соединений.

В object storage файл — объект под ключом, метаданные — строка в PG со ссылкой. Хранилище плоское и бесконечное, отдача — напрямую клиенту через presigned URL, минуя сервис; но целостность пары «строка + объект» — теперь забота приложения.

Семь критериев

1. Размер файла

БД, пока файлы — десятки-сотни килобайт: подписи, маленькие превью, сертификаты.

S3 — мегабайты и больше. bytea читается целиком в память на каждый SELECT, TOAST раздувается, строка с 50 МБ PDF делает таблицу неуправляемой.

2. Совокупный объём

БД, пока файлы — проценты от размера базы.

S3, когда файлы начинают определять размер бэкапа: каждый дамп PG тащит гигабайты неизменяемых PDF-ов, restore растягивается на часы. Файлы в S3 бэкапятся своим механизмом (versioning, replication), база остаётся компактной.

3. Трафик отдачи

БД, если файлы читаются редко и только через сервис.

S3, если файлы — заметный исходящий трафик: каждая отдача из БД проходит через пул соединений, память JVM и сетевой стек сервиса. Presigned URL снимает всё это: сервис выдаёт подписанную ссылку, байты ходят между клиентом и хранилищем.

4. Транзакционность

БД: файл обязан появляться и исчезать строго вместе с записью — например, вложение, без которого запись невалидна. Атомарность бесплатно.

S3: пара «метаданные в PG + объект в S3» согласуется протоколом (ниже) — это решаемо, но это код и операционные случаи, которых в варианте «всё в БД» нет.

5. Жизненный цикл

БД: файлы живут столько же, сколько записи.

S3: «через 90 дней — в холодное хранилище, через год — удалить» — lifecycle policies делают это декларативно; в БД это самописные джобы.

6. Обработка содержимого

БД: файл — чёрный ящик, его никто не трогает.

S3: ресайз изображений, конвертация, антивирус, OCR — конвейеры обработки строятся вокруг object storage (события на загрузку, обработчики), а не вокруг bytea.

7. Доступ извне

БД: файлы видит только сам сервис.

S3: файлы нужны другим сервисам, фронту напрямую, внешним партнёрам — presigned URL и bucket policy выражают это штатно, без проксирования через Java.

Чек-лист «возьми балл»

Балл S3 за каждое «да»:

  1. Типичный файл больше ~1 МБ.
  2. Файлы уже занимают (или займут через год) больше четверти объёма БД.
  3. Файлы отдаются пользователям регулярно, не «раз в месяц из админки».
  4. Нужен lifecycle: тиринг, автоудаление, версии.
  5. Файлы обрабатываются (превью, конвертация, антивирус).
  6. К файлам ходят не только эндпоинты этого сервиса.

0–1 — bytea рядом с метаданными, и не усложняйте. 2+ — object storage; дальше — выбор провайдера и интеграция через AWS SDK.

Целостность «БД + S3»

Главная цена S3-варианта. Рабочий протокол загрузки — двухфазный:

  1. Сервис создаёт запись метаданных в статусе PENDING и выдаёт presigned URL на загрузку.
  2. Клиент грузит файл напрямую в S3.
  3. Клиент подтверждает (или S3-событие приходит само) — сервис проверяет объект (размер, тип) и переводит запись в ACTIVE.

Брошенные PENDING-записи и объекты-сироты подчищает фоновая джоба — несогласованность здесь не исключается, а ограничивается и убирается. Удаление — зеркально: запись помечается удалённой в транзакции, объект удаляет асинхронный обработчик (outbox), потому что «удалить из S3» нельзя откатить вместе с транзакцией PG. Это тот же класс решений, что и outbox для событий.

Типичные ошибки

  • 50-мегабайтные PDF в bytea — «так проще», пока не пришли бэкапы по часу и OOM на параллельных скачиваниях.
  • Отдача файлов через сервис при живом S3. Контроллер, стримящий байты из S3 клиенту, — пул, память и латентность на ровном месте. Проверка прав — да, в сервисе; байты — по presigned URL.
  • Запись в S3 внутри @Transactional. Загрузка удалась — транзакция откатилась — объект-сирота навсегда. Файловые операции не участвуют в транзакции БД; отсюда и двухфазный протокол.
  • Ссылки без владельца. В PG хранится «голый URL», по которому невозможно понять, чей объект и можно ли его удалять. Храните ключ объекта + bucket + статус — URL генерируется.
  • Публичный bucket «чтобы проще». Прямые постоянные ссылки вместо presigned — до первого сканера, нашедшего перебором чужие документы.

Когда оба — нормально

Почти всегда: маленькие служебные блобы (подпись, ключ, миниатюра в пару КБ) живут в БД, пользовательские файлы — в S3. Граница проводится по критериям выше per-тип-файла, а не одним решением на весь сервис.

Что почитать дальше

  • Object storage fundamentals — модель S3, presigned URLs, multipart.
  • Spring + AWS SDK v2 — интеграция, паттерны, MinIO в тестах.
  • S3 operations — lifecycle, репликация, стоимость.
  • Распределённые паттерны — outbox и согласованность двух систем в общем виде.