Файлы — аватары, документы, чеки, экспорты — рано или поздно появляются в каждом сервисе, и класть их «пока в базу» проще всего: транзакция, бэкап и доступ из коробки. Это даже правильный ответ — до определённой границы. Эта статья — про то, где граница и что меняется за ней.
Два способа и их природа
В 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 МБ.
- Файлы уже занимают (или займут через год) больше четверти объёма БД.
- Файлы отдаются пользователям регулярно, не «раз в месяц из админки».
- Нужен lifecycle: тиринг, автоудаление, версии.
- Файлы обрабатываются (превью, конвертация, антивирус).
- К файлам ходят не только эндпоинты этого сервиса.
0–1 — bytea рядом с метаданными, и не усложняйте. 2+ — object storage; дальше — выбор провайдера и интеграция через AWS SDK.
Целостность «БД + S3»
Главная цена S3-варианта. Рабочий протокол загрузки — двухфазный:
- Сервис создаёт запись метаданных в статусе
PENDINGи выдаёт presigned URL на загрузку. - Клиент грузит файл напрямую в S3.
- Клиент подтверждает (или 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 и согласованность двух систем в общем виде.