Blob и объектное хранилище
Объектное хранилище (S3) держит байты по ключу за HTTP; блочное и файловое цепляются к машине. Долговечный паттерн — метаданные в БД, байты в объекте через presigned-ссылки, никогда блобы в БД. Долговечность и доступность — разные числа; lifecycle-тиринг управляет стоимостью.
Инженер хранил загруженные пользователями картинки как колонки BYTEA в Postgres — «на одну систему меньше, а транзакции держат строку и байты согласованными». Работало на десяти пользователях. На ста тысячах база была дымящимися руинами: каждый бэкап теперь тащил терабайты JPEG, буфер-кеш, который должен держать горячие страницы индекса, был забит байтами картинок, лаг репликации скакал, потому что каждый insert гнал мегабайты в WAL-поток, а одна страница товара веером тянула двадцать крупных блобов через пул соединений и душила все остальные запросы. Починкой была не база побольше. Это было осознание, что реляционный движок построен отвечать на вопросы о маленьких структурированных строках, и что просьба быть ещё и файловым сервером отравляет ту единственную вещь, в которой он хорош.
Три формы хранения: блок, файл, объект
Через десять минут ты поймёшь, почему блобы в базе ломаются структурно, какая из трёх форм хранения это решает — и что отвечать, когда кто-то предлагает грузить файлы прямо в Postgres.
Хранение бывает в трёх абстракциях, и они не взаимозаменяемы:
- Блочное хранилище (AWS EBS, сырой SSD/том) — отдаёт сырые блоки фиксированного размера; ОС кладёт сверху файловую систему. Цепляется к одной машине за раз, низкая задержка, на нём живут файлы данных твоей базы. Думай «диск».
- Файловое хранилище (NFS, AWS EFS) — общая иерархическая файловая система (каталоги, пути, POSIX-семантика), которую несколько машин монтируют разом. Думай «сетевой диск».
- Объектное хранилище (AWS S3, Cloudflare R2, Google Cloud Storage) — плоское пространство имён объектов: каждый — непрозрачный блоб плюс метаданные, адресуемый ключом (строкой вроде
users/42/avatar.jpg), читается и пишется по HTTP. Нет файловой системы, нет частичной правки на месте — тыPUT-ишь объект целиком иGET-ишь объект целиком (или диапазон байтов). Думай «бесконечно большое, долговечное HTTP key-value хранилище для файлов».
Определяющее свойство объектного хранилища — оно не привязано к машине. Это сервис, который ты вызываешь по сети, он масштабируется до практически безграничной ёмкости, и провайдер сам реплицирует каждый объект по множеству дисков и площадок. Ровно это делает его правильным домом для пользовательских загрузок, логов, бэкапов, артефактов сборки и статики — «больших, в основном неизменяемых байтов» системы. Когда видишь вопрос «где хранить эти файлы?», блок, файл и объект дают три совершенно разных ответа — и выбор не того варианта приводит ровно к инженеру из вступления.
Почему блобы никогда не кладут в базу
Ошибка из вступления — одна из самых частых в junior backend-дизайне, и причины её провала структурны, а не случайны:
- Бэкапы и восстановления раздуваются. Бэкап базы теперь включает каждый байт каждого файла. Схема в 5 ГБ становится бэкапом в 5 ТБ, восстановление длится часами, а point-in-time recovery становится непрактичным — ровно когда он нужен больше всего.
- Буфер-кеш отравлен. База кеширует горячие страницы в RAM, чтобы запросы были быстрыми. Крупные блобы вытесняют страницы индекса и строк, которые реально важны, так что твои структурированные запросы тормозят, потому что кеш забит байтами картинок, читаемыми один раз.
- Раздувание репликации и WAL. Каждый insert/update гонит блоб в поток репликации, взвинчивая лаг и трафик. Картинка в мегабайт становится мегабайтом WAL (write-ahead log — журнал предзаписи, хранит каждое изменение перед применением) на запись.
- Голодание пула соединений. Стриминг крупного блоба держит драгоценное соединение к БД на всю передачу; горстка параллельных скачиваний исчерпывает пул (вспомни 04-data-distribution — stateful-уровень там, где масштабирование болит).
Правило прямое: базы — для маленьких, структурированных, часто запрашиваемых данных; объектные хранилища — для крупных непрозрачных байтов. Их смешение делает базу плохой в её работе. Немногие исключения (крошечные блобы до нескольких КБ или настоящая нужда в транзакционной согласованности строки и байтов) реальны, но узки — и даже тогда сначала измеряют.
Долговечный паттерн: метаданные в БД, байты в объектном хранилище
Паттерн, к которому сходится каждая зрелая система, чисто разделяет две вещи. Байты живут в объектном хранилище под ключом. Метаданные — владелец, имя файла, content-type, размер, ключ объекта, метка времени загрузки, поле status — живут маленькой строкой в твоей реляционной базе, где их дёшево запрашивать, джойнить и проверять права. Строка указывает на объект; она его не содержит.
Это даёт лучшее из обоих: можно гнать SELECT * FROM files WHERE owner_id = 42 ORDER BY created_at по быстрой индексированной таблице, а потом вручить клиенту ключ объекта, чтобы он забрал байты напрямую. Твой запрос «покажи мои фото» не касается ни единого байта картинки. Контроль доступа, поиск и связи живут в базе, где им место; твои терабайты байтов живут там, где место терабайтам.
▸Почему это работает
Почему presigned-ссылки тут так важны? Наивно каждое скачивание течёт сквозь твой сервер приложения: клиент → приложение → объектное хранилище → приложение → клиент. Это делает твой stateless-уровень узким местом по пропускной способности и центром затрат, проксирующим терабайты, которых ему и видеть не надо. Presigned-ссылка ломает это: твоё приложение, используя свои креды, генерирует подписанную ссылку с ограниченным временем жизни, дающую держателю право на GET (или PUT) одного конкретного объекта прямо из объектного хранилища, и отдаёт эту ссылку клиенту. Байты текут клиент ↔ объектное хранилище, никогда через твои серверы. Загрузки работают так же в обратную сторону — браузер PUT-ит прямо в S3/R2 по presigned-ссылке, так что загрузка видео в 2 ГБ ни разу не занимает сервер приложения или соединение к БД. Подпись кодирует объект, операцию и срок, так что грант узкий и короткоживущий. Так ты отдаёшь безграничные файлы с маленького stateless-флота.
Долговечность против доступности: разные «девятки»
Объектные хранилища рекламируют зашкаливающую долговечность (durability) — S3 спроектирован на 99,999999999% (одиннадцать девяток) годовой долговечности объектов. Люди путают это с доступностью (availability), но это разные гарантии о разных сбоях:
- Долговечность — вероятность того, что твои байты всё ещё существуют и не повреждены — провайдер достигает её, реплицируя каждый объект по множеству дисков и площадок (идея из 04-data-distribution/01-replication, применённая к байтам). Одиннадцать девяток означают, что статистически ты потеряешь один объект из десятков миллионов за миллионы лет.
- Доступность — вероятность того, что ты достанешь байты прямо сейчас — обычно число пониже (например, ~99,9%), потому что сбой региона может сделать совершенно долговечные данные временно недостижимыми.
Следствие для дизайна: долговечность защищает от потери данных; она не защищает от падения региона. Если тебе нужно читать байты во время сбоя региона, нужна межрегиональная репликация поверх встроенной долговечности — отдельный платный выбор. Не читай «одиннадцать девяток» как «всегда достижимо».
▸Частая ошибка
Тонкая дорогая ловушка: забыть, что объектное хранилище берёт деньги за операции и egress, а не только за хранимые байты — и что листинг не бесплатен и не мгновенен. Команды проектируют систему, которая делает LIST по бакету на каждый запрос, чтобы «найти файлы пользователя», и потом обнаруживают, что листинг миллионов ключей медленный и тарифицируется, а объектные хранилища — не базы: у них нет вторичных индексов, нет WHERE, нет джойнов. Починка — паттерн выше: никогда не спрашивай у объектного хранилища, какие объекты существуют или кто ими владеет — для этого есть таблица метаданных. Используй объектное хранилище только чтобы читать/писать байты ключа, который ты уже знаешь. Если ты листишь или сканируешь бакет, чтобы ответить на вопрос, ты положил логику запросов не на тот уровень.
Lifecycle и тиринг: платить меньше за холодные байты
Не все байты одинаково горячи. Фото смотрят постоянно первую неделю, потом почти никогда; лог запрашивают месяц, потом держат лишь ради комплаенса. Объектные хранилища выставляют классы хранения (storage classes) в разных точках цена/задержка — горячий стандартный тир, тиры нечастого доступа и глубокие холодные архивные тиры, которые куда дешевле за ГБ, но берут больше (и добавляют задержку, иногда минуты или часы) за извлечение. Lifecycle-политика автоматизирует переезд: «объекты старше 30 дней → нечастый доступ; старше 365 дней → архив; старше 7 лет → удалить». Ты объявляешь правило один раз, и провайдер двигает байты между тирами по расписанию, так что стоимость следует за тем, насколько данные реально холодны. Senior-ход — задавать lifecycle-правила на этапе дизайна: хранилище, которое только дорожает, — медленная течь, а сюрпризы извлечения из архива («нам нужны эти 2 ТБ сегодня») — провал планирования, а не платформы.
Ты строишь видеоплатформу. Пользователи грузят многогигабайтные файлы и потом стримят их. Где живут байты и как клиенты двигают их, не расплавив твой уровень приложения?
Коллега говорит: «S3 долговечен на одиннадцать девяток, так что наши картинки не могут быть проблемой при сбое». В чём точная поправка?
Чтобы загрузить крупный файл так, чтобы он ни разу не прошёл через твои stateless-серверы приложения, приложение генерирует короткоживущую подписанную _______ ссылку, дающую клиенту право записать один конкретный объект прямо в хранилище.
- 01Различи блочное, файловое и объектное хранилище по модели доступа.
- 02Почему блобы никогда не кладут в базу и какой паттерн правильный?
- 03Сопоставь долговечность и доступность и объясни lifecycle-тиринг.
Хранение бывает в трёх формах: блочное (диск, одна машина, на нём файлы БД), файловое (общий сетевой диск) и объектное (S3/R2/GCS — непрозрачные блобы по ключу за HTTP, машинонезависимо, реплицировано провайдером, практически безгранично). Объектное хранилище — дом для крупных, в основном неизменяемых байтов: загрузки, логи, бэкапы, статика. Блобы никогда не кладут в базу: это раздувает бэкапы, отравляет буфер-кеш, раздувает WAL/репликацию и душит пул соединений, потому что реляционный движок построен для маленьких структурированных строк, а не для файлового сервинга. Паттерн, к которому сходится каждая зрелая система, — метаданные в БД, байты в объектном хранилище: маленькая индексированная строка указывает на ключ объекта, так что логика листинга/запроса/прав не касается байтов, а presigned-ссылки дают клиентам читать и писать байты напрямую против хранилища, так что stateless-уровень приложения никогда не становится узким местом по пропускной способности. Наконец, долговечность ≠ доступность — одиннадцать девяток значат, что байты не потеряются, а не что регион не может погаснуть (для этого — межрегиональная репликация) — а lifecycle-тиринг автоматически двигает холодные байты в дешёвые классы, чтобы стоимость следовала за реальной холодностью данных. Теперь, когда увидишь в код-ревью колонку BYTEA (бинарные данные переменной длины) с пользовательскими загрузками, у тебя есть ровно четыре аргумента, почему это сломает прод — и один паттерн, который решает все четыре сразу.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.