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

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

Хранение в базе данных

В 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. Типичный файл больше ~1 МБ? Мегабайты в bytea раздувают таблицу и бэкапы.
  2. Файлы займут больше четверти объёма базы? Бэкап базы не должен тащить неизменяемые PDF-ы.
  3. Файлы отдаются пользователям регулярно? Каждая отдача через сервис — нагрузка на пул соединений и память.
  4. Нужен lifecycle: архивировать, автоматически удалять? В object storage это декларативная политика; в базе — самописные задания.
  5. Файлы обрабатываются: ресайз, конвертация, антивирус? Конвейеры обработки строятся вокруг object storage естественным образом.
  6. К файлам ходят другие сервисы или внешние партнёры? 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 и согласованность двух систем в общем виде.