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

Настоящая проблема с загрузками от пользователей
Загрузки файлов кажутся простыми: принять файл, сохранить, показать позже. Это работает при небольшом числе пользователей и небольших файлах. Но затем объёмы растут, файлы становятся больше, и проблемы появляются там, где вы их не ждёте — не возле кнопки «загрузить».
Скачивания замедляются, потому что ваш сервер приложения или база данных тянут на себя тяжёлую работу. Бэкапы становятся огромными и медленными, восстановление занимает больше времени именно тогда, когда это нужно. Счета за хранение и трафик (egress) могут взлететь, когда файлы отдаются неэффективно, дублируются или никогда не удаляются.
Обычно вы хотите скучное и надёжное решение: быстрые передачи под нагрузкой, понятные правила доступа, простые операции (бэкап, восстановление, очистка) и затраты, которые остаются предсказуемыми по мере роста использования.
Чтобы этого добиться, разделите то, что часто смешивают:
Метаданные — это небольшая информация о файле: кто владеет, как он называется, размер, тип, когда загружен и где находится. Метаданные принадлежат базе данных (например, Postgres): их нужно запрашивать, фильтровать и объединять с пользователями, проектами и правами доступа.
Байты файла — это содержимое файла (фото, PDF, видео). Хранить байты внутри блобов базы можно, но это делает базу тяжелее, бэкапы больше, а производительность менее предсказуемой. Помещение байтов в объектное хранилище удерживает базу в её роли, а файлы отдаются быстро и дешево системами, предназначенными для этого.
Объектное хранилище против блобов в базе, простыми словами
Когда говорят «хранить загрузки в базе данных», обычно имеют в виду блобы: либо колонку BYTEA (сырые байты в строке), либо Postgres «large objects» (функция для больших значений). Оба подхода могут работать, но в обоих случаях база отвечает за отдачу байтов файла.
Объектное хранилище — это другая идея: файл живёт в бакете как объект и адресуется по ключу (например, uploads/2026/01/file.pdf). Оно заточено под большие файлы, дешёвое хранение и потоковые скачивания. Объектное хранилище хорошо справляется с множественными параллельными чтениями, не занимая соединения с базой.
Postgres хорош в запросах, ограничениях и транзакциях. Он отлично подходит для метаданных: кто владеет файлом, что это за файл, когда загрузили и можно ли его скачать. Эти данные небольшие, их легко индексировать и держать консистентными.
Практическое правило:
- Используйте Postgres для метаданных файлов, прав и связей.
- Используйте объектное хранилище для байтов, когда файлы больше нескольких мегабайт или скачивания частые.
- Рассматривайте блобы в БД только для крошечных ресурсов, которые должны транзакционно принадлежать записи (например, маленькая иконка), и только если вы уверены, что рост базы останется скромным.
Быстрая проверка здравого смысла: если бэкапы, реплики и миграции станут болезненными с байтами внутри, держите байты вне Postgres.
Простая архитектура, которая остаётся управляемой
Схема, к которой приходят большинство команд, проста: байты в объектном хранилище, запись о файле (кто владелец, что это, где лежит) — в Postgres. Ваш API координирует и авторизует, но не проксирует большие загрузки и скачивания.
Это даёт три чёткие ответственности:
- Postgres — по одной небольшой строке на файл: стабильный
file_id, владелец, размер, content type и указатель на объект. - Объектное хранилище — само хранит байты, оптимизированное для больших файлов и дешёвого хранения.
- Ваш API — создаёт и авторизует записи о файлах, выдаёт краткоживущие права на хранилище.
Этот стабильный file_id становится первичным ключом для всего: комментарии, которые ссылаются на вложение, счета, аудиты, инструменты поддержки. Пользователь может переименовать файл или вы переместите его между бакетами, а file_id останется тем же.
По возможности рассматривайте объекты как неизменяемые. Если пользователь заменяет документ, создавайте новый объект (и обычно новую строку или новую версию строки), вместо перезаписи байтов на месте. Это упрощает кеширование, предотвращает ситуацию «по старой ссылке — новый файл» и даёт простую стратегию отката.
Решите вопрос приватности заранее: по умолчанию приватно, публично — только по исключению. Хорошее правило: база данных — источник правды по доступу к файлу; объектное хранилище применяет краткоживые права, которые выдаёт ваш API.
Как моделировать метаданные файлов в Postgres
С чистым разделением 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 заниматься координацией, а не байтами. База остаётся отзывчивой, а нагрузку по трафику берёт на себя объектное хранилище.
Начните с создания записи о загрузке до передачи байтов. API возвращает upload_id, где будет лежать файл (object_key) и краткоживое разрешение на загрузку.
Обычный поток:
- Клиент запрашивает загрузку: ваш API создаёт строку со статусом
pending, ожидаемым размером и предполагаемым content type. - API возвращает presigned URL: для больших файлов сгенерируйте presigned URL на загрузку. Для маленьких файлов (аватары) можно проксировать через бэкенд, если нужно упростить клиентский код.
- Клиент загружает напрямую в объектное хранилище: браузер или мобильное приложение отправляют байты в хранилище, а не через ваш API.
- Финализация: клиент вызывает ваш API с
upload_idи данными ответа хранилища (например, ETag). Сервер проверяет размер, checksum (если используется) и content type, затем помечает строку какuploaded. - Безопасное падение: если проверка провалилась, ставьте
failedи, по желанию, удаляйте объект.
Повторы и дубли нормальны. Сделайте вызов финализации идемпотентным: если тот же upload_id финализируют дважды, возвращайте успех без побочных эффектов.
Чтобы уменьшить дубликаты при повторах и повторных загрузках, храните checksum и считайте сочетание «тот же владелец + тот же checksum + тот же размер» как один и тот же файл.
Пошаговый поток скачивания (быстро и удобно для кеширования)
Хороший поток скачивания начинается с одного стабильного URL в приложении, даже если байты живут в другом месте. Например: /files/{file_id}. Ваш API по file_id смотрит метаданные в Postgres, проверяет права и решает, как отдать файл.
- Клиент запрашивает стабильный URL с
file_id. - API проверяет, что пользователь имеет доступ и файл в состоянии
uploaded. - API возвращает либо редирект в объектное хранилище (часто лучше), либо краткоживой presigned GET URL для приватных файлов.
- Клиент качает напрямую из объектного хранилища, не перегружая ваш API.
Редиректы просты и быстры для публичных или полупубличных файлов. Для приватных — presigned GET URL сохраняет приватность хранилища и при этом даёт возможность браузеру скачивать напрямую.
Для видео и больших файлов убедитесь, что хранилище (и любой прокси) поддерживает range-запросы (Range заголовки). Это позволяет перематывать и возобновлять скачивание. Если вы прогоняете байты через API, поддержка range часто ломается или становится дорогой.
Кеширование даёт скорость. Ваш стабильный /files/{file_id} обычно не кешируем (это точка проверки прав), тогда как ответ объектного хранилища можно кешировать по содержимому. Если файлы неизменяемы (новая загрузка = новый ключ), можно ставить долгий срок кеша. Если перезаписываете файлы — держите короткий срок кеша или используйте версионированные ключи.
CDN полезен при большой глобальной аудитории или для тяжёлых файлов. Если аудитория небольшая или сосредоточена в одном регионе, объектного хранилища часто достаточно и дешевле на старте.
Как держать затраты предсказуемыми со временем
Неожиданные счета обычно приходят от скачиваний и churn, а не от самих байт на диске.
Оценивайте четыре драйвера затрат: сколько вы храните, как часто читаете и пишете (запросы), сколько данных уходит из провайдера (egress) и используете ли CDN, чтобы уменьшить частые запросы к истоку. Маленький файл, скачанный 10 000 раз, может обойтись дороже, чем большой файл, к которому никто не обращается.
Контрмеры для контроля расходов:
- Ограничьте размер загрузки и задавайте квоты по пользователям в зависимости от плана.
- Ограничивайте скорость загрузок и скачиваний, чтобы предотвратить злоупотребления и петли.
- Используйте правила жизненного цикла, чтобы старые файлы переводить в более дешёвые классы или удалять, когда они не нужны.
- Дедуплицируйте по checksum, чтобы повторы и повторные загрузки не создавали лишних копий.
- Храните счётчики использования в Postgres, чтобы биллинг и оповещения опирались на факты, а не на догадки.
Правила жизненного цикла — часто самый выгодный ход. Например: держите оригиналы фотографий «горячими» 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: это работает для маленьких приложений, но затем бэкапы раздуваются, восстановление занимает вечность, и регулярные операции становятся рискованными. Одна большая таблица может замедлить vacuum, репликацию и даже простые запросы.
- Использование имени файла от пользователя как ключа: коллизии случаются (двое загрузили «invoice.pdf»), а странные символы создают пограничные случаи. Держите имя файла для UI, а в хранилище используйте уникальный ключ (UUID).
- Пропуск валидации на этапе финализации: даже если вы валидируете на клиенте, обязательно проверяйте на сервере размер, content type и владение перед пометкой как завершённого.
- Случайное открытие доступа и отсутствие ротации: «временная» публичная политика бакета или долгоживущие URL часто становятся постоянными. Предпочитайте краткоживые ссылки и способ быстро отозвать доступ.
- Удаление только одной стороны (метаданных или байтов): удалили строку в Postgres, но оставили объект — и деньги продолжают капать. Удалили объект, но оставили метаданные — и пользователи видят битые ссылки.
Конкретный пример: если пользователь трижды заменяет фото профиля, вы можете трижды платить за старые объекты, пока не запланируете очистку. Надёжный паттерн — мягкое удаление в Postgres, затем фоновая задача, которая удаляет объект и пишет результат.
Быстрая проверка перед запуском
Большинство проблем проявляются, когда приходит большой первый файл, пользователь обновляет страницу посреди загрузки или кто-то удаляет аккаунт, а байты остаются.
Убедитесь, что таблица в Postgres фиксирует размер файла, checksum (для проверки целостности) и ясный путь состояния (например: pending, uploaded, failed, deleted).
Контрольный список на финише:
- Убедитесь, что повторы безопасны: повторные попытки не должны создавать лишних объектов или строк
uploadedс отсутствующими байтами. - Сделайте загрузки возобновляемыми или по крайней мере перезапускаемыми без тикетов в поддержку (таймауты и мобильные сети будут случаться).
- Проверьте, что скачивания поддерживают range-запросы, чтобы большие файлы начинались быстро и можно было возобновлять передачу.
- Определите процесс удаления end-to-end: пометка tombstone, удаление байтов и обработка задержек очистки, если фоновые задачи падают.
- Добавьте базовый мониторинг: ошибки загрузок/скачиваний, рост хранилища и резкие всплески egress.
Один конкретный тест: загрузите файл 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 ГБ». Держите всё простым, пока реальное использование не заставит усложнить.
FAQ
Хранить загруженные файлы в Postgres или в объектном хранилище?
Используйте Postgres для метаданных, которые вам нужно запрашивать и защищать (owner, права доступа, состояние, checksum, указатель). Байты храните в объектном хранилище, чтобы скачивания и крупные передачи не занимали соединения с базой и не раздували бэкапы.
В чём главный минус хранения байтов файлов в BLOB-ах Postgres?
Это заставляет базу данных выполнять роль файлового сервера. Размер таблиц растёт, бэкапы и восстановление замедляются, репликация нагружается, и при массовых скачиваниях предсказуемость производительности падает.
Какова самая простая архитектура загрузки, которая масштабируется без усложнений?
Да. Держите один стабильный file_id в приложении, храните метаданные в Postgres, а байты — в объектном хранилище по bucket и object_key. Ваш API должен авторизовать доступ и выдавать краткоживущие права на загрузку/скачивание, а не проксировать байты.
Как должен работать поток загрузки, если я не хочу, чтобы API обрабатывал байты файлов?
Создайте сначала запись pending, сгенерируйте уникальный object_key, затем позвольте клиенту загрузить напрямую в хранилище по краткоживущему разрешению. После загрузки клиент вызывает endpoint финализации, чтобы сервер проверил размер и контрольную сумму (если используете) и пометил запись как uploaded.
Зачем нужно состояние загрузки вроде pending/uploaded/failed/deleted?
Потому что реальные загрузки могут падать и повторяться. Поле состояния позволяет отличать ожидаемые, но отсутствующие файлы (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) может сгенерировать эндпоинты, схему и фоновые задачи из чата, чтобы не переписывать одно и то же каркасное ПО.