Антипаттерн: хранить картинки и файлы в базе данных
Когда в проекте появляется загрузка аватарок, документов или вложений, самое простое решение выглядит очевидным: добавить колонку BYTEA или LONGBLOB и сохранить файл прямо в строке таблицы рядом с остальными данными. Никакой отдельной инфраструктуры, один бэкап на всё, никаких прав доступа к файловой системе. Через полгода-год база весит на порядок больше, чем должна, ночной дамп не укладывается в окно, а один эндпоинт со списком фотографий кладёт пул соединений. Разберём, почему хранение бинарных файлов в БД — это ловушка с отложенным сроком, и как перенести файлы туда, где им место.
Содержание
- Почему к этому вообще приходят
- Раздувание базы: что это ломает на практике
- БД — это не веб-сервер: как файл едет до клиента
- Кэширование и CDN не дружат с BLOB-полями
- Как правильно: файл — в хранилище, в БД — только путь и метаданные
- Как мигрировать уже разросшуюся таблицу с BLOB
- Когда хранение файлов в БД всё же оправдано
Почему к этому вообще приходят
Идея хранить файлы в БД не глупая — у неё есть реальные причины появляться на старте проекта:
- Транзакционность из коробки. Файл и запись метаданных о нём создаются и удаляются в одной транзакции — нет окна, где файл уже есть, а запись о нём ещё нет (или наоборот).
- Один источник бэкапа. Не нужно следить, что дамп БД и снапшот файлового хранилища сделаны синхронно, в одну и ту же секунду.
- Простая модель доступа. Права на файл регулируются той же логикой (и тем же ORM), что и права на любую другую строку — не нужен отдельный слой авторизации для статики.
- Меньше движущихся частей. На старте, когда файлов сотня и все они по 20 КБ, разница между BLOB-полем и файлом на диске незаметна вообще.
Все четыре причины разумны, пока проект маленький. Проблема в том, что ни одна из них не масштабируется вместе с ростом объёма — а рост объёма для картинок и вложений почти неизбежен.
Раздувание базы: что это ломает на практике
BLOB-данные физически лежат в тех же табличных файлах (heap), что и обычные строки, и попадают в те же механизмы, что обслуживают всю остальную базу:
- Бэкапы становятся тяжелее и медленнее.
pg_dump/mysqldumpсериализуют и сжимают те же самые байты файлов при каждом запуске — время бэкапа растёт пропорционально объёму BLOB, а не объёму «полезных» бизнес-данных. Физический бэкап (pg_basebackup,xtrabackup) копирует файлы БД целиком — с картинками внутри. - Репликация тащит лишний трафик. В PostgreSQL WAL и логическая репликация передают изменения BLOB-полей так же, как изменения любой другой колонки — при активной загрузке файлов реплики получают поток бинарных данных, который не имеет отношения к транзакционной логике приложения.
- VACUUM и обслуживание таблиц дорожают. Таблица с BLOB растёт быстрее, autovacuum на ней работает дольше,
VACUUM FULL/pg_repackдля возврата места на диске держат блокировки дольше — потому что физически нужно переписать больше данных. - Point-in-time recovery выходит за разумные рамки. Чем больше данных нужно прогнать через восстановление и WAL-replay, тем дольше происходит recovery — а это время простоя в реальном инциденте, а не в тестовой среде.
Проверить масштаб проблемы легко на любой существующей базе:
-- Размер таблицы с файлами против размера всей базы
SELECT pg_size_pretty(pg_total_relation_size('attachments')) AS files_table,
pg_size_pretty(pg_database_size(current_database())) AS whole_db;
Если таблица с файлами — это 80-90% размера базы, каждая операция с базой (бэкап, реплика, миграция мажорной версии) на деле в основном двигает картинки, а не бизнес-данные — и окно бэкапа, и стоимость хранения резервных копий считаются от этого раздутого объёма, а не от реального объёма бизнес-данных.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверБД — это не веб-сервер: как файл едет до клиента
Когда файл лежит в БД, путь запроса выглядит так: клиент стучится в приложение → приложение шлёт SQL-запрос → СУБД поднимает страницы BLOB из буферного кеша или с диска → драйвер десериализует байты в приложение → приложение формирует HTTP-ответ → клиент получает файл. На каждом хопе — лишняя сериализация, лишняя копия данных в памяти, и всё это время открыто соединение к БД.
Веб-сервер, отдающий статику напрямую, работает иначе:
location /uploads/ {
alias /var/www/app/uploads/;
expires 30d;
add_header Cache-Control "public, immutable";
# sendfile отдаёт файл ядром, без копирования в userspace процесса nginx
sendfile on;
tcp_nopush on;
}
sendfile() передаёт данные из файлового кеша ядра в сокет без лишних копирований в память процесса, nginx умеет Range-запросы «из коробки» (важно для видео и PDF, которые открывают частично), If-Modified-Since/ETag для условных запросов — всё это либо не работает через BLOB вообще, либо требует, чтобы вы реализовали это руками в коде приложения.
Отдельная практическая проблема — при отдаче большого файла из БД соединение к базе занято на всё время передачи. Десять параллельных скачиваний по 50 МБ — это десять занятых соединений в пуле, которые не делают полезной работы с точки зрения БД, а просто держат канал открытым. На нагруженном сервисе это первый кандидат на исчерпание max_connections.
Кэширование и CDN не дружат с BLOB-полями
CDN и браузерный кеш работают со стабильным URL, который мапится на статический ресурс с корректными заголовками (ETag, Cache-Control, Last-Modified) — и умеют отдавать этот ресурс из кеша edge-узла, вообще не доходя до вашего сервера. Если файл живёт в БД, у вас нет статического ресурса — есть эндпоинт приложения, который на каждый (первый для данного узла CDN) запрос обязан сходить в базу.
Это не значит, что CDN невозможен поверх БД-хранилища — можно поставить кеш перед приложением и отдавать через него, — но вы теряете самый дешёвый вариант: указать CDN прямо на файл в объектном хранилище или на статику веб-сервера и забыть про это. О том, как физически устроена раздача через CDN и где на самом деле лежит картинка, которую видит браузер, подробно разобрано в статье что такое CDN изнутри: где лежит картинка.
Как правильно: файл — в хранилище, в БД — только путь и метаданные
Правильная модель — держать в БД лёгкую строку с метаданными, а сам файл — в файловой системе или объектном хранилище:
CREATE TABLE attachments (
id bigserial PRIMARY KEY,
owner_id bigint NOT NULL REFERENCES users(id),
storage_key text NOT NULL, -- путь на диске или ключ в объектном хранилище
filename text NOT NULL,
content_type text NOT NULL,
size_bytes bigint NOT NULL,
sha256 char(64) NOT NULL, -- для дедупликации и проверки целостности
created_at timestamptz NOT NULL DEFAULT now()
);
Такая таблица весит килобайты даже при миллионах записей — она нормально бэкапится, реплицируется и не влияет на время восстановления базы.
Для самого хранения есть два рабочих варианта:
Файловая система на своём сервере. Подходит, если файлов не миллиарды и всё крутится на одном или нескольких серверах под вашим контролем. Важный нюанс — не складывайте всё в одну плоскую директорию: у файловых систем деградирует производительность поиска при огромном числе файлов в одном каталоге. Используйте шардирование по первым символам ключа (/uploads/a1/a1b2c3.../file.jpg) или по дате. Про то, почему это важно и как проявляется на практике, — в статье миллион мелких файлов убивает диск.
Объектное хранилище (S3-совместимое). Подходит, когда файлов много, они растут в объёме, нужна отдача из нескольких точек или горизонтальное масштабирование хранилища отдельно от вычислительных серверов. Можно использовать внешний S3-сервис или поднять MinIO у себя — это разбирается в статье S3-совместимое хранилище у себя, а общее сравнение подходов — в материале объектное хранилище против файловой системы.
Схема загрузки с presigned URL позволяет вообще не гонять байты файла через ваш сервер приложений:
1. Клиент → приложение: "хочу загрузить file.jpg"
2. Приложение генерирует presigned PUT URL в объектном хранилище (срок действия — минуты)
3. Клиент → хранилище: PUT напрямую по presigned URL
4. Клиент → приложение: "файл загружен, вот storage_key" → приложение пишет строку в БД
Приложение и БД в этой схеме вообще не видят содержимое файла — только метаданные. Отдача работает так же: приложение либо генерирует presigned GET URL и редиректит на него, либо (для внутренней сети) отдаёт через X-Accel-Redirect в nginx, который проксирует поток от хранилища к клиенту, не проходя через код приложения.
Как мигрировать уже разросшуюся таблицу с BLOB
Если файлы уже накопились в БД, миграцию стоит делать поэтапно, а не одним большим ALTER TABLE:
- Добавьте новые колонки (
storage_key, оставив старую BLOB-колонку нетронутой) — это безопасная, обратно совместимая миграция. - Напишите скрипт переноса батчами, который читает старые записи, выгружает содержимое в хранилище и проставляет
storage_key. Батчи по несколько сотен-тысяч строк с паузами — чтобы не создавать всплеск нагрузки на диск и не растить лаг репликации.
# Псевдокод, суть — батчами, с проверкой контрольной суммы
for batch in fetch_unmigrated_rows(limit=500):
for row in batch:
data = read_blob(row.id)
key = upload_to_storage(data, content_type=row.content_type)
assert sha256(data) == row.sha256 # если считали заранее
update_storage_key(row.id, key)
time.sleep(1) # не душить диск и реплики
- Переключите приложение на чтение по
storage_key, оставив старый путь чтения BLOB как fallback на время наблюдения. - После подтверждения, что всё перенесено и работает (сверка количества строк, контрольных сумм, мониторинг ошибок отдачи) — уберите старую колонку:
ALTER TABLE attachments DROP COLUMN blob_data;. - Верните место на диске. Удаление колонки само по себе не сжимает файл таблицы — потребуется
VACUUM FULLилиpg_repack(последний не держит долгую эксклюзивную блокировку, что предпочтительнее на проде). Планируйте это отдельным окном обслуживания, точное время зависит от объёма и типа диска — заранее не назову цифру, которая будет верна для вашего случая.
Если база и так уже переезжает на новый сервер, миграцию файлов имеет смысл совместить с этим переездом, чтобы не проходить через разросшуюся таблицу дважды.
Когда хранение файлов в БД всё же оправдано
Антипаттерн — это не «никогда», а «не по умолчанию». Есть ситуации, где BLOB в БД — разумный компромисс:
| Ситуация | Почему БД может быть ок |
|---|---|
| Файлы очень маленькие (иконки, подписи, миниатюры до нескольких КБ) и их немного | Накладные расходы малы, а транзакционность важнее удобства раздачи |
| Строгое требование атомарности файл+метаданные без возможности рассинхрона | Отдельное хранилище всегда даёт микроокно несогласованности между записью файла и записью метаданных |
| Закрытый контур без доступа к внешним сервисам, где поднимать ещё один компонент (объектное хранилище) организационно дороже, чем жить с BLOB | Регуляторные и инфраструктурные ограничения иногда перевешивают технические неудобства |
| Заведомо предсказуемый и малый суммарный объём (известно, что файлов будет не больше нескольких тысяч) | Риск разрастания в этом случае невелик |
Ключевой вопрос для принятия решения — не «удобно ли сейчас», а «что будет, когда файлов станет в 100 раз больше». Если ответ «база и бэкапы это не переживут» — храните файлы отдельно с самого начала, переносить потом дороже.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
А если файлов совсем немного — десятки мегабайт на всю таблицу?
Тогда риск невелик, и хранение в БД может быть осознанным выбором ради простоты. Проблема появляется не из-за самого факта BLOB, а из-за неограниченного роста — стоит заранее решить, при каком объёме вы мигрируете, а не откладывать это до момента, когда бэкап перестанет укладываться в ночь.
PostgreSQL Large Objects (lo) лучше, чем bytea?
У них разная внутренняя механика (объекты lo хранятся отдельно от строки и стримятся через специальный API), но архитектурная проблема остаётся той же: файлы всё ещё внутри вашей СУБД, всё ещё попадают в бэкап и WAL, всё ещё не отдаются веб-сервером напрямую.
Работает ли то же самое рассуждение для MongoDB и GridFS?
Да. GridFS специально нарезает большие файлы на чанки в отдельной коллекции, чтобы обойти лимит размера документа, но это решает только эту конкретную техническую проблему — вопросы объёма бэкапа, отдачи через CDN и загрузки диска базы остаются теми же.
Что делать, если мигрировать страшно, а таблица уже огромная?
Мигрируйте новые записи сразу через хранилище (не наращивайте проблему), а старые переносите постепенно батчами в фоне, без остановки сервиса. Полная миграция не обязана быть одной операцией.
Нужно ли объектное хранилище с первого дня проекта, или можно начать с папки на диске?
Можно начать с обычной файловой системы и storage_key, указывающего на локальный путь. Переход на S3-совместимое хранилище — это в первую очередь смена реализации upload_to_storage()/read_from_storage(), а не изменение схемы БД, если изначально проектировать по пути/ключу, а не по прямому монтированию.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →