Объектное хранилище vs блобы в БД: храните метаданные файлов в Postgres, байты — в объектном хранилище, чтобы скачивания были быстрыми, а затраты предсказуемыми.

Загрузки файлов кажутся простыми: принять файл, сохранить, показать позже. Это работает при небольшом числе пользователей и небольших файлах. Но затем объёмы растут, файлы становятся больше, и проблемы появляются там, где вы их не ждёте — не возле кнопки «загрузить».
Скачивания замедляются, потому что ваш сервер приложения или база данных тянут на себя тяжёлую работу. Бэкапы становятся огромными и медленными, восстановление занимает больше времени именно тогда, когда это нужно. Счета за хранение и трафик (egress) могут взлететь, когда файлы отдаются неэффективно, дублируются или никогда не удаляются.
Обычно вы хотите скучное и надёжное решение: быстрые передачи под нагрузкой, понятные правила доступа, простые операции (бэкап, восстановление, очистка) и затраты, которые остаются предсказуемыми по мере роста использования.
Чтобы этого добиться, разделите то, что часто смешивают:
Метаданные — это небольшая информация о файле: кто владеет, как он называется, размер, тип, когда загружен и где находится. Метаданные принадлежат базе данных (например, Postgres): их нужно запрашивать, фильтровать и объединять с пользователями, проектами и правами доступа.
Байты файла — это содержимое файла (фото, PDF, видео). Хранить байты внутри блобов базы можно, но это делает базу тяжелее, бэкапы больше, а производительность менее предсказуемой. Помещение байтов в объектное хранилище удерживает базу в её роли, а файлы отдаются быстро и дешево системами, предназначенными для этого.
Когда говорят «хранить загрузки в базе данных», обычно имеют в виду блобы: либо колонку BYTEA (сырые байты в строке), либо Postgres «large objects» (функция для больших значений). Оба подхода могут работать, но в обоих случаях база отвечает за отдачу байтов файла.
Объектное хранилище — это другая идея: файл живёт в бакете как объект и адресуется по ключу (например, uploads/2026/01/file.pdf). Оно заточено под большие файлы, дешёвое хранение и потоковые скачивания. Объектное хранилище хорошо справляется с множественными параллельными чтениями, не занимая соединения с базой.
Postgres хорош в запросах, ограничениях и транзакциях. Он отлично подходит для метаданных: кто владеет файлом, что это за файл, когда загрузили и можно ли его скачать. Эти данные небольшие, их легко индексировать и держать консистентными.
Практическое правило:
Быстрая проверка здравого смысла: если бэкапы, реплики и миграции станут болезненными с байтами внутри, держите байты вне Postgres.
Схема, к которой приходят большинство команд, проста: байты в объектном хранилище, запись о файле (кто владелец, что это, где лежит) — в Postgres. Ваш API координирует и авторизует, но не проксирует большие загрузки и скачивания.
Это даёт три чёткие ответственности:
file_id, владелец, размер, content type и указатель на объект.Этот стабильный file_id становится первичным ключом для всего: комментарии, которые ссылаются на вложение, счета, аудиты, инструменты поддержки. Пользователь может переименовать файл или вы переместите его между бакетами, а file_id останется тем же.
По возможности рассматривайте объекты как неизменяемые. Если пользователь заменяет документ, создавайте новый объект (и обычно новую строку или новую версию строки), вместо перезаписи байтов на месте. Это упрощает кеширование, предотвращает ситуацию «по старой ссылке — новый файл» и даёт простую стратегию отката.
Решите вопрос приватности заранее: по умолчанию приватно, публично — только по исключению. Хорошее правило: база данных — источник правды по доступу к файлу; объектное хранилище применяет краткоживые права, которые выдаёт ваш API.
С чистым разделением Postgres хранит факты о файле, а объектное хранилище — байты. Это держит базу меньшей, бэкапы быстрее, а запросы проще.
Практичная таблица uploads нужна лишь с несколькими полями, чтобы отвечать на вопросы вроде «кто владеет?», «где хранится?» и «безопасно ли скачивать?».
CREATE TABLE uploads (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL,
bucket text NOT NULL,
object_key text NOT NULL,
size_bytes bigint NOT NULL,
content_type text,
original_filename text,
checksum text,
state text NOT NULL CHECK (state IN ('pending','uploaded','failed','deleted')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX uploads_owner_created_idx ON uploads (owner_id, created_at DESC);
CREATE INDEX uploads_checksum_idx ON uploads (checksum);
Пара решений, которые сэкономят вам проблемы позже:
bucket + object_key как указатель на хранение. Держите это неизменным после загрузки.state. Когда пользователь начинает загрузку — вставляйте строку pending. Переводите в uploaded лишь после того, как система подтвердит наличие объекта и совпадение размера (и, по возможности, checksum).original_filename только для отображения. Не полагайтесь на него при принятии решений о типе или безопасности.Если вы поддерживаете замену файлов, добавьте отдельную таблицу upload_versions с полями upload_id, version, object_key и created_at. Так вы сохраните историю, сможете откатываться и не ломать старые ссылки.
Держите загрузки быстрыми, заставляя API заниматься координацией, а не байтами. База остаётся отзывчивой, а нагрузку по трафику берёт на себя объектное хранилище.
Начните с создания записи о загрузке до передачи байтов. API возвращает upload_id, где будет лежать файл (object_key) и краткоживое разрешение на загрузку.
Обычный поток:
pending, ожидаемым размером и предполагаемым content type.upload_id и данными ответа хранилища (например, ETag). Сервер проверяет размер, checksum (если используется) и content type, затем помечает строку как uploaded.failed и, по желанию, удаляйте объект.Повторы и дубли нормальны. Сделайте вызов финализации идемпотентным: если тот же upload_id финализируют дважды, возвращайте успех без побочных эффектов.
Чтобы уменьшить дубликаты при повторах и повторных загрузках, храните checksum и считайте сочетание «тот же владелец + тот же checksum + тот же размер» как один и тот же файл.
Хороший поток скачивания начинается с одного стабильного URL в приложении, даже если байты живут в другом месте. Например: /files/{file_id}. Ваш API по file_id смотрит метаданные в Postgres, проверяет права и решает, как отдать файл.
file_id.uploaded.Редиректы просты и быстры для публичных или полупубличных файлов. Для приватных — presigned GET URL сохраняет приватность хранилища и при этом даёт возможность браузеру скачивать напрямую.
Для видео и больших файлов убедитесь, что хранилище (и любой прокси) поддерживает range-запросы (Range заголовки). Это позволяет перематывать и возобновлять скачивание. Если вы прогоняете байты через API, поддержка range часто ломается или становится дорогой.
Кеширование даёт скорость. Ваш стабильный /files/{file_id} обычно не кешируем (это точка проверки прав), тогда как ответ объектного хранилища можно кешировать по содержимому. Если файлы неизменяемы (новая загрузка = новый ключ), можно ставить долгий срок кеша. Если перезаписываете файлы — держите короткий срок кеша или используйте версионированные ключи.
CDN полезен при большой глобальной аудитории или для тяжёлых файлов. Если аудитория небольшая или сосредоточена в одном регионе, объектного хранилища часто достаточно и дешевле на старте.
Неожиданные счета обычно приходят от скачиваний и churn, а не от самих байт на диске.
Оценивайте четыре драйвера затрат: сколько вы храните, как часто читаете и пишете (запросы), сколько данных уходит из провайдера (egress) и используете ли CDN, чтобы уменьшить частые запросы к истоку. Маленький файл, скачанный 10 000 раз, может обойтись дороже, чем большой файл, к которому никто не обращается.
Контрмеры для контроля расходов:
Правила жизненного цикла — часто самый выгодный ход. Например: держите оригиналы фотографий «горячими» 30 дней, затем переводите в более дешёвый класс; храните счета 7 лет; удаляйте части неудачных загрузок через 7 дней. Даже базовая политика удержания останавливает постепенный рост хранилища.
Дедупликация может быть простой: храните хеш содержимого (например, SHA-256) в таблице метаданных и обеспечьте уникальность по владельцу. Когда пользователь загружает один и тот же PDF дважды, вы можете переиспользовать объект и создать новую строку метаданных.
Наконец, отслеживайте использование там, где уже ведёте учёт пользователей: в Postgres. Храните bytes_uploaded, bytes_downloaded, object_count и last_activity_at для пользователя или рабочего пространства. Это упрощает показ лимитов в UI и отправку предупреждений до неожиданных расходов.
Безопасность загрузок сводится к двум вещам: кто может получить доступ к файлу и что вы можете доказать позже, если что-то пойдёт не так.
Начните с понятной модели доступа и зафиксируйте её в метаданных Postgres, а не в разрозненных правилах по сервисам.
Простая модель, покрывающая большинство приложений:
Для приватных файлов избегайте раскрытия сырых object_key. Выдавайте временные, ограниченные по области действия presigned URL для загрузки и скачивания и регулярно их ротацируйте.
Обеспечьте шифрование в пути и в покое. В пути — HTTPS end-to-end, включая прямые загрузки в хранилище. В покое — серверное шифрование у провайдера хранения и шифрование бэкапов/реплик.
Добавьте контрольные точки безопасности и качества данных: валидируйте content type и размер до выдачи URL на загрузку, затем проверяйте снова после загрузки (по фактическим байтам, а не по имени файла). Если риск высок, запускайте асинхронное сканирование на вредоносное ПО и карантиньте файл до прохождения проверок.
Храните поля аудита, чтобы можно было расследовать инциденты и соответствовать базовым требованиям: uploaded_by, ip, user_agent, last_accessed_at — практический минимум.
Если есть требования по локализации данных, выбирайте регион хранения осознанно и держите его согласованным с тем, где вы выполняете вычисления.
Большинство проблем с загрузками не про сырую скорость. Они про архитектурные решения, которые удобны в начале, а потом больно бьют при росте трафика, объёмов данных и обращений в поддержку.
Конкретный пример: если пользователь трижды заменяет фото профиля, вы можете трижды платить за старые объекты, пока не запланируете очистку. Надёжный паттерн — мягкое удаление в Postgres, затем фоновая задача, которая удаляет объект и пишет результат.
Большинство проблем проявляются, когда приходит большой первый файл, пользователь обновляет страницу посреди загрузки или кто-то удаляет аккаунт, а байты остаются.
Убедитесь, что таблица в Postgres фиксирует размер файла, checksum (для проверки целостности) и ясный путь состояния (например: pending, uploaded, failed, deleted).
Контрольный список на финише:
uploaded с отсутствующими байтами.Один конкретный тест: загрузите файл 2 ГБ, обновите страницу на 30%, затем возобновите. Потом скачайте на медленном соединении и перемотайте в середину. Если один из потоков ненадёжный, исправьте до релиза.
Простое SaaS-приложение часто имеет два разных типа загрузок: фото профиля (частые, маленькие, можно кешировать) и PDF-счета (чувствительные, приватные). Тут разделение метаданных в Postgres и байтов в объектном хранилище окупается.
Вот как могут выглядеть метаданные в одной таблице files, с полями, которые влияют на поведение:
| field | пример фото профиля | пример PDF-счёта |
|---|---|---|
kind | avatar | invoice_pdf |
visibility | private (отдаётся через signed URL) | private |
cache_control | public, max-age=31536000, immutable | no-store |
object_key | users/42/avatars/2026-01-17T120102Z.webp | orgs/7/invoices/INV-1049.pdf |
status | uploaded | uploaded |
size_bytes | 184233 | 982341 |
Когда пользователь заменяет фото, обращайтесь с ним как с новым файлом, а не как с перезаписью. Создайте новую строку и новый object_key, затем обновите профиль так, чтобы он ссылался на новый file_id. Пометьте старую запись как replaced_by=<new_id> (или deleted_at) и удалите старый объект позже фоновой задачей. Это сохраняет историю, упрощает откаты и избегает гонок.
Поддержке и отладке легче, потому что метаданные рассказывают историю. Когда говорят «моя загрузка упала», служба поддержки может посмотреть status, читаемую ошибку last_error, storage_request_id или etag (для трассировки логов хранилища), метки времени (зависала ли загрузка?), owner_id и kind (правильна ли политика доступа?).
Начните с малого и сделайте путь успеха неприметным: файлы загружаются, метаданные сохраняются, скачивания быстрые и ничего не теряется.
Хороший первый этап — минимальная таблица в Postgres для метаданных файла плюс один поток загрузки и один поток скачивания, которые вы сможете объяснить на доске. Когда это отлажено, добавьте версии, квоты и правила жизненного цикла.
Определите одну политику хранения на тип файла и зафиксируйте её. Например: фото профиля кешируемы, а счета приватны и доступны только по краткоживым ссылкам. Смешивание политик в одном префиксе бакета без плана — частая причина случайных раскрытий.
Раннее добавление телеметрии. Числа, которые нужны с первого дня: процент неудачных финализаций загрузок, отношение орфанов (объект без строки в БД и наоборот), egress по типам файлов, P95 latency скачиваний и средний размер объекта.
Если хотите ускорить прототипирование этого паттерна, Koder.ai (koder.ai) позволяет генерировать целые приложения из чата и хорошо подходит под стек, описанный здесь (React, Go, Postgres). Это удобно для итерации над схемой, эндпоинтами и фоновыми задачами очистки без переписывания каркаса.
После этого добавляйте только то, что можно объяснить в одной фразе: «держим старые версии 30 дней» или «каждому рабочему пространству — 10 ГБ». Держите всё простым, пока реальное использование не заставит усложнить.
Используйте Postgres для метаданных, которые вам нужно запрашивать и защищать (owner, права доступа, состояние, checksum, указатель). Байты храните в объектном хранилище, чтобы скачивания и крупные передачи не занимали соединения с базой и не раздували бэкапы.
Это заставляет базу данных выполнять роль файлового сервера. Размер таблиц растёт, бэкапы и восстановление замедляются, репликация нагружается, и при массовых скачиваниях предсказуемость производительности падает.
Да. Держите один стабильный file_id в приложении, храните метаданные в Postgres, а байты — в объектном хранилище по bucket и object_key. Ваш API должен авторизовать доступ и выдавать краткоживущие права на загрузку/скачивание, а не проксировать байты.
Создайте сначала запись pending, сгенерируйте уникальный object_key, затем позвольте клиенту загрузить напрямую в хранилище по краткоживущему разрешению. После загрузки клиент вызывает endpoint финализации, чтобы сервер проверил размер и контрольную сумму (если используете) и пометил запись как uploaded.
Потому что реальные загрузки могут падать и повторяться. Поле состояния позволяет отличать ожидаемые, но отсутствующие файлы (pending), успешно загруженные (uploaded), сломанные (failed) и удалённые (deleted) — это важно для UI, фоновых заданий очистки и инструментов поддержки.
Используйте original_filename только для отображения. Сгенерируйте уникальный ключ для хранения (например, на основе UUID), чтобы избежать коллизий, проблем с символами и уязвимостей. В UI можно показывать оригинальное имя, но пути в хранилище держите предсказуемыми.
Используйте стабильный URL в приложении, например /files/{file_id}, как точку проверки прав. После проверки доступа в Postgres возвращайте редирект или краткоживую signed-ссылку на скачивание, чтобы клиент скачивал напрямую из объектного хранилища и не нагружал ваш API.
Как правило, доминируют расходы на исходящий трафик и повторные скачивания, а не на сырое хранение. Ограничьте размер файлов и квоты, применяйте правила жизненного цикла, используйте дедупликацию по хэшу там, где это имеет смысл, и храните счётчики использования, чтобы предупреждать до роста счёта.
Храните права и видимость в Postgres как источник истины, держите хранилище приватным по умолчанию. Валидируйте тип и размер до выдачи URL на загрузку и повторно после загрузки (по фактическим байтам), используйте HTTPS, шифрование at-rest и записывайте поля аудита для последующего расследования.
Начните с одной таблицы метаданных, одного потока «клиент → хранилище → финализация» и одного эндпоинта-гейта для скачивания, затем добавьте фоновые задания для очистки орфанов и мягко удалённых записей. Если нужно быстро прототипировать на стеке React/Go/Postgres, Koder.ai (koder.ai) может сгенерировать эндпоинты, схему и фоновые задачи из чата, чтобы не переписывать одно и то же каркасное ПО.