Аватары, документы, чеки, экспорты — файлы появляются в каждом сервисе рано или поздно. Положить их «пока в базу» — это самое простое: транзакция, бэкап и доступ из коробки. Это даже правильный выбор — но только до определённой черты. Разберём, где эта черта и что меняется за ней.
Когда строка перестаёт умещаться примерно в два килобайта, PostgreSQL сжимает её длинные значения и уводит в TOAST — служебную таблицу, которая принадлежит той же таблице: её тащит каждый дамп, а чтение колонки собирает куски обратно в память. В object storage в строке остаются бакет и ключ, несколько десятков байт: дамп базы худеет, а байты клиент забирает по временной ссылке напрямую из хранилища. Для файла в пару килобайт выгода обратная — одна транзакция и один бэкап дешевле двух систем.
Хранение в базе данных
Дамп базы вырос до 300 ГБ и восстанавливается часами, а самих данных в таблицах на десять гигабайт: остальное файлы, которые когда-то положили в колонку типа bytea прямо рядом с остальными данными строки. Плюс очевидный: файл живёт в транзакции. Загрузили запись вместе с файлом — она либо полностью есть, либо полностью нет. Удалили запись — файл ушёл автоматически. Бэкап базы включает и файлы.
CREATE TABLE contracts (
id bigserial PRIMARY KEY,
title text NOT NULL,
signed_at timestamptz,
document bytea -- сам файл здесь
);
Подходит, когда файлы маленькие (десятки-сотни килобайт) и нужна строгая атомарность — например, подпись или сертификат, без которого запись теряет смысл.
Проблема начинается с размером. Строка целиком должна умещаться примерно в два килобайта, поэтому длинное значение PostgreSQL сначала сжимает, а если оно всё равно не влезает — режет на куски и уводит в TOAST, служебную таблицу при этой же таблице. Место эти куски занимают там же, в дамп попадают там же, а чтение колонки собирает их обратно в память.
Про «сначала сжимает» есть важная оговорка: ровно те файлы, ради которых всё и затевается, сжиматься отказываются. PDF, JPEG, PNG, ZIP, видео — внутри уже сжаты, и встроенный упаковщик PostgreSQL выигрывает на них проценты, а иногда и вовсе ничего. Так что двухмегабайтный чек и в TOAST будет занимать свои два мегабайта. Сжатие помогает на длинном тексте и JSON, но не на готовых форматах файлов. Больше гигабайта в bytea не положить вовсе. Формально в PostgreSQL есть и второй механизм — «большие объекты», которые умеют читаться кусками; но у них своё хранилище, свои права доступа и своя уборка, и в прикладном коде их почти не используют. Строка с 50-мегабайтным PDF делает таблицу неуправляемой: каждый дамп тащит гигабайты неизменяемых файлов, восстановление растягивается на часы, а параллельные скачивания приводят к нехватке памяти.
Хранение в object storage
Object storage (Amazon S3, MinIO, Yandex Object Storage) — это «бесконечная полка» для файлов. Каждый файл — отдельный объект под ключом. В PostgreSQL хранится только строка с метаданными и ключом объекта; сам файл — в хранилище.
Отдача файла клиенту выглядит иначе: сервис выдаёт клиенту presigned URL — временную подписанную ссылку, по которой клиент скачивает файл напрямую из хранилища, минуя сервис. Пул соединений, память приложения и сетевой стек сервиса в передаче байт не участвуют.
Цена: пара «строка в базе + объект в хранилище» — две разные системы. Транзакция базы не знает про файл; если что-то пошло не так, надо согласовывать руками.
Как выбрать
Шесть вопросов. Каждое «да» — аргумент в пользу object storage:
- Типичный файл больше ~1 МБ? Мегабайты в
byteaраздувают таблицу и бэкапы. - Файлы займут больше четверти объёма базы? Бэкап базы не должен тащить неизменяемые PDF-ы.
- Файлы отдаются пользователям регулярно? Каждая отдача через сервис — нагрузка на пул соединений и память.
- Нужен жизненный цикл: архивировать, автоматически удалять? В object storage это декларативная политика; в базе — самописные задания.
- Файлы обрабатываются: ресайз, конвертация, антивирус? Конвейеры обработки строятся вокруг object storage естественным образом.
- К файлам ходят другие сервисы или внешние партнёры? Presigned URL и bucket policy выражают это штатно, без проксирования.
0–1 ответов «да» — bytea рядом с метаданными, и не усложняйте.
2 и более — object storage.
Первые два вопроса считаются заранее. Возьмём заказы песочницы и прикинем, во что превратится таблица, если к каждому приложить чек на 2 МБ, — и сколько та же таблица весит, если в ней лежит только ключ объекта:
живой пример
WITH receipts AS (
SELECT count(*) AS n FROM orders
)
SELECT n AS orders,
pg_size_pretty(n * 2 * 1024 * 1024) AS bytea_column,
pg_size_pretty(n * 80) AS object_keys
FROM receipts;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Разница между двумя колонками ответа — это и есть разница в размере дампа и во времени восстановления.
Третий вариант, который встречается чаще всех: диск рядом с приложением
Прежде чем сравнивать базу и объектное хранилище, стоит закрыть вариант, который выбирают «по умолчанию»: складывать файлы в каталог на диске сервера приложения (или на сетевой диск, смонтированный к нему).
Работает это ровно до первого настоящего события. Выкатили новую версию в контейнере — каталог уехал вместе со старым контейнером, файлы исчезли. Подняли второй экземпляр приложения — половина запросов приходит на узел, где файла нет. Умер узел — файлы умерли с ним, потому что резервное копирование настроено на базу, а не на этот каталог. Сетевой диск снимает часть проблем и добавляет свои: он становится единой точкой отказа, его пропускная способность делится между всеми узлами, а права и блокировки при одновременной записи — отдельная тема.
Поэтому правильных вариантов остаётся два — база или объектное хранилище, — а диск рядом с приложением годится только для настоящих временных файлов: распакованный архив на время обработки, буфер загрузки перед отправкой в хранилище.
CDN перед бакетом
Если файлы регулярно отдаются пользователям — картинки товаров, аватары, документы, — то между хранилищем и браузером почти всегда ставят сеть доставки содержимого (CDN). Причин три, и все практические.
Первая: близость. Узел сети доставки стоит рядом с пользователем, и картинка приходит из его города, а не из региона, где живёт бакет. Вторая: деньги. Исходящий трафик из объектного хранилища обычно стоит заметно дороже трафика с узлов сети доставки, а закешированный файл не тарифицируется повторно; на публичных картинках это основная статья экономии. Третья: нагрузка. Бакет перестаёт получать миллион запросов на один и тот же логотип.
Три вещи, которые к этому прилагаются. Бакет закрывают от прямого доступа и разрешают читать только сети доставки (в облаках это делается отдельной идентичностью для доступа к источнику), иначе ссылку на источник можно получить и обойти кеш вместе с тарифом. Ключ файла делают неизменяемым: новая версия картинки — новое имя, тогда кеш можно держать вечно и не думать о сбросе; менять файл по тому же ключу означает разбираться со сбросом кеша на всех узлах. И для приватных файлов сеть доставки настраивают с подписанными ссылками — иначе она раздаст закрытые документы всем.
Классы хранения и жизненный цикл
«Архивировать и автоматически удалять» — это правило жизненного цикла плюс выбор класса хранения. Классы различаются ценой хранения и ценой доступа, и у них обратная зависимость.
Горячий (стандартный) — дороже всего за гигабайт, бесплатно и мгновенно читается. Для файлов, которые нужны постоянно.
Тёплый (редкий доступ) — примерно вдвое дешевле за хранение, но чтение уже тарифицируется, и есть минимальный срок хранения (обычно месяц): удалить раньше — заплатить как за месяц. Для того, что читают раз в несколько месяцев.
Холодный и архивный — дешевле горячего в разы (иногда на порядок), зато данные надо «доставать»: восстановление занимает от минут до часов, стоит отдельных денег, а минимальный срок хранения месяцы. Для того, что по закону нельзя удалить, но читать не планируют: чеки семилетней давности, журналы для аудита.
Правило жизненного цикла описывается в бакете и работает само:
файлы с префиксом invoices/ : через 90 дней → тёплый, через 365 → архивный, через 7 лет → удалить
незавершённые составные загрузки : удалять через 7 дней
предыдущие версии объектов : удалять через 30 дней
Последние два пункта пропускают чаще всего, и именно они дают счёт, который никто не может объяснить: брошенные части многочастевых загрузок и старые версии объектов при включённом версионировании продолжают храниться и тарифицироваться, хотя в списке файлов их не видно.
Практический порядок: сначала посчитать, сколько данных и как часто их читают, потом выбрать класс, потом описать правило — а не наоборот.
Двухфазная загрузка файлов
Когда используют object storage, загрузку разбивают на две фазы — «договорились» и «подтвердили», — чтобы избежать ситуации «файл есть, а записи нет» (или наоборот). Со стороны клиента это выглядит как три шага, потому что между двумя обращениями к сервису он ещё и сам кладёт файл в хранилище.
Шаг 1. Сервис создаёт запись в базе со статусом PENDING и выдаёт клиенту presigned URL на загрузку в хранилище.
Шаг 2. Клиент загружает файл напрямую в хранилище.
Шаг 3. Клиент сообщает сервису «загрузил». Сервис проверяет объект и переводит запись в статус ACTIVE.
Подтверждение переводит запись в ACTIVE, а два нижних состояния - тупики: их снимает фоновая уборка.
На этой проверке стоит задержаться: размера и типа для неё мало. Файл мог доехать наполовину, могла побиться сеть, клиент мог подсунуть не то. Поэтому вместе с размером сверяют контрольную сумму: клиент считает её у себя (хранилища принимают её при загрузке — например, заголовком с SHA-256), а сервис при подтверждении сравнивает с тем, что вернуло хранилище. Есть и более простой ориентир — ETag объекта: для файла, загруженного одним куском, это просто MD5 содержимого. Но у файла, загруженного частями, ETag считается иначе, и наивное сравнение с MD5 исходника не сойдётся — на этом обжигаются регулярно.
Кстати о частях. Файл в несколько мегабайт ещё кладут одним запросом, а вот десятки и сотни мегабайт грузят частями (multipart upload): хранилище принимает куски по отдельности, а в конце собирает их в один объект. Плюсы понятны — оборвалась сеть, и переотправить надо кусок, а не весь файл. Но и подписанная ссылка тут устроена иначе: подписывают не одну загрузку, а каждую часть отдельно, плюс отдельно старт и завершение сборки. То есть шаг 1 из «выдать одну ссылку» превращается в небольшой диалог, и это закладывают в схему заранее, а не когда пользователи начнут грузить видео.
Записи, зависшие в PENDING, и объекты без записей — «осиротевшие объекты» — подчищает фоновое задание. Несогласованность здесь не исключается полностью, а ограничивается и убирается периодически.
Удаление работает зеркально: запись помечается удалённой в транзакции, а объект удаляет асинхронный обработчик (по схеме outbox), потому что «удалить из S3» нельзя откатить вместе с транзакцией базы.
Срок жизни и безопасность подписанной ссылки
Подписанная ссылка — это сама авторизация: кто угодно, у кого есть эта строка, может выполнить действие до истечения срока. Отсюда несколько решений, которые принимают сразу.
Срок жизни. Для загрузки достаточно минут (пользователь нажал «выбрать файл» — ссылку получил — загрузил). Для скачивания небольшого файла — тоже минуты. Большой файл на медленном канале — отдельный случай: если срок кончится посередине, загрузка сорвётся, поэтому под большие файлы дают больше времени (десятки минут) или используют составную загрузку по частям, где на каждую часть своя ссылка. Сутками не подписывают никогда: такую ссылку успеют переслать и проиндексировать.
Ссылку можно переслать — и это дыра в доступе, про которую надо знать: пользователь копирует адрес из браузера и отправляет в чат, а по нему открывается закрытый документ. Лечится не запретом, а коротким сроком и тем, что ссылка выдаётся на конкретное действие; для совсем чувствительных файлов отдают не ссылку, а поток через своё приложение, которое проверяет права на каждый запрос.
Что можно ограничить в самой подписи. Подпись покрывает не только ключ и срок, но и условия: максимальный размер (content-length-range), тип содержимого, обязательные заголовки. Это важно, потому что иначе клиент, получивший ссылку на загрузку, может положить по этому ключу файл на десять гигабайт или подменить тип: image/png в запросе, а внутри исполняемый файл. Тип содержимого нельзя брать на веру — его проверяют по первым байтам файла на стороне сервера, а не по заголовку.
Антивирус до перевода в рабочее состояние. Файл, загруженный пользователем, до проверки не должен быть доступен никому, кроме проверяющего: он лежит в отдельном бакете или с префиксом «на проверке», и только после проверки переезжает (или помечается) как рабочий. Порядок «сначала показали всем, потом проверили» — это способ раздать заражённый файл своим же пользователям.
Большие объекты в PostgreSQL: зачем они существуют
Про них сказано, что их «почти не используют», и это верно, но стоит назвать то, ради чего они есть. Обычная колонка (bytea) читается и пишется целиком: строка на сто мегабайт означает сто мегабайт в памяти приложения и столько же в памяти базы. Большие объекты (интерфейс lo_*) умеют потоковое чтение и запись кусками: можно прочитать байты с миллионного по миллион-первый, не поднимая весь файл в память.
То есть это ровно лекарство от «параллельные скачивания приводят к нехватке памяти» — и объясняет, почему совет «не храните файлы в базе» точнее звучит как «не храните файлы в bytea». Цена у больших объектов своя: они живут в отдельной системной таблице, не удаляются автоматически вместе со строкой (нужен триггер или vacuumlo), не работают через обычные средства выгрузки так же просто и не поддерживаются частью драйверов. Поэтому правильный вывод не «берите большие объекты», а «если файлы всё-таки в базе, то с потоковым доступом; а лучше — в объектном хранилище, которое умеет отдавать диапазоны байт и не занимает память приложения вовсе».
Типичные ошибки
Большие файлы сразу в bytea. Поначалу незаметно, потом — бэкап на несколько часов и нехватка памяти при параллельных скачиваниях.
Отдача файлов через сервис при живом S3. Контроллер, который считывает файл из хранилища и переливает его клиенту, добавляет лишние задержки и нагрузку на пул. Правильно: выдать presigned URL, байты пусть идут напрямую.
Загрузка файла внутри транзакции базы. Если транзакция откатилась, файл в S3 уже не вернуть — образуется объект-сирота. Файловые операции не участвуют в транзакции базы; поэтому двухфазный протокол выглядит именно так.
«Голый» URL в базе. Если хранить только URL, невозможно понять, чей это объект и можно ли его удалять. Храните ключ объекта, имя бакета и статус — URL для отдачи генерируется из них каждый раз.
Публичный бакет «чтобы проще». Постоянные прямые ссылки вместо presigned URL — до первого сканера, нашедшего перебором чужие документы.
Когда разумно сочетать оба варианта
Почти всегда можно сочетать. Маленькие служебные файлы — подпись, ключ, миниатюра в несколько килобайт — живут в базе. Пользовательские файлы — в object storage. Границу проводят по типу файла и его размеру, а не одним решением для всего сервиса.
Глубже: S3 как хранилище таблиц: Parquet, Iceberg и озеро данныхрасширенное
Выше объектное хранилище выглядит полкой для файлов пользователя. В аналитическом контуре у него другая роль: там лежат таблицы, и это самое дешёвое место для них.
Всё начинается с формата файла. CSV и JSON читаются целиком, а аналитический запрос обычно трогает три колонки из пятидесяти и один месяц из пяти лет. Parquet хранит данные по колонкам, группами строк, и для каждой группы держит статистику: минимум и максимум каждой колонки. Движок читает только нужные колонки и пропускает группы, где по статистике нужных значений быть не может. Файл на гигабайт JSON в Parquet весит сто мегабайт, а запрос по двум колонкам читает из них десять. ORC делает то же самое и живёт в мире Hive; для новых систем берут Parquet.
Одних файлов мало: набор из тысячи Parquet-файлов в каталоге s3://lake/orders/2026/09/ не знает, какие из них уже дописаны, а какие в процессе, и не переживёт смену схемы. Это решают табличные форматы: Iceberg, Delta Lake, Hudi. Каждый ведёт поверх файлов журнал снимков: какие файлы входят в таблицу на такой-то момент, какая у неё схема, как она секционирована. Из этого получаются атомарное добавление данных (снимок либо опубликован, либо нет), эволюция схемы без перезаписи файлов, чтение «как было вчера» и смена схемы секционирования без переливки. Iceberg стал общим знаменателем: его читают Spark, Trino, ClickHouse, DuckDB и облачные склады.
ClickHouse читает такие данные напрямую, без загрузки в свои таблицы:
CREATE TABLE lake_orders ENGINE = Iceberg('https://s3.example.com/lake/orders/', 'key', 'secret');
SELECT toStartOfMonth(created_at) AS m, sum(total) FROM lake_orders GROUP BY m;
Так S3 становится общей полкой для нескольких движков, а ClickHouse со своим MergeTree остаётся там, где нужны миллисекунды. Хранение в S3 стоит в разы дешевле дисков узлов, и данные за пять лет держат именно там, а горячий год в ClickHouse.
Расклад слоёв в таком контуре называют озером данных: сырой слой с событиями как они пришли (его не меняют никогда), очищенный слой с типами и дедупликацией, витрины под конкретные отчёты. Сырой слой это страховка: любую витрину можно пересчитать заново, о чём говорит статья про производные данные. Для сервиса это означает одно: события уходят в Kafka или в S3 файлами по часу, а не «сразу в витрину», и аналитики строят слои сами.
Коротко
byteaв PostgreSQL — разумный выбор для маленьких файлов (до сотен килобайт), где нужна строгая транзакционность.- Как только строка перестаёт умещаться примерно в 2 КБ, длинные значения уезжают в TOAST при той же таблице: их тащат и дамп, и восстановление, и чтение колонки. На PDF, JPEG и ZIP встроенное сжатие почти ничего не даёт — они уже сжаты.
- Object storage подходит, когда файлы мегабайты и больше, регулярно отдаются пользователям или требуют жизненного цикла; диск рядом с приложением не вариант — он не переживает выката и отказа узла.
- Подписанная ссылка отдаёт файл напрямую из хранилища и является авторизацией: срок в минутах, ограничение размера и типа в самой подписи, проверка содержимого по байтам и антивирус до того, как файл станет доступен.
- Двухфазный протокол загрузки (PENDING → ACTIVE) защищает от рассогласования метаданных и объекта, а удаление объекта делают асинхронно — транзакция базы не знает про S3.
- При подтверждении сверяют не только размер и тип, но и контрольную сумму; большие файлы грузят частями (multipart), и подписанные ссылки для этого устроены иначе.
- В базе храните ключ объекта и статус, а не готовый URL; в одном сервисе оба способа обычно сочетаются — служебные мелочи в базе, пользовательские файлы в хранилище.
- В аналитике S3 хранит таблицы: Parquet по колонкам со статистикой групп, Iceberg или Delta ведут снимки и схему поверх файлов, ClickHouse читает их напрямую; сырой слой не меняют, витрины пересчитывают.
- Файлы пользователям отдают через сеть доставки с закрытым от прямого доступа бакетом и неизменяемыми ключами — это и близость, и экономия на трафике.
- Классы хранения выбирают по частоте чтения (у тёплых и холодных есть плата за доступ и минимальный срок), а правило жизненного цикла обязано убирать незавершённые загрузки и старые версии.
Что почитать дальше
- Что такое object storage: bucket, объект, ключ, классы хранения и presigned URL — устройство S3 с нуля.
- Spring + AWS SDK v2 для S3: интеграция, паттерны, MinIO — загрузка, подтверждение, MinIO в тестах на примере Java; для Go, Node и Python те же шаги делают
aws-sdk-go-v2,@aws-sdk/client-s3иboto3. - Распределённые паттерны — outbox и согласованность двух систем в общем виде.
- S3 в production: резервные копии, репликация, стоимость и мониторинг — сколько стоит хранить и отдавать файлы.