open atlas
↑ К треку
Разборы System Design SDC · 03 · 03

Спроектируй объектное хранилище (как S3)

Спроектируй S3: плоское пространство ключ→байты, где сервис метаданных отображает ключи в локации, а data plane хранит байты долговечно. Erasure coding даёт 11 девяток долговечности при ~1.5× накладных вместо 3× репликации, а multipart upload делает огромные объекты надёжными.

SDC Senior ◷ 35 min
Уровень
ОсновыJuniorMiddleSenior

Прошлые два урока опирались на «положи байты в объектное хранилище», будто это примитив. Не примитив — это распределённая система с одним из самых требовательных требований в области: одиннадцать девяток долговечности (99,999999999%), что значит — храня десять миллионов объектов, ты ожидаешь потерять примерно один из них раз в десять тысяч лет. Ни один диск и близко к этому не подходит; диски отказывают постоянно, тысячами, на масштабе флота. Так что вопрос дизайна жесток и конкретен: как взять commodity-диски, каждый из которых отказывает за пару лет, и собрать из них систему, где данные практически никогда не теряются — не платя за хранение каждого байта трижды? И как дать пользователю сделать PUT объекта на 5 ТБ по нестабильному соединению, не начиная с нуля, когда сеть моргнёт на 90%? Ответы — erasure coding и multipart upload, и они сидят поверх обманчиво простого на вид плоского пространства имён, что прячет огромное количество механики.

Требования

Прежде чем идти дальше, спроси себя: если одиннадцать девяток значит примерно один потерянный объект за десять тысяч лет на каждые десять миллионов хранимых, какой вид избыточности тебе реально нужен — и почему одна реплицированная копия не дотянет до этого дёшево? Ответы формируют каждое решение ниже. Функциональные — храни объект (непрозрачные байты) под ключом в бакете; верни его по ключу GET; DELETE; LIST ключей в бакете (часто по префиксу). Это почти весь API: PUT(bucket, key, bytes), GET(bucket, key), DELETE, LIST(prefix). Никаких частичных обновлений — объекты неизменяемы, ты заменяешь целиком. Multipart upload для крупных объектов. Этот минимализм намеренный: узкий интерфейс и позволяет реализации масштабироваться.

Нефункциональные — заголовок — долговечность: ~11 девяток, ведь клиенты строят весь свой бизнес на «мы не потеряем твои данные». Затем доступность (высокодоступные чтения/записи), масштабируемость (эксабайты, триллионы объектов, без практического лимита) и стоимость (это объёмное хранилище — каждый 1× накладных стоит состояние на масштабе эксабайт, так что платить 3× за репликацию всего больно). Задержка вторична — объектное хранилище ориентировано на пропускную и долговечность, а не низколатентная база.

Долговечность ~11 девяток с минимальными накладными по хранению — требование, что порождает весь интересный дизайн: это и есть причина, по которой erasure coding вообще в ответе.

Оценка

Масштаб:    триллионы объектов, эксабайты данных, без фиксированного потолка
Размер:     бимодальный — много крошечных объектов (КБ) + много огромных (ГБ–ТБ)
Долговечность: ~11 девяток (99,999999999%) → ~1 объект потерян на 1e7 хранимых за ~1e4 лет
Реальность дисков: годовая частота отказа диска ~1–4%; на масштабе флота тысячи/день
             → долговечность ОБЯЗАНА идти от избыточности по дискам/стойкам/ЦОД
Накладные:  3× репликация = +200% хранилища; erasure coding (напр. 10+4) ≈ +40%
             на масштабе эксабайт 1.5× vs 3× — разница между жизнеспособным и банкротством
Метаданные: ключ → {bucket, размер, карта локаций/шардов, версия, чексумма} ≈ сотни байт
             триллионы ключей → петабайты метаданных в своём шардированном хранилище

Строка стоимости — весь аргумент за erasure coding: на этом масштабе нельзя позволить 3× репликацию для холодных/крупных данных, так что разменивай немного CPU и задержку реконструкции на ~1.5× хранилище, что всё ещё бьёт 11 девяток. А метаданные сами по себе — крупная распределённая база: индекс, отображающий ключи в то, где живут байты.

Высокоуровневый дизайн

Раздели на сервис метаданных (ключ → локация, индекс) и data plane (само хранение байт по многим storage-узлам), за тиром API/балансировщика. PUT пишет байты в data plane (долговечно, через erasure coding или репликацию) и записывает отображение ключ→локация в метаданные; GET смотрит локацию в метаданных и стримит байты с data-узлов.

Пользователь видит плоское пространство имён: ключ вроде photos/2024/cat.jpgодна непрозрачная строка, не путь сквозь директории. Слеши — соглашение для префиксных LIST-запросов, но реальных папок нет — именно поэтому пространство масштабируется до триллионов ключей: нет дерева директорий для обхода или ребаланса, лишь гигантский сортированный индекс ключ→локация, разбиваемый по диапазону ключей или хешу по хранилищу метаданных.

Глубокое погружение: долговечность через erasure coding

Когда видишь «одиннадцать девяток» как требование, сразу переведи: один потерянный объект на десять миллионов хранимых за десять тысяч лет. Это число сразу говорит, что репликация тремя копиями — не единственный и не дешёвый путь. Чтобы пережить отказы дисков и машин, надо хранить избыточность. Очевидный путь — репликация: держи N полных копий (обычно 3), на разных стойках и в разных ЦОД; потеряй две копии — третья всё ещё обслуживает. Просто и даёт быстрые чтения, но стоит +200% хранилища — на масштабе эксабайт разорительно.

Erasure coding (кодирование с исправлением стираний — метод избыточности, где из фрагментов можно восстановить оригинал даже при потере некоторых) даёт ту же или лучшую долговечность куда дешевле. Разбей объект на k data-шардов, посчитай m parity-шардов (математика Рида-Соломона) и храни все k+m шардов на независимых узлах. Гарантия: ты можешь реконструировать оригинал из любых k из k+m шардов, так что терпишь потерю до m шардов. Частая схема — k=10, m=4: 14 шардов, переживают любые 4 отказа, при лишь 14/10 = 1.4× накладных по хранению — против для тройной репликации, при лучшей отказоустойчивости (4 одновременные потери vs 2). Разбросай шарды по стойкам, зонам и ЦОД — и получишь одиннадцать-девяток долговечность, какой требует требование, ведь вероятность потерять больше m шардов до завершения ремонта астрономически мала.

Почему это работает

Почему не все просто используют erasure coding для всего, раз он настолько дешевле? Потому что он разменивает хранилище на CPU и сложность чтения, и этот размен плох для малых горячих объектов. Реплицированный объект читается из любой одной копии одним дешёвым запросом; erasure-coded объект требует собрать k шардов с k разных узлов и прогнать реконструкцию — больше fan-out по I/O и CPU на чтение, и хуже хвостовая задержка. Для крошечного, часто читаемого объекта накладные на чтение затмевают экономию хранилища. Так что реальные системы тирят это: реплицируй малые и горячие данные (дешёвые чтения, скромная стоимость хранения, ведь объекты малы) и erasure-кодируй крупные и холодные (огромная экономия хранилища, где чтения редки, а объект достаточно велик, чтобы амортизировать fan-out). Согласовать схему избыточности с размером объекта и частотой доступа — сеньорское решение; «всегда erasure code» и «всегда реплицируй» оба неверны.

Глубокое погружение: сервис метаданных, согласованность и multipart upload

Сервис метаданных — индекс: он отображает каждый ключ в его бакет, размер, версию, чексумму и локации его шардов (какие узлы держат какой шард). Это крупная распределённая база сама по себе — триллионы ключей, разбитых по хешу или диапазону ключа — и это та часть, что нуждается в согласованности. Когда PUT завершается, коммит метаданных — момент, когда объект становится читаемым, и он должен быть атомарным: объект либо полностью проиндексирован (все шарды записаны), либо вовсе не виден, никогда не наполовину записан. Современный S3 предлагает сильную read-after-write согласованность, то есть GET сразу после успешного PUT всегда возвращает новый объект — достигнуто тем, что обновление метаданных делают точкой линеаризации и не подтверждают PUT, пока оно долговечно не записано.

Multipart upload решает проблему 5 ТБ-по-нестабильному-каналу. Клиент разбивает крупный объект на части, грузит каждую часть независимо (параллельно и с ретраем по отдельности) и наконец шлёт «complete»-вызов, перечисляющий части; сервис сшивает их в один объект. Если часть 37 отвалилась на 90%, ретраишь лишь часть 37, не все 5 ТБ. Части могут грузиться параллельно ради пропускной, а объект не виден, пока «complete» не соберёт его — так что multipart даёт надёжность (ретрай по части), пропускную (параллелизм) и атомарность (всё-или-ничего завершение) для огромных объектов, чего один монолитный PUT не может.

Частая ошибка

Классическое недопонимание — относиться к объектному хранилищу как к файловой системе: полагать, что LIST photos/ — дешёвый листинг директории, или что переименование a/ в b/ — атомарный флип метаданных. Ни то, ни другое не верно. Директорий нет — пространство плоское, а LIST с префиксом — это range-скан по индексу ключей, что нормально для умеренных префиксов, но патологично, если набить миллиарды ключей под один префикс (горячая партиция в хранилище метаданных). «Переименовать папку» значит скопировать каждый объект под новые ключи и удалить старые — O(число объектов), не O(1). А листинг в некоторых системах eventually consistent даже там, где GET сильный. Проектируй именование ключей так, чтобы размазать нагрузку по индексу (избегай последовательных/монотонных префиксов, что все бьют в одну партицию), и никогда не предполагай семантику файловой системы под капотом плоского пространства имён.

Викторина

Тебе нужно достичь ~11 девяток долговечности для эксабайт крупных холодных объектов при минимальной стоимости хранения. Репликация или erasure coding и почему?

Викторина

Клиент грузит объект на 5 ТБ по соединению, что временами рвётся. Наивный одиночный PUT всё время падает под конец. Какой верный механизм?

Закончи аналогию

Ключ вроде photos/2024/cat.jpg — одна непрозрачная строка, не путь сквозь реальные директории — слеши лишь соглашение для префиксных LIST-запросов. Поскольку дерева директорий для обхода нет, этот дизайн называют _______ пространством имён.

Узкие места и компромиссы

Определяющий компромисс — схема избыточности vs профиль объекта: реплицируй малые/горячие (дешёвые чтения, малый удар по хранилищу) и erasure-кодируй крупные/холодные (огромная экономия хранилища, дороже реконструкция чтений) — ошибись здесь и впустую тратишь либо деньги (переплачивая репликацией холодных данных), либо задержку (erasure-кодируя горячие крошечные объекты). Сервис метаданных — узкое место согласованности и горячая точка: он должен быть масштабируемым, сильно-согласованным индексом, а плохо выбранные ключи (монотонные префиксы) создают горячие партиции. Пропускная ремонта значит больше, чем кажется: долговечность предполагает, что отказавшие шарды перестраиваются до того, как откажет достаточно других, так что фоновая ре-репликация/ре-кодировка должна обгонять частоту отказов — одиннадцать девяток это утверждение о гонке между отказом и ремонтом. А плоское пространство имён — фича, не ограничение: отказываясь от семантики файловой системы, оно обходит проблемы масштабирования дерева директорий ценой дорогих префиксных операций, под которые надо проектировать ключи. Как и во всём этом юните, байты живут в долговечном data plane, а согласованность живёт в индексе метаданных.

Вспомните перед уходом
  1. 01
    Что такое плоское пространство имён и каков архитектурный раскол за ним?
  2. 02
    Как erasure coding даёт 11 девяток дешевле репликации и когда всё же реплицировать?
  3. 03
    Зачем multipart upload и где живёт согласованность?
Итог

«Положи в объектное хранилище» само по себе — трудная распределённая система. Интерфейс — намеренно минимальное плоское пространство ключ→байты (PUT/GET/DELETE/LIST-по-префиксу, неизменяемые объекты) — ключ это одна непрозрачная строка, не путь директории, именно поэтому оно масштабируется до триллионов ключей без дерева для ребаланса. Архитектура раскалывается на сервис метаданных (крупный, сильно-согласованный индекс, отображающий ключи в локации шардов — где живут согласованность и гарантия read-after-write) и data plane (storage-узлы, держащие байты долговечно). Заголовочное требование, ~11 девяток долговечности на commodity-дисках, что отказывают тысячами, выполняется размазыванием избыточности по независимым доменам отказа — а строка стоимости форсирует erasure coding (разбить на k data + m parity шардов, реконструировать из любых k, напр. 10+4 ради терпимости к 4 отказам при ~1.4× накладных) над 3× репликацией для крупных холодных данных, при этом всё же реплицируя малые горячие объекты, чей fan-out реконструкции бил бы больно. Multipart upload делает загрузки 5 ТБ надёжными через независимые, параллельные, ретраемые части, собираемые атомарным complete. Глубочайший инсайт: одиннадцать девяток — утверждение о гонке между отказом и ремонтом — фоновая ре-кодировка должна обгонять частоту отказов. И, как везде в этом юните, байты живут в долговечном data plane, а согласованность живёт в индексе метаданных. Теперь, когда будешь использовать «положи в объектное хранилище» как дизайн-примитив, ты знаешь, что за ним стоит: система, где долговечность — это гонка, холодные данные erasure-кодируются, а не реплицируются, и LIST — это range-скан, а не листинг директории.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.