MAATRIX / Блог / Антипаттерн: хранить картинки и файлы в базе данных

Антипаттерн: хранить картинки и файлы в базе данных

MAATRIX

Когда в проекте появляется загрузка аватарок, документов или вложений, самое простое решение выглядит очевидным: добавить колонку BYTEA или LONGBLOB и сохранить файл прямо в строке таблицы рядом с остальными данными. Никакой отдельной инфраструктуры, один бэкап на всё, никаких прав доступа к файловой системе. Через полгода-год база весит на порядок больше, чем должна, ночной дамп не укладывается в окно, а один эндпоинт со списком фотографий кладёт пул соединений. Разберём, почему хранение бинарных файлов в БД — это ловушка с отложенным сроком, и как перенести файлы туда, где им место.

Почему к этому вообще приходят

Идея хранить файлы в БД не глупая — у неё есть реальные причины появляться на старте проекта:

  • Транзакционность из коробки. Файл и запись метаданных о нём создаются и удаляются в одной транзакции — нет окна, где файл уже есть, а запись о нём ещё нет (или наоборот).
  • Один источник бэкапа. Не нужно следить, что дамп БД и снапшот файлового хранилища сделаны синхронно, в одну и ту же секунду.
  • Простая модель доступа. Права на файл регулируются той же логикой (и тем же 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:

  1. Добавьте новые колонки (storage_key, оставив старую BLOB-колонку нетронутой) — это безопасная, обратно совместимая миграция.
  2. Напишите скрипт переноса батчами, который читает старые записи, выгружает содержимое в хранилище и проставляет 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)  # не душить диск и реплики
  1. Переключите приложение на чтение по storage_key, оставив старый путь чтения BLOB как fallback на время наблюдения.
  2. После подтверждения, что всё перенесено и работает (сверка количества строк, контрольных сумм, мониторинг ошибок отдачи) — уберите старую колонку: ALTER TABLE attachments DROP COLUMN blob_data;.
  3. Верните место на диске. Удаление колонки само по себе не сжимает файл таблицы — потребуется 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 ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →