Аватары, документы, чеки, экспорты — файлы появляются в каждом сервисе рано или поздно. Положить их «пока в базу» — это самое простое: транзакция, бэкап и доступ из коробки. Это даже правильный выбор — но только до определённой черты. Разберём, где эта черта и что меняется за ней.
Хранение в базе данных
В PostgreSQL файл кладут в колонку типа bytea — прямо рядом с остальными данными строки. Плюс очевидный: файл живёт в транзакции. Загрузили запись вместе с файлом — она либо полностью есть, либо полностью нет. Удалили запись — файл ушёл автоматически. Бэкап базы включает и файлы.
CREATE TABLE contracts (
id bigserial PRIMARY KEY,
title text NOT NULL,
signed_at timestamptz,
document bytea -- сам файл здесь
);
Подходит, когда файлы маленькие (десятки-сотни килобайт) и нужна строгая атомарность — например, подпись или сертификат, без которого запись теряет смысл.
Проблема начинается с размером. PostgreSQL читает bytea целиком в память на каждый SELECT. Строка с 50-мегабайтным PDF делает таблицу неуправляемой: каждый дамп тащит гигабайты неизменяемых файлов, восстановление растягивается на часы, а параллельные скачивания приводят к нехватке памяти.
Хранение в object storage
Object storage (Amazon S3, MinIO, Yandex Object Storage) — это «бесконечная полка» для файлов. Каждый файл — отдельный объект под ключом. В PostgreSQL хранится только строка с метаданными и ключом объекта; сам файл — в хранилище.
Отдача файла клиенту выглядит иначе: сервис выдаёт клиенту presigned URL — временную подписанную ссылку, по которой клиент скачивает файл напрямую из хранилища, минуя сервис. Пул соединений, память приложения и сетевой стек сервиса в передаче байт не участвуют.
┌──────────┐ 1. дай ссылку ┌─────────┐
│ Клиент │ ─────────────────► │ Сервис │
│ │ ◄───────────────── │ │
│ │ presigned URL └─────────┘
│ │
│ │ 2. скачать файл ┌──────────────┐
│ │ ─────────────────► │ Object store │
└──────────┘ └──────────────┘
Цена: пара «строка в базе + объект в хранилище» — две разные системы. Транзакция базы не знает про файл; если что-то пошло не так, надо согласовывать руками.
Как выбрать
Шесть вопросов. Каждое «да» — аргумент в пользу object storage:
- Типичный файл больше ~1 МБ? Мегабайты в
byteaраздувают таблицу и бэкапы. - Файлы займут больше четверти объёма базы? Бэкап базы не должен тащить неизменяемые PDF-ы.
- Файлы отдаются пользователям регулярно? Каждая отдача через сервис — нагрузка на пул соединений и память.
- Нужен lifecycle: архивировать, автоматически удалять? В object storage это декларативная политика; в базе — самописные задания.
- Файлы обрабатываются: ресайз, конвертация, антивирус? Конвейеры обработки строятся вокруг object storage естественным образом.
- К файлам ходят другие сервисы или внешние партнёры? Presigned URL и bucket policy выражают это штатно, без проксирования.
0–1 ответов «да» — bytea рядом с метаданными, и не усложняйте.
2 и более — object storage.
Двухфазная загрузка файлов
Когда используют object storage, загрузку организуют в два шага, чтобы избежать ситуации «файл есть, а записи нет» (или наоборот).
Шаг 1. Сервис создаёт запись в базе со статусом PENDING и выдаёт клиенту presigned URL на загрузку в хранилище.
Шаг 2. Клиент загружает файл напрямую в хранилище.
Шаг 3. Клиент сообщает сервису «загрузил». Сервис проверяет, что объект действительно существует (нужный размер, тип), и переводит запись в статус ACTIVE.
Записи, зависшие в PENDING, и объекты без записей — «осиротевшие объекты» — подчищает фоновое задание. Несогласованность здесь не исключается полностью, а ограничивается и убирается периодически.
Удаление работает зеркально: запись помечается удалённой в транзакции, а объект удаляет асинхронный обработчик (по схеме outbox), потому что «удалить из S3» нельзя откатить вместе с транзакцией базы.
Типичные ошибки
Большие файлы сразу в bytea. Поначалу незаметно, потом — бэкап на несколько часов и нехватка памяти при параллельных скачиваниях.
Отдача файлов через сервис при живом S3. Контроллер, который считывает файл из хранилища и стримит его клиенту, добавляет лишние задержки и нагрузку на пул. Правильно: выдать presigned URL, байты пусть идут напрямую.
Загрузка файла внутри транзакции базы. Если транзакция откатилась, файл в S3 уже не вернуть — образуется объект-сирота. Файловые операции не участвуют в транзакции базы; поэтому двухфазный протокол выглядит именно так.
«Голый» URL в базе. Если хранить только URL, невозможно понять, чей это объект и можно ли его удалять. Храните ключ объекта, имя бакета и статус — URL для отдачи генерируется из них каждый раз.
Публичный бакет «чтобы проще». Постоянные прямые ссылки вместо presigned URL — до первого сканера, нашедшего перебором чужие документы.
Когда разумно сочетать оба варианта
Почти всегда можно сочетать. Маленькие служебные файлы — подпись, ключ, миниатюра в несколько килобайт — живут в базе. Пользовательские файлы — в object storage. Границу проводят по типу файла и его размеру, а не одним решением для всего сервиса.
Коротко
byteaв PostgreSQL — разумный выбор для маленьких файлов (до сотен килобайт), где нужна строгая транзакционность.- Object storage подходит, когда файлы мегабайты и больше, регулярно отдаются пользователям, занимают заметную долю в бэкапе или требуют жизненного цикла.
- Presigned URL позволяют отдавать файлы напрямую из хранилища, минуя сервис.
- Двухфазный протокол загрузки (PENDING → ACTIVE) защищает от рассогласования метаданных и объекта.
- Удаление объектов из S3 делают асинхронно (outbox): транзакция базы не знает про S3.
- В базе храните ключ объекта и статус, а не готовый URL — он генерируется при запросе.
- В большинстве сервисов оба способа сочетаются: маленькие служебные файлы — в базе, пользовательские — в хранилище.
Что почитать дальше
- Что такое object storage: bucket, объект, ключ и presigned URL — устройство S3 с нуля.
- AWS SDK: интеграция с сервисом — паттерны загрузки, подтверждение, MinIO в тестах.
- Распределённые паттерны — outbox и согласованность двух систем в общем виде.