Предисловие
Мы рады, что вы решили присоединиться к нам в изучении системного дизайна для собеседований. Вопросы по системному дизайну — самые сложные среди всех технических собеседований. Они требуют от интервьюируемого спроектировать архитектуру программной системы: ленту новостей, поиск Google, систему чата и т.д. Эти вопросы пугают, и универсального шаблона для них не существует. Вопросы, как правило, очень широки по охвату и размыты по формулировке. Процесс открытый и неоднозначный — правильного или единственно верного ответа нет.
Компании повсеместно используют интервью по системному дизайну, потому что навыки коммуникации и решения задач, проверяемые на таких собеседованиях, схожи с теми, что нужны инженеру в повседневной работе. Интервьюируемого оценивают по тому, как он анализирует расплывчатую задачу и шаг за шагом решает её. Также проверяется умение объяснять идеи, дискутировать, оценивать и оптимизировать систему.
Вопросы по системному дизайну открытые. Как и в реальном мире, систем и подходов к их решению существует множество. Цель — предложить архитектуру, достигающую поставленных целей. Обсуждение может развиваться в разных направлениях в зависимости от интервьюера. Одни предпочитают высокоуровневую архитектуру, охватывающую все аспекты; другие фокусируются на одной-двух областях. Как правило, необходимо чётко понять требования, ограничения и узкие места системы.
Цель этого курса — дать надёжную стратегию подхода к вопросам системного дизайна. Правильная стратегия и знания — залог успеха на собеседовании.
Курс предоставляет глубокие знания по построению масштабируемых систем. Чем больше вы усвоите из этого курса, тем лучше будете подготовлены к вопросам системного дизайна.
Курс также предлагает пошаговую методологию работы с вопросами системного дизайна, иллюстрирует системный подход на множестве примеров с детальными шагами. При регулярной практике вы будете во всеоружии на собеседованиях по системному дизайну.
Присоединяйтесь к сообществу
Мы создали закрытую группу в Discord для участников курса. Она предназначена для обсуждения следующих тем:
- Основы системного дизайна. - Публикация диаграмм дизайна и получение обратной связи. - Поиск партнёров для практических мок-собеседований. - Общий чат с участниками сообщества.
Присоединяйтесь и представьтесь сообществу уже сегодня!
Масштабирование от нуля до миллионов пользователей
Проектирование системы для миллионов пользователей — сложная задача, требующая непрерывного совершенствования. В этой главе мы строим систему для одного пользователя и постепенно масштабируем её до миллионов. После прочтения вы освоите ключевые техники, которые помогут на собеседованиях по системному дизайну.
Один сервер
Путь в тысячу миль начинается с одного шага — построение сложной системы не исключение. Для начала всё работает на одном сервере: веб-приложение, база данных, кэш и т.д. (Рисунок 1).

Поток запросов и источники трафика
Разберём поток запросов (Рисунок 2):
1. Пользователи обращаются к сайту по доменному имени (api.mysite.com). DNS — платный сервис сторонних провайдеров. 2. Браузер или мобильное приложение получает IP-адрес (например, 15.125.23.214). 3. HTTP-запросы отправляются напрямую на веб-сервер. 4. Веб-сервер возвращает HTML-страницы или JSON-ответы.
Источники трафика: - **Веб-приложение:** серверные языки (Java, Python и др.) для бизнес-логики + клиентские (HTML, JavaScript) для отображения. - **Мобильное приложение:** HTTP-протокол для связи с сервером. JSON — стандартный формат ответа API.
``` GET /users/12 – Получить объект пользователя с id = 12
{ "id": 12, "firstName": "John", "lastName": "Smith", ... } ```

База данных
С ростом аудитории одного сервера недостаточно. Нужны отдельные серверы: для веб/мобильного трафика (веб-уровень) и для базы данных (уровень данных) (Рисунок 3). Разделение уровней позволяет масштабировать их независимо.

Какую базу данных выбрать?
**Реляционные БД (RDBMS/SQL)** — MySQL, Oracle, PostgreSQL. Хранят данные в таблицах со строками, поддерживают JOIN-операции.
**Нереляционные БД (NoSQL)** — CouchDB, Neo4j, Cassandra, HBase, Amazon DynamoDB. Делятся на 4 категории: хранилища «ключ-значение», графовые, колоночные, документные. Как правило, JOIN не поддерживают.
Для большинства разработчиков реляционные БД — лучший выбор: более 40 лет проверенной практики. Однако NoSQL может подойти если: - Требуется сверхнизкая задержка. - Данные неструктурированы или нет реляционных связей. - Нужна только сериализация/десериализация (JSON, XML, YAML). - Необходимо хранить огромные объёмы данных.
Вертикальное и горизонтальное масштабирование
**Вертикальное масштабирование (scale up)** — добавление мощности (CPU, RAM) к существующему серверу. Просто, но имеет жёсткие ограничения: нельзя бесконечно наращивать ресурсы, нет отказоустойчивости.
**Горизонтальное масштабирование (scale out)** — добавление серверов. Предпочтительно для крупных систем.
При прямом подключении пользователей к веб-серверу: если сервер недоступен — сайт недоступен. При перегрузке — деградация производительности. Решение: **балансировщик нагрузки**.
Балансировщик нагрузки
Балансировщик равномерно распределяет входящий трафик между веб-серверами (Рисунок 4).
Пользователи подключаются к публичному IP балансировщика. Веб-серверы недоступны клиентам напрямую — используются приватные IP. Это повышает безопасность.
После добавления балансировщика и второго сервера: - Если сервер 1 отказывает — весь трафик идёт на сервер 2, сайт остаётся доступным. - При росте трафика — достаточно добавить серверы в пул, балансировщик автоматически включит их.

Репликация базы данных
**Схема master/slave:** мастер принимает операции записи, реплики (slave) — только чтение. Большинство приложений имеют значительно больше операций чтения, поэтому реплик обычно больше (Рисунок 5).
**Преимущества:** - **Производительность:** запросы на чтение распределяются по репликам — больше параллельных операций. - **Надёжность:** данные хранятся на нескольких серверах — стихийные бедствия не приводят к потере данных. - **Высокая доступность:** если один сервер недоступен, другие продолжают работать.
**Отказоустойчивость:** - Отказ реплики → чтение временно идёт к мастеру; добавляется новая реплика. - Отказ мастера → реплика повышается до мастера. В production это сложнее — может потребоваться синхронизация данных.
Рисунок 6 показывает дизайн после добавления балансировщика и репликации.

Кэш
Кэш — временное хранилище, где сохраняются результаты дорогостоящих запросов или часто запрашиваемые данные. Это позволяет обслуживать повторные запросы значительно быстрее и снизить нагрузку на базу данных.
Уровень кэша
Уровень кэша — временное хранилище данных, намного быстрее БД. Преимущества: лучшая производительность, снижение нагрузки на БД, независимое масштабирование (Рисунок 7).
При запросе веб-сервер сначала проверяет кэш. Если данные есть — возвращает их клиенту. Если нет — запрашивает БД, сохраняет ответ в кэш и возвращает клиенту. Эта стратегия называется **read-through cache**.
Пример Memcached API: ``` SECONDS = 1 cache.set('myKey', 'hi there', 3600 * SECONDS) cache.get('myKey') ```
Соображения по использованию кэша
- **Когда использовать кэш:** когда данные часто читаются, но редко изменяются. Кэш хранит данные в памяти — при перезапуске они теряются. Важные данные должны храниться в персистентном хранилище. - **Политика истечения срока:** не делайте TTL слишком коротким (частые обращения к БД) или слишком длинным (устаревшие данные). - **Согласованность:** операции записи в БД и кэш не атомарны — возможна рассинхронизация. При мультирегиональном масштабировании поддерживать согласованность сложнее. - **Единая точка отказа (SPOF):** один кэш-сервер — SPOF. Рекомендуется несколько кэш-серверов в разных датацентрах + запас памяти (Рисунок 8). - **Политика вытеснения (Eviction Policy):** при заполнении кэша старые записи удаляются. LRU (Least Recently Used) — наиболее популярная политика. Также используются LFU и FIFO.

Сеть доставки контента (CDN)
CDN — сеть географически распределённых серверов для доставки статического контента: изображений, видео, CSS, JavaScript.
Принцип работы: пользователь обращается к сайту → ближайший CDN-сервер отдаёт статику. Чем дальше пользователь от CDN, тем медленнее загрузка (Рисунок 9).
Цикл работы CDN (Рисунок 10): 1. Пользователь A запрашивает image.png через URL CDN-провайдера. 2. Если файла в кэше CDN нет — запрос к источнику (веб-сервер или Amazon S3). 3. Источник возвращает файл с опциональным HTTP-заголовком TTL. 4. CDN кэширует файл и возвращает пользователю A. 5. Пользователь B запрашивает тот же файл. 6. Файл возвращается из кэша CDN (пока TTL не истёк).

Соображения по использованию CDN
- **Стоимость:** CDN тарифицирует передачу данных. Редко запрашиваемые ресурсы не стоит хранить в CDN. - **TTL кэша:** для чувствительного ко времени контента важно подобрать правильный TTL — не слишком короткий (нагрузка на источник) и не слишком длинный (устаревший контент). - **CDN fallback:** при сбое CDN клиенты должны уметь переключиться на источник. - **Инвалидация файлов:** удалить файл до истечения TTL можно через API CDN-провайдера или версионирование URL (`image.png?v=2`).
Рисунок 11: дизайн после добавления CDN и кэша: 1. Статические ресурсы (JS, CSS, изображения) отдаются из CDN. 2. Нагрузка на БД снижается благодаря кэшированию.

Stateless веб-уровень
Для горизонтального масштабирования веб-уровня нужно вынести состояние (например, данные сессии) из него. Правильная практика — хранить данные сессий в персистентном хранилище (реляционная БД или NoSQL). Каждый веб-сервер в кластере читает состояние из общего хранилища. Это называется **stateless веб-уровень**.
Stateful архитектура
Stateful сервер помнит данные клиента (состояние) между запросами. Stateless — нет.
В stateful архитектуре (Рисунок 12) данные сессии и профиль пользователя A хранятся на Сервере 1. HTTP-запросы пользователя A должны всегда попадать на Сервер 1 — иначе аутентификация не пройдёт. Это создаёт проблему «липких сессий» (sticky sessions) в балансировщике, усложняет добавление/удаление серверов и обработку отказов.

Stateless архитектура
В stateless архитектуре (Рисунок 13) HTTP-запросы от пользователей могут отправляться на любой веб-сервер. Состояние хранится в общем хранилище данных вне веб-серверов. Система становится проще, надёжнее и масштабируемее.
Рисунок 14 — обновлённый дизайн со stateless веб-уровнем: данные сессий вынесены в персистентное хранилище (реляционная БД, Memcached/Redis или NoSQL). NoSQL выбирается за простоту масштабирования. Автомасштабирование (auto-scaling) — добавление/удаление серверов в зависимости от нагрузки — становится тривиальным.


Датацентры
При быстром росте аудитории и выходе на международный рынок необходимо несколько датацентров.
Рисунок 15: два датацентра. В штатном режиме пользователи маршрутизируются по GeoDNS к ближайшему датацентру (x% на US-East, (100–x)% на US-West).
Рисунок 16: при отказе датацентра 2 (US-West) весь трафик направляется на датацентр 1 (US-East).
**Технические задачи при мультидатацентровой настройке:** - **Перенаправление трафика:** GeoDNS направляет пользователей к ближайшему датацентру. - **Синхронизация данных:** при failover трафик может попасть в датацентр без актуальных данных. Решение — репликация между датацентрами. - **Тестирование и развёртывание:** важно тестировать сайт из разных локаций; CI/CD-инструменты обеспечивают консистентность развёртывания.


Очередь сообщений
Очередь сообщений — надёжный компонент в памяти для асинхронного обмена данными. Буферизует и распределяет асинхронные запросы. Производители (producers) публикуют сообщения в очередь; потребители (consumers) подключаются к очереди и обрабатывают их (Рисунок 17).
Развязка делает очередь сообщений предпочтительной архитектурой для масштабируемых и надёжных приложений: производитель может публиковать сообщения даже когда потребитель недоступен, и наоборот.
Пример (Рисунок 18): веб-серверы публикуют задачи по обработке фотографий (обрезка, резкость, размытие) в очередь; воркеры обрабатывают их асинхронно. Производители и потребители масштабируются независимо: большая очередь — добавить воркеров, пустая — уменьшить.
Логирование, метрики, автоматизация
При работе с небольшим сайтом это желательно, но не критично. При росте бизнеса — необходимо:
- **Логирование:** мониторинг ошибок — на уровне сервера или через агрегацию в централизованный сервис. - **Метрики:** понимание здоровья системы и бизнес-показателей: - Метрики хостов: CPU, память, дисковый I/O. - Агрегированные метрики: производительность уровней БД, кэша и т.д. - Бизнес-метрики: DAU, retention, выручка. - **Автоматизация:** CI/CD ускоряет разработку: автоматическая проверка кода, сборка, тестирование, деплой.
Добавление очередей и инструментов
Рисунок 19 — обновлённый дизайн (показан один датацентр): 1. Очередь сообщений делает систему слабосвязанной и устойчивой к отказам. 2. Инструменты логирования, мониторинга, метрик и автоматизации включены.
С ростом данных БД начинает перегружаться — время масштабировать уровень данных.

Масштабирование базы данных
Два основных подхода: вертикальное и горизонтальное масштабирование.
Вертикальное масштабирование
Добавление ресурсов (CPU, RAM, диск) к существующей машине. Amazon RDS позволяет получить сервер с 24 ТБ RAM. Недостатки: - Аппаратные ограничения — нельзя наращивать бесконечно. - Повышенный риск единой точки отказа. - Высокая стоимость мощных серверов.
Горизонтальное масштабирование (шардирование)
Добавление серверов. Рисунок 20 сравнивает вертикальное и горизонтальное масштабирование.
**Шардирование** разбивает большую БД на меньшие части — шарды. У всех шардов одинаковая схема, но данные уникальны для каждого.
Рисунок 21: данные пользователей распределяются по шардам функцией `user_id % 4`. Рисунок 22: таблица пользователей в шардированных БД.
**Выбор ключа шардирования** (partition key) критически важен — данные должны распределяться равномерно.
**Сложности шардирования:** - **Решардирование:** при росте данных или неравномерном распределении нужно перераспределять функцию шардирования. Помогает **consistent hashing**. - **Проблема «знаменитости»** (hotspot key): чрезмерный доступ к одному шарду (все данные Katy Perry, Justin Bieber в одном шарде). Решение: отдельный шард на каждую «знаменитость». - **JOIN и денормализация:** JOIN через несколько шардов затруднён. Обходное решение — денормализация таблиц.
Рисунок 23: шардированные БД + NoSQL для нереляционных данных.

Миллионы пользователей и больше
Масштабирование — итеративный процесс. Применяя изученные техники, можно далеко продвинуться. Резюме подходов для масштабирования до миллионов пользователей:
- Держать веб-уровень stateless - Строить избыточность на каждом уровне - Кэшировать данные по максимуму - Поддерживать несколько датацентров - Хранить статику в CDN - Масштабировать данные через шардирование - Разбивать уровни на отдельные сервисы - Мониторить систему и использовать инструменты автоматизации
Быстрые оценки «на салфетке»
На собеседованиях по системному дизайну иногда просят оценить ёмкость или требования к производительности системы. По словам Джеффа Дина (Google Senior Fellow), «быстрые расчёты — это оценки, которые вы делаете с помощью мысленных экспериментов и ключевых числовых характеристик производительности, чтобы понять, какие архитектурные решения соответствуют требованиям».
Для эффективных быстрых оценок необходимо хорошо понимать основы масштабируемости: степени двойки, числа задержек, которые должен знать каждый программист, и показатели доступности.
Степени двойки
Объёмы данных в распределённых системах огромны, но все расчёты сводятся к основам. Для корректных вычислений важно знать единицы данных — степени двойки. Байт = 8 бит. Символ ASCII = 1 байт.
| Степень | Приблизительное значение | Полное название | Краткое обозначение | |---------|--------------------------|-----------------|--------------------| | 10 | 1 тысяча | 1 Килобайт | 1 КБ | | 20 | 1 миллион | 1 Мегабайт | 1 МБ | | 30 | 1 миллиард | 1 Гигабайт | 1 ГБ | | 40 | 1 триллион | 1 Терабайт | 1 ТБ | | 50 | 1 квадриллион | 1 Петабайт | 1 ПБ |
**Таблица 1: Единицы объёма данных (степени двойки)**
Числа задержек, которые должен знать каждый программист
Доктор Дин из Google опубликовал типичные времена операций компьютера (2010 г.). Часть чисел устарела, но они по-прежнему дают представление о быстрых и медленных операциях.
| Операция | Время | |----------|-------| | Обращение к L1-кэшу | 0.5 нс | | Неверное предсказание ветвления | 5 нс | | Обращение к L2-кэшу | 7 нс | | Блокировка/разблокировка мьютекса | 100 нс | | Обращение к основной памяти | 100 нс | | Сжатие 1 КБ через Zippy | 10 000 нс = 10 мкс | | Передача 2 КБ по сети 1 Гбит/с | 20 000 нс = 20 мкс | | Последовательное чтение 1 МБ из памяти | 250 000 нс = 250 мкс | | Round-trip внутри датацентра | 500 000 нс = 500 мкс | | Позиционирование головки диска (seek) | 10 000 000 нс = 10 мс | | Чтение 1 МБ последовательно из сети | 10 000 000 нс = 10 мс | | Чтение 1 МБ последовательно с диска | 30 000 000 нс = 30 мс | | Пакет CA→Нидерланды→CA | 150 000 000 нс = 150 мс |
**Таблица 2: Числа задержек**
> нс = наносекунда, мкс = микросекунда, мс = миллисекунда > 1 нс = 10⁻⁹ с · 1 мкс = 10⁻⁶ с = 1 000 нс · 1 мс = 10⁻³ с = 1 000 мкс
Рисунок 1 показывает визуализацию чисел задержек по состоянию на 2020 год.
Выводы о задержках
Из чисел выше следует: - Память быстрая, диск — медленный. - Избегать обращений к диску там, где возможно. - Простые алгоритмы сжатия работают быстро. - Сжимать данные перед передачей по интернету, если возможно. - Датацентры, как правило, в разных регионах — передача данных между ними занимает значительное время.
Числа доступности
Высокая доступность — способность системы работать непрерывно. Измеряется в процентах: 100% = нулевое время простоя. Большинство сервисов — от 99% до 100%.
SLA (Service Level Agreement) — соглашение об уровне обслуживания между провайдером и клиентом, формально определяющее гарантированное время работы. Amazon, Google и Microsoft устанавливают SLA на уровне 99.9% и выше.
| Доступность % | Простой/день | Простой/неделю | Простой/месяц | Простой/год | |--------------|-------------|----------------|----------------|-------------| | 99% | 14.40 мин | 1.68 ч | 7.31 ч | 3.65 дня | | 99.9% | 1.44 мин | 10.08 мин | 43.83 мин | 8.77 ч | | 99.99% | 8.64 сек | 1.01 мин | 4.38 мин | 52.60 мин | | 99.999% | 864 мс | 6.05 сек | 26.30 сек | 5.26 мин | | 99.9999% | 86.40 мс | 604.80 мс | 2.63 сек | 31.56 сек |
**Таблица 3: Числа доступности — «девятки»**
Пример: оценка QPS и требований к хранилищу Twitter
Числа используются только в учебных целях и не являются реальными данными Twitter.
**Допущения:** - 300 млн ежемесячных активных пользователей. - 50% пользуются Twitter ежедневно. - В среднем 2 твита в день на пользователя. - 10% твитов содержат медиа. - Данные хранятся 5 лет.
**Оценки:**
QPS: - DAU = 300 млн × 50% = 150 млн - QPS твитов = 150 млн × 2 / 24 ч / 3600 с ≈ **3500** - Пиковый QPS = 2 × QPS ≈ **7000**
Хранилище медиа: - tweet_id: 64 байта, текст: 140 байт, медиа: 1 МБ - 150 млн × 2 × 10% × 1 МБ = **30 ТБ в день** - За 5 лет: 30 ТБ × 365 × 5 ≈ **~55 ПБ**
Советы
Быстрые оценки — это прежде всего процесс. Важно продемонстрировать умение решать задачи, а не получить точный ответ.
- **Округление и приближение:** не тратьте время на точные вычисления. «99987 / 9.1» → «100 000 / 10». Точность не требуется. - **Записывайте допущения:** их можно будет использовать позже в обсуждении. - **Указывайте единицы:** «5» — это 5 КБ или 5 МБ? Всегда пишите «5 МБ» для ясности. - **Типичные задачи для тренировки:** QPS, пиковый QPS, хранилище, кэш, количество серверов. Практика — залог успеха.
Методология системного дизайн-собеседования
Вы получили приглашение на очное собеседование в компанию своей мечты. Просматривая расписание, всё выглядит неплохо — пока взгляд не останавливается на одном пункте: «Интервью по системному дизайну».
Такие собеседования нередко пугают. Вопрос может звучать настолько расплывчато, как «Спроектируйте известный продукт X?». Вопросы неоднозначны и кажутся неоправданно широкими. Это понятно: как вообще можно спроектировать продукт за час, на создание которого ушли сотни, если не тысячи инженеров?
Хорошая новость: никто этого и не ожидает. Реальный системный дизайн невероятно сложен. Если никто не ожидает от вас создать реальную систему за час — какой тогда смысл в этом собеседовании?
Собеседование по системному дизайну имитирует реальное решение задач, когда два коллеги совместно работают над неоднозначной проблемой. Задача открытая, идеального ответа нет. Итоговый дизайн менее важен, чем сам процесс: демонстрация навыков проектирования, обоснование архитектурных решений и конструктивная реакция на обратную связь.
Главная цель интервьюера — точно оценить ваши способности. Эффективное собеседование даёт чёткие сигналы о навыках сотрудничества, работы под давлением и конструктивного разрешения неопределённости. Умение задавать правильные вопросы — тоже важный навык.
**Красные флаги:** избыточное усложнение (over-engineering), узкое мышление, упрямство. Многие инженеры увлекаются «чистотой дизайна», игнорируя компромиссы.
4-шаговый процесс эффективного собеседования
Каждое собеседование по системному дизайну уникально. Идеального универсального решения не существует, но есть общие шаги, которые нужно пройти.
Шаг 1 — Понять задачу и определить рамки
Не торопитесь отвечать, не разобравшись в вопросе.
На собеседовании по системному дизайну быстрый ответ без обдумывания не приносит бонусов. Отвечать, не понимая требований — серьёзный красный флаг: это не викторина, правильного ответа нет.
Замедлитесь. Думайте. Задавайте вопросы для уточнения требований и допущений — это крайне важно.
Если интервьюер просит вас самостоятельно сформулировать допущения — запишите их на доске. Они могут понадобиться позже.
**Какие вопросы задавать:** - Какие конкретные функции мы разрабатываем? - Сколько пользователей у продукта? - Как быстро компания планирует масштабироваться? Каков ожидаемый масштаб через 3, 6 месяцев, год? - Какой технологический стек использует компания? Какие существующие сервисы можно задействовать?
**Пример разговора для «Системы ленты новостей»:**
> Кандидат: Это мобильное или веб-приложение? Или оба? > Интервьюер: Оба. > > Кандидат: Какие функции наиболее важны? > Интервьюер: Создание постов и просмотр ленты новостей друзей. > > Кандидат: Лента отсортирована в обратном хронологическом порядке или по-другому? > Интервьюер: Для простоты — в обратном хронологическом порядке. > > Кандидат: Максимальное количество друзей? > Интервьюер: 5000. > > Кандидат: Объём трафика? > Интервьюер: 10 миллионов DAU. > > Кандидат: Лента содержит только текст или также изображения и видео? > Интервьюер: Медиафайлы, включая изображения и видео.
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Цель — разработать высокоуровневый дизайн и согласовать его с интервьюером.
- Набросайте начальный план. Попросите обратную связь. Относитесь к интервьюеру как к коллеге по команде. - Нарисуйте блок-схемы с ключевыми компонентами: клиенты (мобильные/веб), API, веб-серверы, хранилища данных, кэш, CDN, очереди сообщений и т.д. - Сделайте быстрые оценки для проверки соответствия масштабу. Думайте вслух. - По возможности, пройдитесь по конкретным сценариям использования — это поможет выявить граничные случаи.
**Пример для «Ленты новостей»:**
Высокоуровневый дизайн делится на два потока: публикация постов и построение ленты. - **Публикация:** пост записывается в кэш/БД и распространяется в ленты друзей. - **Построение ленты:** агрегация постов друзей в обратном хронологическом порядке.
Рисунки 1 и 2 — высокоуровневые дизайны потоков публикации и построения ленты.


Шаг 3 — Детальный дизайн
К этому шагу вы уже должны были: - Согласовать общие цели и границы функционала. - Набросать высокоуровневый план. - Получить обратную связь интервьюера. - Определить области для углублённого рассмотрения.
Вместе с интервьюером определите и приоритизируйте компоненты для детального разбора. Управление временем критически важно — легко увлечься мелкими деталями, которые не демонстрируют ваши способности.
**Пример для «Ленты новостей»:**
Рисунки 3 и 4 — детальный дизайн публикации постов и получения ленты (подробнее в главе «Проектирование системы ленты новостей»).


Шаг 4 — Подведение итогов
На финальном этапе интервьюер может задать дополнительные вопросы. Возможные направления:
- Определите узкие места системы и обсудите потенциальные улучшения. Никогда не говорите, что дизайн идеален — улучшить можно всегда. - Кратко резюмируйте дизайн — особенно если было предложено несколько решений. - Обсудите обработку ошибок (отказ сервера, потеря сети и т.д.). - Упомяните вопросы эксплуатации: мониторинг метрик, логи ошибок, процедуры развёртывания. - Как масштабировать систему дальше? Если текущий дизайн поддерживает 1 млн пользователей — что нужно изменить для 10 млн? - Предложите дополнительные улучшения при наличии времени.
Можно и нельзя
**Нужно:** - Всегда уточнять требования — не предполагать. - Понять задачу. - Помнить, что нет правильного или лучшего ответа. - Сообщать интервьюеру, о чём вы думаете. - Предлагать несколько подходов, где возможно. - После согласования плана — детализировать критические компоненты в первую очередь. - Обсуждать идеи с интервьюером — хороший интервьюер работает как партнёр. - Не сдаваться.
**Нельзя:** - Приходить неподготовленным к типичным вопросам. - Давать решение без уточнения требований и допущений. - Углубляться в один компонент в начале — сначала высокоуровневый дизайн. - При затруднении — не стесняться просить подсказку. - Думать молча — коммуникация обязательна. - Считать интервью завершённым до подтверждения интервьюера. Запрашивайте обратную связь как можно раньше.
Распределение времени по шагам
Вопросы по системному дизайну широкие, и 45 минут–час — недостаточно для полного охвата. Управление временем критически важно.
Примерное распределение для 45-минутного собеседования:
- **Шаг 1** — Понять задачу и определить рамки: **3–10 минут** - **Шаг 2** — Высокоуровневый дизайн и одобрение: **10–15 минут** - **Шаг 3** — Детальный дизайн: **10–25 минут** - **Шаг 4** — Подведение итогов: **3–5 минут**
Проектирование ограничителя частоты запросов
В сетевой системе ограничитель частоты запросов (rate limiter) контролирует интенсивность трафика от клиента или сервиса. В контексте HTTP он ограничивает количество запросов за определённый период. При превышении порога лишние вызовы блокируются.
Примеры: - Пользователь может публиковать не более 2 постов в секунду. - С одного IP-адреса можно создать не более 10 аккаунтов в день. - Получить награду можно не более 5 раз в неделю с одного устройства.
**Преимущества rate limiting:** - **Защита от DoS-атак.** Блокировка лишних запросов предотвращает как преднамеренные, так и непреднамеренные атаки. Twitter ограничивает до 300 твитов за 3 часа; Google Docs — до 300 запросов в 60 секунд на пользователя. - **Снижение затрат.** Меньше избыточных запросов → меньше серверов → высокоприоритетные API получают больше ресурсов. Особенно важно при использовании платных сторонних API. - **Защита серверов от перегрузки.** Фильтрация запросов от ботов и недобросовестных пользователей.
Шаг 1 — Понять задачу и определить рамки
**Требования:** - Точное ограничение избыточных запросов. - Низкая задержка — rate limiter не должен замедлять HTTP-ответы. - Минимальное потребление памяти. - Распределённое ограничение — rate limiter разделяется между несколькими серверами и процессами. - Понятная обработка исключений — чёткие сообщения при превышении лимита. - Высокая отказоустойчивость — проблемы с rate limiter не должны влиять на всю систему.
Шаг 2 — Высокоуровневый дизайн
Используем простую модель клиент-сервер.
Где разместить rate limiter?
Варианты размещения:
- **На стороне клиента** — ненадёжно: запросы легко подделать, контроля над реализацией нет. - **На стороне сервера** (Рисунок 1) — стандартный подход. - **Middleware** (Рисунок 2) — отдельный слой между клиентом и API серверами, контролирует поток запросов.
Рисунок 3 иллюстрирует работу rate limiter: API допускает 2 запроса/сек, клиент отправляет 3 — первые два проходят, третий получает HTTP 429.
В облачных микросервисах rate limiting обычно реализуется в **API gateway** — управляемом сервисе с поддержкой rate limiting, SSL-терминации, аутентификации, IP-вайтлистинга.
**Рекомендации по выбору:** - Оцените текущий технологический стек. - Определите подходящий алгоритм для ваших бизнес-нужд. - Если используется API gateway в микросервисной архитектуре — добавьте rate limiter туда. - Разработка собственного rate limiter затратна по времени; при нехватке ресурсов используйте коммерческий API gateway.



Алгоритмы ограничения частоты
Популярные алгоритмы: - Token bucket (корзина токенов) - Leaking bucket (дырявая корзина) - Fixed window counter (фиксированное окно) - Sliding window log (скользящий журнал) - Sliding window counter (скользящий счётчик)
Алгоритм Token Bucket
Широко используется (Amazon, Stripe). Прост и хорошо изучен.
**Принцип работы:** - Корзина с заданной ёмкостью. Токены добавляются с фиксированной скоростью. При заполнении — переполнение (Рисунок 4). - Каждый запрос потребляет 1 токен (Рисунок 5): - Есть токены → запрос проходит. - Нет токенов → запрос отклоняется. - Рисунок 6: логика потребления, пополнения и ограничений (ёмкость 4, пополнение 4/мин).
**Параметры:** размер корзины + скорость пополнения.
**Плюсы:** прост в реализации, экономит память, допускает всплески трафика. **Минусы:** подобрать параметры корзины может быть непросто.
Алгоритм Leaking Bucket
Похож на token bucket, но запросы обрабатываются с фиксированной скоростью. Реализуется через FIFO-очередь (Рисунок 7). Используется в Shopify.
**Принцип:** запрос в очередь → обрабатывается с фиксированным интервалом; если очередь полна — запрос отклоняется.
**Параметры:** размер корзины (= размер очереди) + скорость выхода (outflow rate).
**Плюсы:** экономия памяти, стабильный поток — идеально для стриминга. **Минусы:** всплеск трафика заполняет очередь старыми запросами, блокируя новые.
Алгоритм Fixed Window Counter
**Принцип:** временная шкала делится на окна фиксированного размера со счётчиком. Каждый запрос увеличивает счётчик. При достижении порога — отклонение до начала нового окна.
Рисунок 8: лимит 3 запроса/сек. Рисунок 9: проблема краёв окна — за одну минуту может пройти вдвое больше допустимого.
**Плюсы:** экономия памяти, прост, понятен. **Минусы:** всплески на границах окна позволяют превысить лимит.
Алгоритм Sliding Window Log
Решает проблему краёв фиксированного окна.
**Принцип:** 1. Хранит временны́е метки запросов (обычно в Redis sorted sets). 2. При новом запросе удаляет устаревшие метки (старше начала текущего окна). 3. Добавляет метку нового запроса. 4. Если размер лога ≤ допустимого — запрос принят; иначе — отклонён.
Рисунок 10: пример (лимит 2 запроса/мин). Точность высокая.
**Плюсы:** точное соблюдение лимита в любом скользящем окне. **Минусы:** потребляет много памяти — метки хранятся даже для отклонённых запросов.
Алгоритм Sliding Window Counter
Гибридный подход — сочетает Fixed Window Counter и Sliding Window Log.
Рисунок 11: лимит 7 запросов/мин, в предыдущей минуте 5 запросов, в текущей — 3. Новый запрос приходит на позиции 30% текущей минуты: ``` 3 + 5 × 0.7 = 6.5 → округляется до 6 < 7 → запрос проходит ```
**Плюсы:** сглаживает всплески трафика, экономит память. **Минусы:** приблизительная оценка (но согласно экспериментам Cloudflare — лишь 0.003% неверных решений из 400 млн запросов).
Высокоуровневая архитектура
Суть любого алгоритма — счётчик для отслеживания запросов от одного пользователя/IP. Если счётчик превышает лимит — запрос отклоняется.
**Где хранить счётчики?** База данных слишком медленная (дисковый доступ). Используется кэш в памяти — быстрый, с поддержкой TTL. **Redis** — популярный выбор: команды `INCR` (увеличить счётчик) и `EXPIRE` (установить таймаут).
Рисунок 12 — высокоуровневая архитектура: 1. Клиент → middleware rate limiter. 2. Middleware читает счётчик из Redis, проверяет лимит. 3. Лимит достигнут → запрос отклоняется. 4. Лимит не достигнут → запрос проходит на API-сервер, счётчик увеличивается.

Шаг 3 — Детальный дизайн
Правила ограничения
Lyft открыл исходный код своего компонента rate limiting. Примеры правил:
```yaml domain: messaging descriptors: - key: message_type value: marketing rate_limit: unit: day requests_per_unit: 5 ``` Максимум 5 маркетинговых сообщений в день.
```yaml domain: auth descriptors: - key: auth_type value: login rate_limit: unit: minute requests_per_unit: 5 ``` Не более 5 попыток входа в минуту. Правила хранятся в конфигурационных файлах на диске.
Превышение лимита
При превышении лимита API возвращает **HTTP 429 (Too Many Requests)**. Заблокированные запросы могут быть поставлены в очередь для последующей обработки.
**HTTP-заголовки для клиентов:** - `X-Ratelimit-Remaining` — оставшееся число запросов в окне. - `X-Ratelimit-Limit` — лимит запросов на окно. - `X-Ratelimit-Retry-After` — время ожидания в секундах до следующего запроса.
Детальный дизайн
Рисунок 13 — детальный дизайн системы: - Правила хранятся на диске; воркеры периодически загружают их в кэш. - Запрос клиента → middleware rate limiter. - Middleware загружает правила из кэша, читает счётчики из Redis. - Если лимит не достигнут → запрос на API-серверы. - Если лимит достигнут → HTTP 429, запрос отклоняется или попадает в очередь.

Rate limiter в распределённой среде
Два ключевых вызова:
**1. Гонки условий (Race condition)**
При высокой конкурентности два запроса могут одновременно прочитать один счётчик и оба записать значение 4 вместо 5 (Рисунок 14). Решения: Lua-скрипты (атомарные операции в Redis) или sorted sets в Redis.
**2. Проблема синхронизации**
При нескольких серверах rate limiter у каждого свой счётчик (Рисунок 15). Решение: централизованное хранилище (Redis), как на Рисунке 16.


Оптимизация производительности
- **Мультидатацентровая настройка:** размещение edge-серверов ближе к пользователям снижает задержку. Cloudflare размещает edge-серверы по всему миру (Рисунок 17). - **Синхронизация с eventual consistency:** репликация данных между узлами без строгой согласованности.

Мониторинг
После внедрения важно собирать аналитику для проверки эффективности: - Алгоритм работает корректно? - Правила дают нужный результат?
Если правила слишком строгие — легитимные запросы блокируются → нужно их смягчить. При всплесках (flash sales) → возможно, нужен алгоритм с поддержкой бёрстов (token bucket).
Шаг 4 — Подведение итогов
**Алгоритмы и их характеристики:** - **Token bucket** — допускает всплески, прост в реализации - **Leaking bucket** — стабильный поток, подходит для стриминга - **Fixed window** — прост, но проблема краёв окна - **Sliding window log** — точный, но память-ёмкий - **Sliding window counter** — приближённый, но экономичный
**Дополнительные темы:**
- **Жёсткое vs мягкое ограничение:** - Жёсткое (hard): запросы не могут превысить порог. - Мягкое (soft): допускается кратковременное превышение.
- **Ограничение на разных уровнях.** В этой главе рассматривался уровень приложения (HTTP, Layer 7). Возможно ограничение по IP через iptables (Layer 3).
- **Клиентские рекомендации для предотвращения блокировок:** - Кэшировать данные на клиенте. - Знать лимиты и не превышать их. - Корректно обрабатывать исключения. - Использовать экспоненциальный backoff при повторных запросах.
Проектирование согласованного хеширования
Для горизонтального масштабирования важно эффективно и равномерно распределять запросы и данные по серверам. Согласованное хеширование — широко применяемый метод для достижения этой цели.
Проблема повторного хеширования
Если у вас n кеш-серверов, распространённый способ балансировки нагрузки — следующий метод:
``` serverIndex = hash(key) % N, где N — размер пула серверов. ```
При 4 серверах и 8 строковых ключах (Таблица 1), чтобы определить сервер хранения ключа, выполняем операцию `f(key) % 4`. Рисунок 1 показывает распределение ключей по Таблице 1.
| ключ | хеш | хеш % 4 | |------|-----|----------| | key0 | 18358617 | 1 | | key1 | 26143584 | 0 | | key2 | 18131146 | 2 | | key3 | 35863496 | 0 | | key4 | 34085809 | 1 | | key5 | 27581703 | 3 | | key6 | 38164978 | 2 | | key7 | 22530351 | 3 |
**Таблица 1**
Этот подход работает, когда пул серверов фиксирован и данные распределены равномерно. Однако проблемы возникают при добавлении или удалении серверов. Например, если сервер 1 отключается, пул сокращается до 3 серверов. Операция по модулю даёт другие индексы.
| ключ | хеш | хеш % 3 | |------|-----|----------| | key0 | 18358617 | 0 | | key1 | 26143584 | 0 | | key2 | 18131146 | 1 | | key3 | 35863496 | 2 | | key4 | 34085809 | 1 | | key5 | 27581703 | 0 | | key6 | 38164978 | 1 | | key7 | 22530351 | 0 |
**Таблица 2**
Как показано на Рисунке 2, большинство ключей перераспределяется, а не только те, что хранились на отключённом сервере. Это означает, что кеш-клиенты будут подключаться к неверным серверам — возникнет шквал промахов кеша. Согласованное хеширование — эффективный метод снижения этой проблемы.
Согласованное хеширование
Цитата из Wikipedia: «Согласованное хеширование — особый вид хеширования, при котором при изменении размера хеш-таблицы в среднем нужно переместить только k/n ключей, где k — количество ключей, n — количество слотов. В большинстве традиционных хеш-таблиц изменение числа слотов вызывает переназначение почти всех ключей [1]».
Хеш-пространство и хеш-кольцо
Предположим, используется хеш-функция SHA-1 f, выходной диапазон которой: x0, x1, x2, x3, …, xn. В криптографии пространство SHA-1 — от 0 до 2^160 - 1. То есть x0 = 0, xn = 2^160 – 1. Рисунок 3 показывает хеш-пространство.
Соединив оба конца, получаем хеш-кольцо, как показано на Рисунке 4.
Хеширование серверов
Используя ту же хеш-функцию f, отображаем серверы по IP или имени на кольцо. Рисунок 5 показывает 4 сервера на хеш-кольце.
Хеширование ключей
Используемая здесь хеш-функция отличается от функции из «проблемы повторного хеширования», и операция по модулю не применяется. Как показано на Рисунке 6, 4 ключа кеша (key0, key1, key2, key3) хешируются на хеш-кольцо.
Поиск сервера
Чтобы определить, на каком сервере хранится ключ, идём по часовой стрелке от позиции ключа на кольце до первого найденного сервера. Рисунок 7 поясняет этот процесс: по часовой стрелке key0 → сервер 0; key1 → сервер 1; key2 → сервер 2; key3 → сервер 3.
Добавление сервера
При описанной логике добавление нового сервера требует перераспределения лишь части ключей.
На Рисунке 8 после добавления сервера 4 перераспределяется только key0. k1, k2 и k3 остаются на прежних серверах. До добавления сервера 4 key0 хранился на сервере 0. Теперь key0 будет храниться на сервере 4, так как это первый сервер по часовой стрелке от позиции key0 на кольце.
Удаление сервера
При удалении сервера согласованное хеширование требует перераспределения лишь небольшой части ключей. На Рисунке 9 при удалении сервера 1 только key1 перемапируется на сервер 2. Остальные ключи не затрагиваются.
Две проблемы базового подхода
Алгоритм согласованного хеширования был введён Karger и соавт. из MIT [1]. Базовые шаги:
1. Отобразить серверы и ключи на кольцо с помощью равномерно распределённой хеш-функции. 2. Чтобы определить сервер для ключа, идти по часовой стрелке от позиции ключа до первого сервера на кольце.
**Проблема 1: Неравномерные партиции.** При добавлении или удалении серверов невозможно поддерживать одинаковый размер партиций. На Рисунке 10 после удаления s1 партиция s2 становится вдвое больше партиций s0 и s3.
**Проблема 2: Неравномерное распределение ключей.** Если серверы расположены как на Рисунке 11, большинство ключей попадает на сервер 2, а серверы 1 и 3 остаются без данных.
Для решения этих проблем используется техника **виртуальных узлов** (replicas).
Виртуальные узлы
Виртуальный узел ссылается на реальный узел, и каждый сервер представлен несколькими виртуальными узлами на кольце. На Рисунке 12 серверы 0 и 1 имеют по 3 виртуальных узла. Вместо s0 используются s0_0, s0_1 и s0_2; аналогично s1_0, s1_1 и s1_2 для сервера 1. С виртуальными узлами каждый сервер отвечает за несколько партиций.
Чтобы определить сервер для ключа, идём по часовой стрелке и находим первый виртуальный узел. На Рисунке 13 для ключа k0 движемся по часовой стрелке и находим виртуальный узел s1_1, который ссылается на сервер 1.
С увеличением числа виртуальных узлов распределение ключей становится более равномерным — стандартное отклонение уменьшается. По данным онлайн-исследований [2], при 100-200 виртуальных узлах стандартное отклонение составляет 5-10% от среднего. Однако для хранения данных о виртуальных узлах требуется больше места — это компромисс.
Нахождение затронутых ключей
При добавлении или удалении сервера часть данных нужно перераспределить.
На Рисунке 14 на кольцо добавляется сервер 4. Затронутый диапазон начинается от s4 (новый узел) и движется против часовой стрелки до ближайшего сервера (s3). Таким образом, ключи между s3 и s4 перераспределяются на s4.
При удалении сервера s1 (Рисунок 15) затронутый диапазон начинается от s1 и движется против часовой стрелки до s0. Ключи между s0 и s1 перераспределяются на s2.
Подведение итогов
Преимущества согласованного хеширования:
- **Минимальное перераспределение** при добавлении или удалении серверов. - **Простое горизонтальное масштабирование** — данные распределяются более равномерно. - **Снижение проблемы горячих ключей.** Избыточное обращение к конкретному шарду может перегрузить сервер. Согласованное хеширование помогает снизить нагрузку за счёт более равномерного распределения данных.
Согласованное хеширование широко используется в реальных системах:
- Компонент партиционирования в Amazon Dynamo [3] - Партиционирование данных в кластере Apache Cassandra [4] - Мессенджер Discord [5] - CDN Akamai [6] - Балансировщик нагрузки Maglev от Google [7]
Проектирование хранилища ключ-значение
Хранилище ключ-значение, также известное как база данных ключ-значение, — это нереляционная база данных. Каждый уникальный идентификатор хранится как ключ вместе с соответствующим значением. Такая пара данных называется «ключ-значение».
В паре ключ-значение ключ должен быть уникальным, а значение доступно через ключ. Ключи могут быть обычным текстом или хешированными значениями. Для производительности лучше использовать короткие ключи.
- Текстовый ключ: `"last_logged_in_at"` - Хешированный ключ: `253DDEC4`
Значение в паре ключ-значение может быть строкой, списком, объектом и т.д. Значение обычно рассматривается как непрозрачный объект в хранилищах ключ-значение: Amazon DynamoDB [1], Memcached [2], Redis [3] и др.
В этой главе вас просят спроектировать хранилище ключ-значение, поддерживающее следующие операции:
- `put(key, value)` — вставить «value» для «key» - `get(key)` — получить «value» для «key»
Понять задачу и определить рамки
Идеального дизайна не существует. Каждый дизайн достигает определённого баланса между чтением, записью и использованием памяти, а также между согласованностью и доступностью. В этой главе мы проектируем хранилище ключ-значение со следующими характеристиками:
- Размер пары ключ-значение не более 10 КБ. - Возможность хранить большие данные. - Высокая доступность: быстрый ответ даже при сбоях. - Высокая масштабируемость: поддержка большого набора данных. - Автоматическое масштабирование: добавление и удаление серверов автоматически по трафику. - Настраиваемая согласованность. - Низкая задержка.
Односерверное хранилище ключ-значение
Создать хранилище ключ-значение на одном сервере несложно. Интуитивный подход — хранить пары в хеш-таблице в памяти. Хотя доступ к памяти быстрый, всё поместить в память невозможно из-за ограничений. Возможные оптимизации:
- Сжатие данных - Хранить в памяти только часто используемые данные, остальные — на диске
Даже с оптимизациями один сервер быстро исчерпывает ёмкость. Для работы с большими данными необходимо распределённое хранилище ключ-значение.
Распределённое хранилище ключ-значение
Распределённое хранилище ключ-значение также называют распределённой хеш-таблицей — оно распределяет пары ключ-значение по множеству серверов. При проектировании распределённых систем важно понимать теорему CAP (Согласованность, Доступность, Устойчивость к разделению).
Теорема CAP
Теорема CAP утверждает, что невозможно одновременно обеспечить все три гарантии: согласованность, доступность и устойчивость к разделению.
- **Согласованность (Consistency):** все клиенты видят одинаковые данные в любой момент времени независимо от узла. - **Доступность (Availability):** любой клиент, запрашивающий данные, получает ответ, даже если некоторые узлы недоступны. - **Устойчивость к разделению (Partition Tolerance):** система продолжает работать при разделении сети.
Теорема CAP утверждает, что одним из трёх свойств необходимо пожертвовать (Рисунок 1).
Классификация хранилищ по двум поддерживаемым характеристикам CAP:
- **CP-системы:** согласованность + устойчивость к разделению (жертвуют доступностью). - **AP-системы:** доступность + устойчивость к разделению (жертвуют согласованностью). - **CA-системы:** согласованность + доступность (жертвуют устойчивостью к разделению). Так как сетевые сбои неизбежны, CA-системы в реальных условиях невозможны.
**Идеальная ситуация (Рисунок 2):** разделение сети не происходит. Данные, записанные на n1, автоматически реплицируются на n2 и n3. Достигаются и согласованность, и доступность.
**Реальный сценарий (Рисунок 3):** n3 отключается. Нужно выбрать между: - **CP-система:** блокировать все записи на n1 и n2 (система временно недоступна). Банки обычно выбирают это. - **AP-система:** принимать чтения (возможно устаревшие) и записи, синхронизировать n3 после восстановления.
Компоненты системы
Ключевые компоненты и техники для построения хранилища ключ-значение:
- Партиционирование данных - Репликация данных - Согласованность - Разрешение несогласованностей - Обработка сбоев - Архитектурная диаграмма - Путь записи - Путь чтения
Контент ниже основан на трёх популярных системах: Dynamo [4], Cassandra [5] и BigTable [6].
Партиционирование данных
Для больших приложений невозможно хранить всё на одном сервере. Согласованное хеширование — отличный метод партиционирования:
1. Серверы размещаются на хеш-кольце. На Рисунке 4 — 8 серверов (s0–s7). 2. Ключ хешируется на то же кольцо и хранится на первом сервере по часовой стрелке. Например, key0 → s1.
Преимущества: - **Автоматическое масштабирование:** серверы добавляются и удаляются автоматически по нагрузке. - **Гетерогенность:** число виртуальных узлов для сервера пропорционально его мощности.
Репликация данных
Для высокой доступности данные асинхронно реплицируются на N серверов (N — настраиваемый параметр). После определения позиции ключа на кольце выбираются первые N серверов по часовой стрелке. На Рисунке 5 (N = 3) key0 реплицируется на s1, s2 и s3.
С виртуальными узлами первые N узлов могут принадлежать менее чем N физическим серверам. Чтобы избежать этого, при обходе выбираем только уникальные физические серверы.
Для надёжности реплики размещаются в разных дата-центрах, соединённых высокоскоростными сетями.
Согласованность
Так как данные реплицируются на нескольких узлах, необходима синхронизация реплик. Кворумный консенсус гарантирует согласованность для операций чтения и записи.
**Определения:** - **N** = Количество реплик - **W** = Кворум записи размером W. Запись считается успешной, если получено подтверждение от W реплик. - **R** = Кворум чтения размером R. Чтение считается успешным, если получены ответы от R реплик.
Рисунок 6 показывает пример с N = 3. W = 1 означает, что координатор должен получить хотя бы одно подтверждение. Координатор выступает прокси между клиентом и узлами.
**Компромиссы конфигурации:** - R = 1 и W = N: оптимизация быстрого чтения. - W = 1 и R = N: оптимизация быстрой записи. - W + R > N: гарантируется строгая согласованность (обычно N = 3, W = R = 2). - W + R ≤ N: строгая согласованность не гарантируется.
Модели согласованности
Модель согласованности определяет степень согласованности данных:
- **Строгая согласованность:** операция чтения всегда возвращает последнее записанное значение. Клиент никогда не видит устаревших данных. - **Слабая согласованность:** последующие чтения могут не видеть последнее обновление. - **Конечная согласованность:** форма слабой согласованности. Со временем все обновления распространяются и все реплики становятся согласованными.
Строгая согласованность обычно достигается блокировкой новых чтений/записей до согласования всех реплик — это не подходит для высокодоступных систем. Dynamo и Cassandra используют **конечную согласованность** — рекомендуемую модель.
Разрешение несогласованностей: версионирование
Репликация обеспечивает высокую доступность, но вызывает несогласованности между репликами. Для решения используются версионирование и векторные часы.
Как показано на Рисунке 7, оба реплика n1 и n2 имеют одинаковое значение (исходное). Сервер 1 меняет имя на «johnSanFrancisco», сервер 2 — на «johnNewYork» одновременно (Рисунок 8). Это создаёт конфликтующие версии v1 и v2.
**Векторные часы** — распространённый метод разрешения конфликтов. Векторные часы — пара [сервер, версия], связанная с элементом данных. Позволяют определить, предшествует ли одна версия другой, следует за ней или конфликтует.
Представим векторные часы как D([S1, v1], [S2, v2], …, [Sn, vn]). При записи элемента D на сервер Si: - Увеличить vi, если [Si, vi] существует. - Иначе создать новую запись [Si, 1].
Рисунок 9 иллюстрирует процесс: 1. Клиент записывает D1 на сервер Sx → D1[(Sx, 1)] 2. Другой клиент читает D1, обновляет до D2, записывает на Sx → D2([Sx, 2]) 3. Другой клиент читает D2, обновляет до D3, записывает на Sy → D3([Sx, 2], [Sy, 1]) 4. Другой клиент читает D2, обновляет до D4, записывает на Sz → D4([Sx, 2], [Sz, 1]) 5. Клиент читает D3 и D4, обнаруживает конфликт. Разрешает конфликт и отправляет D5 → D5([Sx, 3], [Sy, 1], [Sz, 1])
**Версия X является предком Y** (нет конфликта), если все счётчики X ≤ соответствующим счётчикам Y. **Версия X является сиблингом Y** (конфликт), если в векторных часах Y есть участник с счётчиком меньше соответствующего в X.
**Недостатки векторных часов:** 1. Усложняют клиент (нужна логика разрешения конфликтов). 2. Пары [сервер: версия] могут быстро разрастаться — решается установкой порога и удалением старейших пар.
Обработка сбоев
Как и в любой крупной системе, сбои неизбежны и часты.
Обнаружение сбоев
В распределённой системе недостаточно верить, что сервер упал, если так говорит только один другой сервер. Обычно требуется как минимум два независимых источника.
Как показано на Рисунке 10, мультикаст «все ко всем» — простое решение, но неэффективное при большом числе серверов.
Лучше использовать децентрализованные методы обнаружения сбоев, например **протокол gossip**: - Каждый узел хранит список участников с их счётчиками сердцебиений. - Каждый узел периодически увеличивает свой счётчик. - Каждый узел периодически отправляет сердцебиения случайным узлам, которые распространяют их дальше. - Если счётчик не увеличивался дольше заданного периода — узел считается недоступным.
Рисунок 11 показывает gossip-протокол в действии: узел s0 замечает, что счётчик s2 давно не увеличивался, и распространяет информацию. После подтверждения другими узлами s2 помечается как недоступный.
Обработка временных сбоев
Для повышения доступности используется техника **«мягкого кворума» (sloppy quorum)** [4]. Вместо строгого кворума система выбирает первые W здоровых серверов для записи и первые R — для чтения. Отключённые серверы игнорируются.
Если сервер недоступен, другой временно обрабатывает запросы. При восстановлении изменения возвращаются — это называется **hinted handoff** (передача с подсказкой). На Рисунке 12 s2 недоступен, s3 временно обрабатывает его запросы. Когда s2 восстановится, s3 передаст ему данные.
Обработка постоянных сбоев
**Протокол анти-энтропии** синхронизирует реплики, сравнивая данные и обновляя до последней версии. **Дерево Меркла** используется для обнаружения несогласованностей с минимальной передачей данных.
Цитата из Wikipedia [7]: «Хеш-дерево или дерево Меркла — дерево, в котором каждый нелистовой узел подписан хешем дочерних узлов».
Построение дерева Меркла (ключевое пространство 1–12):
**Шаг 1:** Разделить ключевое пространство на корзины (Рисунок 13).
**Шаг 2:** Хешировать каждый ключ в корзине (Рисунок 14).
**Шаг 3:** Создать единый хеш-узел для каждой корзины (Рисунок 15).
**Шаг 4:** Строить дерево вверх до корня, вычисляя хеши дочерних узлов (Рисунок 16).
Для сравнения двух деревьев Меркла начинаем с корневых хешей. Если корневые хеши совпадают — данные идентичны. При расхождении обходим дерево и синхронизируем только отличающиеся корзины.
Использование деревьев Меркла: объём синхронизируемых данных пропорционален **различиям** между репликами, а не их размеру.
Архитектурная диаграмма системы
Рисунок 17 показывает основную архитектуру хранилища ключ-значение.
Основные характеристики: - Клиенты взаимодействуют через простые API: `get(key)` и `put(key, value)`. - Координатор — узел-прокси между клиентом и хранилищем. - Узлы распределены по кольцу с согласованным хешированием. - Полностью децентрализованная система — добавление и перемещение узлов автоматически. - Данные реплицируются на нескольких узлах. - Нет единой точки отказа — каждый узел несёт одинаковый набор обязанностей.
Рисунок 18 показывает задачи, выполняемые каждым узлом.
Путь записи
Рисунок 19 объясняет, что происходит при записи на конкретный узел (на основе архитектуры Cassandra [8]):
1. Запрос записи сохраняется в файле журнала транзакций (commit log). 2. Данные сохраняются в кеше памяти. 3. При заполнении кеша или достижении порога данные сбрасываются в SSTable [9] на диске.
Путь чтения
При чтении узел сначала проверяет наличие данных в кеше памяти. Если данные найдены — возвращаются клиенту (Рисунок 20).
Если данных нет в памяти — извлекаются с диска. **Bloom filter** [10] используется для определения, в каком SSTable находится ключ.
Путь чтения при отсутствии данных в памяти (Рисунок 21): 1. Проверить кеш памяти. Если нет — перейти к шагу 2. 2. Проверить bloom filter. 3. Bloom filter определяет, в каких SSTable может быть ключ. 4. SSTable возвращает данные. 5. Результат возвращается клиенту.
Резюме
| Цель/Проблема | Техника | |---|---| | Хранение больших данных | Согласованное хеширование для равномерного распределения | | Высокая доступность при чтении | Репликация данных + мульти-дата-центровая архитектура | | Высокая доступность при записи | Версионирование и разрешение конфликтов с векторными часами | | Партиционирование набора данных | Согласованное хеширование | | Инкрементальное масштабирование | Согласованное хеширование | | Гетерогенность | Согласованное хеширование | | Настраиваемая согласованность | Кворумный консенсус | | Обработка временных сбоев | Мягкий кворум и hinted handoff | | Обработка постоянных сбоев | Дерево Меркла | | Обработка сбоя дата-центра | Межцентровая репликация |
Проектирование генератора уникальных ID в распределённых системах
В этой главе вас просят спроектировать генератор уникальных ID для распределённых систем. Первой мыслью может быть использование первичного ключа с атрибутом auto_increment в традиционной базе данных. Однако auto_increment не работает в распределённой среде: один сервер базы данных недостаточно мощный, а генерация уникальных ID в нескольких базах с минимальной задержкой — сложная задача.
Несколько примеров уникальных ID (Рисунок 1):
Шаг 1 — Понять задачу и определить рамки
**Требования:**
- ID должны быть уникальными. - ID — только числовые значения. - ID вмещаются в 64 бита. - ID упорядочены по дате. - Возможность генерировать более 10 000 уникальных ID в секунду.
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Несколько вариантов генерации уникальных ID в распределённых системах:
- Мульти-мастер репликация - Universally Unique Identifier (UUID) - Ticket-сервер - Подход Twitter Snowflake
Мульти-мастер репликация
Как показано на Рисунке 2, первый подход — мульти-мастер репликация.
Использует функцию auto_increment базы данных. Вместо увеличения на 1 — увеличиваем на k, где k — количество серверов. Следующий ID = предыдущий ID на этом сервере + k. Это частично решает проблемы масштабируемости — ID растут вместе с числом серверов.
**Недостатки:** - Сложно масштабировать для нескольких дата-центров. - ID не растут со временем глобально по всем серверам. - Плохо масштабируется при добавлении или удалении серверов.
UUID
UUID — ещё один простой способ получить уникальные ID. UUID — 128-битное число для идентификации информации в компьютерных системах. Вероятность коллизии UUID крайне мала — «для 50% вероятности создания хотя бы одного дубликата потребуется генерировать миллиард UUID в секунду в течение примерно 100 лет» [1].
Пример UUID: `09c93e62-50b4-468d-bf8a-c07e1040bfb2`
UUID можно генерировать независимо без координации между серверами. На Рисунке 3 каждый веб-сервер имеет собственный генератор ID.
**Плюсы:** - Генерация UUID проста. Координация между серверами не нужна — нет проблем синхронизации. - Легко масштабировать — каждый веб-сервер генерирует свои ID самостоятельно.
**Минусы:** - UUID — 128 бит, а требование — 64 бита. - ID не растут со временем. - ID могут быть нечисловыми.
Ticket-сервер
Ticket-серверы — интересный способ генерации уникальных ID. Flickr разработал ticket-серверы для генерации распределённых первичных ключей [2]. Идея: использовать централизованную функцию auto_increment в едином сервере базы данных (Ticket Server).
**Плюсы:** - Числовые ID. - Легко реализовать, подходит для приложений малого и среднего масштаба.
**Минусы:** - Единая точка отказа. Если ticket-сервер упадёт — все зависящие системы столкнутся с проблемами. Для устранения можно использовать несколько ticket-серверов, но это вводит новые сложности с синхронизацией.
Подход Twitter Snowflake
Система генерации уникальных ID Twitter под названием «snowflake» [3] вдохновляет и удовлетворяет наши требования. Вместо прямой генерации ID делим его на разные секции. Рисунок 5 показывает структуру 64-битного ID.
**Секции 64-битного Snowflake ID:**
- **Знаковый бит:** 1 бит. Всегда 0. Зарезервирован для будущего использования. Потенциально различает знаковые и беззнаковые числа. - **Временная метка:** 41 бит. Миллисекунды с начала эпохи или пользовательской эпохи. Twitter использует эпоху 1288834974657, соответствующую 04 ноября 2010, 01:42:54 UTC. - **ID дата-центра:** 5 бит → 2^5 = 32 дата-центра. - **ID машины:** 5 бит → 2^5 = 32 машины на дата-центр. - **Порядковый номер:** 12 бит. Для каждого сгенерированного ID увеличивается на 1. Сбрасывается в 0 каждую миллисекунду.
Шаг 3 — Детальный дизайн
Выбираем подход, основанный на генераторе ID Twitter Snowflake (Рисунок 6).
ID дата-центра и ID машины выбираются при запуске и обычно фиксируются. Любые изменения требуют тщательного рассмотрения — случайное изменение этих значений может привести к конфликтам ID. Временная метка и порядковый номер генерируются в процессе работы.
Временная метка
Наиболее важные 41 бит — секция временной метки. По мере роста временных меток ID сортируются по времени. Рисунок 7 показывает пример преобразования двоичного представления в UTC.
Максимальная временная метка в 41 бите:
``` 2^41 - 1 = 2 199 023 255 551 мс ≈ 69 лет ```
Это означает, что генератор ID будет работать 69 лет. Использование пользовательской эпохи, близкой к текущей дате, откладывает переполнение. По истечении 69 лет потребуется новая эпоха или миграция ID.
Порядковый номер
Порядковый номер — 12 бит, что даёт 2^12 = 4096 комбинаций. Это поле равно 0, если в одну миллисекунду на одном сервере генерируется не более одного ID. Теоретически машина поддерживает максимум 4096 новых ID в миллисекунду.
Шаг 4 — Подведение итогов
Мы рассмотрели разные подходы к проектированию генератора уникальных ID: мульти-мастер репликация, UUID, ticket-сервер и Twitter Snowflake. Выбрали Snowflake — он поддерживает все наши сценарии и масштабируется в распределённой среде.
**Дополнительные темы для обсуждения:**
- **Синхронизация часов.** Мы предполагаем, что серверы генерации ID имеют одинаковые часы. Это может быть неверно при работе на нескольких ядрах. Network Time Protocol (NTP) — наиболее популярное решение [4].
- **Настройка длины секций.** Например, меньше порядковых номеров и больше бит для метки времени эффективно для низкой нагрузки и долгосрочных приложений.
- **Высокая доступность.** Генератор ID — критически важная система, он должен быть высокодоступным.
Проектирование сервиса сокращения URL
В этой главе мы разберём интересный и классический вопрос собеседования по системному дизайну: проектирование сервиса сокращения URL, подобного tinyurl.
Шаг 1 — Понять задачу и определить рамки
**Основные сценарии использования:** - Сокращение URL: из длинного URL → возвращается короткий URL - Перенаправление по URL: из короткого URL → перенаправление на оригинальный URL - Требования: высокая доступность, масштабируемость, отказоустойчивость
**Быстрые оценки:** - Операции записи: 100 млн URL в день → ~1160 запросов/сек - Операции чтения (соотношение 10:1): ~11 600 запросов/сек - Хранение за 10 лет: 100М × 365 × 10 = 365 млрд записей - Средняя длина URL: 100 байт - Хранилище за 10 лет: 365 млрд × 100 байт = **36,5 ТБ**
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
API-эндпоинты
Сервис сокращения URL требует два основных API-эндпоинта:
**Сокращение URL** — POST-запрос с исходным длинным URL: ``` POST api/v1/data/shorten параметр запроса: {longUrl: longURLString} возвращает: shortURL ```
**Перенаправление по URL** — GET-запрос с коротким URL: ``` GET api/v1/shortUrl возвращает: longURL для HTTP-перенаправления ```
Перенаправление по URL
Рисунок 1 показывает, что происходит при вводе tinyurl в браузере. При получении запроса сервер выполняет перенаправление короткого URL на длинный с кодом 301.
Детальное взаимодействие клиента и сервера показано на Рисунке 2.
**Перенаправление 301 vs 302:**
- **Перенаправление 301:** Запрошенный URL «постоянно» перемещён на длинный URL. Браузер кешируют ответ, и последующие запросы на тот же URL НЕ отправляются в сервис сокращения — они идут напрямую на длинный URL. Используется для **снижения нагрузки на сервер**.
- **Перенаправление 302:** URL «временно» перемещён. Последующие запросы будут снова отправляться в сервис сокращения, а затем перенаправляться. Используется, когда важна **аналитика** (отслеживание кликов и источников).
Наиболее интуитивный способ реализации перенаправления — хеш-таблица с парами `<shortURL, longURL>`.

Сокращение URL
Предположим, короткий URL выглядит как: `www.tinyurl.com/{hashValue}`. Для поддержки сокращения нужна хеш-функция fx, которая отображает длинный URL в hashValue (Рисунок 3).
Требования к хеш-функции: - Каждый longURL должен хешироваться в одно значение hashValue. - Каждое значение hashValue должно однозначно указывать на longURL.
Шаг 3 — Детальный дизайн
Модель данных
В высокоуровневом дизайне всё хранится в хеш-таблице — хороший стартовый вариант, но в реальных системах он нежизнеспособен из-за ограниченности памяти. Лучше хранить пары `<shortURL, longURL>` в реляционной базе данных. Рисунок 4 показывает простую таблицу с тремя столбцами: id, shortURL, longURL.
Хеш-функция
Хеш-функция хеширует длинный URL в короткий (hashValue).
**Длина значения хеша:**
HashValue состоит из символов [0-9, a-z, A-Z] = 62 возможных символа. Найдём длину n такую, что 62^n ≥ 365 млрд:
| n | Максимальное число URL | |---|------------------------| | 1 | 62 | | 5 | 916 132 832 | | 6 | 56 800 235 584 | | **7** | **~3,5 триллиона** | | 8 | ~218 триллионов |
При n = 7, 62^7 ≈ 3,5 трлн — более чем достаточно для 365 млрд URL. Таким образом, **длина hashValue = 7**.
Хеш + разрешение коллизий
Простой подход — использовать известные хеш-функции: CRC32, MD5 или SHA-1. Даже кратчайший хеш (CRC32) слишком длинный. Первый подход — брать первые 7 символов хеша, но это может приводить к коллизиям.
Для разрешения коллизий рекурсивно добавляем предопределённую строку до тех пор, пока коллизия не исчезнет (Рисунок 5).
Этот метод устраняет коллизии, но запрос к базе при каждом обращении дорогостоящий. **Bloom filter** [2] улучшает производительность — вероятностная структура для проверки принадлежности элемента множеству.
Кодирование в основание 62
Кодирование в основание 62 — ещё один распространённый подход. Base 62 использует 62 символа: 0→0, ..., 9→9, 10→a, 11→b, ..., 35→z, 36→A, ..., 61→Z.
Пример: конвертируем 11157 (десятичное) в основание 62:
``` 11157 = 2 × 62² + 55 × 62¹ + 59 × 62⁰ = [2, 55, 59] = [2, T, X] в основании 62 ```
Рисунок 6 показывает процесс конвертации. Короткий URL: `https://tinyurl.com/2TX`
**Сравнение двух подходов:**
| | Хеш + разрешение коллизий | Кодирование в основание 62 | |---|---|---| | Длина URL | Фиксированная | Нефиксированная — растёт с ID | | Уникальный ID | Не нужен генератор | Зависит от генератора уникальных ID | | Коллизии | Возможны, нужно разрешение | Невозможны (ID уникален) | | Безопасность | Нельзя угадать следующий URL | Легко предсказать следующий URL (риск безопасности) |
Детальный разбор сокращения URL
В нашем дизайне используем кодирование в основание 62. Рисунок 7 демонстрирует поток:
1. Входные данные: longURL. 2. Проверить, есть ли longURL в базе. 3. Если да → получить shortURL из базы и вернуть клиенту. 4. Если нет → сгенерировать новый уникальный ID (первичный ключ) через генератор. 5. Конвертировать ID в shortURL через кодирование в основание 62. 6. Создать новую строку в базе: ID, shortURL, longURL.
**Конкретный пример:** - Ввод: `https://en.wikipedia.org/wiki/Systems_design` - Генератор ID возвращает: `2009215674938` - Конвертация в основание 62: `2009215674938` → `"zn9edcu"` - Запись в базу: `(2009215674938, "zn9edcu", longURL)`
Детальный разбор перенаправления URL
Рисунок 8 показывает детальный дизайн перенаправления. Поскольку чтений больше, чем записей, пары `<shortURL, longURL>` кешируются для улучшения производительности.
**Поток перенаправления:** 1. Пользователь нажимает на короткую ссылку: `https://tinyurl.com/zn9edcu` 2. Балансировщик нагрузки передаёт запрос веб-серверам. 3. Если shortURL есть в кеше — вернуть longURL напрямую. 4. Если нет — получить longURL из базы данных. Если и в базе нет — пользователь ввёл неверный shortURL. 5. longURL возвращается пользователю.

Шаг 4 — Подведение итогов
В этой главе мы обсудили дизайн API, модель данных, хеш-функцию, сокращение и перенаправление URL.
**Дополнительные темы для обсуждения:**
- **Ограничение запросов:** Потенциальная угроза безопасности — злоумышленники отправляют огромное количество запросов на сокращение. Rate limiter фильтрует запросы по IP и другим правилам. - **Масштабирование веб-серверов:** Веб-слой без состояния — легко масштабировать добавлением или удалением серверов. - **Масштабирование базы данных:** Репликация и шардирование базы данных. - **Аналитика:** Интеграция аналитического решения: сколько людей нажали на ссылку? Когда они кликнули? - **Доступность, согласованность и надёжность:** Основа успеха любой крупной системы.
Проектирование веб-краулера
Веб-краулер (также известный как робот или паук) широко применяется поисковыми системами для обнаружения нового или обновлённого контента в интернете: веб-страниц, изображений, видео, PDF-файлов и т.д. Краулер начинает работу с нескольких страниц, а затем переходит по ссылкам для сбора нового контента. Рисунок 1 показывает визуальный пример процесса обхода.
**Сценарии использования веб-краулеров:** - **Индексирование для поисковых систем:** Сбор веб-страниц для создания локального индекса. Например, Googlebot — краулер поисковой системы Google. - **Веб-архивирование:** Сохранение информации из интернета для будущего использования. Примеры: Библиотека Конгресса США [1], веб-архив ЕС [2]. - **Веб-майнинг:** Извлечение полезных знаний из интернета. Крупные финансовые компании используют краулеры для загрузки материалов акционерных собраний и годовых отчётов. - **Мониторинг:** Обнаружение нарушений авторских прав и товарных знаков. Например, Digimarc [3] использует краулеры для выявления пиратских материалов.

Шаг 1 — Понять задачу и определить рамки
**Характеристики хорошего веб-краулера:**
- **Масштабируемость:** Сверхэффективный обход с использованием параллелизма. - **Надёжность:** Обработка некорректного HTML, недоступных серверов, сбоев, вредоносных ссылок. - **Вежливость:** Краулер не должен отправлять слишком много запросов к одному сайту за короткое время. - **Расширяемость:** Гибкая система с минимальными изменениями для поддержки новых типов контента.
**Быстрые оценки:** - 1 млрд веб-страниц в месяц - QPS: 1B / 30 / 24 / 3600 ≈ **400 страниц/сек** (пик: 800) - Средний размер страницы: 500 КБ - Хранилище за месяц: 1B × 500 КБ = **500 ТБ/месяц** - Хранилище за 5 лет: 500 ТБ × 12 × 5 = **30 ПБ**
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Предлагаем высокоуровневый дизайн, показанный на Рисунке 2.
Ключевые компоненты
- **Начальные URL (Seed URLs):** Отправная точка процесса обхода. Выбираются по географии или теме. - **Очередь URL (URL Frontier):** Хранит URL для загрузки (очередь FIFO). Обеспечивает вежливость, приоритизацию и актуальность. - **Загрузчик HTML (HTML Downloader):** Загружает веб-страницы через HTTP. - **DNS Resolver:** Преобразует URL в IP-адреса. - **Парсер контента (Content Parser):** Разбирает и проверяет загруженные страницы. Отдельный от сервера обхода для предотвращения замедления. - **Проверка дублей контента:** Обнаружение дублирующегося контента через сравнение хешей [7]. ~29% страниц в интернете — дубликаты [6]. - **Хранилище контента (Content Storage):** Система хранения HTML-контента. Популярный контент в памяти, остальной — на диске. - **Извлечение URL (URL Extractor):** Разбирает и извлекает ссылки с HTML-страниц. Конвертирует относительные пути в абсолютные URL (Рисунок 3). - **Фильтр URL (URL Filter):** Исключает определённые типы контента, расширения файлов, ссылки с ошибками и заблокированные сайты. - **Проверка посещённых URL:** Структура данных (bloom filter + хеш-таблица) для отслеживания посещённых URL. - **Хранилище URL (URL Storage):** Хранит уже посещённые URL.
Рабочий процесс веб-краулера
Рисунок 4 показывает пошаговый процесс:
1. Добавить начальные URL в очередь URL. 2. HTML Downloader получает список URL из очереди. 3. HTML Downloader получает IP-адреса от DNS resolver и начинает загрузку. 4. Парсер контента разбирает HTML-страницы и проверяет их корректность. 5. Разобранный контент передаётся в компонент «Проверка дублей». 6. «Проверка дублей» проверяет наличие страницы в хранилище: - Если есть → отбросить (дублирующийся контент по другому URL). - Если нет → передать в извлечение ссылок. 7. Извлечение ссылок извлекает их из HTML-страниц. 8. Извлечённые ссылки передаются в фильтр URL. 9. Отфильтрованные ссылки передаются в «Проверку посещённых URL». 10. «Проверка посещённых URL» проверяет наличие в хранилище; если есть — ничего не делаем. 11. Если URL ещё не обрабатывался — добавить в очередь URL.
Шаг 3 — Детальный дизайн
Ключевые темы: DFS vs BFS, очередь URL, HTML Downloader, надёжность, расширяемость и проблемный контент.
DFS vs BFS
Интернет можно представить как направленный граф, где веб-страницы — узлы, а гиперссылки — рёбра. DFS обычно не подходит — глубина может быть очень большой.
BFS широко применяется и реализуется очередью FIFO. Однако у него две проблемы:
1. **Проблема вежливости:** Большинство ссылок одной страницы — внутренние, из-за чего краулер перегружает один и тот же хост (Рисунок 5). 2. **Проблема приоритетов:** Стандартный BFS не учитывает важность URL. Нужна приоритизация по PageRank, трафику, частоте обновлений.
Очередь URL
Очередь URL решает проблемы BFS: вежливость, приоритизацию и актуальность.
**Вежливость:** Загружать одну страницу за раз с одного хоста. Между задачами добавляется задержка.
Рисунок 6 показывает дизайн вежливости: - **Маршрутизатор очередей:** Гарантирует, что каждая очередь (b1, b2, …, bn) содержит URL только одного хоста. - **Таблица маппинга:** Отображает каждый хост на очередь. - **FIFO-очереди b1–bn:** Каждая содержит URL одного хоста. - **Селектор очередей:** Назначает рабочие потоки на FIFO-очереди. - **Рабочие потоки 1–N:** Загружают страницы по одной с одного хоста с задержкой.
**Приоритизация:** Ранжируем URL по полезности (PageRank [10], трафик, частота обновлений).
Рисунок 7 показывает дизайн приоритизации: - **Приоритизатор:** Принимает URL и вычисляет приоритеты. - **Очереди f1–fn:** Каждой назначен приоритет. Очереди с более высоким приоритетом выбираются чаще. - **Селектор очередей:** Выбирает очередь случайно со смещением в сторону высокого приоритета.
Рисунок 8 показывает полный дизайн очереди URL с передними очередями (управление приоритетами) и задними очередями (управление вежливостью).
**Актуальность:** Страницы нужно периодически перекачивать: - Перекачивать на основе истории обновлений. - Приоритизировать важные страницы для более частого перекачивания.
**Хранилище:** Гибридный подход — большинство данных на диске, буферы в памяти для постановки/снятия из очереди, периодически сбрасываются на диск.
HTML Downloader
**Robots.txt (Протокол исключения роботов):** Стандарт для общения сайтов с краулерами. Указывает, какие страницы краулерам разрешено загружать. Результаты Robots.txt периодически кешируются.
Пример из `https://www.amazon.com/robots.txt`: ``` User-agent: Googlebot Disallow: /creatorhub/* Disallow: /rss/people/*/reviews Disallow: /gp/pdp/rss/*/reviews Disallow: /gp/cdp/member-reviews/ Disallow: /gp/aw/cr/ ```
**Оптимизации производительности:**
1. **Распределённый обход:** Задачи обхода распределяются по нескольким серверам с несколькими потоками каждый. Пространство URL делится на части (Рисунок 9).
2. **Кеш DNS Resolver:** DNS-разрешение занимает 10–200 мс и блокирует другие потоки. Ведём кеш маппинга доменных имён на IP, обновляемый cron-задачами.
3. **Локальность:** Распределённое размещение серверов обхода. Серверы ближе к хостам сайтов загружают быстрее.
4. **Короткий таймаут:** Устанавливаем максимальное время ожидания. Если хост не отвечает — краулер прерывает задачу и переходит к другим страницам.
Надёжность
- **Согласованное хеширование:** Распределение нагрузки между загрузчиками. Добавление и удаление серверов без проблем. - **Сохранение состояния обхода:** Защита от сбоев. Прерванный обход можно перезапустить с сохранённого состояния. - **Обработка исключений:** Краулер должен обрабатывать исключения без аварийного завершения. - **Валидация данных:** Предотвращение системных ошибок из-за некорректных входных данных.
Расширяемость
Краулер можно расширить, подключая новые модули (Рисунок 10):
- **Модуль загрузки PNG:** Подключается для загрузки PNG-файлов. - **Модуль веб-мониторинга:** Добавляется для мониторинга нарушений авторских прав и товарных знаков.
Обнаружение и избегание проблемного контента
1. **Дублирующийся контент:** ~30% веб-страниц — дубликаты. Для обнаружения используются хеши или контрольные суммы [11].
2. **Ловушки для краулеров (Spider traps):** Страницы, вызывающие бесконечный цикл (например, бесконечные вложенные директории). Решение — ограничение максимальной длины URL. Сайты с ловушками определяются по аномально большому числу обнаруженных страниц.
3. **Информационный шум:** Реклама, фрагменты кода, спам-ссылки — по возможности исключаются.
Шаг 4 — Подведение итогов
Мы обсудили характеристики хорошего краулера: масштабируемость, вежливость, расширяемость и надёжность.
**Дополнительные темы для обсуждения:**
- **Серверный рендеринг:** Сайты с JavaScript/AJAX генерируют ссылки динамически. Нужен серверный рендеринг перед разбором [12]. - **Фильтрация нежелательных страниц:** Антиспам-компонент для фильтрации низкокачественных и спамерских страниц [13] [14]. - **Репликация и шардирование базы данных:** Улучшение доступности, масштабируемости и надёжности уровня данных. - **Горизонтальное масштабирование:** Для крупномасштабного обхода нужны сотни или тысячи серверов. Серверы должны быть без состояния. - **Доступность, согласованность и надёжность:** Основа успеха любой крупной системы. - **Аналитика:** Сбор и анализ данных важны для тонкой настройки.
Проектирование системы уведомлений
Система уведомлений — одна из наиболее популярных функций во многих приложениях. Она оповещает пользователей о важной информации: срочных новостях, обновлениях продуктов, событиях, акциях и т.д. В этой главе мы проектируем систему уведомлений.
Уведомление — это не только мобильные push-уведомления. Существует три типа: мобильные push-уведомления, SMS-сообщения и электронная почта. Рисунок 1 показывает примеры каждого из них.
Шаг 1 — Понять задачу и определить рамки
**Требования:**
- Поддержка push-уведомлений, SMS и email. - Мягкое реальное время. Уведомления должны доставляться как можно быстрее; небольшая задержка при высокой нагрузке допустима. - Поддерживаемые устройства: iOS, Android, ноутбук/десктоп. - Уведомления инициируются клиентскими приложениями или планируются на стороне сервера. - Пользователи могут отказаться от получения уведомлений. - 10 млн мобильных push-уведомлений, 1 млн SMS и 5 млн email в день.
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
В этом разделе представлены высокоуровневые дизайны для различных типов уведомлений: iOS push, Android push, SMS и email.
Различные типы уведомлений
**iOS push-уведомления:**
Рисунок 2 показывает, как работают iOS push-уведомления.
Для отправки iOS push-уведомления нужны три компонента: - **Провайдер (Provider):** Формирует и отправляет запросы уведомлений в Apple Push Notification Service (APNs). Запрос включает токен устройства (device token) и полезную нагрузку (payload). Токен устройства — уникальный идентификатор для отправки уведомлений. Payload — JSON-словарь с деталями уведомления. - **APNs:** Удалённый сервис Apple для доставки push-уведомлений на iOS-устройства. - **iOS-устройство:** Конечный клиент, получающий push-уведомления.
**Android push-уведомления:**
Android использует аналогичный поток. Вместо APNs применяется Firebase Cloud Messaging (FCM) для отправки push-уведомлений на Android-устройства (Рисунок 3).
**SMS-сообщения:**
Для SMS используются сторонние сервисы: Twilio [1], Nexmo [2] и другие (Рисунок 4).
**Email:**
Многие компании используют коммерческие email-сервисы. Рисунок 5 показывает поток email-уведомлений. Sendgrid [3] и Mailchimp [4] — самые популярные сервисы с высокой доставляемостью и аналитикой.




Сбор контактной информации
Для отправки уведомлений нужно собрать токены мобильных устройств, телефонные номера или email-адреса. Как показано на Рисунке 6, при установке приложения или первичной регистрации API-серверы собирают контактную информацию и сохраняют в базе.
Рисунок 7 показывает упрощённые таблицы базы данных для хранения контактных данных. Email и телефон хранятся в таблице пользователей. Токены устройств — в таблице устройств. У пользователя может быть несколько устройств — push-уведомления отправляются на все.


Поток отправки/получения уведомлений
Рисунок 8 показывает таблицы базы данных: пользователи, устройства, уведомления.
**Высокоуровневый дизайн (Рисунок 9):**
- **Сервисы 1–N:** Могут быть микросервисами, cron-задачами или распределёнными системами, инициирующими отправку уведомлений. - **Система уведомлений:** Центральный элемент. Начально используется один сервер уведомлений. Предоставляет API для сервисов 1–N и формирует полезные нагрузки для сторонних сервисов. - **Сторонние сервисы:** Отвечают за доставку уведомлений пользователям. Интеграция требует тщательного рассмотрения с учётом возможной смены провайдеров. - **iOS, Android, SMS, Email:** Пользователи получают уведомления на устройствах.
**Проблемы исходного дизайна:** - Единая точка отказа (SPOF): Один сервер уведомлений — любой сбой останавливает систему. - Сложно масштабировать: Все задачи обрабатываются одним сервером. - Узкое место производительности: Обработка и отправка уведомлений ресурсоёмки; один сервер может перегрузиться.

Улучшенный высокоуровневый дизайн
Рисунок 10 показывает улучшенный дизайн.
- **Вынести базу данных и кеш из сервера уведомлений.** - **Добавить несколько серверов уведомлений с автоматическим горизонтальным масштабированием.** - **Ввести очереди сообщений для разъединения компонентов.**
Компоненты: - **Сервисы 1–N:** Отправляют уведомления через API серверов уведомлений. - **Серверы уведомлений:** Предоставляют API для сервисов; базовая валидация email, телефонов; получение данных из базы/кеша; постановка данных уведомлений в очереди для параллельной обработки. - **Кеш:** Информация о пользователях, устройствах, шаблоны уведомлений. - **БД:** Данные пользователей, уведомлений, настроек. - **Очереди сообщений:** Буферы при высоких объёмах уведомлений. Для каждого типа уведомлений — отдельная очередь. - **Воркеры:** Извлекают события уведомлений из очередей и отправляют в сторонние сервисы. - **Сторонние сервисы, iOS, Android, SMS, Email:** Как в исходном дизайне.

Шаг 3 — Детальный дизайн
Ключевые темы: надёжность, дополнительные компоненты и соображения, обновлённый дизайн.
Надёжность
**Как предотвратить потерю данных?**
Уведомления обычно нельзя терять. Для сохранения используется база данных журнала уведомлений (Рисунок 11). Журнал содержит: ID уведомления, тип, ID пользователя, канал, статус и т.д.
**Будут ли получатели получать уведомление ровно один раз?**
Краткий ответ — нет. Хотя уведомления в большинстве случаев доставляются один раз, распределённая природа системы может привести к дублированию. Для уменьшения дублей вводим механизм дедупликации: - При поступлении события уведомления проверяем, видели ли мы этот event ID ранее. - Если видели — отбрасываем. Иначе — отправляем уведомление.
Дополнительные компоненты и соображения
**Шаблоны уведомлений:** Крупная система отправляет миллионы уведомлений в день, многие по схожему формату. Шаблоны уведомлений позволяют не создавать каждое уведомление с нуля. Шаблон — предварительно отформатированное уведомление с настраиваемыми параметрами, стилями и ссылками отслеживания.
**Настройки уведомлений:** Пользователям предоставляется тонкий контроль над настройками. Перед отправкой уведомления проверяем, подписан ли пользователь на этот тип.
**Ограничение запросов:** Чтобы не перегружать пользователей, ограничиваем число уведомлений. Это важно — при слишком частой отправке пользователи могут полностью отключить уведомления.
**Механизм повторных попыток:** При сбое стороннего сервиса уведомление добавляется в очередь для повтора. Если проблема сохраняется — разработчики получают предупреждение.
**Безопасность push-уведомлений:** Для iOS и Android приложений appKey и appSecret защищают API push-уведомлений. Только аутентифицированные клиенты могут отправлять уведомления через наши API.
**Мониторинг очередей уведомлений:** Ключевая метрика — общее число уведомлений в очереди. Если число велико — воркеры не успевают обрабатывать события. Добавляем воркеров (Рисунок 12).
**Отслеживание событий:** Метрики уведомлений — открываемость, кликабельность, вовлечённость — важны для понимания поведения пользователей. Аналитический сервис реализует отслеживание событий (Рисунок 13).
Обновлённый дизайн
Рисунок 14 показывает обновлённый дизайн системы уведомлений, включающий: журнал уведомлений, настройки уведомлений, ограничение запросов, механизм повторных попыток, безопасность, мониторинг и отслеживание событий.

Шаг 4 — Подведение итогов
Уведомления незаменимы — они информируют нас о важных событиях. Мы описали дизайн системы, поддерживающей push-уведомления, SMS и email.
Для разъединения компонентов системы используются очереди сообщений.
Дополнительные темы для обсуждения:
- **Надёжность:** Механизм повторных попыток для минимизации сбоев. - **Безопасность:** Пара appKey/appSecret — только верифицированные клиенты отправляют уведомления. - **Отслеживание и мониторинг:** Отслеживание событий и мониторинг для обеспечения работоспособности системы. - **Учёт настроек пользователя:** Пользователи могут отказаться от уведомлений. Система уважает их настройки. - **Ограничение запросов:** Лимит частоты уведомлений для пользователей.
Проектирование системы ленты новостей
В этой главе вас просят спроектировать систему ленты новостей. Что такое лента новостей? По данным справочной страницы Facebook: «Лента новостей — это постоянно обновляемый список историй в центре вашей домашней страницы. Она включает обновления статусов, фото, видео, ссылки, активность приложений и лайки от людей, страниц и групп, на которые вы подписаны» [1]. Это популярный вопрос на собеседованиях. Похожие вопросы: спроектировать ленту новостей Facebook, ленту Instagram, временну́ю шкалу Twitter и т.д.
Шаг 1 — Понять задачу и определить рамки
**Требования:**
- Поддержка мобильного и веб-приложения. - Пользователь может публиковать посты и видеть посты друзей в ленте. - Лента новостей упорядочена в обратном хронологическом порядке. - У пользователя может быть до 5000 друзей. - 10 миллионов DAU. - Лента может содержать медиафайлы: изображения и видео.
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Дизайн разделён на два потока: публикация поста и формирование ленты.
- **Публикация поста:** при публикации соответствующие данные записываются в кеш и базу данных. Пост распространяется в ленты друзей. - **Формирование ленты:** для простоты лента формируется из постов друзей в обратном хронологическом порядке.
**API ленты новостей:**
API основаны на HTTP. Два наиболее важных:
**API публикации поста:** ``` POST /v1/me/feed Параметры: content: текст поста auth_token: аутентификация API-запросов ```
**API получения ленты:** ``` GET /v1/me/feed Параметры: auth_token: аутентификация API-запросов ```
Публикация поста
Рисунок 2 показывает высокоуровневый дизайн потока публикации поста.
- **Пользователь:** публикует пост через API `/v1/me/feed?content=Hello&auth_token={auth_token}` - **Балансировщик нагрузки:** распределяет трафик по веб-серверам. - **Веб-серверы:** перенаправляют трафик внутренним сервисам. - **Сервис постов:** сохраняет пост в базе данных и кеше. - **Fanout-сервис:** распространяет новый контент в ленты друзей. Данные ленты хранятся в кеше для быстрого получения. - **Сервис уведомлений:** оповещает друзей о новом контенте и отправляет push-уведомления.

Формирование ленты
Рисунок 3 показывает высокоуровневый дизайн потока формирования ленты.
- **Пользователь:** отправляет запрос на получение ленты: `/v1/me/feed` - **Балансировщик нагрузки:** перенаправляет трафик веб-серверам. - **Веб-серверы:** маршрутизируют запросы в сервис ленты. - **Сервис ленты:** получает ленту новостей из кеша. - **Кеш ленты:** хранит ID постов, необходимых для формирования ленты.

Шаг 3 — Детальный дизайн
Высокоуровневый дизайн кратко охватил два потока: публикацию поста и формирование ленты. Здесь обсудим их подробнее.
Детальный разбор публикации поста
Рисунок 4 описывает детальный дизайн публикации поста. Сосредоточимся на веб-серверах и fanout-сервисе.
**Веб-серверы:** Помимо общения с клиентами, веб-серверы обеспечивают аутентификацию и ограничение запросов. Публиковать посты могут только пользователи с действительным auth_token. Система ограничивает количество постов за период — важно для предотвращения спама.
**Fanout-сервис:** Fanout — процесс доставки поста всем друзьям. Две модели:
**Fanout при записи (push-модель):** Лента предварительно формируется при записи. Новый пост немедленно доставляется в кеши друзей. - *Плюсы:* Лента генерируется в реальном времени; быстрое получение, так как предварительно вычислено. - *Минусы:* Медленно для пользователей с большим числом друзей (проблема горячих ключей); расход ресурсов на неактивных пользователей.
**Fanout при чтении (pull-модель):** Лента генерируется при чтении. Недавние посты загружаются при открытии домашней страницы. - *Плюсы:* Нет расхода ресурсов на неактивных пользователей; нет проблемы горячих ключей. - *Минусы:* Медленное получение ленты (не предварительно вычислено).
**Гибридный подход:** Push-модель для большинства пользователей. Для знаменитостей или пользователей с большим числом подписчиков — pull-модель по требованию. Согласованное хеширование помогает снизить проблему горячих ключей.

Fanout-сервис
Рисунок 5 показывает детальный поток fanout-сервиса:
1. Получить ID друзей из графовой базы данных. Графовые базы хорошо подходят для управления связями. 2. Получить информацию о друзьях из кеша пользователей. Фильтровать по настройкам (заглушённые пользователи, ограниченные посты). 3. Отправить список друзей и ID нового поста в очередь сообщений. 4. Fanout-воркеры читают данные из очереди и сохраняют в кеш ленты как таблицу маппинга `<post_id, user_id>`. Хранятся только ID для экономии памяти. Настраиваемый лимит. 5. Сохранить `<post_id, user_id>` в кеш ленты (Рисунок 6).
**Рисунок 6 — Таблица кеша ленты:**
| post_id | user_id | |---------|---------|
Детальный разбор получения ленты
Рисунок 7 иллюстрирует детальный дизайн получения ленты. Медиаконтент (изображения, видео) хранится в CDN для быстрого доступа.
1. Пользователь отправляет запрос: `/v1/me/feed` 2. Балансировщик перераспределяет запросы по веб-серверам. 3. Веб-серверы вызывают сервис ленты для получения постов. 4. Сервис ленты получает список ID постов из кеша ленты. 5. Сервис ленты получает полные объекты пользователей и постов из кешей для формирования полностью заполненной ленты. 6. Полностью заполненная лента возвращается клиенту в формате JSON для рендеринга.

Архитектура кеша
Кеш критически важен для системы ленты новостей. Уровень кеша делится на 5 слоёв (Рисунок 8):
- **Лента новостей:** Хранит ID постов ленты. - **Контент:** Хранит данные каждого поста. Популярный контент в горячем кеше. - **Социальный граф:** Хранит данные о связях пользователей. - **Действия:** Хранит информацию о лайках, ответах и других действиях пользователей. - **Счётчики:** Хранит счётчики лайков, ответов, подписчиков, подписок и т.д.
Шаг 4 — Подведение итогов
Мы спроектировали систему ленты новостей с двумя потоками: публикацией и получением ленты.
**Масштабирование базы данных:** - Вертикальное vs горизонтальное масштабирование - SQL vs NoSQL - Репликация мастер-реплика - Реплики для чтения - Модели согласованности - Шардинг базы данных
**Другие темы для обсуждения:** - Веб-уровень без состояния - Максимальное использование кеша - Поддержка нескольких дата-центров - Слабосвязанные компоненты через очереди сообщений - Мониторинг ключевых метрик (QPS в часы пик, задержка при обновлении ленты)
Проектирование системы чатов
В этой главе мы разберём дизайн системы чатов. Чат-приложения используют почти все. Они выполняют разные функции: чат один на один (Facebook Messenger, WeChat, WhatsApp), рабочий чат (Slack), игровой чат (Discord).

Шаг 1 — Понять задачу и определить рамки
**Требования:**
- Поддержка чата один на один и группового чата. - Мобильное и веб-приложение. - 50 миллионов DAU. - Максимальный размер группового чата — 100 человек. - Функции: чат 1:1, групповой чат, индикатор онлайн. Только текстовые сообщения. - Ограничение размера сообщения: менее 100 000 символов. - Сквозное шифрование: не требуется на данном этапе. - История чатов хранится вечно.
**Ключевые функции:** - Чат один на один с низкой задержкой доставки - Небольшой групповой чат (максимум 100 человек) - Индикатор присутствия онлайн - Поддержка нескольких устройств (один аккаунт на нескольких устройствах) - Push-уведомления
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Клиенты не общаются напрямую друг с другом. Каждый клиент подключается к чат-сервису. Чат-сервис должен: - Принимать сообщения от других клиентов. - Находить правильных получателей и пересылать сообщения. - Если получатель не в сети — хранить сообщения до его появления.
Рисунок 2 показывает взаимодействие клиентов (отправитель и получатель) с чат-сервисом.

Поллинг
Как показано на Рисунке 3, поллинг — техника, при которой клиент периодически спрашивает сервер о наличии сообщений. В зависимости от частоты, поллинг может быть дорогостоящим: потребляет ресурсы сервера для ответа, который в большинстве случаев будет «нет».
Long polling
Так как поллинг неэффективен, следующий шаг — long polling (Рисунок 4). При long polling клиент удерживает соединение открытым до появления новых сообщений или истечения таймаута. Получив новые сообщения, сразу отправляет новый запрос.
**Недостатки:** - Отправитель и получатель могут быть подключены к разным серверам (HTTP-серверы без состояния). - Сервер не может надёжно определить, отключился ли клиент. - Неэффективно для пользователей, редко общающихся — long polling всё равно создаёт периодические соединения.
WebSocket
WebSocket — наиболее распространённое решение для асинхронных обновлений от сервера к клиенту (Рисунок 5). Соединение WebSocket инициируется клиентом. Оно двунаправленное и постоянное. Начинается как HTTP-соединение и «улучшается» через рукопожатие. Работает через брандмауэры по портам 80 или 443.
Рисунок 6 показывает WebSocket (ws) как для отправителя, так и для получателя. Использование WebSocket для обоих упрощает дизайн и реализацию. Поскольку соединения WebSocket постоянные, эффективное управление соединениями на стороне сервера критически важно.

Высокоуровневый дизайн
WebSocket выбран как основной протокол для двунаправленной связи. Однако большинство функций (регистрация, вход, профиль) использует традиционный HTTP запрос-ответ.
Как показано на Рисунке 7, система чатов делится на три категории: сервисы без состояния, сервисы с состоянием и сторонние интеграции.
- **Сервисы без состояния:** Обрабатывают вход, регистрацию, профиль пользователя и т.д. За балансировщиком нагрузки. Обнаружение сервисов предоставляет клиентам список DNS-имён серверов чатов. - **Сервис с состоянием:** Чат-сервис. Каждый клиент поддерживает постоянное WebSocket-соединение с сервером чата. - **Сторонние интеграции:** Push-уведомления — важнейшая интеграция. Оповещает пользователей о новых сообщениях даже когда приложение не запущено.
Рисунок 8 показывает скорректированный высокоуровневый дизайн: - Клиент поддерживает постоянное WebSocket-соединение с сервером чата для обмена в реальном времени. - Серверы чатов обеспечивают отправку/получение сообщений. - Серверы присутствия управляют статусом онлайн/офлайн. - API-серверы обрабатывают вход, регистрацию, изменение профиля. - Серверы уведомлений отправляют push-уведомления. - Хранилище ключ-значение хранит историю чатов.


Хранилище
В системе чатов существует два типа данных:
1. **Общие данные** (профиль пользователя, настройки, список друзей): Хранятся в реляционных базах. Репликация и шардинг для доступности и масштабируемости.
2. **История чатов**: Ключевые характеристики: - Огромный объём данных (Facebook Messenger и WhatsApp обрабатывают 60 млрд сообщений/день [2]). - Только недавние чаты используются часто. - Нужен случайный доступ для поиска, упоминаний, перехода к конкретным сообщениям. - Соотношение чтение/запись примерно 1:1 для чата 1:1.
**Рекомендуются хранилища ключ-значение:** - Лёгкое горизонтальное масштабирование. - Очень низкая задержка доступа к данным. - Реляционные базы плохо справляются с длинным хвостом данных. - Проверено в production: Facebook Messenger использует HBase [4], Discord — Cassandra [5].
Модели данных
**Таблица сообщений для чата 1:1 (Рисунок 9):** Первичный ключ — `message_id`, определяет порядок сообщений. Нельзя использовать `created_at`, так как два сообщения могут быть созданы одновременно.
**Таблица сообщений для группового чата (Рисунок 10):** Составной первичный ключ `(channel_id, message_id)`. `channel_id` — ключ партиции, так как все запросы в групповом чате работают в рамках канала.
**Генерация message_id:** Должна удовлетворять: - ID должны быть уникальными. - ID должны быть сортируемыми по времени (новые строки имеют больший ID).
Подходы: - `auto_increment` в MySQL — недоступно в NoSQL. - Глобальный 64-битный генератор последовательных номеров, например Snowflake (глава 8). - Локальный генератор — ID уникальны только в рамках группы/канала.


Шаг 3 — Детальный дизайн
Ключевые темы: обнаружение сервисов, потоки сообщений, индикаторы онлайн/офлайн.
Обнаружение сервисов
Обнаружение сервисов рекомендует лучший сервер чата для клиента на основе геолокации, мощности сервера и т.д. Apache Zookeeper [7] — популярное решение с открытым исходным кодом.
Рисунок 11 показывает, как работает Zookeeper: 1. Пользователь A пытается войти в систему. 2. Балансировщик нагрузки отправляет запрос на вход на API-серверы. 3. После аутентификации обнаружение сервисов находит лучший сервер для пользователя A (выбирается сервер 2). 4. Пользователь A подключается к серверу чата 2 через WebSocket.

Поток чата 1:1
Рисунок 12 объясняет, что происходит когда пользователь A отправляет сообщение пользователю B:
1. Пользователь A отправляет сообщение на сервер чата 1. 2. Сервер чата 1 получает message_id от генератора ID. 3. Сервер чата 1 отправляет сообщение в очередь синхронизации. 4. Сообщение сохраняется в хранилище ключ-значение. 5a. Если пользователь B онлайн — сообщение пересылается на сервер чата 2, где подключён пользователь B. 5b. Если пользователь B офлайн — отправляется push-уведомление. 6. Сервер чата 2 пересылает сообщение пользователю B через постоянное WebSocket-соединение.

Синхронизация сообщений на нескольких устройствах
Рисунок 13 показывает синхронизацию сообщений между устройствами. Каждое устройство поддерживает переменную `cur_max_message_id`, отслеживающую последний message_id на устройстве.
Сообщения считаются новыми, если: - ID получателя совпадает с ID текущего пользователя. - message_id в KV-хранилище больше `cur_max_message_id`.
С отдельным `cur_max_message_id` на каждом устройстве каждое может независимо получать новые сообщения из KV-хранилища.

Поток небольшого группового чата
Рисунок 14 показывает, что происходит, когда пользователь A отправляет сообщение в групповой чат (3 участника: A, B, C). Сообщение копируется в очередь синхронизации каждого участника — одна для пользователя B, одна для C. Очередь синхронизации — это входящий ящик получателя.
Этот дизайн хорош для небольших групповых чатов: - Упрощает синхронизацию — каждый клиент проверяет только свой входящий ящик. - Хранение копии в ящике каждого получателя не накладно для небольших групп.
WeChat ограничивает группы 500 участниками [8]. Для больших групп хранить копию для каждого неприемлемо.
Рисунок 15 показывает входящий ящик получателя — очередь синхронизации с сообщениями от разных отправителей.


Онлайн-присутствие
Серверы присутствия управляют статусом онлайн и взаимодействуют с клиентами через WebSocket.
**Вход пользователя (Рисунок 16):** После установки WebSocket-соединения онлайн-статус пользователя A и метка `last_active_at` сохраняются в KV-хранилище.
**Выход пользователя (Рисунок 17):** Онлайн-статус меняется на офлайн в KV-хранилище.
**Отключение пользователя:** Наивный подход (офлайн при отключении, онлайн при переподключении) ухудшает UX — пользователи часто кратковременно отключаются (например, в тоннеле).
**Механизм сердцебиения (Рисунок 18):** Онлайн-клиенты периодически отправляют событие сердцебиения на серверы присутствия. Если сердцебиение получено в течение x секунд — пользователь онлайн. Иначе — офлайн. Пример: клиент отправляет сердцебиение каждые 5 секунд; отсутствие сердцебиения 30 секунд → статус офлайн.
**Распространение статуса онлайн (Рисунок 19):** Серверы присутствия используют модель публикации-подписки. Для каждой пары друзей — отдельный канал. Когда статус пользователя A меняется, он публикуется в каналы A-B, A-C, A-D. На эти каналы подписаны соответственно B, C, D.
Для больших групп (100 000 участников) смена статуса генерирует 100 000 событий — слишком накладно. Решение: загружать статус онлайн только при входе пользователя в группу или ручном обновлении списка друзей.



Шаг 4 — Подведение итогов
Мы представили систему чатов с поддержкой чата 1:1 и небольшого группового чата. WebSocket используется для общения в реальном времени. Компоненты: серверы чатов, серверы присутствия, серверы push-уведомлений, KV-хранилища, API-серверы.
**Дополнительные темы для обсуждения:**
- **Медиафайлы:** Сжатие, облачное хранилище и миниатюры для фото и видео. - **Сквозное шифрование:** Только отправитель и получатель могут читать сообщения (как в WhatsApp [9]). - **Кеширование на стороне клиента:** Сокращает передачу данных между клиентом и сервером. - **Географически распределённая сеть:** Подход Slack для кеширования данных пользователей [10]. - **Обработка ошибок:** - Сбой сервера чата: обнаружение сервисов (Zookeeper) предоставляет новый сервер для переподключения. - Механизм повторной отправки: повторные попытки и очереди для неудачной доставки.
Проектирование системы автодополнения поиска
При поиске в Google или на Amazon по мере набора текста в строке поиска отображаются варианты автодополнения. Эта функция называется автодополнением, typeahead, поиском при вводе или инкрементальным поиском. Вопрос собеседования: спроектировать систему автодополнения поиска, также называемую «top k» или «top k наиболее популярных запросов».

Шаг 1 — Понять задачу и определить рамки
**Требования:**
- Совпадение только с начала поискового запроса. - Возвращать 5 подсказок автодополнения. - Подсказки определяются по популярности (исторической частоте запросов). - Нет проверки орфографии или автокоррекции. - Запросы только на английском языке (строчные буквы). - 10 миллионов DAU.
**Ключевые нефункциональные требования:** - Быстрый ответ: результаты в течение 100 миллисекунд [1]. - Релевантность: подсказки соответствуют поисковому запросу. - Сортировка: по популярности или моделям ранжирования. - Масштабируемость и высокая доступность.
**Быстрые оценки:** - 10М DAU × 10 поисков/день × 20 символов/запрос ≈ 24 000 QPS (пик: ~48 000) - 20% новых запросов в день: 10М × 10 × 20 байт × 20% = **0,4 ГБ новых данных/день**
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Система состоит из двух компонентов:
- **Сервис сбора данных:** Собирает поисковые запросы и агрегирует их в реальном времени (отправная точка; оптимизируется при детальном разборе). - **Сервис запросов:** По поисковому запросу или префиксу возвращает 5 наиболее часто встречающихся запросов.
Сервис сбора данных
Упрощённая таблица частот хранит строки запросов и их частоты. Рисунок 2 показывает, как таблица обновляется по мере ввода запросов «twitch», «twitter», «twitter» и «twillo».
Сервис запросов
Имея таблицу частот с полями Запрос и Частота, при вводе «tw» отображаются топ-5 запросов (Рисунок 3).
**Пример таблицы частот (Таблица 1):**
| Запрос | Частота | |--------|--------| | twitter | 35 | | twitch | 29 | | twilight | 25 | | twin peak | 21 | | twitch prime | 18 | | twitter search | 14 | | twillo | 10 | | twin peak sf | 8 |
SQL-запрос для получения топ-5 показан на Рисунке 4. Приемлемо для небольшого набора данных; для больших наборов база данных становится узким местом.

Шаг 3 — Детальный дизайн
Темы детального разбора: структура данных trie, сервис сбора данных, сервис запросов, масштабирование хранилища, операции trie.
Структура данных trie
Получение топ-5 запросов из реляционной базы неэффективно. Структура данных **trie (префиксное дерево)** решает эту проблему.
**Базовый trie:** Древовидная структура для компактного хранения строк. Корень — пустая строка. Каждый узел хранит символ и имеет 26 дочерних узлов (по одному на каждый символ). Каждый узел представляет одно слово или префикс.
Рисунок 5 показывает trie с запросами «tree», «try», «true», «toy», «wish», «win».
Для поддержки сортировки по частоте в узлах добавляется информация о частоте (Рисунок 6).
**Алгоритм получения топ-k наиболее популярных запросов:**
Определения: p — длина префикса, n — всего узлов в trie, c — дочерние узлы.
1. Найти префикс. Время: O(p) 2. Обойти поддерево от узла-префикса для получения всех дочерних. Время: O(c) 3. Отсортировать и получить топ-k. Время: O(c log c)
Рисунок 7 показывает алгоритм для k=2 и префикса «tr»: - Шаг 1: Найти узел префикса «tr». - Шаг 2: Обойти поддерево: [tree: 10], [true: 35], [try: 29]. - Шаг 3: Отсортировать и получить топ-2: [true: 35] и [try: 29].
Общая сложность: O(p) + O(c) + O(c log c) — слишком медленно в худшем случае.
**Две оптимизации:**
1. **Ограничить максимальную длину префикса:** Пользователи редко вводят длинные запросы, поэтому ограничиваем p до ~50. Уменьшает «поиск префикса» с O(p) до O(1).
2. **Кешировать топ-k запросов в каждом узле (Рисунок 8):** Хранить топ-k запросов в каждом узле. Например, узел «be» хранит [best: 35, bet: 29, bee: 20, be: 15, beer: 10]. С обеими оптимизациями алгоритм занимает O(1).
Сервис сбора данных
Обновлять trie при каждом запросе нецелесообразно (миллиарды запросов/день; топ-подсказки меняются редко). Рисунок 9 показывает переработанный сервис сбора данных.
**Компоненты:** - **Аналитические логи:** Только-для-добавления исходные данные поисковых запросов. - **Агрегаторы:** Агрегируют исходные данные для обработки. Trie пересоздаётся еженедельно для большинства случаев. - **Агрегированные данные:** Еженедельная таблица запрос + частота. - **Воркеры:** Асинхронные серверы для создания trie и сохранения в Trie DB. - **Кеш trie:** Распределённый кеш с trie в памяти для быстрого чтения (еженедельный снимок БД). - **Trie DB:** Постоянное хранилище. Два варианта: 1. Документная БД (MongoDB) — сериализация снимка trie еженедельно. 2. Хранилище ключ-значение — каждый префикс trie → ключ, данные узла → значение (Рисунок 10).
Сервис запросов
Рисунок 11 показывает улучшенный дизайн сервиса запросов:
1. Поисковый запрос отправляется на балансировщик нагрузки. 2. Балансировщик направляет на API-серверы. 3. API-серверы получают данные trie из кеша и формируют подсказки автодополнения. 4. Промах кеша → пополнение данных из Trie DB в кеш.
**Оптимизации:** - **AJAX-запрос:** Браузер отправляет асинхронные запросы без перезагрузки всей страницы. - **Кеш браузера (Рисунок 12):** Подсказки кешируются в браузере (например, Google кеширует на 1 час через `cache-control: private, max-age=3600`). - **Выборка данных:** Логируется только 1 из N запросов для снижения нагрузки на хранилище.

Операции trie
**Создание:** Trie строится воркерами из агрегированных данных аналитических логов/БД.
**Обновление — два варианта:** 1. Заменять весь trie еженедельно новым. 2. Обновлять отдельные узлы напрямую (медленно; при обновлении узла нужно обновить всех предков до корня). Рисунок 13 показывает обновление «beer» с 10 до 30 — обновляются все предки.
**Удаление:** Удаление нежелательных, насильственных или опасных подсказок. Перед кешем trie добавляется фильтрующий слой (Рисунок 14). Физическое удаление из БД происходит асинхронно в следующем цикле обновления.

Масштабирование хранилища
Когда trie вырастает больше одного сервера, делаем шардинг по первому символу (до 26 серверов). Для большего числа серверов — по второму или третьему символу.
Простой шардинг по первому символу создаёт неравномерное распределение (слов на «c» значительно больше, чем на «x»). Для выравнивания анализируем историческое распределение и применяем умный шардинг через менеджер карты шардов (Рисунок 15). Карта шардов ведёт таблицу для определения расположения строк.
Шаг 4 — Подведение итогов
**Дополнительные темы для обсуждения:**
- **Поддержка нескольких языков:** Хранить символы Unicode в узлах trie. - **Запросы по стране:** Создавать отдельные trie для каждой страны; хранить в CDN для ускорения ответа. - **Актуальные (реальное время) поисковые запросы:** Более сложная задача: - Уменьшить рабочий набор данных через шардинг. - Изменить модель ранжирования с бо́льшим весом недавних запросов. - Системы потоковой обработки: Apache Hadoop MapReduce [6], Spark Streaming [7], Apache Storm [8], Apache Kafka [9].
Проектирование YouTube
В этой главе вас просят спроектировать YouTube. Решение применимо и к другим платформам видеохостинга: Netflix и Hulu.
**Статистика YouTube (2020):** - 2 миллиарда ежемесячных активных пользователей. - 5 миллиардов просмотров видео в день. - 73% взрослых жителей США используют YouTube. - 50 миллионов авторов контента. - $15,1 млрд дохода от рекламы в 2019 году (рост 36% по сравнению с 2018). - 37% всего мобильного интернет-трафика. - Доступен на 80 языках.

Шаг 1 — Понять задачу и определить рамки
**Требования:** - Функции: загрузка видео и просмотр видео. - Клиенты: мобильные приложения, веб-браузеры, Smart TV. - 5 миллионов DAU. - В среднем 30 минут в день на платформе. - Поддержка международных пользователей. - Поддержка большинства форматов и разрешений видео. - Требуется шифрование. - Максимальный размер видео: 1 ГБ. - Можно использовать существующую облачную инфраструктуру (Amazon, Google, Microsoft).
**Ключевые функции:** - Быстрая загрузка видео - Плавная потоковая передача видео - Возможность менять качество видео - Низкие затраты на инфраструктуру - Высокая доступность, масштабируемость и надёжность
**Быстрые оценки:** - Ежедневное хранилище: 5М × 10% загрузок × 300 МБ = **150 ТБ/день** - Стоимость CDN (CloudFront): 5М × 5 видео × 0,3 ГБ × $0,02/ГБ = **$150 000/день**

Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Используем готовые облачные сервисы (CDN и blob-хранилище), а не строим с нуля — создать масштабируемое blob-хранилище или CDN крайне сложно (Netflix использует AWS [4], Facebook — Akamai CDN [5]).
Система состоит из трёх компонентов (Рисунок 3): - **Клиент:** Компьютер, мобильный телефон, Smart TV. - **CDN:** Видео хранятся в CDN; при воспроизведении стримятся из CDN. - **API-серверы:** Всё, кроме потоковой передачи видео (рекомендации, генерация URL загрузки, метаданные БД/кеш, регистрация пользователей и т.д.).

Поток загрузки видео
Рисунок 4 показывает высокоуровневый поток загрузки видео с компонентами: - **Пользователь, балансировщик нагрузки, API-серверы** - **Metadata DB:** Шардинг и репликация для производительности и высокой доступности. - **Кеш метаданных:** Кеширует метаданные видео и объекты пользователей. - **Исходное хранилище:** Blob-хранилище для оригинальных видео. - **Серверы транскодирования:** Конвертируют видео в другие форматы (MPEG, HLS и т.д.) для разных устройств и скоростей. - **Хранилище транскодированных видео:** Blob-хранилище для транскодированных видео. - **CDN:** Видео кешируются в CDN и стримятся отсюда. - **Очередь завершения:** Очередь сообщений для событий завершения транскодирования. - **Обработчик завершения:** Воркеры, обновляющие кеш и базу метаданных.
Поток выполняет два параллельных процесса:
**Поток a — Загрузка самого видео (Рисунок 5):** 1. Видео загружается в исходное хранилище. 2. Серверы транскодирования получают и транскодируют видео. 3. Параллельно: - 3a. Транскодированные видео → хранилище → CDN. - 3b. События завершения → очередь → обработчик → Metadata DB/кеш. 4. API-серверы уведомляют клиента о готовности видео к стримингу.
**Поток b — Обновление метаданных видео (Рисунок 6):** Одновременно с загрузкой клиент отправляет запрос на обновление метаданных (имя файла, размер, формат). API-серверы обновляют кеш метаданных и базу данных.



Поток потоковой передачи видео
Стриминг — получение небольших чанков непрерывно; пользователи могут смотреть сразу без загрузки всего видео.
**Популярные протоколы стриминга:** - MPEG-DASH (Moving Picture Experts Group — Dynamic Adaptive Streaming over HTTP) - Apple HLS (HTTP Live Streaming) - Microsoft Smooth Streaming - Adobe HTTP Dynamic Streaming (HDS)
Видео стримятся напрямую из CDN; ближайший к пользователю граничный сервер доставляет видео с минимальной задержкой (Рисунок 7).

Шаг 3 — Детальный дизайн
Улучшаем оба потока с оптимизациями и механизмами обработки ошибок.
Транскодирование видео
**Почему транскодирование важно:** - Сырое видео огромное (HD-видео 60fps длительностью час = сотни ГБ). - Многие устройства/браузеры поддерживают только определённые форматы. - Высокое разрешение для быстрых соединений, низкое — для медленных. - Сетевые условия меняются, особенно на мобильных — адаптивное качество необходимо.
**Форматы кодирования состоят из двух частей:** - **Контейнер:** «Корзина» для видео, аудио и метаданных (`.avi`, `.mov`, `.mp4`). - **Кодеки:** Алгоритмы сжатия/распаковки (H.264, VP9, HEVC).
Модель направленного ациклического графа (DAG)
Транскодирование дорогостоящее, а требования авторов различны (водяные знаки, превью, HD). Используем DAG-модель по образцу движка видеострима Facebook [8] для поддержки разных пайплайнов обработки с высоким параллелизмом.
Рисунок 8 показывает DAG для транскодирования. Исходное видео разделяется на видео, аудио и метаданные. Применяемые задачи: - **Инспекция:** Проверка качества и корректности видео. - **Видеокодирование:** Конвертация в разные разрешения, кодеки, битрейты (Рисунок 9). - **Превью:** Загружается автором или генерируется автоматически. - **Водяной знак:** Наложение изображения с идентификационной информацией.
Архитектура транскодирования видео
Рисунок 10 показывает архитектуру транскодирования с шестью основными компонентами:
**Препроцессор (Рисунок 11):** Четыре обязанности: 1. Разделение видео на GOP-выровненные чанки — независимо воспроизводимые единицы, обычно несколько секунд. 2. Поддержка старых устройств без клиентского разделения. 3. Генерация DAG из файлов конфигурации (Рисунки 12, 13). 4. Кеширование сегментированных видео (GOP + метаданные) во временном хранилище для повтора при сбое.
**Планировщик DAG (Рисунок 14):** Разбивает DAG на этапы задач и помещает в очередь менеджера ресурсов. Рисунок 15 показывает этапы: Этап 1 (видео/аудио/метаданные), Этап 2 (видеокодирование + превью, аудиокодирование).
**Менеджер ресурсов (Рисунок 16):** Управляет распределением ресурсов с 3 очередями и планировщиком задач (Рисунок 17): - **Очередь задач:** Приоритетная очередь задач для выполнения. - **Очередь воркеров:** Приоритетная очередь информации о загрузке воркеров. - **Очередь выполнения:** Текущие задачи и воркеры. - **Планировщик задач:** Выбирает оптимальную задачу/воркер, инструктирует воркер, управляет очередью выполнения.
**Воркеры задач (Рисунок 18):** Выполняют задачи, определённые в DAG. Разные воркеры — разные задачи (Рисунок 19).
**Временное хранилище (Рисунок 20):** Метаданные кешируются в памяти, видео/аудио — в blob-хранилище. Освобождается после завершения обработки.
**Закодированное видео (Рисунок 21):** Финальный результат (например, `funny_720p.mp4`).

Оптимизации системы
**Скорость — параллельная загрузка видео (Рисунок 22):** Разбиваем видео на GOP-выровненные чанки. Обеспечивает возобновляемую загрузку при сбоях. Разбивку выполняет клиент (Рисунок 23).
**Скорость — центры загрузки рядом с пользователями (Рисунок 24):** Несколько центров загрузки глобально (Северная Америка, Азия и т.д.) с использованием CDN.
**Скорость — параллелизм везде (Рисунки 25, 26):** Последовательный поток (Рисунок 25) затрудняет параллелизм. После введения очередей сообщений (Рисунок 26) модуль кодирования не ждёт модуль загрузки — события в очереди обрабатываются независимо.
**Безопасность — предподписанные URL загрузки (Рисунок 27):** Поток: 1. Клиент запрашивает предподписанный URL у API-серверов. 2. API-серверы возвращают предподписанный URL. 3. Клиент загружает видео по предподписанному URL. (AWS называет это pre-signed URL; Azure — «Shared Access Signature» [10])
**Безопасность — варианты защиты видео:** - Управление цифровыми правами (DRM): Apple FairPlay, Google Widevine, Microsoft PlayReady. - AES-шифрование с политикой авторизации. - Визуальный водяной знак.
**Экономия затрат (Рисунок 28):** YouTube следует распределению длинного хвоста [11][12] — небольшое число популярных видео получает большинство просмотров. 1. Обслуживать из CDN только самые популярные видео; остальные — с серверов хранилища. 2. Менее популярный контент кодировать по требованию (меньше версий). 3. Популярные в конкретном регионе видео не распространять глобально. 4. Создать собственный CDN, как Netflix, и сотрудничать с провайдерами.



Обработка ошибок
**Восстановимые ошибки** (например, сбой транскодирования сегмента): несколько повторных попыток, затем возврат кода ошибки. **Невосстановимые ошибки** (например, повреждённое видео): остановить задачи, вернуть код ошибки.
**Порядок действий по компонентам:** - Ошибка загрузки: повторная попытка. - Ошибка разбивки видео: сервер берёт разбивку на себя, если старый клиент не поддерживает. - Ошибка транскодирования: повторная попытка. - Ошибка препроцессора: пересоздать DAG. - Ошибка планировщика DAG: перепланировать задачу. - Очередь менеджера ресурсов недоступна: использовать реплику. - Воркер задачи упал: повторить на новом воркере. - API-сервер упал: направить на другой без-состоянный API-сервер. - Кеш метаданных упал: читать с реплик; поднять новый кеш-сервер. - Мастер-сервер Metadata DB упал: продвинуть реплику в мастер. - Реплика Metadata DB упала: использовать другую реплику, поднять замену.
Шаг 4 — Подведение итогов
**Дополнительные темы для обсуждения:** - **Масштабирование API-уровня:** Без-состоянные API-серверы масштабируются горизонтально. - **Масштабирование базы данных:** Репликация и шардинг. - **Прямые трансляции (Live streaming):** Требования к задержке выше, другой протокол, меньше параллелизма (небольшие чанки в реальном времени), другая обработка ошибок. - **Удаление видео:** Удаление нарушений авторских прав, порнографии, незаконного контента — обнаруживается при загрузке или через жалобы пользователей.
Проектирование Google Drive
В этой главе вас просят спроектировать Google Drive — сервис хранения и синхронизации файлов, позволяющий хранить документы, фото, видео и другие файлы в облаке с доступом с любого компьютера, смартфона и планшета.

Шаг 1 — Понять задачу и определить рамки
**Функции в рамках задачи:** - Добавление файлов (перетаскивание). - Скачивание файлов. - Синхронизация файлов между несколькими устройствами. - Просмотр истории версий файла. - Общий доступ к файлам для друзей, родственников, коллег. - Уведомления при редактировании, удалении или совместном использовании файла.
**Вне рамок:** Редактирование и совместная работа в Google Docs.
**Требования:** - Любые типы файлов; файлы должны быть зашифрованы. - Максимальный размер файла: 10 ГБ. - 10 миллионов DAU.
**Нефункциональные требования:** - Надёжность: потеря данных недопустима. - Быстрая синхронизация. - Минимальное использование полосы пропускания. - Масштабируемость и высокая доступность.
**Быстрые оценки:** - 50М пользователей × 10 ГБ бесплатного места = **500 ПБ общего хранилища** - QPS загрузки: 10М × 2 загрузки / 86400 ≈ **240 QPS** (пик: 480)
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Начинаем с одного сервера, затем масштабируем: - Веб-сервер для загрузки/скачивания файлов. - MySQL база данных для метаданных (данные пользователей, авторизация, файлы). - Директория `drive/` как корневое хранилище (1 ТБ).
Внутри `drive/` — список директорий (пространств имён), каждое содержит все загруженные файлы пользователя. Файлы уникально идентифицируются по пространству имён и относительному пути.
Рисунок 3 показывает структуру директории `drive/`.
API
Три основных API:
**1. Загрузка файла:** - Простая загрузка: небольшие файлы. - Возобновляемая загрузка: большие файлы или нестабильные сети. ``` POST https://api.example.com/files/upload?uploadType=resumable Параметры: uploadType=resumable, data: локальный файл ``` Шаги возобновляемой загрузки: отправить исходный запрос → получить возобновляемый URL → загрузить данные и отслеживать → возобновить при прерывании.
**2. Скачивание файла:** ``` GET https://api.example.com/files/download Параметры: path: путь к файлу для скачивания ```
**3. Получение истории версий:** ``` GET https://api.example.com/files/list_revisions Параметры: path, limit ```
Все API требуют аутентификации и используют HTTPS/SSL.
Переход от одного сервера
По мере загрузки файлов хранилище заполняется (Рисунок 4). Решение: шардинг данных по нескольким серверам (Рисунок 5 — шардинг по user_id).
Для защиты от потери данных используется Amazon S3 с поддержкой репликации в пределах региона и между регионами (Рисунок 6). Репликация в нескольких регионах обеспечивает доступность и долговечность.
Дополнительные улучшения: - **Балансировщик нагрузки:** Распределяет трафик; перенаправляет при отказе веб-сервера. - **Больше веб-серверов:** Легко масштабировать после добавления балансировщика. - **База метаданных:** Вынести на отдельный сервер; настроить репликацию и шардинг. - **Хранилище файлов:** S3 с репликацией в двух географических регионах.
Рисунок 7 показывает обновлённый дизайн с разделёнными веб-серверами, базой метаданных и хранилищем файлов.


Конфликты синхронизации
Когда два пользователя одновременно изменяют один файл, возникает конфликт. **Стратегия: первая обработанная версия побеждает; более поздняя версия получает конфликт (Рисунок 8).**
Пользователь 2 получает конфликт синхронизации. Система показывает обе копии — локальную копию пользователя 2 и последнюю серверную версию (Рисунок 9). Пользователь 2 может объединить оба файла или заменить одну версию другой.
Высокоуровневый дизайн
Рисунок 10 показывает предлагаемый высокоуровневый дизайн с компонентами:
- **Пользователь:** Использует браузер или мобильное приложение. - **Блочные серверы (Block servers):** Загружают блоки в облачное хранилище. Файлы разбиваются на блоки (максимум 4 МБ каждый, как у Dropbox [6]), каждый с уникальным хеш-значением. Для восстановления файла блоки соединяются по порядку. - **Облачное хранилище:** Блоки файлов хранятся в S3. - **Холодное хранилище:** Для неактивных данных (файлов, не используемых месяцами/годами). - **Балансировщик нагрузки:** Распределяет запросы между API-серверами. - **API-серверы:** Аутентификация пользователей, управление профилем, обновление метаданных. - **База метаданных:** Хранит метаданные пользователей, файлов, блоков, версий. - **Кеш метаданных:** Кеширует часто используемые метаданные. - **Сервис уведомлений:** Система издатель/подписчик — уведомляет клиентов при добавлении/изменении/удалении файла. - **Очередь офлайн-резервирования:** Хранит ожидающие изменения для офлайн-клиентов; синхронизируется при подключении.

Шаг 3 — Детальный дизайн
Детальный разбор: блочные серверы, база метаданных, поток загрузки, поток скачивания, сервис уведомлений, экономия хранилища, обработка сбоев.
Блочные серверы
Две оптимизации для минимизации использования полосы пропускания:
1. **Delta sync:** Синхронизируются только изменённые блоки, а не весь файл [7][8]. 2. **Сжатие:** Блоки сжимаются алгоритмами в зависимости от типа файла (gzip/bzip2 для текста; другие алгоритмы для изображений/видео).
Рабочий процесс блочного сервера для нового файла (Рисунок 11): 1. Файл разбивается на блоки. 2. Каждый блок сжимается. 3. Каждый блок шифруется перед отправкой в облачное хранилище. 4. Блоки загружаются в облачное хранилище.
Для delta sync (Рисунок 12): загружаются только изменённые блоки («block 2» и «block 5»).

Требование строгой согласованности
Требуется строгая согласованность — файл не должен отображаться по-разному на разных клиентах одновременно.
- Кеши памяти по умолчанию используют конечную согласованность — нужно следить за синхронизацией реплик и мастера. - Инвалидировать кеши при записи в базу данных. - Выбираем **реляционные базы данных** — ACID-свойства (Атомарность, Согласованность, Изолированность, Долговечность) поддерживаются нативно [9]. NoSQL базы не поддерживают ACID по умолчанию.
База метаданных
Рисунок 13 показывает упрощённую схему базы данных:
- **User:** Имя пользователя, email, фото профиля. - **Device:** Данные устройства, push_id для мобильных уведомлений. У пользователя может быть несколько устройств. - **Namespace:** Корневая директория пользователя. - **File:** Актуальная информация о файле. - **File_version:** История версий. Существующие строки доступны только для чтения для сохранения целостности. - **Block:** Всё о блоке файла. Любую версию можно восстановить, соединив блоки в правильном порядке.
Поток загрузки
Рисунок 14 показывает диаграмму последовательности загрузки файла. Два параллельных запроса от клиента 1:
**Добавление метаданных файла:** 1. Клиент 1 отправляет запрос на добавление метаданных. 2. Метаданные сохраняются в базе со статусом «pending». 3. Уведомить сервис уведомлений о новом файле. 4. Сервис уведомлений оповещает клиент 2.
**Загрузка файла в облачное хранилище:** 2.1 Клиент 1 загружает контент на блочные серверы. 2.2 Блочные серверы разбивают, сжимают, шифруют и загружают блоки в облако. 2.3 Облачное хранилище вызывает коллбэк завершения на API-серверах. 2.4 Статус файла меняется на «uploaded» в базе метаданных. 2.5-2.6 Сервис уведомлений оповещает клиент 2 о завершении загрузки.
Поток скачивания
Клиент узнаёт об изменении файла через: - **Онлайн:** Сервис уведомлений сообщает об изменениях. - **Офлайн:** Данные сохраняются в кеш; загружаются при подключении.
Рисунок 15 показывает поток скачивания: 1. Сервис уведомлений сообщает клиенту 2 об изменении. 2. Клиент 2 запрашивает метаданные у API-серверов. 3-4. API-серверы получают метаданные из базы. 5. Клиент 2 получает метаданные. 6. Клиент 2 запрашивает блоки у блочных серверов. 7-8. Блочные серверы загружают блоки из облачного хранилища. 9. Клиент 2 загружает блоки и восстанавливает файл.
Сервис уведомлений
Варианты: **Long polling** (используется Dropbox [10]) vs **WebSocket**.
Выбираем **long polling** потому что: - Связь однонаправленная (сервер → клиент для изменений файлов). - Уведомления Google Drive нечастые и не являются потоками реального времени, как в чате.
С long polling каждый клиент устанавливает долгое соединение. При обнаружении изменения файла клиент закрывает соединение, получает последние изменения с сервера метаданных, затем немедленно открывает новое соединение.
Экономия пространства хранилища
Три техники для снижения затрат на хранение:
1. **Дедупликация блоков данных:** Два блока идентичны, если имеют одинаковое хеш-значение — устраняем избыточные блоки на уровне аккаунта. 2. **Умная стратегия резервного копирования:** - Ограничить число хранимых версий. - Хранить только ценные версии (ограничить часто изменяемые файлы). 3. **Холодное хранилище для редко используемых данных:** Amazon S3 Glacier [11] значительно дешевле стандартного S3.
Обработка сбоев
- **Сбой балансировщика нагрузки:** Резервный становится активным; мониторинг через сердцебиение. - **Сбой блочного сервера:** Другие серверы берут незавершённые задачи. - **Сбой облачного хранилища:** S3-бакеты реплицированы в разных регионах; получить из другого региона. - **Сбой API-сервера:** Без состояния; трафик перенаправляется балансировщиком. - **Сбой кеша метаданных:** Реплицирован; поднять новый сервер взамен. - **Сбой базы метаданных:** - Мастер упал: продвинуть реплику в мастер, поднять новую реплику. - Реплика упала: использовать другую; поднять замену. - **Сбой сервиса уведомлений:** Более 1М соединений на машину (по данным Dropbox 2012 [6]). При сбое все long-poll соединения теряются; клиенты переподключаются постепенно. - **Сбой офлайн-очереди резервирования:** Очереди реплицированы; потребители переподписываются на резервную очередь.
Шаг 4 — Подведение итогов
Мы спроектировали Google Drive с двумя потоками: управлением метаданными файлов и синхронизацией. Сервис уведомлений использует long polling для обновления клиентов.
**Обсуждение альтернативного дизайна:** - Загрузка файлов напрямую из клиента в облачное хранилище (минуя блочные серверы): быстрее, но требует логики разбивки/сжатия/шифрования на каждой платформе (сложно в обслуживании), а шифрование на стороне клиента — риск безопасности. - **Сервис присутствия:** Вынести логику онлайн/офлайн в отдельный сервис для лучшей модульности и переиспользования другими сервисами.
Сервис поиска поблизости
Сервис поиска поблизости (proximity service) используется для обнаружения ближайших мест — ресторанов, отелей, театров, музеев и т.д. Он лежит в основе таких функций, как поиск лучших ресторанов рядом на Yelp или поиск ближайших заправочных станций на Google Maps.

Шаг 1 — Понять задачу и определить рамки
**Функциональные требования:** - Возвращать все заведения на основе местоположения пользователя (широта/долгота) и радиуса. - Владельцы заведений могут добавлять, удалять или обновлять информацию (не в реальном времени; изменения вступают в силу на следующий день). - Клиенты могут просматривать подробную информацию о заведении.
**Нефункциональные требования:** - Низкая задержка: пользователи должны быстро видеть ближайшие заведения. - Конфиденциальность данных: соответствие требованиям GDPR [4] и CCPA [5]. - Высокая доступность и масштабируемость: обработка всплесков трафика в часы пик в густонаселённых районах.
**Оценка на основе расчётов:** - 100 миллионов DAU, 200 миллионов заведений. - QPS поиска = 100M × 5 запросов/день ÷ 10^5 секунд = **5 000 QPS**
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Дизайн API
**Поиск ближайших заведений:** ``` GET /v1/search/nearby Параметры: latitude, longitude, radius (по умолчанию 5000 м) Ответ: { total: 10, businesses: [{объект заведения}] } ```
**CRUD API для заведений:**
| API | Описание | |-----|----------| | GET /v1/businesses/{:id} | Возвращает подробную информацию о заведении | | POST /v1/businesses | Добавляет заведение | | PUT /v1/businesses/{:id} | Обновляет данные заведения | | DELETE /v1/businesses/{:id} | Удаляет заведение |
Модель данных
Система с преобладанием чтения (поиск + просмотр) при низком объёме записи. Реляционная база данных (MySQL) хорошо подходит для этой задачи.
**Таблица заведений (Таблица 3):** Содержит подробную информацию о заведении с `business_id` в качестве первичного ключа.

Высокоуровневый дизайн
На Рисунке 2 показана система, состоящая из двух частей: Location-Based Service (LBS) и сервис заведений.
- **Балансировщик нагрузки:** Распределяет трафик; маршрутизирует API-вызовы на основе URL-путей. - **LBS (Location-Based Service):** Основной компонент для поиска ближайших заведений. Преобладает чтение, высокий QPS, stateless (легко масштабировать горизонтально). - **Сервис заведений:** Обрабатывает операции записи (добавление/обновление/удаление) и чтение подробной информации. - **Кластер БД:** Схема «первичный — реплики». Первичный узел обрабатывает запись; реплики — чтение. Небольшая задержка репликации приемлема (информация о заведениях не требует реального времени).
Алгоритмы поиска ближайших заведений
**Вариант 1: Двумерный поиск (Рисунок 3):** Нарисовать окружность с заданным радиусом, найти все заведения внутри. SQL с условием BETWEEN по широте/долготе — требует полного сканирования таблицы. Даже с индексами по обоим столбцам (Рисунок 4) пересечение двух больших наборов данных неэффективно.
**Типы геопространственных индексов (Рисунок 5):** - Хеш: равномерная сетка, геохеш, декартовы уровни. - Дерево: quadtree, Google S2, RTree.
**Вариант 2: Равномерно разбитая сетка (Рисунок 6):** Разделить мир на равные сетки. Проблема: неравномерное распределение заведений — центр Нью-Йорка vs пустыни/океаны.
**Вариант 3: Геохеш** — преобразование 2D-координат в одномерную строку. Рекурсивно делит мир на меньшие сетки с каждым дополнительным битом.
Разбиение геохеша: - Разделить планету на квадранты (Рисунок 7). - Каждая сетка рекурсивно делится на 4 части (Рисунок 8). - Использует кодирование base32.
Длина геохеша и размер ячейки (Таблица 4): длина 4 = 39,1×19,5 км, длина 5 = 4,9×4,9 км, длина 6 = 1,2 км×609 м.
Радиус и длина геохеша (Таблица 5): 0,5 км→6, 1 км→5, 2 км→5, 5 км→4, 20 км→4.
**Проблемы с граничными условиями:** - Проблема 1 (Рисунки 9, 10): Два близких места могут не иметь общего префикса (например, по разные стороны экватора). Простой запрос по префиксу не работает. - Проблема 2 (Рисунок 11): Два места могут иметь длинный общий префикс, но принадлежать разным геохешам. - Решение: получать заведения из текущей ячейки И всех 8 соседних.
**Недостаточно заведений (Рисунок 12):** Убрать последний символ геохеша для расширения поиска до большей ячейки; повторять до получения нужного количества результатов.
**Вариант 4: Quadtree (Рисунок 13):** Рекурсивно делит 2D-пространство на 4 квадранта, пока в ячейке не останется ≤100 заведений. Хранится в памяти, строится при запуске сервера.
Процесс построения (Рисунок 14): - Всего памяти: ~1,71 ГБ для 200 млн заведений. - Время построения: несколько минут при старте; используется постепенное развёртывание для избежания простоя.
Пример реального quadtree вблизи Денвера (Рисунок 15): мелкие ячейки для плотных районов, крупные — для редких.
**Вариант 5: Google S2 (Рисунок 16):** Проецирует сферу на 1D-индекс через кривую Гильберта. Два близких по кривой Гильберта точки близки и в 1D. Поддерживает геозоны (geofencing, Рисунок 17) — определение периметров и отправка уведомлений пользователям за пределами зоны.
**Рекомендации:**
| Геоиндекс | Компании | |-----------|----------| | Геохеш | Bing Maps, Redis, MongoDB, Lyft | | Quadtree | Yext | | Оба | Elasticsearch | | S2 | Google Maps, Tinder |
Для собеседований рекомендуется выбирать геохеш или quadtree (S2 слишком сложен для объяснения).
**Геохеш vs Quadtree:** - Геохеш: прост в использовании и реализации; поддерживает поиск по радиусу; фиксированный размер ячейки; лёгкое обновление индекса (Рисунок 18). - Quadtree: поддерживает поиск k ближайших; динамически адаптирует размер ячейки к плотности; сложнее реализовать и обновлять (обход O(log n), Рисунок 19).














Шаг 3 — Детальный дизайн
Масштабирование базы данных
**Таблица заведений:** Шардирование по business_id для равномерного распределения нагрузки.
**Таблица геопространственного индекса (геохеш):** Два варианта хранения: - Вариант 1: `geohash → JSON-массив business_id` (одна строка на геохеш). - Вариант 2: Составной ключ `(geohash, business_id)` — одна строка на заведение (Таблицы 9, 10).
**Рекомендация: Вариант 2** — простое добавление/удаление без блокировок, не нужно сканировать дубликаты.
**Масштабирование геопространственного индекса:** Полный набор данных невелик (~1,71 ГБ для quadtree). Использовать реплики для чтения вместо шардирования (проще в разработке и поддержке).


Кеширование
Соображения по кешу: нагрузка с преобладанием чтения, но набор данных относительно невелик — возможно, он уже умещается в рабочем наборе БД. Сначала добавить реплики для чтения; добавлять кеш, если бенчмаркинг покажет необходимость.
**Ключ кеша:** Координаты местоположения ненадёжны в качестве ключа (неточность GPS, незначительное движение). Использовать геохеш как ключ кеша — небольшие изменения положения отображаются на тот же геохеш.
**Два типа кешируемых данных (Таблица 12):** 1. `geohash → список business_id` — предварительно вычислен для каждой точности геохеша (4, 5, 6). Итого: ~5 ГБ (8 байт × 200M заведений × 3 точности). 2. `business_id → объект заведения` — полные данные для рендеринга.
Развернуть Redis-кеш глобально (одна копия) для низкой задержки во всех регионах.
Регионы и зоны доступности
Развернуть LBS в нескольких регионах и зонах доступности (Рисунок 20): - Физическое приближение системы к пользователям. - Гибкое распределение нагрузки (Япония/Корея имеют высокую плотность населения). - Соответствие законодательству о конфиденциальности (некоторые страны требуют хранить данные локально).

Итоговая диаграмма дизайна
На Рисунке 21 показан итоговый дизайн.
**Поток получения ближайших заведений:** 1. Клиент отправляет местоположение (широта/долгота) и радиус (например, 500 м) на балансировщик нагрузки. 2. Балансировщик перенаправляет запрос в LBS. 3. LBS определяет длину геохеша для радиуса 500 м → длина 6. 4. LBS вычисляет соседние геохеши (8 соседей + текущий). 5. LBS параллельно получает business_id из Redis-кеша «Geohash» для каждого геохеша. 6. LBS получает полные объекты заведений из Redis-кеша «Business info», вычисляет расстояния, ранжирует и возвращает результаты.
**Просмотр/обновление/добавление/удаление заведения:** - Сервис заведений сначала проверяет Redis-кеш «Business info». - Промах кеша → запрос к БД с последующим кешированием результата. - Кешированные данные обновляются ночным заданием (новые/обновлённые заведения вступают в силу на следующий день).

Шаг 4 — Подведение итогов
Мы спроектировали сервис поиска поблизости с использованием геопространственного индексирования (геохеш). Ключевые темы: - Пять вариантов индексирования: двумерный поиск, равномерная сетка, геохеш, quadtree, Google S2. - Кеширование с геохешем в качестве ключа. - Масштабирование БД через реплики для чтения. - Мультирегиональное развёртывание для доступности и соответствия требованиям.
Друзья поблизости
Мы проектируем масштабируемый бэкенд для функции «Друзья поблизости» (Nearby Friends). Для пользователей, включивших эту функцию, мобильное приложение отображает список географически близких друзей. В отличие от сервисов поиска заведений, где местоположения статичны, координаты пользователей постоянно меняются — это требует динамичного дизайна с поддержкой реального времени.

Шаг 1 — Понять задачу и определить рамки
**Функциональные требования:** - Пользователи видят друзей поблизости в мобильном приложении. - Каждая запись показывает расстояние и метку времени последнего обновления. - Список обновляется каждые несколько секунд. - «Поблизости» = в радиусе 5 миль (настраивается). - Друзья, неактивные более 10 минут, исчезают из списка. - История местоположений сохраняется.
**Нефункциональные требования:** - Низкая задержка при обновлениях местоположения. - Надёжность (потеря отдельных точек данных допустима). - Итоговая согласованность (задержка в несколько секунд допустима).
**Оценка на основе расчётов:** - 1 млрд пользователей; 10% используют функцию = 100 млн DAU. - 10% одновременно активны = 10 млн конкурентных пользователей. - Интервал обновления координат: 30 секунд. - В среднем 400 друзей, 10% онлайн и рядом. - **QPS обновлений = 10M / 30 ≈ 334 000 QPS** - QPS пересылки = 334K × 400 × 10% = **14 млн обновлений координат/секунду**
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Высокоуровневый дизайн
Концептуально пользователь мог бы поддерживать P2P-соединения со всеми активными близкими друзьями (Рисунок 2) — непрактично для мобильных устройств (нестабильные соединения, расход батареи).
Общий бэкенд (Рисунок 3) берёт на себя: - Приём обновлений координат от всех активных пользователей. - Пересылку каждого обновления всем активным друзьям в радиусе. - Отбрасывание обновлений для друзей за пределами порогового расстояния.
На Рисунке 4 показан предлагаемый дизайн со следующими компонентами:
- **Балансировщик нагрузки:** Распределяет трафик между RESTful API-серверами и WebSocket-серверами. - **RESTful API-серверы:** Stateless HTTP-серверы для вспомогательных задач (добавление/удаление друзей, обновление профилей). - **WebSocket-серверы:** Stateful, двунаправленные. Каждый клиент поддерживает одно постоянное WebSocket-соединение. Обрабатывают обновления координат в квазиреальном времени и инициализируют клиент координатами всех близких онлайн-друзей. - **Redis location cache:** Хранит последнее местоположение каждого активного пользователя с TTL. TTL обновляется при каждом апдейте; истёкший TTL = пользователь неактивен. - **База данных пользователей:** Данные пользователей и связей (реляционная или NoSQL). - **База данных истории местоположений:** Исторические данные о координатах (не используется напрямую функцией «Друзья поблизости»). - **Redis pub/sub сервер:** Лёгкая шина сообщений. У каждого пользователя отдельный канал. Обработчики WebSocket подписываются на каналы друзей и получают обновления координат.
Периодическое обновление местоположения
На Рисунке 7 показан поток периодического обновления местоположения: 1. Мобильный клиент отправляет обновление координат через постоянное WebSocket-соединение. 2. Балансировщик нагрузки перенаправляет запрос на WebSocket-сервер клиента. 3. WebSocket-сервер сохраняет данные в базу истории местоположений. 4. WebSocket-сервер обновляет Redis location cache (обновляет TTL); хранит координаты в переменной обработчика соединения. 5. WebSocket-сервер публикует новые координаты в канал пользователя в Redis pub/sub. 6. Redis pub/sub рассылает сообщение всем подписчикам (обработчикам WebSocket-соединений друзей). 7. Каждый принимающий обработчик WebSocket вычисляет расстояние между отправителем обновления и подписчиком. 8. Если расстояние ≤ радиусу поиска, отправить координаты + метку времени клиенту подписчика. Иначе — отбросить.
На Рисунке 8 показан конкретный пример с пользователями 1–6.
Дизайн API
**WebSocket API:** 1. Периодическое обновление координат: отправить широту, долготу, метку времени. 2. Получение обновлений координат: получить данные о местоположении друга + метку времени. 3. Инициализация WebSocket: отправить координаты; получить местоположения всех близких друзей. 4. Подписка на нового друга: отправить friend_id; получить последнее местоположение. 5. Отписка от друга: отправить friend_id.
**Модель данных:** - **Location cache (Redis):** `user_id → {latitude, longitude, timestamp}` с TTL. - Redis выбран за быстрое чтение/запись, поддержку TTL, отсутствие необходимости в долгосрочном хранении. - **База данных истории координат (Cassandra):** `user_id, latitude, longitude, timestamp`. - Большой объём записи; горизонтально масштабируется шардированием по user_id.
Шаг 3 — Детальный дизайн
Масштабирование WebSocket-серверов
WebSocket-серверы stateful — перед удалением узла пометить его как «draining» на балансировщике нагрузки (новые соединения на него не направляются). После закрытия существующих соединений сервер удаляется.
**Инициализация клиента:** При установке WebSocket-соединения: 1. Обновить местоположение пользователя в Redis cache. 2. Сохранить координаты в переменной обработчика соединения. 3. Загрузить всех друзей из базы данных пользователей. 4. Пакетно получить координаты из Redis cache. 5. Для каждого друга в радиусе вернуть клиенту профиль, координаты, метку времени. 6. Подписаться на pub/sub-каналы всех друзей (даже неактивных — они не потребляют CPU, минимум памяти). 7. Опубликовать текущие координаты пользователя в его собственном pub/sub-канале.
Масштабирование Redis pub/sub серверов
**Использование памяти:** 100M каналов × 20 байт × 100 друзей = ~200 ГБ всего. Нужно ~2 Redis-сервера (по 100 ГБ каждый).
**Использование CPU:** 14M пушей/сек ÷ 100K пушей/сервер ≈ **140 Redis-серверов**.
Узкое место — CPU, а не память. Необходим распределённый кластер Redis pub/sub.
**Распределённый кластер pub/sub:** Шардировать каналы по сотням Redis-серверов с помощью консистентного хеширования (Рисунок 9). Использовать компонент обнаружения сервисов (etcd или Zookeeper) для хранения хеш-кольца и уведомления WebSocket-серверов об изменениях.
На Рисунке 10 показано, как WebSocket-сервер находит нужный pub/sub-сервер для канала пользователя.
**Соображения по масштабированию:** - Pub/sub-каналы stateless (сообщения не персистируются), но списки подписчиков stateful. - Относиться к кластеру pub/sub как к stateful хранилищу — масштабировать осторожно. - Избыточная ёмкость для обработки дневных пиков. Изменять размер только в периоды наименьшей нагрузки. - При изменении размера: обновление хеш-кольца вызывает массовую переподписку — следить за CPU-пиком WebSocket-серверов.
**Замена отказавшего pub/sub-сервера (Рисунок 11):** - Обновить хеш-кольцо в service discovery новым узлом вместо отказавшего. - WebSocket-серверы переподписывают затронутые каналы на новый сервер.



Случайные люди поблизости (бонус)
Для отображения случайных пользователей по согласию (не только друзей): добавить пул pub/sub-каналов по геохешам (Рисунок 12). Все пользователи в одной ячейке подписываются на один канал.
Рисунок 13: Когда пользователь 2 обновляет координаты, обработчик WebSocket вычисляет геохеш и публикует в соответствующий канал. Все ближайшие подписчики (кроме отправителя) получают обновление.
Рисунок 14: Каждый клиент подписывается на текущий геохеш и 8 окружающих для обработки граничных случаев.

Альтернатива Redis pub/sub
**Erlang/Elixir (BEAM VM + OTP):** Лучшее решение для данной задачи. - Лёгкие Erlang-процессы (~300 байт каждый) — можно моделировать каждого пользователя как процесс. - 10M процессов легко размещаются на современных серверах. - Нативный межпроцессный обмен сообщениями и подписка через OTP. - Образует меш для эффективной маршрутизации обновлений от одного пользователя ко многим друзьям. - Отличные инструменты для распределённых операций и отладки. - Компромисс: нишевая технология, сложнее найти специалистов.
Шаг 4 — Подведение итогов
Основные компоненты: WebSocket (реальное время), Redis location cache (быстрое чтение/запись с TTL), Redis pub/sub (уровень маршрутизации). Решённые ключевые задачи: 14 млн обновлений координат/секунду, масштабирование stateful WebSocket с drain, распределённый кластер pub/sub с консистентным хешированием.
Google Maps
Мы проектируем упрощённую версию Google Maps — веб-сервиса картографии со спутниковыми снимками, уличными картами, информацией о дорожной обстановке в реальном времени и прокладкой маршрутов. По состоянию на март 2021 года Google Maps насчитывал 1 млрд DAU, покрывал 99% поверхности Земли и получал 25 млн обновлений ежедневно. Реализуемые функции: обновление местоположения, навигация, ETA и рендеринг карт. Тайлы карт в этой главе предоставлены Stamen Design; данные — OpenStreetMap.
Шаг 1 — Понять задачу и определить рамки
**Ключевые требования:** - 1 млрд DAU. - Функции: обновление местоположения, навигация, ETA и рендеринг карт. - Поддержка нескольких режимов передвижения (автомобиль, пешком, общественный транспорт). - Учёт дорожной обстановки. - Нет маршрутов с несколькими остановками, нет информации о заведениях/фото.
**Нефункциональные требования:** - Точность: пользователям не должны выдаваться неверные маршруты. - Плавная навигация: плавный рендеринг карты на стороне клиента. - Трафик данных и заряд батареи: минимизировать для мобильных устройств. - Высокая доступность и масштабируемость.
Основы картографии (Map 101)
**Система координат:** - Широта (latitude): насколько далеко к северу или югу. - Долгота (longitude): насколько далеко к востоку или западу.
На Рисунке 1 показана система координат широта/долгота.
**От 3D к 2D (картографическая проекция):** Перенос точек с трёхмерного глобуса на двумерную плоскость называется картографической проекцией. Любая проекция искажает реальную геометрию. Google Maps использует Web Mercator (модифицированная проекция Меркатора). На Рисунке 2 показаны несколько примеров проекций.
**Геокодирование:** Преобразование адресов в географические координаты (и обратное геокодирование — широта/долгота в читаемый адрес). Один метод: интерполяция на основе данных ГИС.
**Геохеширование (Рисунок 3):** Кодирует географическую область в короткую строку. Рекурсивно делит Землю на подсетки. Используется в нашем дизайне для тайлинга карт.
**Рендеринг карт:** Мир разбивается на меньшие тайлы. Клиент загружает только релевантные тайлы для текущей области/уровня масштаба и соединяет их. Для разных уровней масштаба существуют разные наборы тайлов — клиент выбирает подходящий.
**Обработка дорожных данных для алгоритмов навигации:** Алгоритмы маршрутизации (алгоритм Дейкстры, A*) работают с графом, где пересечения — узлы, дороги — рёбра (Рисунок 4). Граф всего мира слишком велик для памяти — он разбивается на маршрутные тайлы (routing tiles) с использованием разбиения на основе геохеша. Каждый маршрутный тайл содержит узлы/рёбра для своей географической области и ссылки на соседние тайлы. Иерархические маршрутные тайлы (3 уровня): местные дороги (мелкие тайлы), артериальные дороги (средние), магистральные шоссе (крупные) — показаны на Рисунках 5 и 6.




Оценка на основе расчётов
**Использование хранилища:** - На уровне масштаба 21: ~4,4 трлн тайлов × 100 КБ = 440 ПБ. - 90% поверхности Земли хорошо сжимается (океаны, пустыни) → уменьшение на 80–90% → **~50 ПБ** для максимального масштаба. - Итого для всех уровней масштаба (геометрический ряд): **~100 ПБ**. - Дорожные данные: терабайты сырых данных из внешних источников → маршрутные тайлы также терабайты.
**Пропускная способность сервера (обновления координат):** - 1 млрд DAU × 35 мин/неделю навигации = 5 млрд мин/день. - Обновление GPS каждую секунду → 300 млрд запросов/день = 3M QPS. - Пакетная отправка каждые 15 секунд → **200 000 QPS** в среднем. - Пиковый QPS = 200 000 × 5 = **1 млн QPS**.
**Использование CDN:** - При скорости 30 км/ч, тайл 200×200 м = 100 КБ → 1,25 МБ/мин. - 5 млрд мин навигации/день × 1,25 МБ = 6,25 млрд МБ/день → 62 500 МБ/сек. - 200 PoP CDN → ~300 МБ/сек на PoP.
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Высокоуровневый дизайн
На Рисунке 7 показан высокоуровневый дизайн с тремя основными сервисами: сервис местоположения, сервис навигации и рендеринг карт.
**Сервис местоположения (Рисунок 8):** Клиенты отправляют пакетные обновления координат каждые 15 секунд (буферизуются локально, снижая QPS с 3M до 200K). Протокол: HTTP с keep-alive. ``` POST /v1/locations Параметры: locs — JSON-массив кортежей (latitude, longitude, timestamp) ``` БД нужна высокая пропускная способность записи и горизонтальная масштабируемость → Cassandra. Также данные пишутся в поток Kafka для downstream-сервисов.
**Сервис навигации:** Находит разумно быстрый маршрут из точки A в точку B. Небольшая задержка допустима; точность критична. ``` GET /v1/nav?origin=1355+market+street,SF&destination=Disneyland ``` Возвращает расстояние, продолжительность, HTML-инструкции, полилинию, режим передвижения.
**Рендеринг карт:** Клиент загружает тайлы по запросу. Два варианта: - Вариант 1: Генерация тайлов на лету → высокая нагрузка на сервер, сложно кешировать. Не рекомендуется. - Вариант 2: Предварительно сгенерированные статические тайлы для каждого уровня масштаба, раздаваемые через CDN (Рисунок 10).
На Рисунке 11 показаны PoP CDN, раздающие тайлы глобально — клиенты обращаются к ближайшему PoP.
URL тайла использует геохеш: `https://cdn.map-provider.com/tiles/9q9hvu.png`
Альтернативный поток рендеринга (Рисунок 12): Вместо хардкода алгоритма геохеша на клиенте, сервис тайлов карт переводит (местоположение, уровень масштаба) → 9 URL тайлов (текущий + 8 окружающих). Клиент затем загружает тайлы из CDN.

Шаг 3 — Детальный дизайн
Модель данных
Четыре типа данных:
**Маршрутные тайлы:** Генерируются offline-сервисом обработки маршрутных тайлов из сырых наборов дорожных данных. Три уровня разрешения (местные дороги, артериальные, шоссе). Хранятся как бинарные списки смежности в объектном хранилище (S3), организованные по геохешу для быстрого поиска. Агрессивно кешируются сервисами маршрутизации.
**Данные о местоположении пользователя (Рисунок 14):** Высокий объём записи → Cassandra. Схема: `(user_id, timestamp) → (lat, lng, user_mode, navigation_mode)`. `user_id` как partition key; `timestamp` как clustering key для эффективного чтения диапазонов.
**База данных геокодирования:** Преобразует адреса/названия мест в широту/долготу. Хранилище «ключ-значение» (Redis) для быстрого чтения (частые чтения, редкие записи).
**Предварительно вычисленные тайлы карт:** PNG-изображения на 21 уровне масштаба. Хранятся в CDN с резервным хранением в S3. Закодированы геохешем для удобного поиска.
Сервис местоположения (детальный разбор)
Данные о местоположении пишутся в Kafka помимо записи в Cassandra. Downstream-сервисы потребляют поток Kafka (Рисунок 15): - **Сервис дорожной обстановки в реальном времени:** извлекает условия дорожного движения → обновляет базу данных дорожной обстановки. - **Сервис обработки маршрутных тайлов:** обнаруживает новые/закрытые дороги → обновляет маршрутные тайлы в S3. - Другие сервисы для аналитики, персонализации и т.д.
Рендеринг карт (детальный разбор)
Google Maps использует 21 уровень масштаба (Рисунок 16): - Уровень 0: весь мир в одном тайле 256×256 пикселей. - Каждый уровень масштаба: количество тайлов удваивается в обоих направлениях (4× тайлов на уровень). - Уровень 21: ~4,4 трлн тайлов.
**Векторные тайлы (будущее улучшение):** Вместо PNG-изображений отправлять векторные данные (пути/полигоны). Преимущества: - Векторные данные сжимаются значительно лучше изображений. - Плавное масштабирование — векторы масштабируются без пикселизации (рендеринг на основе WebGL).

Сервис навигации (детальный разбор)
На Рисунке 17 показан полный дизайн сервиса навигации. Компоненты:
1. **Сервис геокодирования:** Преобразует адрес/название места → пару широта/долгота. 2. **Route planner (планировщик маршрутов):** Оркестрирует конвейер маршрутизации. Вызывает сервис кратчайшего пути, получает прогнозы ETA, передаёт в ранжировщик. 3. **Сервис кратчайшего пути:** Запускает вариацию A* на маршрутных тайлах в S3. Алгоритм: - Конвертировать широту/долготу начала/конца → геохеши → загрузить маршрутные тайлы. - Обходить граф, загружая соседние тайлы из S3 по требованию. - Переходить между уровнями разрешения тайлов (местные → артериальные → шоссе) через межтайловые связи. - На Рисунке 18 показан концептуальный обход графа между тайлами. 4. **ETA-сервис:** Прогнозирует ETA с помощью ML на основе текущей и исторической дорожной обстановки, включая прогноз будущего трафика. 5. **Сервис ранжирования:** Применяет фильтры пользователя (избегать платных дорог, избегать шоссе), ранжирует маршруты от быстрейшего к медленнейшему. 6. **Сервисы обновлений (асинхронные):** - Сервис обработки маршрутных тайлов: обнаруживает изменения дорог из потока координат. - Сервис обновления дорожной обстановки: извлекает данные о трафике в реальном времени из потока координат → база данных дорожной обстановки.
**Адаптивный ETA и перепрокладка маршрута (Рисунки 19, 20):** Сервер отслеживает активно навигирующих пользователей. Наивный подход: хранить маршруты как списки маршрутных тайлов, сканирование O(n×m) для поиска затронутых пользователей.
Оптимизированный подход: для каждого пользователя хранить иерархическую цепочку маршрутных тайлов — текущий тайл + родительский + прародительский... пока не будет охвачена вся дистанция до пункта назначения. Для проверки, затронут ли пользователь изменением трафика в тайле T, достаточно проверить, содержится ли T в последнем (наибольшем) тайле цепочки пользователя. Это быстро отфильтровывает большинство пользователей.
Периодически пересчитывать ETA для всех активно навигирующих пользователей; уведомлять при нахождении более быстрого маршрута.
**Протокол доставки:** WebSocket выбран вместо SSE или long polling — поддерживает двунаправленную связь для перепрокладки маршрута и доставки на «последней миле».

Шаг 4 — Подведение итогов
Мы спроектировали упрощённый Google Maps с обновлением местоположения, ETA, прокладкой маршрутов и рендерингом карт. Ключевые компоненты: Cassandra для высоконагруженных данных о местоположении, Kafka для потоковой обработки событий, CDN для предварительно вычисленных тайлов карт, иерархические маршрутные тайлы в S3, алгоритм A* с загрузкой тайлов по требованию.
На Рисунке 21 показан итоговый дизайн, объединяющий все компоненты.
Возможное расширение: навигация с несколькими остановками для служб доставки (DoorDash, Uber, Lyft) — поиск оптимального порядка посещения точек с учётом дорожной обстановки в реальном времени.

Распределённая очередь сообщений
Мы проектируем распределённую очередь сообщений — систему, обеспечивающую коммуникацию и координацию между независимыми компонентами. Преимущества: слабая связанность (устраняется тесное сцепление), улучшенная масштабируемость (независимое масштабирование производителей/потребителей), повышенная доступность (другие компоненты работают при отказе одного), более высокая производительность (асинхронная коммуникация).
На Рисунке 1 показаны популярные распределённые очереди сообщений. Примечание: Kafka и Pulsar технически являются платформами потоковой передачи событий, но наш дизайн включает функции стриминга (длительное хранение данных, повторное потребление), которые обычно есть только в таких системах.

Шаг 1 — Понять задачу и определить рамки
**Функциональные требования:** - Производители отправляют сообщения в очередь. - Потребители получают сообщения из очереди. - Сообщения можно потреблять многократно или только один раз. - Исторические данные можно усекать (хранение 2 недели). - Размер сообщения: килобайтный диапазон, только текст. - Упорядоченная доставка (в порядке производства). - Настраиваемая семантика доставки: at-least-once, at-most-once, exactly-once.
**Нефункциональные требования:** - Высокая пропускная способность или низкая задержка (настраивается под сценарий). - Масштабируемость: распределённость, поддержка резких всплесков объёма. - Персистентность и надёжность: данные на диске, реплицированы по узлам.
**Примечание по традиционным очередям:** Очереди типа RabbitMQ не хранят сообщения долгосрочно и не гарантируют порядок. Снятие этих требований значительно упрощает дизайн.
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Модели обмена сообщениями
**Point-to-point (Рисунок 3):** Сообщение потребляется ровно одним потребителем и удаляется из очереди после подтверждения. Данные не сохраняются.
**Publish-subscribe (Рисунок 4):** Сообщения, отправленные в топик, получают все подписанные потребители. Наш дизайн поддерживает обе модели — pub-sub через топики, point-to-point имитируется через consumer groups.
Топики, партиции и брокеры
**Партиции (Рисунок 5):** Данные топика шардируются по партициям, распределённым по брокерам. Каждая партиция — FIFO-очередь, сохраняющая порядок сообщений внутри. Позиция сообщения = offset.
Маршрутизация сообщений: если ключ сообщения задан → hash(key) % numPartitions; иначе случайная партиция.
**Кластер очереди сообщений (Рисунок 6):** Брокеры хранят партиции. Масштабирование топика = увеличение числа партиций.
**Consumer group (Рисунок 7):** - Набор потребителей, совместно обрабатывающих топики. - Каждая группа поддерживает собственные consumed offsets. - Ключевое ограничение: одну партицию может потреблять только ОДИН потребитель в группе → гарантирует порядок на уровне партиции. - Если потребителей больше, чем партиций, часть потребителей не получает данных. - Имитация point-to-point: поместить всех потребителей в одну группу.
Высокоуровневая архитектура
На Рисунке 8 показан высокоуровневый дизайн:
**Клиенты:** - Producer: отправляет сообщения в конкретные топики. - Consumer group: подписывается и потребляет сообщения.
**Основные сервисы и хранилище:** - Брокер: хранит несколько партиций. - Data storage: сообщения персистированы в партициях. - State storage: состояния потребителей (offsets, маппинг партиция-потребитель). - Metadata storage: конфигурация топика (количество партиций, хранение, распределение реплик). - Coordination service (Zookeeper/etcd): service discovery (живые брокеры), выбор лидера (активный контроллер, назначающий партиции).
Шаг 3 — Детальный дизайн
Три ключевых решения для высокой пропускной способности при большом объёме хранимых данных: 1. Структура данных на диске, использующая преимущества последовательного доступа. 2. Структура данных сообщения с передачей без копирования (producer → queue → consumer). 3. Пакетная обработка (batching) везде: producer, broker, consumer.
Хранилище данных
**Паттерн трафика:** преобладание записи и чтения; нет обновлений/удалений; последовательное чтение/запись.
**Вариант 1 — база данных:** Реляционная или NoSQL — не идеально. Сложно проектировать под тяжёлую запись и чтение одновременно.
**Вариант 2 — Write-Ahead Log (WAL):** Лог только для дозаписи. Используется в MySQL redo log, ZooKeeper WAL.
Новые сообщения дописываются в конец партиции с монотонно увеличивающимся offset (Рисунок 9). Файлы делятся на сегменты — только активный сегмент принимает запись. Неактивные сегменты обслуживают чтение. Старые сегменты усекаются при достижении лимита хранения/ёмкости.
На Рисунке 10 показаны сегментные файлы в папках `Partition-{:partition_id}`.
**Замечание о производительности диска:** Распространённое заблуждение, что диски медленные — это верно только для случайного доступа. Последовательный доступ на современных RAID-дисках даёт несколько сотен МБ/сек. ОС также агрессивно кеширует данные диска в памяти (WAL получает большую выгоду).
Структура данных сообщения
Схема: `key (byte[]), value (byte[]), topic (string), partition (int), offset (long), timestamp (long), size (int), crc (int)`
- **Key:** определяет партицию (hash(key) % numPartitions). Не уникален — отличается от ключей KV-хранилища. - **Value:** полезная нагрузка — обычный текст или сжатые бинарные данные. - **Offset:** позиция в партиции. Сообщение находится по (topic, partition, offset). - **CRC:** cyclic redundancy check для проверки целостности данных. - Дополнительные поля (например, теги) можно добавить для фильтрации.
Поток producer
**Начальный дизайн (Рисунок 11):** Отдельный routing layer читает распределение реплик из метаданных, направляет к брокеру-лидеру реплики.
Недостатки: дополнительный сетевой переход, нет batching.
**Улучшенный дизайн (Рисунок 12):** Routing layer + буфер встроены в клиентскую библиотеку producer. - Преимущества: меньше сетевых переходов → меньше задержка, настраиваемая логика партиционирования, batching для высокой пропускной способности.
**Компромисс размера батча (Рисунок 13):** Большой батч → высокая пропускная способность, высокая задержка. Маленький батч → низкая задержка, низкая пропускная способность. Настраивается под конкретный сценарий.
Поток consumer
Consumer указывает offset в партиции и получает блок сообщений начиная с этой позиции (Рисунок 14).
**Push vs pull:** - Push: низкая задержка, но может перегрузить consumer; брокер управляет скоростью. - **Pull (выбранный):** consumer управляет скоростью; подходит для пакетной обработки; поддерживает long polling для избежания бесполезных запросов при отсутствии сообщений.
**Рабочий процесс pull-модели (Рисунок 15):** 1. Новый consumer находит coordinator хешированием имени группы → все потребители одной группы подключаются к одному coordinator. 2. Coordinator назначает партиции (round-robin, range и т.д.). 3. Consumer получает данные начиная с последнего потреблённого offset (из state storage). 4. Consumer обрабатывает сообщения и фиксирует offset в брокере.
Consumer rebalancing
Rebalancing происходит при: подключении/отключении consumer, сбое consumer или изменении партиций.
**Coordinator (Рисунок 16):** Один брокер на consumer group, найденный хешированием имени группы. Ведёт список потребителей, получает heartbeat, управляет offsets. При изменении списка → выбирает нового group leader → leader генерирует план партиций → coordinator рассылает план.
**Сценарии:** - Рисунок 17: Общий поток rebalancing. - Рисунок 18: Новый consumer B подключается → coordinator уведомляет A → выбирается leader → план партиций генерируется и рассылается. - Рисунок 19: Consumer A корректно отключается → coordinator rebalances оставшихся. - Рисунок 20: Consumer A падает → coordinator обнаруживает отсутствие heartbeat → помечает как мёртвого → запускает rebalancing.
State storage
Хранит: маппинг партиция-consumer, последний consumed offset для каждой consumer group на каждую партицию (Рисунок 21).
Паттерны доступа: частые чтение/запись, данные обновляются часто, произвольный доступ, важна согласованность.
Рекомендация: KV-хранилище типа Zookeeper. Kafka перенесла хранение offsets из Zookeeper в сами брокеры.
Metadata storage и ZooKeeper
Metadata storage: конфигурация топиков (партиции, хранение, распределение реплик). Малый объём, редкие изменения, высокая согласованность → Zookeeper.
**Упрощённый дизайн с ZooKeeper (Рисунок 22):** - Metadata и state storage перенесены в Zookeeper. - Брокер хранит только данные сообщений. - Zookeeper управляет выбором лидера брокеров.
Репликация
Каждая партиция имеет 3 реплики на разных брокерах (Рисунок 23). Одна реплика — leader, остальные — followers. Producers пишут только leader. Followers вытягивают данные от leader.
План распределения реплик: выбранный broker controller генерирует план и сохраняет в metadata.
**In-sync replicas (ISR, Рисунок 24):** ISR = реплики в пределах `replica.lag.max.messages` от leader. Leader отслеживает список ISR по отставанию. - Replica-2, Replica-3: полностью синхронизированы → в ISR. - Replica-4: отстаёт сверх порога → удаляется из ISR до наверстывания.
**Настройки ACK:** - ACK=all (Рисунок 25): подтверждение после получения сообщения ВСЕМИ ISR → наивысшая надёжность, наибольшая задержка. - ACK=1 (Рисунок 26): подтверждение после сохранения leader → улучшенная задержка, риск потери данных при отказе leader до репликации. - ACK=0 (Рисунок 27): без подтверждения, без повторных попыток → наименьшая задержка, допустима потеря данных (метрики, логирование).
Масштабируемость
**Producer:** stateless — свободно добавлять/удалять инстансы.
**Consumer:** consumer groups изолированы → свободно добавлять/удалять группы. Rebalancing обрабатывает изменения потребителей внутри групп.
**Отказ и восстановление брокера (Рисунок 28):** Брокер 3 падает → controller обнаруживает через coordination service → генерирует новый план распределения реплик → новые реплики на оставшихся брокерах догоняют leaders.
**Добавление брокера (Рисунок 29):** Controller временно разрешает лишние реплики → новый брокер догоняет → избыточная реплика на старом брокере корректно удаляется. Без потери данных.
**Увеличение партиций (Рисунок 30):** Существующие сообщения остаются в старых партициях (без миграции). Новые сообщения распределяются по всем партициям. Producers/consumers уведомляются автоматически.
**Уменьшение партиций (Рисунок 31):** Выводимая из эксплуатации партиция прекращает принимать новые сообщения, но остаётся читаемой до истечения срока хранения. Место освобождается только по истечении периода хранения.
Семантика доставки данных
**At-most once (Рисунок 32):** - Producer: ACK=0, без повторных попыток. - Consumer: фиксирует offset ДО обработки. - Сообщения могут теряться, но никогда не доставляются повторно. Сценарий: метрики мониторинга.
**At-least once (Рисунок 33):** - Producer: ACK=1 или ACK=all, повторные попытки при сбое. - Consumer: фиксирует offset только ПОСЛЕ успешной обработки. - Сообщения никогда не теряются, но могут дублироваться. Сценарий: большинство общих случаев (дедупликация по уникальному ключу на стороне consumer).
**Exactly once (Рисунок 34):** - Наиболее сложная реализация; наибольшие затраты. - Сценарий: финансовые транзакции (платежи, торговля, бухгалтерия), где дублирование недопустимо.
Расширенные функции
**Фильтрация сообщений (Рисунок 35):** Consumer groups могут нуждаться только в подтипах сообщений топика. Наивный подход: consumer получает всё и фильтрует локально (тратит bandwidth). Лучше: прикреплять теги к сообщениям, фильтровать на стороне брокера без доступа к payload. Consumer подписывается по тегу.
**Отложенные/запланированные сообщения (Рисунок 36):** Отложенные сообщения отправляются во временное хранилище на брокере, доставляются в топик после истечения задержки. Основные компоненты: временное хранилище (специальные топики) + функция тайминга (очереди задержки с предопределёнными уровнями или иерархическое timing wheel).
Сценарий: 30-минутный таймаут оплаты — немедленно отправить отложенное сообщение, доставить consumer через 30 мин для проверки статуса оплаты.
Шаг 4 — Подведение итогов
Ключевые решения дизайна: WAL на диске, передача сообщений без копирования, повсеместный batching, репликация на основе ISR, настраиваемый ACK.
**Дополнительные темы:** - Протокол: AMQP или Kafka protocol — производство/потребление/heartbeat, эффективный транспорт данных, проверка целостности. - Повторное потребление: неудачные сообщения → специальный retry topic для переобработки. - Архивирование исторических данных: HDFS или object storage для воспроизведения усечённых исторических сообщений.

Система мониторинга метрик и оповещений
Мы проектируем масштабируемую систему мониторинга метрик и оповещений для внутреннего использования крупной компанией (аналогичную Datadog, Splunk). На Рисунке 1 показаны популярные сервисы в этой области. Хорошо спроектированная система обеспечивает чёткую видимость состояния инфраструктуры, гарантируя высокую доступность и надёжность.

Шаг 1 — Понять задачу и определить рамки
**Требования:** - Внутренняя система для крупной компании. - Сбор операционных метрик системы (нагрузка CPU, память, диск, запросов/сек, счётчики message queue). Без мониторинга логов, без distributed tracing. - Масштаб: 100M DAU, 1 000 серверных пулов × 100 машин × 100 метрик = **~10 миллионов метрик**. - Хранение 1 год с понижением разрешения: - 0–7 дней: сырые данные. - 7–30 дней: разрешение 1 минута. - 30 дней–1 год: разрешение 1 час. - Каналы оповещений: email, телефон, PagerDuty, webhooks.
**Нефункциональные требования:** - Масштабируемость (растущий объём метрик и оповещений). - Низкая задержка (дашборды и оповещения). - Высокая надёжность (не пропускать критические оповещения). - Гибкость pipeline (простая интеграция технологий).
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Основы
Пять компонентов системы мониторинга метрик (Рисунок 2): 1. **Data collection:** сбор метрик из источников. 2. **Data transmission:** доставка данных в систему мониторинга. 3. **Data storage:** организация и хранение поступающих данных. 4. **Alerting:** анализ данных, обнаружение аномалий, отправка оповещений по каналам. 5. **Visualization:** отображение данных в графиках и диаграммах.

Модель данных
Данные метрик — это time series: набор значений с ассоциированными временными метками, уникально идентифицируемый именем метрики + labels.
**Пример (Рисунок 3):** Нагрузка CPU на сервере i631 в 20:00 → `{metric_name: cpu.load, labels: host:i631,env:prod, timestamp: 1613707265, value: 0.29}`
**Формат line protocol:** `CPU.load host=webserver01,region=us-west 1613707265 50`
Схема time series: - Имя метрики (строка) - Labels/tags (список пар key:value) - Массив пар (value, timestamp)
**Паттерн доступа к данным (Рисунок 4):** - **Write:** высокая нагрузка — ~10M метрик постоянно записываются с высокой частотой. - **Read:** всплески — visualization и alerting создают пиковое чтение.
**Хранилище данных — зачем time-series DB:** - Реляционная БД (MySQL): плохо подходит — сложные SQL-запросы для временны́х окон, требует индексов по каждому тегу, плохо работает при постоянной интенсивной записи. - NoSQL (Cassandra, Bigtable): возможно, но требует глубокой экспертизы для масштабируемой схемы time series. - **Time-series DB (InfluxDB, Prometheus):** оптимизирована под последовательную запись, собственные языки запросов, встроенная индексация labels, функции retention/aggregation.
Рисунок 5: InfluxDB на 8 ядрах + 32 ГБ RAM обрабатывает **250 000+ записей/сек**.

Высокоуровневый дизайн
На Рисунке 6 показаны компоненты высокоуровневого дизайна: - **Metrics source:** серверы приложений, БД, message queues. - **Metrics collector:** собирает метрики, пишет в time-series DB. - **Time-series database:** хранит метрики с собственным интерфейсом запросов + индексами labels. - **Query service:** тонкая обёртка над time-series DB для систем visualization и alerting. - **Alerting system:** отправляет уведомления по каналам. - **Visualization system:** графики и диаграммы для инженеров.
Шаг 3 — Детальный дизайн
Сбор метрик
Случайная потеря данных допустима (fire and forget). На Рисунке 7 показан поток сбора метрик.
**Pull-модель (Рисунок 8):** Выделенные metrics collectors опрашивают работающие приложения по HTTP (например, эндпоинт `/metrics`).
**Service discovery (Рисунок 9):** Collectors используют etcd/Zookeeper для обнаружения эндпоинтов сервисов. Конфигурация включает: интервал опроса, IP-адреса, таймаут, параметры повторных попыток.
**Pull-модель в деталях (Рисунок 10):** 1. Collector получает конфигурацию эндпоинтов из Service Discovery. 2. Опрашивает метрики через HTTP-эндпоинт `/metrics` (требуется клиентская библиотека). 3. Опционально подписывается на события изменений из Service Discovery.
**Масштабирование pull-модели (Рисунок 11):** Использовать кольцо consistent hashing с несколькими collectors. Каждый collector владеет диапазоном кольца → каждый источниковый сервер назначен ровно одному collector (без дублирования).
**Push-модель (Рисунок 12):** Collection agents, установленные на каждом сервере, периодически отправляют метрики в collectors. Agent может агрегировать данные локально перед отправкой. Автомасштабируемый кластер collectors с load balancer (Рисунок 13) предотвращает перегрузку.
**Сравнение pull и push:**
| | Pull | Push | |---|---|---| | Отладка | Легко — `/metrics` доступен в любое время | Сложнее | | Health check | Легко — нет ответа = сервер упал | Сложно отличить проблему сети от отсутствия данных | | Короткоживущие задания | Пропускает (решение: push gateways) | Выигрывает | | Сетевые настройки | Все эндпоинты должны быть доступны | Load balancer принимает от любого источника | | Протокол | TCP | UDP (меньше задержка) | | Подлинность данных | Определена в конфигурационных файлах | Любой клиент может отправлять (решение: whitelist/auth) |
Примеры: Pull → Prometheus. Push → Amazon CloudWatch, Graphite.

Масштабирование pipeline передачи метрик
Кластер metrics collectors получает огромный объём данных от любой модели (Рисунок 14). Риск: потеря данных при недоступности time-series DB.
**Решение (Рисунок 15):** Добавить Kafka как очередь между collectors и time-series DB. Stream processors (Apache Storm, Flink, Spark) потребляют из Kafka и пишут в time-series DB.
Преимущества: - Высоконадёжная и масштабируемая messaging platform. - Разделяет data collection от data processing. - Предотвращает потерю данных при недоступности БД.
**Масштабирование через Kafka (Рисунок 16):** - Настроить количество партиций на основе пропускной способности. - Партиционировать по имени метрики → consumers агрегируют по метрике. - Дополнительно партиционировать по tags/labels. - Приоритизировать важные метрики.
**Альтернатива Kafka:** in-memory time-series DB Gorilla от Facebook избегает промежуточной очереди благодаря проектированию под высокую доступность записи даже при частичных сбоях сети.
Query service
Кластер query servers, обращающихся к time-series DB для клиентов visualization/alerting. Разделяет time-series DB от клиентов — гибкость замены любой из сторон.
**Cache layer (Рисунок 17):** Cache servers хранят результаты запросов для снижения нагрузки на time-series DB.
**Язык запросов для time series:** Prometheus и InfluxDB используют собственные языки (PromQL, Flux) вместо SQL. Причина: сложность SQL для анализа временны́х окон.
SQL для exponential moving average (сложно): ```sql select id, temp, avg(temp) over (partition by group_nr order by time_read) ... ```
Flux (просто): ``` from(db:"telegraf") |> range(start:-1h) |> filter(...) |> exponentialMovingAverage(size:-10s) ```
Storage layer
**Кодирование и сжатие данных (Рисунок 18):** 85% запросов касаются данных за последние 26 часов (исследование Facebook). Использовать delta encoding: хранить 1610087371, 10, 10, 9, 11 вместо полных timestamps → 4 бита vs 32 бита на дельту.
**Понижение разрешения:** Конвертация высокого разрешения в низкое для уменьшения использования диска. - 0–7 дней: без выборки (сырые данные). - 7–30 дней: разрешение 1 минута. - 30 дней–1 год: разрешение 1 час.
Пример: данные с шагом 10 секунд → данные с шагом 30 секунд усреднением трёх точек.
**Cold storage:** Неактивные/старые данные перемещаются в cold storage (значительно дешевле).
Alerting system
На Рисунке 19 показан поток alerting system:
1. **Загрузка конфигурации оповещений в кеш:** Правила определяются в YAML-файлах. ```yaml - name: instance_down rules: - alert: instance_down expr: up == 0 for: 5m labels: severity: page ``` 2. **Alert manager получает конфигурацию** из кеша. 3. **Alert manager вызывает query service** с заданными интервалами. При нарушении порогового значения создаётся alert event. Обязанности: - **Filter, merge, dedupe (Рисунок 20):** Объединение нескольких оповещений для одного инстанса в коротком временном окне. - **Access control:** Ограничение операций авторизованными пользователями. - **Retry:** Гарантия доставки уведомлений хотя бы раз. 4. **Alert store:** KV-база данных (Cassandra), хранящая состояния оповещений (inactive → pending → firing → resolved). 5. Подходящие оповещения → Kafka. 6. Alert consumers читают из Kafka. 7. Alert consumers отправляют уведомления по email, SMS, PagerDuty, HTTP webhooks.
**Покупать или разрабатывать:** Промышленные alerting systems (например, Grafana Alerting) интегрируются с популярными time-series DB и каналами уведомлений. Веские основания покупать готовое решение.
Visualization system
Строится поверх data layer. На Рисунке 21 показан UI Grafana с запросами к серверу, использованием памяти/CPU, временем загрузки страниц, трафиком, информацией о входах.
Рекомендация: Использовать Grafana (готовое решение). Создать качественную visualization system сложно; Grafana хорошо интегрируется с популярными time-series базами данных.

Шаг 4 — Подведение итогов
Ключевые темы дизайна: pull vs push сбор данных, Kafka для масштабирования, выбор time-series DB, понижение разрешения для оптимизации хранения, покупать или строить alerting/visualization.
На Рисунке 22 показан итоговый интегрированный дизайн.

Агрегация событий кликов по рекламе
Мы проектируем систему агрегации событий кликов по рекламе масштаба Facebook/Google. Цифровая реклама использует Real-Time Bidding (RTB) — инвентарь покупается и продаётся менее чем за одну секунду (Рисунок 1). Ключевые метрики, такие как CTR (click-through rate) и CVR (conversion rate), зависят от агрегированных данных о кликах. Точность данных критически важна, так как напрямую влияет на выставление счетов рекламодателям.
Шаг 1 — Понять задачу и определить рамки
**Функциональные требования:** - Входные данные: log-файлы с полями `ad_id, click_timestamp, user_id, ip, country`. - Запрос 1: количество click events для заданного ad_id за последние M минут. - Запрос 2: топ-100 наиболее кликаемых объявлений за последнюю 1 минуту (настраивается). - Запрос 3: фильтрация по ip, user_id или country. - Обработка опоздавших событий, дублированных событий и восстановление системы.
**Нефункциональные требования:** - Корректность (используется для RTB и выставления счетов). - Правильная обработка задержанных и дублированных событий. - Устойчивость (resilient к частичным отказам). - End-to-end задержка: не более нескольких минут.
**Оценка на основе расчётов:** - 1 миллиард событий кликов по рекламе в день. - Средний QPS = 10^9 / 10^5 = **10 000 QPS**; пиковый QPS = **50 000**. - Хранилище: 0,1 КБ × 1B = **100 ГБ/день** (~3 ТБ/месяц).
Шаг 2 — Предложить высокоуровневый дизайн и получить одобрение
Дизайн Query API
**API 1:** Агрегированное количество кликов для объявления. ``` GET /v1/ads/{:ad_id}/aggregated_count Параметры: from (начальная минута), to (конечная минута), filter (id стратегии фильтрации) Ответ: { ad_id, count } ```
**API 2:** Топ N кликаемых объявлений. ``` GET /v1/ads/popular_ads Параметры: count (топ N), window (M минут), filter (id стратегии фильтрации) Ответ: { ad_ids: [...] } ```
Модель данных
**Сырые данные (log-файлы):** ``` [AdClickEvent] ad001, 2021-01-01 00:00:01, user1, 207.148.22.22, USA ``` Поля: `ad_id, click_timestamp, user_id, ip, country`
**Агрегированные данные (поминутно):** `ad_id, click_minute, count` — опционально с `filter_id` для поддержки фильтрации.
**Данные топ-N:** `window_size, update_time_minute, most_clicked_ads (JSON array)`
**Рекомендация: хранить КАК сырые, ТАК И агрегированные данные.** - Сырые данные: резервная копия для пересчёта, отладки. Переносятся в cold storage при устаревании. - Агрегированные данные: активные данные, оптимизированные для производительности запросов.
**Выбор БД:** Высокая нагрузка на запись (10K среднего QPS). Предпочтительны NoSQL типа Cassandra или InfluxDB для сырых и агрегированных данных (оптимизированы под запись + запросы по временным диапазонам). Альтернатива: хранить сырые данные в S3 в колоночных форматах (ORC, Parquet, AVRO).
Высокоуровневый дизайн
На Рисунке 2 показан aggregation workflow. На Рисунке 3 показан полный высокоуровневый дизайн с двумя Kafka-очередями:
- **Kafka 1:** сырые события кликов по рекламе. - **Aggregation service:** обработка MapReduce. - **Kafka 2:** агрегированные результаты (количество кликов в минуту + топ-N объявлений в минуту). - **Database writer:** читает из Kafka 2, пишет в БД.
Причина двух Kafka (не писать напрямую в БД): необходимо для end-to-end exactly-once семантики через atomic commit (Рисунок 4).
**Aggregation service — MapReduce DAG (Рисунок 5):** - **Map node (Рисунок 6):** читает из источника данных, фильтрует и маршрутизирует события. Например, объявления с `ad_id % 2 = 0` → узел 1, остальные → узел 2. - **Aggregate node:** подсчитывает click events по ad_id в памяти каждую минуту (in-memory counting). - **Reduce node (Рисунок 7):** сводит агрегированные результаты от всех Aggregate nodes к финальному результату.
Промежуточные данные хранятся в памяти; узлы общаются через TCP (разные процессы) или shared memory (один процесс).
**Сценарии использования:** - Рисунок 8: подсчёт кликов — входные данные партиционированы по `ad_id % 3` → каждый Aggregate node считает независимо. - Рисунок 9: топ-N объявлений — каждый Aggregate node поддерживает heap для топ-3 объявлений → Reduce node объединяет в финальный топ-N. - Фильтрация: star schema — предварительная агрегация по измерению (country, ip, user_id). Записи содержат `ad_id, click_minute, country, count`.
Шаг 3 — Детальный дизайн
Streaming vs batching
Наш дизайн использует как stream processing (агрегация в реальном времени), так и batch processing (резервное копирование исторических данных).
**Lambda architecture:** Два пути обработки (batch + streaming) одновременно. Недостаток: нужно поддерживать две кодовые базы.
**Kappa architecture (наш выбор, Рисунок 10):** Единый путь stream processing как для real-time, так и для исторического пересчёта. Проще — одна кодовая база.
**Пересчёт данных (Рисунок 11):** При обнаружении ошибки в aggregation service: 1. Recalculation service извлекает сырые данные из cold storage (batch job). 2. Отправляет в выделенный aggregation service (отдельно от real-time, без влияния на него). 3. Агрегированные результаты → Kafka 2 → обновление БД.
Время и окна агрегации
**Event time vs processing time (Рисунок 12):** - Event time: момент клика по рекламе → более точно, но зависит от часов клиента (могут быть неправильными/злонамеренными). - Processing time: системное время сервера → более надёжно, но неточно для опоздавших событий. - Рекомендация: **event time** для точности.
**Watermark technique (Рисунки 13, 14):** Расширяет окно агрегации на настраиваемую длительность (например, 15 секунд) для захвата слегка опоздавших событий. - Длинный watermark: лучшая точность, более высокая задержка. - Короткий watermark: меньше задержка, часть данных теряется. - Очень опоздавшие события: обрабатываются через end-of-day reconciliation.
**Tumbling window (Рисунок 15):** Фиксированные непересекающиеся временны́е интервалы. Лучше всего для сценария 1 (количество в минуту).
**Sliding window (Рисунок 16):** Перекрывающееся окно, скользящее с заданным интервалом. Лучше всего для сценария 2 (топ-N за последние M минут).
Гарантии доставки
Поскольку агрегация используется для выставления счетов, требуется **exactly-once** доставка (не at-least-once). Даже несколько процентов расхождения = миллионы долларов ошибок в счетах.
**Дедупликация данных — источники дубликатов:** 1. На стороне клиента: злонамеренная повторная отправка → обрабатывается компонентами ad fraud/risk control. 2. Сбой сервера (Рисунок 17): Aggregator падает после отправки в downstream, но до фиксации offset → следующий Aggregator повторно обрабатывает те же события.
**Решения для отслеживания offset:** - Рисунок 18: Сохранить offset во внешнем хранилище (HDFS/S3) ДО отправки в downstream. Проблема: если отправка в downstream упала, offset уже записан → события потеряны. - Рисунок 19: Сохранить offset ПОСЛЕ получения ACK от downstream. Проблема: если Aggregator падает до сохранения offset → события обрабатываются повторно (дубликат). - Рисунок 20: Обернуть шаги 4–6 в **distributed transaction** → гарантия exactly-once. При сбое любого шага вся транзакция откатывается.
Exactly-once в крупномасштабных системах сложна — подробности см. в Apache Flink's end-to-end exactly-once.
Масштабирование системы
Бизнес растёт на 30%/год → удваивается каждые 3 года. Масштабировать каждый компонент независимо.
**Масштабирование message queue:** - Producers: без ограничений на инстансы. - Consumers (Рисунок 21): добавить consumers в группу → rebalancing распределяет партиции. Делать в off-peak часы (rebalancing может занять минуты). - Brokers: использовать `ad_id` как Kafka hashing key → события одного объявления идут в одну партицию. Предварительно выделить достаточно партиций. Шардировать топики по географии или типу бизнеса.
**Масштабирование aggregation service (Рисунок 22):** - Вариант 1: multi-threading — разные диапазоны `ad_id` на разные потоки (Рисунок 23). Проще реализовать. - Вариант 2: multi-processing через YARN — горизонтально масштабируется. Более широко используется в production.
**Масштабирование БД:** Cassandra нативно поддерживает горизонтальное масштабирование с consistent hashing (Рисунок 24 — virtual nodes). Добавление узла автоматически перебалансирует — без ручного решардинга.
**Проблема hotspot (Рисунок 25):** Популярные объявления (крупные рекламодатели) получают непропорциональный объём событий. - Aggregation node обнаруживает перегрузку → запрашивает дополнительные ресурсы у resource manager. - Resource manager выделяет дополнительные узлы → исходный узел разделяет события между узлами → результаты агрегируются обратно. - Продвинутые подходы: Global-Local Aggregation или Split Distinct Aggregation.

Fault tolerance
Агрегация происходит в памяти → отказ узла теряет in-memory состояние.
**Snapshot механизм (Рисунок 26):** Периодически сохранять статус системы (upstream Kafka offset + top-N aggregation state) в snapshot.
**Failover (Рисунок 27):** Новый узел стартует с последнего snapshot, воспроизводит только события после snapshot из Kafka broker. Быстрое восстановление — не нужно воспроизводить с самого начала.
Шаг 4 — Подведение итогов
Ключевые темы дизайна: MapReduce DAG для агрегации, Kappa architecture для unified batch+streaming, watermark для опоздавших событий, distributed transactions для exactly-once, snapshot-based fault tolerance.
На Рисунке 28 показан итоговый дизайн с поддержкой reconciliation. На Рисунке 29 показан альтернативный дизайн на основе Hive + ElasticSearch + ClickHouse/Druid для OLAP-агрегации.

Система бронирования отеля
Проектируем систему бронирования отеля для сети наподобие Marriott International. Описанные принципы применимы и к другим задачам на собеседовании: проектирование Airbnb, системы бронирования авиабилетов, системы покупки билетов в кино.
Шаг 1 — Понять задачу и определить масштаб
**Функциональные требования:** - Сеть из **5 000 отелей и 1 миллиона номеров** в совокупности. - Клиенты оплачивают бронирование полностью в момент создания. - Бронирование только через сайт или приложение отеля. - Клиенты могут отменять бронирования. - Поддерживается **10% overbooking** (отель продаёт номеров больше вместимости в расчёте на отмены). - Охват: страница отеля, страница номера, бронирование, панель администратора, overbooking. - Цены на номера изменяются динамически (зависят от ожидаемой загрузки на конкретный день).
**Нефункциональные требования:** - Поддержка высокой конкурентности — в пиковый сезон популярные отели привлекают множество пользователей, бронирующих один и тот же номер. - Умеренная задержка — несколько секунд на обработку бронирования допустимы.
**Оценка нагрузки (Рисунок 1):** - 5 000 отелей, 1 млн номеров, 70% загрузка, средний срок проживания 3 дня. - Суточных бронирований: (1 млн × 0,7) / 3 ≈ **240 000/день**. - TPS бронирований: 240 000 / 10⁵ секунд ≈ **3 TPS**. - Типичная воронка (конверсия 10% на каждом шаге): - QPS страницы отеля: **300** - QPS страницы бронирования: **30** - Итоговый TPS бронирований: **3**
Шаг 2 — Предложить высокоуровневый дизайн
API design
RESTful API для управления отелями, номерами и бронированиями.
**API отелей:** | API | Описание | |-----|----------| | GET /v1/hotels/ID | Получить данные об отеле | | POST /v1/hotels | Добавить отель (только сотрудники) | | PUT /v1/hotels/ID | Обновить отель (только сотрудники) | | DELETE /v1/hotels/ID | Удалить отель (только сотрудники) |
**API номеров:** | API | Описание | |-----|----------| | GET /v1/hotels/ID/rooms/ID | Получить данные о номере | | POST /v1/hotels/ID/rooms | Добавить номер (только сотрудники) | | PUT /v1/hotels/ID/rooms/ID | Обновить номер (только сотрудники) | | DELETE /v1/hotels/ID/rooms/ID | Удалить номер (только сотрудники) |
**API бронирований:** | API | Описание | |-----|----------| | GET /v1/reservations | Получить историю бронирований | | GET /v1/reservations/ID | Получить детали бронирования | | POST /v1/reservations | Создать новое бронирование | | DELETE /v1/reservations/ID | Отменить бронирование |
**Тело запроса на создание бронирования:** ```json { "startDate": "2021-04-28", "endDate": "2021-04-30", "hotelID": "245", "roomID": "U12354673389", "reservationID": "13422445" } ```
`reservationID` — это **idempotency key**, предотвращающий двойное бронирование.
Модель данных
Паттерны доступа: 1. Просмотр подробной информации об отеле. 2. Поиск доступных типов номеров для заданного диапазона дат. 3. Запись бронирования. 4. Просмотр текущего или прошлого бронирования.
**Почему реляционная база данных:** - Рабочий профиль: много чтений, мало записей (просмотры >> бронирования). - Гарантии ACID — критичны для предотвращения двойных бронирований, отрицательных остатков, двойных списаний. - Чёткая, стабильная модель данных с хорошо определёнными связями (hotel, room, room_type).
**Начальная схема (Рисунок 2):** таблицы hotel → room → reservation. Поле `status` в таблице reservation следует конечному автомату, показанному на Рисунке 3 (pending → paid/canceled → refunded/rejected).
**Важное ограничение:** Эта схема привязывает бронирование к конкретному номеру (room_id). Однако в отелях клиенты бронируют *тип* номера, а не конкретный. Номер комнаты назначается при заселении. Это устраняется в улучшенной модели данных (deep dive).

Высокоуровневый дизайн
Микросервисная архитектура (Рисунок 4):
- **User** — бронирует номера через мобильный или веб-клиент. - **Admin (hotel staff)** — управляет бронированиями, возвратами, информацией о номерах. - **CDN** — кэширует статические ресурсы (JS, изображения, HTML) для ускорения загрузки. - **Public API Gateway** — ограничение частоты запросов, аутентификация, маршрутизация к сервисам. - **Internal APIs** — доступны только авторизованным сотрудникам, защищены VPN. - **Hotel Service** — данные об отелях и номерах (преимущественно статичны, хорошо кэшируются). - **Rate Service** — цены на номера на будущие даты (динамическое ценообразование). - **Reservation Service** — обрабатывает запросы на бронирование и отслеживает инвентарь номеров. - **Payment Service** — выполняет платежи, обновляет статус бронирования (paid/rejected). - **Hotel Management Service** — операции сотрудников: просмотр предстоящих бронирований, резервирование, отмена.
Рисунок 5 показывает связи между сервисами: Reservation Service запрашивает Rate Service для расчёта суммы; Hotel Management Service перенаправляет запросы к сервисам-владельцам данных.
Межсервисное взаимодействие обычно реализуется через gRPC.
Шаг 3 — Deep Dive
Улучшенная модель данных
**Ключевое наблюдение:** Клиенты бронируют *тип номера*, а не конкретный номер. Обновлённый API бронирования использует `roomTypeID` + `roomCount` вместо `roomID`.
**Обновлённый запрос:** ```json { "startDate": "2021-04-28", "endDate": "2021-04-30", "hotelID": "245", "roomTypeID": "12354673389", "roomCount": "3", "reservationID": "13422445" } ```
**Обновлённая схема (Рисунок 6)** вводит ключевую таблицу `room_type_inventory`:
| Столбец | Описание | |---------|----------| | hotel_id | Идентификатор отеля | | room_type_id | Идентификатор типа номера | | date | Конкретная дата (одна строка на дату) | | total_inventory | Всего номеров минус временно выведенные из оборота | | total_reserved | Всего забронировано для данного отеля/типа/даты |
**Составной первичный ключ:** (hotel_id, room_type_id, date)
Строки заранее заполняются для всех будущих дат в пределах 2 лет; ежедневный job сдвигает окно.
**Запрос проверки доступности:** ```sql SELECT date, total_inventory, total_reserved FROM room_type_inventory WHERE room_type_id = ${roomTypeId} AND hotel_id = ${hotelId} AND date BETWEEN ${startDate} AND ${endDate} ```
Для каждой строки: `if (total_reserved + numberOfRoomsToReserve) <= total_inventory` → номер доступен.
**10% overbooking:** изменить условие на `<= 110% * total_inventory`.
**Оценка размера хранилища:** 5 000 отелей × 20 типов номеров × 2 года × 365 дней = **73 миллиона строк** — помещается в одну БД с репликами.

Проблемы конкурентности
**Проблема 1: один пользователь нажимает «забронировать» несколько раз (Рисунок 7).**
Решения: - **На стороне клиента:** отключить кнопку после нажатия. Ненадёжно — пользователь может обойти JavaScript. - **Idempotent API (Рисунок 8):** передавать `reservationID` как idempotency key. Поскольку `reservationID` — первичный ключ таблицы reservation, ограничение уникальности не даёт создать дубликат.
**Поток:** 1. Клиент вводит данные → нажимает «Continue» → создаётся заказ с глобально уникальным `reservationID`. 2. Страница подтверждения (Рисунок 9) показывает детали заказа с `reservationID`. 3. Клиент нажимает «Complete my booking» — `reservationID` передаётся как первичный ключ. 4. Повторный клик → вторая вставка нарушает уникальность первичного ключа → отклонена (Рисунок 10).
**Проблема 2: несколько пользователей бронируют один последний номер (Рисунок 11).**
Состояние гонки (race condition): две транзакции одновременно читают `total_reserved = 99` (1 номер остался), обе проходят проверку, обе фиксируются → 101 забронирован при инвентаре 100.
Три стратегии блокировок:
**Вариант 1: Pessimistic locking (Рисунок 12)** `SELECT ... FOR UPDATE` блокирует строки на время транзакции. - Плюсы: предотвращает конфликты, прост в реализации, эффективен при высокой конкурентности. - Минусы: риск deadlock, не масштабируется (длительные блокировки останавливают другие транзакции). - Вывод: **не рекомендуется** для данной системы.
**Вариант 2: Optimistic locking (Рисунок 13)** Добавляет столбец `version`. Читаем версию → обновляем → пишем с `version + 1`. БД отклоняет запись, если версия не совпадает. - Плюсы: нет блокировок в БД, хорошо работает при низкой конкурентности. - Минусы: при высокой конкурентности деградирует — повторные попытки ухудшают UX. - Вывод: **хороший вариант**, так как QPS бронирований отеля обычно невелик.
**Вариант 3: Database constraint (Рисунок 14)** ```sql CONSTRAINT check_room_count CHECK((total_inventory - total_reserved >= 0)) ``` Нарушение → откат транзакции. - Плюсы: легко реализовать, эффективен при низкой конкурентности. - Минусы: при высокой конкурентности много сбоев; ограничения сложно версионировать; поддерживаются не всеми СУБД. - Вывод: **хороший вариант** для бронирований с низким QPS.

Масштабируемость
Для крупных туристических сайтов (booking.com, expedia.com) QPS может быть в 1 000 раз выше. Все сервисы stateless и масштабируются горизонтально. Узкое место — база данных.
**Шардирование БД (Рисунок 15):** - Большинство запросов фильтруют по `hotel_id` → шардируем по `hotel_id`. - 16 шардов: 30 000 QPS / 16 = **1 875 QPS на шард** (в пределах возможностей MySQL). - Ключ шарда: `hash(hotel_id) % number_of_servers`.
**Кэширование (Рисунок 16):** - Инвентарь отеля ограничен по времени — актуальны только текущие и будущие даты; старые данные не нужны. - Используем **Redis** с TTL + LRU eviction для оптимального использования памяти. - Переносим логику проверки инвентаря в кэш: `key = hotelID_roomTypeID_{date}`, `value = доступные номера`. - Операции чтения (проверка инвентаря) >> операций записи (создание брони) → кэш поглощает большинство чтений.
**Согласованность кэша через CDC (Change Data Capture):** 1. Сначала обновляется БД (источник истины). 2. Изменение асинхронно распространяется в Redis через CDC (например, **Debezium** source connector).
**Обработка рассогласования кэша:** Если кэш говорит «номер доступен», а БД — «занят» → пользователь получает ошибку при проверке в БД. Допустимо — БД всегда является конечным арбитром. Рассогласование кратковременно и самоисправляется.

Согласованность данных между сервисами
**Прагматичный подход (наш дизайн):** Reservation Service обрабатывает как API бронирований, так и API инвентаря, хранятся в **одной реляционной БД**. Это позволяет использовать ACID-свойства для элегантного управления конкурентностью.
**Чистая микросервисная альтернатива (Рисунки 17-19):** Каждый сервис имеет свою БД. Одна логическая операция (забронировать номер + записать платёж) охватывает несколько сервисов → нельзя использовать единую транзакцию.
**Проблема (Рисунок 19):** Если обновление инвентаря прошло, а запись в БД бронирований — нет, нужны компенсирующие транзакции для отката изменений инвентаря. Много сценариев сбоев → рассогласованность данных.
**Индустриальные подходы к распределённой согласованности:** - **Two-phase commit (2PC):** Атомарная фиксация на нескольких узлах (все успешно или все откатываются). Блокирующий протокол — сбой одного узла блокирует прогресс. Низкая производительность. - **Saga:** Последовательность локальных транзакций. Каждый шаг публикует сообщение для запуска следующего. При сбое компенсирующие транзакции отменяют предыдущие шаги. Опирается на eventual consistency (в отличие от сильной согласованности 2PC).
**Решение:** Дополнительная сложность распределённой согласованности не оправдана для данной системы. Данные инвентаря и бронирований хранятся в одной реляционной БД.
Шаг 4 — Итог
Ключевые темы: API design с idempotency keys, модель данных room_type_inventory, три решения для конкурентности (pessimistic locking, optimistic locking, DB constraints), шардирование БД и Redis кэширование для масштаба, 2PC vs Saga для распределённой согласованности в микросервисах.

Распределённый почтовый сервис
Проектируем крупномасштабный почтовый сервис (Рисунок 1) наподобие Gmail, Outlook или Yahoo Mail. В 2020 году у Gmail было более 1,8 миллиарда активных пользователей, у Outlook — более 400 миллионов.

Шаг 1 — Понять задачу и определить масштаб
**Масштаб:** 1 миллиард пользователей.
**Возможности в объёме проектирования:** - Отправка и получение писем. - Получение всех писем. - Фильтрация по статусу прочитано/непрочитано. - Поиск по теме, отправителю и телу письма. - Антиспам и антивирус. - Поддержка вложений. - HTTP-протокол для взаимодействия клиента с сервером (не SMTP/POP/IMAP для нашего API).
**Нефункциональные требования:** - **Надёжность:** отсутствие потерь данных. - **Доступность:** данные автоматически реплицируются; система работает при частичных сбоях. - **Масштабируемость:** обрабатывает растущий объём пользователей и писем без деградации производительности. - **Гибкость/расширяемость:** легко добавлять функции; могут потребоваться нестандартные протоколы — устаревшие POP/IMAP имеют ограниченную функциональность.
**Оценка нагрузки:** - 1 млрд пользователей × 10 писем/день ÷ 10⁵ секунд = **100 000 QPS** на отправку. - 1 млрд × 40 писем в день × 365 дней × 50 КБ метаданных = **730 PB метаданных/год**. - Вложения: 1 млрд × 40 × 365 × 20% × 500 КБ = **1 460 PB/год**. - Вывод: необходимо распределённое решение для хранения данных.
Шаг 2 — Предложить высокоуровневый дизайн
Основы почтовых протоколов
**Почтовые протоколы:** - **SMTP** — стандартный протокол отправки писем между почтовыми серверами. - **POP (Post Office Protocol)** — загружает письма на локальное устройство, удаляет с сервера; доступ с одного устройства; загружает письмо целиком, включая большие вложения. - **IMAP** — загружает только заголовки до открытия; письма остаются на сервере; доступ с нескольких устройств; наиболее широко используется для личных аккаунтов. - **HTTPS/ActiveSync** — используется для веб-почты и мобильных клиентов (например, проприетарный Microsoft ActiveSync).
**DNS MX records (Рисунок 2):** Отправляющие серверы запрашивают DNS для получения MX records домена получателя. Меньший приоритет = предпочтительнее. Резервное переключение на серверы с большим приоритетом при недоступности основного.
**Вложения:** Отправляются в кодировке Base64 через MIME (Multipurpose Internet Mail Extension). Ограничения размера: Gmail 25 МБ, Outlook 20 МБ.

Традиционные почтовые серверы
**Традиционный поток (Рисунок 3):** Alice (Outlook) → SMTP → Outlook сервер → DNS → Gmail SMTP сервер → хранение → IMAP/POP → Gmail клиент Bob.
**Хранение (Рисунок 4):** Письма хранятся в локальных файловых каталогах (по одному файлу на письмо). Популярен формат **Maildir**. Ограничения: - Плохо масштабируется на миллиарды писем. - Сложная структура файлов → узкое место дискового ввода-вывода. - Нет высокой доступности — повреждение диска или сбой сервера приводят к потере данных.
Традиционные протоколы (POP, IMAP, SMTP) не рассчитаны на современные функции (треды, метки, поиск) или миллиарды пользователей.

Распределённые почтовые серверы
**Email API (HTTP/RESTful для веб-почты):** - `POST /v1/messages` — Отправить письмо получателям To, Cc, Bcc. - `GET /v1/folders` — Получить все папки (All, Archive, Drafts, Flagged, Junk, Sent, Trash). - `GET /v1/folders/{folder_id}/messages` — Список писем в папке (с пагинацией). - `GET /v1/messages/{message_id}` — Получить полные данные письма (from, to, subject, body, is_read, attachments).
**Компоненты высокоуровневого дизайна (Рисунок 5):** - **Webmail** — браузерный интерфейс. - **Web servers** — публичные: вход, регистрация, профиль, все запросы Email API. - **Real-time servers** — stateful WebSocket серверы, доставляющие новые письма онлайн-клиентам. Long-polling как резерв для совместимости с браузерами. - **Metadata database** — хранит тему, тело, from/to, метки времени. Кастомная или Cassandra-подобная NoSQL. - **Attachment store** — Amazon S3 (объектное хранилище). Вложения до 25 МБ. Cassandra не подходит (практический лимит blob <1 МБ, проблемы кэша). - **Distributed cache** — Redis для кэширования последних писем (большинство чтений — о свежих сообщениях). - **Search store** — inverted index для полнотекстового поиска.

Поток отправки письма
**Поток (Рисунок 6):** 1. Пользователь нажимает «отправить» → запрос поступает на load balancer. 2. Load balancer применяет ограничение частоты → маршрутизирует на web серверы. 3. Web сервер: - 3a. Валидирует письмо (ограничения размера, формат). - 3b. Если тот же домен: проверка спама/вирусов → сохранить в папку Sent отправителя + Inbox получателя напрямую (внешний SMTP не нужен). 4. Очереди сообщений: - 4a. Валидное письмо → исходящая очередь (большие вложения сохраняются в S3 со ссылкой). - 4b. Невалидное письмо → очередь ошибок. 5. SMTP outgoing workers вычитывают из очереди → проверка спама/вирусов. 6. Письмо сохраняется в папку Sent отправителя. 7. SMTP workers доставляют письмо на почтовый сервер получателя.
**Преимущества очередей:** Разделяет web серверы и SMTP workers; позволяет независимо масштабировать; буферизует всплески трафика.
**Мониторинг очередей:** Зависшие письма → диагностировать причину: сервер получателя недоступен (использовать экспоненциальную задержку при повторных попытках) или недостаточно consumers (добавить ещё).

Поток получения письма
**Поток (Рисунок 7):** 1. Входящее письмо поступает на SMTP load balancer. 2. Load balancer распределяет по SMTP серверам; невалидные письма отбрасываются на уровне SMTP-соединения. 3. Большие вложения сохраняются в S3 перед постановкой в очередь. 4. Письмо помещается во входящую очередь (буферизует всплески, разделяет с SMTP серверами). 5. Mail processing workers фильтруют спам/вирусы. 6. Письмо сохраняется в metadata DB, Redis cache и S3 (вложения). 7. Если получатель онлайн → отправить на WebSocket real-time серверы. 8. WebSocket серверы доставляют письмо клиенту в реальном времени. 9. Офлайн-пользователи: письмо ждёт в хранилище; клиент забирает через RESTful API при переподключении.

Шаг 3 — Deep Dive
База данных метаданных
**Характеристики метаданных письма:** - Заголовки: небольшие, часто запрашиваемые. - Тела: крупнее, читаются редко (обычно один раз). - Почтовые операции выполняются в рамках одного пользователя (нет межпользовательского доступа). - Актуальность данных: 82% чтений — письма моложе 16 дней. - Высокая надёжность: потеря данных недопустима.
**Варианты БД:** - **Реляционная (MySQL/PostgreSQL):** хорошая индексация, но оптимизирована для небольших записей; тела писем (>100 КБ HTML) плохо подходят; поиск по BLOB неэффективен. - **Распределённое объектное хранилище (S3):** хорошо для резервного копирования, но не поддерживает эффективно операции чтения/поиска/трединга. - **NoSQL (Bigtable, Cassandra):** Bigtable используется Gmail, но не open source; Cassandra возможна, но ни один крупный провайдер не использует её.
**Требования к кастомной БД (ответ на интервью):** - Один столбец может быть в несколько МБ. - Строгая согласованность данных. - Разработана для снижения дискового ввода-вывода. - Высокая доступность и отказоустойчивость. - Удобное инкрементное резервное копирование.
**Модель данных — партиционирование по user_id (все данные пользователя на одном шарде):**
**Таблица 1 — Папки пользователя** (partition key: user_id): - Поддерживает: получить все папки пользователя.

Модель данных — запросы
**Таблица 2 — Письма по папке** (составной partition key: user_id + folder_id, clustering key: email_id как TIMEUUID для хронологической сортировки): - Поддерживает: список всех писем в папке, отсортированных по времени.

Запрос 3 — Получить детали письма
**Таблица 3 — Письма пользователя** — полные данные письма с вложениями (выбираются по email_id + filename).
```sql SELECT * FROM emails_by_user WHERE email_id = 123; ```

Запрос 4 — Прочитанные/непрочитанные письма (денормализация NoSQL)
В NoSQL `is_read` не является partition или clustering key → неэффективная фильтрация. Решение: **денормализация** в две таблицы (Таблица 4): - `read_emails` — хранит все прочитанные письма. - `unread_emails` — хранит все непрочитанные письма.
Отметить как прочитанное: удалить из `unread_emails`, вставить в `read_emails`.
```sql SELECT * FROM unread_emails WHERE user_id = <id> AND folder_id = <id> ORDER BY email_id; ```
Компромисс: более сложный код приложения, лучшая производительность чтения в масштабе.
**Бонус: Треды переписки** — использовать поля заголовков письма (Message-Id, In-Reply-To, References) с алгоритмом JWZ для восстановления цепочки тредов из предзагруженных сообщений цепочки ответов.
**Компромисс доступности:** Единственный primary на почтовый ящик — жертвует доступностью ради согласованности. При failover почтовый ящик недоступен до завершения переключения.

Доставляемость писем
Попасть в папку входящих, а не в спам — сложная задача. Более 50% всех отправляемых писем — спам.
**Ключевые факторы:** - **Dedicated IPs** — новые IP-адреса не имеют репутации; используйте dedicated IPs с историей. - **Классификация писем** — раздельные IPs для маркетинговых и транзакционных писем во избежание классификации как спам. - **Прогрев новых IPs** — постепенное наращивание репутации в течение 2-6 недель (по рекомендации Amazon SES). - **Быстрая блокировка спамеров** — предотвратить ущерб репутации до существенного влияния. - **Обработка обратной связи (Рисунок 8):** настроить feedback loops с ISP. Виды: - **Hard bounce** — недопустимый адрес получателя. - **Soft bounce** — временный сбой доставки (ISP занят). - **Complaint** — пользователь нажал «отметить как спам». - Отдельные очереди для soft bounce, hard bounce, жалоб — независимое управление.
**Аутентификация писем (Рисунок 9 — пример заголовка Gmail):** - **SPF** (Sender Policy Framework) — проверяет, что IP отправителя авторизован для домена. - **DKIM** (DomainKeys Identified Mail) — криптографическая подпись для проверки целостности сообщения. - **DMARC** (Domain-based Message Authentication) — политика, объединяющая SPF + DKIM.
Фишинг и социальная инженерия составляют 93% утечек данных (отчёт Verizon 2018).

Поиск
Характеристики поиска по почте vs Google поиск: | Характеристика | Google Search | Email Search | |----------------|---------------|--------------| | Область | Весь интернет | Личный почтовый ящик | | Сортировка | По релевантности | По времени, вложению, статусу прочтения | | Точность | Небольшая задержка индексации допустима | Почти реальное время, обязательная точность |
Почтовый поиск — **write-heavy** (переиндексация при каждой отправке/получении/удалении); запросы относительно редки.
**Вариант 1: Elasticsearch (Рисунок 10)** - Партиционирование документов по user_id → все данные пользователя на одном узле. - Переиндексация выполняется асинхронно через offline jobs; Kafka разделяет триггерные события и workers переиндексации. - Запросы поиска — синхронные (пользователь ждёт результатов). - Используется Tencent QQ Email в масштабе.
**Вариант 2: Кастомный поиск на базе LSM tree (Рисунок 11)** - В масштабе Gmail/Outlook распространены кастомные поисковые движки. - **LSM (Log-Structured Merge-Tree):** оптимизирован для write-heavy нагрузок через последовательные записи. - Новое письмо → уровень 0 in-memory кэш → слияние на следующий уровень при достижении порога. - Разделяет часто меняющиеся данные (информация о папках) от стабильных (содержимое писем). - Используется в BigTable, Cassandra, RocksDB.
| Характеристика | Elasticsearch | Кастомный поисковый движок | |----------------|---------------|----------------------------| | Масштабируемость | Умеренная | Выше (email-специфичные оптимизации) | | Сложность | Две системы (datastore + ES) | Одна система | | Согласованность данных | Две копии, сложно синхронизировать | Одна копия | | Потеря данных | Нет (пересборка из primary) | Нет | | Усилия разработки | Лёгкая интеграция | Значительные инженерные затраты |
**Правило:** Elasticsearch для меньшего масштаба; встроенный нативный поиск для масштаба Gmail/Outlook.

Масштабируемость и доступность
Большинство компонентов — stateless и горизонтально масштабируемы (паттерны доступа к данным пользователей независимы).
Для высокой доступности данные реплицируются между несколькими дата-центрами (Рисунок 12). Пользователи подключаются к ближайшему географически почтовому серверу. При разделении сети пользователи всё равно могут получать доступ к письмам из других дата-центров.
Шаг 4 — Итог
Ключевые темы: эволюция от традиционных к распределённым почтовым серверам, дизайн HTTP API, потоки отправки/получения с очередями, модель данных NoSQL с денормализацией, доставляемость писем (SPF/DKIM/DMARC), Elasticsearch vs LSM-based кастомный поиск, мультидата-центровая репликация.
Дополнительные темы для обсуждения: отказоустойчивость (сбои узлов, сетевые проблемы), соответствие требованиям (GDPR, правовой перехват), безопасность (функции безопасности Gmail), дедупликация вложений (проверять наличие перед сохранением дубликатов S3 для групповых писем).

S3-подобное объектное хранилище
Проектируем сервис объектного хранилища, аналогичный Amazon S3. Ключевые вехи: запущен в 2006 году, добавлены versioning и multipart upload в 2010-м, 2 триллиона объектов к 2013-му, 100 триллионов к 2021-му.
Хранилища данных: основы
Три широкие категории хранилищ (Рисунок 1):
**Block storage** (1960-е): физически или по сети подключённые блоки (HDD, SSD). Используется VM, БД, высокопроизводительными приложениями. Доступ через SAS/iSCSI/FC. Изменяемое, высокая производительность, средняя масштабируемость, высокая стоимость.
**File storage**: построено на block storage. Иерархическая структура каталогов. Доступ через SMB/CIFS, NFS. Универсальное назначение. Изменяемое, средне-высокая производительность и масштабируемость.
**Object storage**: новое. Жертвует производительностью ради высокой надёжности, огромного масштаба и низкой стоимости. Плоская структура (нет иерархии). Доступ через RESTful API. Неизменяемое (versioning поддерживается, обновление на месте — нет). Используется для холодных данных, архивирования, резервного копирования. AWS S3, Google Cloud Storage, Azure Blob.
**Ключевая терминология S3:** - **Bucket** — логический контейнер для объектов; глобально уникальное имя; необходимо создать перед загрузкой. - **Object** — отдельный элемент данных (payload + метаданные в виде пар ключ-значение). - **Versioning** — хранение нескольких вариантов объекта; включается для каждого bucket; позволяет восстановить случайно удалённые/перезаписанные файлы. - **URI** — каждый ресурс (bucket/object) уникально идентифицирован URI. - **SLA** — S3 Standard-Infrequent Access: 99,999999999% (11 девяток) надёжность, 99,9% доступность.
Шаг 1 — Понять задачу и определить масштаб
**Возможности:** создание bucket, загрузка/скачивание объектов, versioning, список объектов в bucket.
**Размеры данных:** как крупные объекты (ГБ), так и множество мелких (десятки КБ).
**Нефункциональные требования:** - **100 PB** данных. - **6 девяток** надёжности данных (99,9999%). - **4 девятки** доступности сервиса (99,99%). - Эффективность хранения: снижение затрат при сохранении надёжности.
**Оценка нагрузки:** - Распределение объектов: 20% малые (<1 МБ), 60% средние (1-64 МБ), 20% крупные (>64 МБ). - Используя медианные значения (0,5 МБ, 32 МБ, 200 МБ) при 40% заполнении: - 10¹¹ × 0,4 / (0,2×0,5 + 0,6×32 + 0,2×200) ≈ **0,68 миллиарда объектов**. - Метаданные 1 КБ/объект: **0,68 ТБ** для всех метаданных. - Узкое место IOPS: обычный диск (7200 rpm SATA) ≈ 100-150 IOPS.
Шаг 2 — Предложить высокоуровневый дизайн
**Ключевые свойства объектного хранилища:** - **Неизменяемость** — объекты можно удалять или заменять целиком, но не обновлять поэтапно. - **Key-value store** — URI — это ключ, данные объекта — значение. - **Write once, read many** — 95% запросов — это чтения (исследование LinkedIn). - **Поддержка мелких и крупных объектов.**
**Аналогия с файловой системой UNIX (Рисунок 2):** - UNIX: имя файла в inode → inode содержит указатели на блоки → данные на диске. - Объектное хранилище: имя объекта в metadata store → метаданные указывают на object ID → данные в data store по сети. - Metadata store ≈ inode (изменяемый). Data store ≈ диск (неизменяемый после записи). - Разделение обеспечивает независимую оптимизацию каждого компонента.
Рисунок 3 показывает структуру bucket и object.
Высокоуровневый дизайн
**Компоненты (Рисунок 4):** - **Load balancer** — распределяет RESTful API запросы по API серверам. - **API service** — stateless оркестратор; обращается к IAM, metadata store и data store. Горизонтально масштабируемый. - **IAM (Identity and Access Management)** — аутентификация (кто вы) + авторизация (что вы можете делать). - **Data store** — хранит/извлекает данные объектов по UUID. Неизменяемые объекты. - **Metadata store** — хранит метаданные объекта (имя, bucket, UUID, метки времени).
Загрузка объекта
**7-шаговый поток загрузки (Рисунок 5):** 1. Клиент отправляет HTTP PUT для создания bucket. 2. API service вызывает IAM для проверки прав WRITE. 3. API service создаёт запись bucket в metadata DB → возвращает успех. 4. Клиент отправляет HTTP PUT для загрузки объекта (например, `script.txt`). 5. API service проверяет идентификацию и права WRITE на bucket. 6. API service отправляет данные объекта в data store → получает UUID. 7. API service создаёт запись метаданных: `{object_id (UUID), bucket_id, object_name}`.
```http PUT /bucket-to-share/script.txt HTTP/1.1 Content-Type: text/plain Content-Length: 4567 [4567 bytes of object data] ```
Скачивание объекта
**Поток скачивания (Рисунок 6):** 1. Клиент: `GET /bucket-to-share/script.txt` 2. API service проверяет права READ через IAM. 3. API service получает UUID объекта из metadata store (по bucket + имени объекта). 4. API service получает данные объекта из data store по UUID. 5. Данные объекта возвращаются клиенту.
Bucket не имеет иерархии каталогов — эмулируется папками через имена объектов, разделённые `/` (например, `abc/d/e/file.txt`).
Шаг 3 — Deep Dive
Data store
Рисунок 7 показывает взаимодействие API service с data store. Data store имеет три основных компонента (Рисунок 8):
**Data routing service** — stateless; запрашивает placement service для получения оптимального data node; читает/пишет данные на/с data nodes.
**Placement service** — определяет, какие data nodes (primary + реплики) хранят каждый объект. Ведёт **virtual cluster map** (Рисунок 9) с физической топологией. Мониторит узлы через heartbeats (15 сек. допуск). Критический сервис — кластер из 5-7 узлов на Paxos или Raft консенсусе.
**Data node** — хранит данные объектов. Запускает data service daemon; отправляет heartbeats в placement service (количество дисков, данные на каждом диске). Реплицирует данные на членов replication group для надёжности.

Поток сохранения данных
**5-шаговый поток сохранения (Рисунок 10):** 1. API service отправляет данные объекта в data store. 2. Data routing service генерирует UUID, запрашивает placement service → получает primary data node (через consistent hashing). 3. Data routing service отправляет данные + UUID на primary data node. 4. Primary сохраняет локально и реплицирует на 2 вторичных узла; отвечает только после подтверждения от всех реплик. 5. UUID возвращается API service.
**Компромисс согласованности и задержки (Рисунок 11):** - Все 3 узла сохраняют → лучшая согласованность, наибольшая задержка. - Primary + 1 вторичный → средняя согласованность и задержка. - Только primary → наихудшая согласованность, наименьшая задержка (варианты 2 и 3 — eventual consistency).

Организация данных
**Проблема «один файл на объект»:** расходует дисковые блоки (минимум 4 КБ) для мелких файлов; риск исчерпания inode; ОС плохо справляется с большим числом inode.
**Решение: объединение мелких объектов в крупные файлы (Рисунок 12)** — работает как WAL (Write-Ahead Log): - Объекты последовательно дописываются в read-write файл. - При достижении порога (несколько ГБ) → файл помечается read-only, создаётся новый read-write файл. - Read-only файлы обслуживают только чтения. - Запись в read-write файл сериализована; использовать один файл на ядро CPU для повышения пропускной способности.
**Поиск объекта** — data node хранит маппинг в локальной SQLite базе данных (Таблица 3): | Поле | Описание | |------|----------| | object_id | UUID | | file_name | Файл данных, содержащий объект | | start_offset | Байтовое смещение в файле | | object_size | Размер объекта в байтах |
SQLite выбран вместо RocksDB: B+ tree обеспечивает лучшую производительность чтения (паттерн write-once, read-many).

Обновлённый поток сохранения данных
**Обновлённый 4-шаговый поток (Рисунок 13):** 1. API service запрашивает сохранение «объекта 4». 2. Data node дописывает объект 4 в конец read-write файла `/data/c`. 3. Новая запись вставляется в таблицу `object_mapping` (SQLite). 4. UUID возвращается API service.
Надёжность
**3-копийная репликация:** Годовая вероятность отказа диска ~0,81% → 1-(0,0081)³ ≈ **6 девяток надёжности**.
**Failure domains:** Отказоустойчивость на уровне стойки и узла. Реплики размещаются в разных Availability Zones (Рисунок 14) для изоляции от крупных сбоев (перебои питания, стихийные бедствия).
**Erasure coding (Рисунок 15 — пример 4+2):** - Данные разбиваются на 4 чанка (d1-d4). - Вычисляются 2 parity чанка (p1, p2) по математической формуле. - Исходные данные восстанавливаются при потере любых 2 чанков.
**Erasure coding 8+4 (Рисунок 16):** 12 чанков в 12 failure domains; допускает отказ любых 4 узлов.
**Сравнение затрат на хранение (Рисунок 17):** - 3-копийная репликация: **200% накладных расходов**. - Erasure coding 8+4: **50% накладных расходов**. - Erasure coding достигает **11 девяток надёжности** против 6 девяток у репликации.
**Компромиссы репликации vs erasure coding:** | | Репликация | Erasure coding | |--|------------|----------------| | Надёжность | 6 девяток | 11 девяток | | Накладные расходы на хранение | 200% | 50% | | Вычисления | Нет | Выше (вычисление parity) | | Производительность записи | Быстрее | Медленнее (вычисление parities) | | Производительность чтения | Быстрее (один узел) | Медленнее (8+ узлов, реконструкция при сбое) |
**Правило:** Репликация для приложений, чувствительных к задержке; erasure coding для хранилища с оптимизацией затрат.
Проверка корректности (checksums)
Повреждение данных в памяти распространено в крупномасштабных системах. Обнаруживается с помощью checksums.
**Генерация checksum (Рисунок 18):** Вычисляется из данных объекта с использованием MD5, SHA1 или HMAC. Хорошие алгоритмы генерируют существенно разные выходные данные даже для небольших изменений входных.
**Сравнение checksum (Рисунок 19):** После передачи пересчитать и сравнить: - Совпадают → данные целы. - Не совпадают → данные повреждены → читать из другого failure domain.
**Размещение (Рисунок 20):** Checksum дописывается после каждого объекта; checksum уровня файла — в конце read-only файла.
**С erasure coding 8+4:** Получить 8 чанков данных + checksums → верифицировать каждый → реконструировать при повреждении → вернуть клиенту.
Модель данных метаданных
**Три запроса для поддержки:** 1. Найти object ID по имени объекта. 2. Вставить/удалить объект по имени объекта. 3. Список объектов в bucket с общим префиксом.
**Схема (Рисунок 21): Две таблицы — `bucket` и `object`.**
- Таблица `bucket`: небольшая (1 млн клиентов × 10 bucket × 1 КБ ≈ 10 ГБ). Помещается в одну БД; масштабировать чтения через реплики. - Таблица `object`: огромная. Требует шардирования.
**Ключ шардирования: hash(bucket_name + object_name)** - Не только по bucket_id → риск hotspot (bucket с миллиардами объектов). - Не только по object_id → нельзя поддерживать URI-запросы (большинство операций используют имя объекта). - Составной ключ → равномерное распределение + поддержка URI-запросов.

Список объектов в bucket
S3 использует **префиксы** для имитации структуры каталогов. Имя объекта: `abc/d/e/f/file.txt` → префикс: `abc/d/e/f/`.
**Три режима листинга:** 1. `aws s3 list-buckets` — список всех buckets пользователя. 2. `aws s3 ls s3://mybucket/abc/` — список на уровне префикса; более глубокие пути сворачиваются до общего префикса. 3. `aws s3 ls s3://mybucket/abc/ --recursive` — рекурсивный список всех объектов с общим префиксом.
**Единая БД:** Простой LIKE запрос: `SELECT * FROM object WHERE bucket_id = '123' AND object_name LIKE 'abc/%'`
**Сложность распределённой шардированной БД:** Объекты распределены по шардам → запрашивать все шарды, агрегировать результаты. Пагинация сложна (на каждом шарде свой offset для отслеживания).
**Решение для шардированного листинга:** Денормализовать данные листинга в отдельную таблицу, шардированную по bucket_id. Запросы листинга обращаются только к этой таблице (поиск на одном шарде). Производительность листинга объектов не является приоритетом в S3-подобных системах — у всех коммерческих объектных хранилищ листинг работает неоптимально.
Versioning объектов
Хранит несколько версий объекта; восстанавливает случайно удалённые/перезаписанные файлы. Включается для каждого bucket отдельно.
**Поток загрузки с versioning (Рисунок 22):** 1. Клиент: `PUT script.txt` 2. API service проверяет права WRITE. 3. Data store сохраняет новый объект → возвращает новый UUID. 4. API service сохраняет метаданные. 5. Вместо перезаписи: вставляется новая строка с теми же `bucket_id` + `object_name`, но новым `object_id` и `object_version` (TIMEUUID). Текущая версия = наибольший TIMEUUID (Рисунок 23).
**Удаление версионированного объекта (Рисунок 24):** Все версии остаются в bucket; в качестве новейшей версии вставляется **delete marker**. GET запрос к delete marker возвращает 404 Object Not Found.
Оптимизация загрузки крупных файлов (multipart upload)
Крупные объекты (ГБ) выигрывают от параллельной загрузки по частям. Сбой сети не требует перезапуска всей загрузки.
**Поток multipart upload (Рисунок 25):** 1. Клиент инициирует multipart upload → получает `uploadID`. 2. Клиент разбивает файл (например, 1,6 ГБ на 8 × 200 МБ частей). 3. Клиент загружает каждую часть с `uploadID` → получает **ETag** (MD5 checksum части). 4. После загрузки всех частей клиент отправляет **complete multipart upload** запрос с `{uploadID, номера частей, ETags}`. 5. Data store собирает объект из частей (может занять минуты) → возвращает успех.
Заброшенные части очищаются сервисом garbage collection.
Сборка мусора (Garbage collection)
Освобождает хранилище, которое больше не используется. Источники мусора: - **Lazy deletion** — объект помечен удалённым, но не удалён немедленно. - **Orphan data** — наполовину загруженные или заброшенные multipart uploads. - **Corrupted data** — не прошедшее проверку checksum.
**Механизм уплотнения (Рисунок 26):** 1. Garbage collector копирует живые объекты из старого файла `/data/b` в новый файл `/data/d`, пропуская объекты с `delete_flag = true`. 2. Обновляет таблицу `object_mapping` (в рамках DB-транзакции): обновляет `file_name` и `start_offset`, сохраняя `obj_id` и `object_size`. 3. Новый файл меньше; ожидает накопления нескольких read-only файлов перед уплотнением, чтобы не создавать много мелких файлов.
Также освобождает пространство реплик: удаление из всех 3 копий (репликация) или всех 12 узлов (erasure coding 8+4).
Шаг 4 — Итог
Ключевые темы: сравнение block/file/object хранилищ, аналогия inode для разделения metadata/data, потоки загрузки/скачивания, организация data node (объединение файлов в WAL-стиле + SQLite маппинг), репликация vs erasure coding надёжность, проверка checksums, шардирование метаданных по (bucket_name + object_name), листинг с префиксными запросами, versioning с TIMEUUID, multipart upload, garbage collection с уплотнением.

Таблица лидеров онлайн-игры в реальном времени
Проектируем таблицу лидеров в реальном времени для мобильной онлайн-игры (Рисунок 1). Таблица ранжирует игроков по очкам, заработанным в матчах, и показывает топовых игроков и позицию конкретного пользователя.
Шаг 1 — Понять задачу и определить масштаб
**Функциональные требования:** - Отображение **топ-10 игроков** в таблице лидеров. - Показать **конкретный ранг пользователя**. - Бонус: показать игроков **на 4 позиции выше и ниже** конкретного пользователя. - Система очков: 1 очко за победу в матче; ежемесячный турнир сбрасывает таблицу. - Одинаковый счёт → одинаковый ранг.
**Масштаб:** - **5 миллионов DAU**, 25 миллионов MAU. - Каждый игрок проводит ~10 матчей в день.
**Нефункциональные требования:** - **Реальное время** обновления очков (батчевые результаты недопустимы). - Общая масштабируемость, доступность, надёжность.
**Оценка нагрузки:** - Средних пользователей/сек: 5 млн / 10⁵ = **~50 пользователей/сек**; пик (5×) = **250/сек**. - QPS начисления очков: 50 × 10 = **500 QPS**; пик = **2 500 QPS**. - QPS получения топ-10: ~**50 QPS** (загружается один раз за ежедневную сессию).
Шаг 2 — Предложить высокоуровневый дизайн
API design
**POST /v1/scores** — Обновить очки пользователя при победе в игре. Только внутренний API (вызывается игровыми серверами, не клиентами — защищает от мошенничества через man-in-the-middle атаки). ```json { "user_id": "user1", "points": 1 } ```
**GET /v1/scores** — Получить топ-10 игроков из таблицы лидеров. ```json { "data": [{"user_id": "user1", "user_name": "alice", "rank": 1, "score": 12543}, ...], "total": 10 } ```
**GET /v1/scores/{:user_id}** — Получить ранг конкретного пользователя. ```json { "user_info": { "user_id": "user5", "score": 1000, "rank": 6 } } ```
Высокоуровневая архитектура
**Два сервиса (Рисунок 2):** 1. **Game service** — валидирует победы в матчах, вызывает leaderboard service для обновления очков. 2. **Leaderboard service** — обновляет очки в хранилище; обслуживает запросы топ-10 и ранга пользователя.
**Почему игровой сервер устанавливает очки (не клиент):** Установка очков на стороне клиента уязвима к man-in-the-middle атакам, где игроки перехватывают и подменяют очки.
**Message queue (Рисунок 4):** Опциональный Kafka между игровым и leaderboard сервисами — полезен, когда очки нужны нескольким потребителям (аналитика, push-уведомления, таблица лидеров). Не обязателен согласно заявленным требованиям.
Модели данных
Решение на реляционной базе данных
**Простой подход (Рисунок 5):** Ежемесячная таблица leaderboard с колонками user_id и score.
```sql -- Пользователь зарабатывает очко: INSERT INTO leaderboard (user_id, score) VALUES ('mary1934', 1); UPDATE leaderboard SET score=score+1 WHERE user_id='mary1934';
-- Найти ранг пользователя (Рисунок 7): SELECT (@rownum := @rownum+1) AS rank, user_id, score FROM leaderboard ORDER BY score DESC; ```
**Почему это не работает в масштабе:** - Нахождение ранга пользователя требует сортировки всей таблицы → O(n) сканирование миллионов строк. - Десятки секунд для запросов ранга — недопустимо для требования реального времени. - Постоянно меняющиеся данные делают кэширование нецелесообразным. - Даже с индексом + LIMIT 10, определение ранга не топовых пользователей всё равно требует полного сканирования.
**Вывод:** Реляционная БД работает только для малых датасетов или batch операций.

Решение на Redis sorted sets
**Redis sorted sets** (Рисунок 8): Каждый элемент уникален и имеет связанный числовой счёт. Внутри используются две структуры данных: - **Hash table** — маппинг пользователей на очки. - **Skip list** — маппинг очков на пользователей для быстрых запросов ранга.
**Skip list (Рисунок 9):** Отсортированный связный список + многоуровневые индексы. Каждый уровень пропускает через один узел предыдущего уровня. Поиск значения начинается с верхнего индекса, существенно сокращая число сравнений по сравнению с O(n) связным списком.
**Рисунок 10:** Skip list с 5 уровнями индексов — находит узел за 11 переходов против 62 для базового связного списка.
**Все операции — O(log n)** — предсказуемая производительность при миллионах пользователей.
**Используемые Redis команды:** - `ZADD` — Вставить/обновить пользователя в sorted set. O(log n). - `ZINCRBY <key> <increment> <user>` — Увеличить очки пользователя; добавляет с очком 0 при отсутствии. O(log n). - `ZRANGE/ZREVRANGE` — Получить диапазон пользователей по очкам (по возрастанию/убыванию). O(log n + m). - `ZRANK/ZREVRANK` — Получить ранг пользователя. O(log n).
**Рабочий процесс:**
1. **Пользователь зарабатывает очко (Рисунок 11):** ``` ZINCRBY leaderboard_feb_2021 1 'mary1934' ```
2. **Получить топ-10 (Рисунок 12):** ``` ZREVRANGE leaderboard_feb_2021 0 9 WITHSCORES ``` Возвращает: `[(user2,score2),(user1,score1)...]`
3. **Получить ранг пользователя (Рисунок 13):** ``` ZREVRANK leaderboard_feb_2021 'mary1934' ```
4. **Получить 4 игроков выше и ниже 361 позиции (Рисунок 14):** ``` ZREVRANGE leaderboard_feb_2021 357 365 ```
**Оценка размера хранилища:** - 25 млн MAU × 26 байт/запись (24-символьный user_id + 2-байтовый счёт) = **~650 МБ** — помещается на одном Redis сервере. - Пиковый QPS 2 500 — хорошо укладывается в возможности одного Redis сервера.
**Персистентность:** Redis настраивается с репликой для чтения; при отказе основного экземпляра → реплика промоутируется.
**Поддерживающие таблицы MySQL:** `user` (данные профиля) и `point` (история побед с метками времени) — используются для истории игр и восстановления таблицы лидеров после сбоя.




Шаг 3 — Deep Dive
Self-managed vs облачный деплой
**Self-managed (Рисунок 15):** Ежемесячный sorted set в Redis + MySQL для профилей пользователей. API серверы запрашивают Redis для данных таблицы + MySQL для имён/изображений. Опциональный кэш для профилей топ-10 пользователей.
**Serverless на AWS (Рисунки 16-17):** Amazon API Gateway → AWS Lambda функции → Redis + MySQL.
| API | Lambda функция | |-----|----------------| | GET /v1/scores | LeaderboardFetchTop10 | | GET /v1/scores/{user_id} | LeaderboardFetchPlayerRank | | POST /v1/scores | LeaderboardUpdateScore |
**Преимущества serverless:** Нет необходимости в провизионировании серверов, автомасштабирование с нагрузкой, нет обслуживания инфраструктуры. Рекомендуется для новых проектов.



Масштабирование Redis
При 500 млн DAU (100× масштаб): таблица лидеров вырастает до **65 ГБ**, пиковый QPS **250 000**. Требуется шардирование.
**Вариант 1: Fixed partitions (Рисунок 18)** - Диапазон очков 1-1000 → 10 шардов, каждый охватывает 100 очков. - Приложение маршрутизирует на правильный шард по очкам пользователя. - Нужен вторичный кэш: user_id → текущий счёт (чтобы знать, в каком шарде пользователь). - При переходе счёта через границу шарда → удалить из старого, добавить в новый. - Получить топ-10: запрашивать только шард с наибольшими очками. - Ранг пользователя: локальный ранг внутри шарда + количество пользователей в шардах с более высокими очками (O(1) через `INFO keyspace`). - **Рекомендуемый подход.**
**Вариант 2: Redis cluster hash partitioning (Рисунок 19)** - 16 384 hash slot; ключ назначается через `CRC16(key) % 16384`. - Автоматическое шардирование; лёгкое добавление/удаление узлов. - Получить топ-10 требует scatter-gather (Рисунок 20): запросить все шарды, приложение сортирует результаты. - Ограничения: высокая задержка для больших K в top-K запросах; высокая задержка с большим числом партиций; нет простого способа определить ранг пользователя. - **Не рекомендуется** для данного случая.


Альтернативное решение: NoSQL (DynamoDB)
DynamoDB с **Global Secondary Indexes (GSI)** как альтернатива Redis.
**Рисунок 21:** Диаграмма системы с DynamoDB вместо Redis + MySQL.
**Начальный дизайн таблицы (Рисунок 22):** Денормализованная таблица leaderboard + user. Проблема: полное сканирование для топ-очков.
**Первая попытка с индексом (Рисунок 23):** Partition key = `game_name#{year-month}`, sort key = `score`. Проблема: все данные текущего месяца — на одной партиции → **hot partition**.
**Решение через write sharding (Рисунок 24):** Partition key = `game_name#{year-month}#p{partition_number}`, где partition_number = `user_id % N`. Данные равномерно распределяются по N партициям.
**Компромисс:** N партиций → меньше нагрузки на партицию, но чтение топ-10 требует **scatter-gather** по всем N партициям (Рисунок 25): 1. Запросить топ-10 из каждой партиции параллельно. 2. Приложение объединяет и сортирует результаты.
**Ограничение ранга пользователя:** Нет прямого абсолютного ранга. Используйте **приближение по перцентилям**: - Cron job анализирует распределение очков по шардам → кэширует пороги перцентилей. - Возвращает «вы в топ 10-20%» вместо точного ранга #1 200 001.
**Выбор числа партиций:** Требует бенчмаркинга — больше партиций = меньше нагрузки, но сложнее scatter.





Шаг 4 — Итог
Ключевые темы: Redis sorted sets с устройством skip list (операции O(log n)), MySQL для персистентности и восстановления, Redis fixed partition шардирование для масштаба 500 млн DAU, DynamoDB с write sharding как альтернатива.
**Дополнительные темы:** - **Обработка ничьей:** Redis Hash хранит `user_id → timestamp последней победы`; более ранний timestamp даёт более высокий ранг при равных очках. - **Восстановление после сбоя:** Воспроизвести историю побед из MySQL через вызовы ZINCRBY для пересборки таблицы с нуля.

Платёжная система
Проектируем платёжный бэкенд для e-commerce приложения наподобие Amazon. Система обрабатывает все денежные потоки: сбор платежей от покупателей (pay-in) и выплаты продавцам (pay-out). Масштаб: 1 миллион транзакций/день = ~10 TPS. Акцент — на корректности, а не на пропускной способности.
Шаг 1 — Понять задачу и определить масштаб
**Функциональные требования:** - **Pay-in flow** — платёжная система получает деньги от покупателей от имени продавцов. - **Pay-out flow** — платёжная система отправляет деньги продавцам. - Пример: оплата кредитной картой через сторонние PSP (Payment Service Provider) — Stripe, Braintree, Square. - Данные карт не хранятся внутри системы (соответствие PCI DSS через PSP). - Одна валюта для упрощения; глобальное приложение.
**Нефункциональные требования:** - Надёжность и отказоустойчивость — тщательная обработка сбоев платежей. - Reconciliation — асинхронная проверка согласованности платежей между внутренними и внешними сервисами.
**Оценка нагрузки:** 1 млн транзакций/день ÷ 10⁵ секунд = **10 TPS**. Задача не о высокой пропускной способности — акцент на корректности.
Шаг 2 — Предложить высокоуровневый дизайн
**Pay-in vs Pay-out (Рисунок 1):** - Покупатель платит → деньги поступают на банковский счёт Amazon (pay-in). - Amazon выступает хранителем денег; большинство денег принадлежат продавцу. - После доставки деньги перетекают от Amazon на банковский счёт продавца (pay-out).
Pay-in flow
**Компоненты (Рисунок 2):** - **Payment service** — принимает платёжные события; проводит проверку рисков (AML/CFT через третью сторону); координирует процесс. - **Payment executor** — выполняет единственный платёжный ордер через PSP. Одно платёжное событие может содержать несколько ордеров (оформление заказа с несколькими продавцами). - **PSP (Payment Service Provider)** — перемещает деньги между счетами (например, Stripe, Braintree). Списывает средства с кредитной карты покупателя. - **Card schemes** — Visa, MasterCard, Discovery. Обрабатывают операции с кредитными картами. - **Ledger** — неизменяемая финансовая запись с использованием двойной бухгалтерии (double-entry accounting). Используется для анализа доходов и прогнозирования. - **Wallet** — отслеживает баланс аккаунта продавца и суммарные платежи пользователей.
**Pay-in flow (9 шагов):** 1. Пользователь нажимает «разместить заказ» → платёжное событие отправляется в payment service. 2. Payment service сохраняет событие в БД. 3. Платёжное событие может содержать несколько ордеров → payment service вызывает executor для каждого. 4. Payment executor сохраняет ордер в БД. 5. Payment executor вызывает PSP для обработки списания с кредитной карты. 6. После успеха → payment service обновляет wallet (баланс продавца). 7. Wallet server сохраняет обновлённый баланс. 8. Payment service вызывает ledger для записи транзакции. 9. Ledger добавляет новую запись.
**API:** - `POST /v1/payments` — Выполнить платёжное событие. Поля: `buyer_info`, `checkout_id`, `credit_card_info`, `payment_orders[]`. - Каждый payment order: `seller_account`, `amount` (строка, не double — избегает ошибок точности с плавающей запятой), `currency` (ISO 4217), `payment_order_id` (используется как PSP idempotency key). - `GET /v1/payments/{:id}` — Получить статус выполнения payment order.
**Почему amount как строка (не double):** Ошибки округления плавающей запятой в разных протоколах/железе; экстремальные значения (ВВП Японии в йенах, Bitcoin satoshi = 10⁻⁸). Преобразовать в число только для отображения/вычисления.
**Модель данных:** - Таблица `payment_event`: `checkout_id (PK)`, `buyer_info`, `seller_info`, `credit_card_info`, `is_payment_done`. - Таблица `payment_order`: `payment_order_id (PK)`, `buyer_account`, `amount`, `currency`, `checkout_id (FK)`, `payment_order_status` (NOT_STARTED → EXECUTING → SUCCESS/FAILED), `ledger_updated`, `wallet_updated`.
**Выбор БД:** Традиционная реляционная с ACID — стабильность важнее производительности. Не NoSQL/NewSQL.
**Double-entry ledger:** Каждая транзакция дебетует один счёт и кредитует другой на ту же сумму. Сумма всех записей = 0. Обеспечивает сквозную прослеживаемость.

Hosted payment page (интеграция с PSP)
Большинство компаний используют страницы оплаты от PSP (iframe/widget/SDK), чтобы избежать хранения данных карт и ответственности по PCI DSS.
**Пример: интеграция с PayPal (Рисунок 3)** — PSP показывает собственный UI для ввода данных карты.
**9-шаговый поток hosted payment (Рисунок 4):** 1. Пользователь нажимает «оформить заказ» → клиент вызывает payment service с данными заказа. 2. Payment service отправляет запрос на регистрацию в PSP с суммой, валютой, сроком действия, redirect URL и **nonce** (UUID idempotency key = payment_order_id). Гарантирует exactly-once регистрацию. 3. PSP возвращает **token** (UUID, идентифицирующий регистрацию платежа). 4. Payment service сохраняет token в БД. 5. Клиент отображает страницу оплаты от PSP (Рисунок 5 — пример Stripe). PSP JS библиотека собирает данные карты напрямую; данные карты никогда не касаются нашей системы. - PSP JS использует token для получения деталей платежа из PSP бэкенда. - Redirect URL указан для навигации после оплаты. 6. Пользователь вводит данные карты → нажимает «оплатить» → PSP начинает обработку. 7. PSP возвращает статус платежа. 8. Браузер перенаправляется на redirect URL (статус платежа добавляется к URL). 9. Асинхронно PSP вызывает зарегистрированный нами **webhook** со статусом платежа → payment service обновляет `payment_order_status`.



Pay-out flow
Аналогичен pay-in, но в обратном направлении. Использует сторонних **pay-out провайдеров** (например, Tipalti) для перевода денег с банковского счёта e-commerce сайта на счета продавцов. Применяются сложные нормативные и бухгалтерские требования.
Шаг 3 — Deep Dive
Reconciliation
**Последняя линия защиты** корректности платежей. Асинхронная проверка согласованности состояний между сервисами.
**Процесс (Рисунок 6):** Каждую ночь PSP/банки отправляют **settlement files** с перечнем всех транзакций и балансом аккаунта за день. Система reconciliation парсит файл и сравнивает с внутренним ledger.
**Три типа расхождений:** 1. **Классифицируемые + автоисправляемые** — известная причина с автоматической корректировкой. 2. **Классифицируемые, но ручные** — известная причина, слишком сложно автоматизировать. Попадают в очередь задач для финансовой команды. 3. **Неклассифицируемые** — неизвестная причина. Специальная очередь для ручного расследования финансовой командой.

Обработка задержек при обработке платежей
Некоторые платёжные запросы обрабатываются часами или днями (помечены как высокорисковые для проверки человеком, требуется аутентификация 3D Secure).
**Обработка через PSP:** - Возвращает **pending статус** клиенту; клиент показывает состояние ожидания. - PSP отслеживает отложенный платёж и уведомляет payment service через webhook при разрешении.
**Альтернатива:** Некоторые PSP требуют, чтобы payment service периодически запрашивал обновления статуса.
Взаимодействие внутренних сервисов
**Синхронное (HTTP):** Простое, но создаёт тесную связь. Недостатки: низкая производительность, плохая изоляция сбоев, сложно масштабировать.
**Асинхронный обмен сообщениями:** - **Один получатель (Рисунки 7-8):** Каждое сообщение обрабатывается одним consumer; сообщение удаляется после обработки. Стандартная message queue (например, SQS). - **Несколько получателей (Рисунок 9):** Одно сообщение потребляется несколькими сервисами. Kafka сохраняет сообщения. Хорошо подходит для платёжной системы: обновление счёта запускает обработку платежа + аналитику + выставление счёта + push-уведомления одновременно.
**Рекомендация:** Асинхронный для крупных платёжных систем — лучшая масштабируемость и устойчивость к сбоям несмотря на добавленную сложность.
Обработка неудавшихся платежей
**Отслеживание состояния платежа:** Состояние платежа сохраняется в append-only таблице на каждом этапе. При сбое текущее состояние определяет: повторить или вернуть деньги.
**Retry queue + dead letter queue (Рисунок 10):** 1. Сбой → проверить, можно ли повторить. - 1a. Повторяемый (транзиентные ошибки) → retry queue. - 1b. Неповторяемый (некорректный ввод) → сохранить в БД. 2. Платёжная система читает из retry queue и повторяет попытку. 3. Если снова неудача: - 3a. Счётчик попыток ниже порога → обратно в retry queue. - 3b. Счётчик попыток превышает порог → **dead letter queue** (для отладки и ручного расследования).
Exactly-once delivery
**Exactly-once = at-least-once (повторные попытки) И at-most-once (идемпотентность)**.
**Retry — гарантия at-least-once (Рисунок 11):** Стратегии (от худшей к лучшей для сетевых проблем): - Немедленная повторная попытка — может усугубить перегрузку. - Фиксированные интервалы. - Нарастающие интервалы. - **Exponential backoff** — удваивает время ожидания (1с → 2с → 4с). Лучший вариант для транзиентных сетевых проблем. - Отмена — для постоянных сбоев.
Передавать заголовок `Retry-After` с кодами ошибок.
**Idempotency — гарантия at-most-once (Рисунок 12):** - Клиент генерирует idempotency key (UUID, например ID корзины) в HTTP заголовке: `idempotency-key: <value>`. - Сервер использует ограничение уникального ключа в БД: первая вставка успешна; дублирующаяся вставка не проходит → возвращает кэшированный ответ. - Параллельные запросы с одним ключом → один обрабатывается, остальные получают 429 Too Many Requests.
**Сценарий 1 (двойной клик):** Idempotency key предотвращает второе списание.
**Сценарий 2 (ошибка сети, пользователь повторно отправляет):** PSP token уникально маппируется на payment order. Тот же ордер → тот же token → PSP идентифицирует как дубликат → возвращает предыдущий статус выполнения.
Согласованность
Задействованные stateful сервисы: payment service, ledger, wallet, PSP, реплики БД.
**Внутренняя согласованность:** Exactly-once обработка предотвращает расхождение между сервисами.
**Внешняя согласованность (с PSP):** Использовать тот же idempotency key для повторных попыток. Даже если PSP поддерживает idempotency, всё равно запускать reconciliation — не полагаться на то, что внешняя система всегда корректна.
**Варианты для отставания репликации:** 1. Чтение + запись только с primary — просто, но тратит ресурсы реплик. 2. Держать реплики всегда синхронизированными — использовать Paxos/Raft консенсус или БД на основе консенсуса (YugabyteDB, CockroachDB).
Безопасность платежей
| Проблема | Решение | |----------|---------| | Перехват запросов/ответов | HTTPS | | Подмена данных | Шифрование + мониторинг целостности | | Man-in-the-middle атака | SSL с certificate pinning | | Потеря данных | Репликация БД + snapshots | | DDoS | Ограничение частоты + firewall | | Кража данных карты | Токенизация (хранить токены, не номера карт) | | PCI соответствие | Стандарт PCI DSS | | Мошенничество | Верификация адреса, CVV, анализ поведения пользователя |
Шаг 4 — Итог
Ключевые темы: потоки pay-in/pay-out, hosted PSP страница оплаты с nonce/token/webhook, double-entry ledger, reconciliation с settlement files, retry queues + dead letter queues, exactly-once delivery через retry + idempotency, безопасность платежей.
Дополнительные темы: мониторинг/алертинг, инструменты отладки, обмен валют, способы оплаты, специфичные для регионов, наличные платежи, интеграция Google/Apple Pay.

Цифровой кошелёк
Платёжные платформы предоставляют сервис цифрового кошелька: пользователи хранят деньги и тратят их позже, или переводят напрямую на другой кошелёк той же платформы (быстрее и обычно бесплатно по сравнению с банковскими переводами). Проектируем бэкенд, поддерживающий переводы между кошельками на 1 млн TPS с транзакционными гарантиями и воспроизводимостью.
Шаг 1 — Понять задачу и определить масштаб
**Требования:** - Перевод баланса только между двумя цифровыми кошельками. - **1 000 000 TPS**. - Надёжность ≥ 99,99%. - Транзакционные гарантии. - **Воспроизводимость** — восстановление исторического баланса путём воспроизведения данных с начала (не просто reconciliation, которая показывает только расхождения). - Нет обмена валют (вне объёма задачи).
Оценка нагрузки
Каждый перевод = 2 операции в БД (списание + начисление). При 1 млн переводов/сек → **2 млн TPS**.
| TPS на узел | Количество узлов | |-------------|------------------| | 100 | 20 000 | | 1 000 | 2 000 | | 10 000 | 200 |
Цель дизайна: максимизировать TPS на узел для снижения затрат на оборудование.
Шаг 2 — Предложить высокоуровневый дизайн
Рассматриваются три высокоуровневых дизайна: 1. Простое решение в памяти (Redis) 2. Решение с распределёнными транзакциями на основе БД (2PC, TC/C, Saga) 3. Решение на event sourcing с воспроизводимостью
API Design
Единственный REST endpoint:
| API | Описание | |-----|----------| | `POST /v1/wallet/balance_transfer` | Перевод баланса с одного кошелька на другой |
**Параметры запроса:**
| Поле | Описание | Тип | |------|----------|-----| | `from_account` | Счёт для списания | string | | `to_account` | Счёт для начисления | string | | `amount` | Сумма (строка, не double — избегает ошибок точности) | string | | `currency` | ISO 4217 | string | | `transaction_id` | UUID для дедупликации/idempotency | uuid |
**Ответ:** `{ "Status": "success", "Transaction_id": "..." }`
Решение с хранением в памяти (Redis)
Использовать кластер Redis-узлов для хранения маппинга `{user → balance}`. Шардировать по `accountID.hashCode() % N`. Zookeeper хранит конфигурацию шардирования. Stateless wallet service находит нужную партицию и обновляет два Redis-узла на перевод.
**Проблема:** Нет атомарности между двумя Redis-узлами. Если wallet service падает после обновления узла A, но до B — перевод незавершён. Два обновления должны быть в единой атомарной транзакции.
Распределённые транзакции
Заменить Redis транзакционными реляционными БД для обеспечения атомарности на узел. Всё равно нужна координация между узлами для единственного перевода.
Шардирование БД
Партиционировать аккаунты по нескольким реляционным БД-узлам (вместо Redis). Каждый БД-узел обеспечивает локальные ACID-гарантии, но перевод между двумя узлами всё равно требует распределённой координации.
Распределённая транзакция: two-phase commit (2PC)
Низкоуровневое решение, использующее поддержку БД (например, стандарт X/Open XA).
**Две фазы:** 1. Координатор (wallet service) выполняет чтение/запись на нескольких БД — **обе заблокированы**. 2. Координатор просит все БД подготовиться: - Все отвечают «да» → координатор фиксирует все. - Любая отвечает «нет» → координатор отменяет все.
**Проблемы:** - **Производительность**: Блокировки удерживаются долгое время в ожидании сообщений от других узлов. - **SPOF**: Координатор — единственная точка отказа — если падает после фазы 1, блокировки удерживаются бесконечно.
Распределённая транзакция: Try-Confirm/Cancel (TC/C)
Компенсирующая транзакция высокого уровня. В отличие от 2PC, каждая фаза — **независимая локальная транзакция** (нет длительных блокировок).
**Фазы:** 1. **Try** — координатор просит все БД зарезервировать ресурсы. 2. **Confirm** (если все согласны) — выполнить фактические операции. 2. **Cancel** (если кто-то отказывает) — отменить эффекты фазы Try (компенсирующие транзакции).
**Пример: Перевод $1 от A к C:**
| Фаза | Операция | A | C | |------|----------|---|---| | 1 | Try | -$1 | NOP | | 2 | Confirm | NOP | +$1 | | | Cancel | +$1 (откат) | NOP |
**Почему только вариант 1 допустим для фазы Try:** - Вариант 2 (NOP на A, +$1 на C): C может потратить деньги до того, как Cancel сможет отменить. - Вариант 3 (-$1 A и +$1 C одновременно): Сложные сценарии сбоев.
**Несбалансированное состояние:** Во время Try $1 списан с A, но C ещё не зачислен — сумма временно меньше. Эта промежуточная несогласованность видна приложению (в отличие от 2PC/DB-транзакций, которые её скрывают).
**Таблица статусов фаз:** Хранится в БД A. Отслеживает: ID/содержание распределённой транзакции, статус фазы Try для каждой БД (не отправлено / отправлено / получен ответ), название второй фазы (Confirm или Cancel), статус второй фазы, флаг out-of-order. Обеспечивает восстановление после сбоя.
**Out-of-order выполнение:** Cancel может прийти в C до Try (из-за сетевых проблем). Решение: Cancel оставляет out-of-order флаг; последующий Try обнаруживает флаг и возвращает ошибку.
**vs 2PC:**
| | Первая фаза | Вторая фаза: успех | Вторая фаза: сбой | |--|-------------|--------------------|--------------| | 2PC | Локальные транзакции ещё не выполнены (заблокированы) | Зафиксировать все | Отменить все | | TC/C | Все локальные транзакции завершены | Выполнить новые локальные транзакции при необходимости | Отменить («откатить») зафиксированные транзакции |
TC/C не зависит от СУБД (работает с любой БД, поддерживающей транзакции), но сложность — в бизнес-логике приложения.
Распределённая транзакция: Saga
Де-факто стандарт в микросервисной архитектуре.
**Правила:** - Операции упорядочены в **линейную последовательность**; каждая — независимая DB-транзакция. - Операции выполняются одна за другой; завершение запускает следующую. - При сбое: **откат** от текущей к первой операции через компенсирующие транзакции. - `n` операций → `2n` итого (n прямых + n компенсирующих).
**Режимы координации:** - **Choreography** — сервисы подписываются на события друг друга (полностью децентрализовано). Сложно управлять при большом числе сервисов — каждый должен поддерживать внутренний конечный автомат. - **Orchestration** — единственный координатор направляет все сервисы по порядку. Хорошо справляется со сложностью; предпочтителен для цифровых кошельков.
**vs TC/C:**
| | TC/C | Saga | |--|------|------| | Компенсирующее действие | Фаза Cancel | Фаза отката | | Центральная координация | Да | Да (orchestration) | | Порядок выполнения операций | Любой | Только линейный | | Параллельное выполнение | Да | Нет | | Частичная несогласованность видима | Да | Да |
**Когда выбирать:** Чувствительность к задержке + много сервисов → TC/C (параллельное). Меньше сервисов / тренд на микросервисы → Saga.
Event sourcing
Выбран для аудитируемости и воспроизводимости — отвечает на вопросы аудитора: - Баланс аккаунта в любой момент времени? (воспроизвести события до этого момента) - Как проверить корректность исторических балансов? (пересчитать из списка событий) - Как доказать корректность логики после изменения кода? (запустить разные версии на одних и тех же событиях)
Контекст
Решения с распределёнными транзакциями сохраняют только обновлённое состояние — невозможно аудировать *почему* изменился баланс. Event sourcing хранит все изменения как неизменяемую историю; состояние — производное представление, всегда восстанавливаемое.
Определение
Четыре ключевых концепции:
**Command** — запланированное действие извне (например, «перевести $1 от A к C»). Commands помещаются в FIFO очередь. Могут быть невалидными.
**Event** — результат валидированного и выполненного command (в прошедшем времени: «переведено $1 от A к C»). Должен быть выполнен. **Детерминированный** — нет случайности или I/O. Events хранятся в FIFO очереди. Один command → 0 или более events (генерация событий может содержать случайность; сами события должны быть детерминированными).
**State** — что изменяется при применении события. В wallet системе: маппинг `{account → balance}`. Хранится в key-value store или реляционной БД.
**State machine** — управляет event sourcing: 1. Валидирует commands и генерирует events. 2. Применяет events для обновления state. Должен быть **полностью детерминированным** — нет внешнего I/O, нет случайных чисел. Одни и те же events всегда дают одно и то же состояние.
Пример wallet service
Commands = запросы на перевод баланса → FIFO очередь Kafka. State = балансы аккаунтов в реляционной БД.
**State machine (5 шагов):** 1. Прочитать command из command queue. 2. Прочитать состояние баланса из БД. 3. Валидировать command; если валидный, генерировать два события (например, `A:-$1`, `C:+$1`). 4. Прочитать следующее событие. 5. Применить событие — обновить баланс в БД.
Воспроизводимость
Список events **неизменяемый**. State machine **детерминированная**. Следовательно: воспроизведение events с начала всегда воссоздаёт те же исторические состояния.
Решения с распределёнными транзакциями теряют историю при обновлении состояния. Event sourcing хранит всю историю; БД — лишь кэш текущего представления event log.
Command-query responsibility segregation (CQRS)
Внешним клиентам нужно запрашивать текущий баланс — но внутреннее состояние event sourcing не экспонируется напрямую.
**Паттерн CQRS:** Вместо публикации state — публиковать все events. Внешние потребители самостоятельно строят кастомизированные представления состояния.
- **Одна write state machine** — ведёт авторитетный event log. - **Множество read-only state machines** — подписываются на очередь events, строят materialized views для разных нужд (текущие балансы, аудиты за период, расследование двойных списаний, reconciliation).
Read-only state machines отстают, но всегда догоняют → **eventual consistency**.
Шаг 3 — Deep Dive
Deep dive в высокую производительность, надёжность и масштабируемость для архитектуры event sourcing.
Высокопроизводительный event sourcing
Переход от удалённого Kafka + удалённой БД к локальному хранилищу на базе файлов для устранения сетевой задержки.
Файловый список commands и events
**Оптимизация 1:** Сохранять commands и events на **локальный диск** (не в удалённый Kafka) — устраняет сетевые round-trip. Использует append-only структуру (последовательные записи, оптимизированные ОС; могут быть быстрее случайного доступа к памяти).
**Оптимизация 2:** Кэшировать последние commands/events в **памяти** — пропускать дисковые чтения для недавно записанных данных.
**Реализация:** Использовать `mmap` — маппинг дискового файла в массив в памяти. ОС кэширует горячие секции в памяти. Для append-only операций: данные почти всегда в кэше памяти.
Файловое хранение состояния
Перенести state из удалённой реляционной БД в локальные файловые хранилища: - **SQLite** — локальная файловая реляционная БД. - **RocksDB** — локальный файловый key-value store на основе LSM tree (оптимизирован для записей; кэширует последние данные для чтений). Предпочтительный выбор.
Snapshot
**Проблема:** Воспроизводимость требует воспроизведения с события #1 каждый раз.
**Решение:** Периодически останавливать state machine и сохранять текущее состояние в **snapshot файл** (неизменяемое историческое представление). При рестарте/воспроизведении: загрузить ближайший snapshot, проверить позицию, продолжить с этой точки.
Финансовые команды часто требуют snapshots в 00:00 для верификации дневных транзакций. Read-only state machines загружают один snapshot вместо воспроизведения всех событий с начала.
Snapshot файлы — крупные бинарные файлы, хранятся в объектном хранилище (например, HDFS).
С полностью файловым подходом система может максимально использовать I/O пропускную способность локального оборудования.
Надёжный высокопроизводительный event sourcing
Локальное файловое хранилище создаёт единственную точку отказа. Требуется репликация.
Анализ надёжности
**Четыре типа данных:** 1. Файловый command 2. Файловый event 3. Файловый state 4. State snapshot
**Анализ:** - State и snapshot всегда можно регенерировать из списка events → достаточно защитить events. - Command не гарантирует воспроизводимость events (генерация событий может быть недетерминированной — содержит внешний I/O, случайные числа). - **Event** — единственные данные, требующие высоких гарантий надёжности. Events — детерминированные факты; их воспроизведение всегда восстанавливает то же состояние.
**Вывод: Только список events нуждается в высоконадёжной репликации.**
Консенсус
Использовать **алгоритм Raft** для репликации списка events на нескольких узлах.
**Гарантии:** Пока большинство узлов онлайн, все append-only списки events содержат одинаковые данные.
**Роли узлов Raft:** Leader, Candidate, Follower. - Не более одного leader в кластере. - Leader принимает commands, надёжно реплицирует events. - **3 узла** → допускает 1 отказ. **5 узлов** → допускает 2 отказа.
Надёжное решение
**3-узловой кластер event sourcing с Raft (Рисунок 24):** - Leader принимает входящие commands, конвертирует в events, добавляет в локальный список events. - Raft реплицирует новые events на followers. - Все узлы (leader + followers) обрабатывают список events и обновляют state → идентичные состояния гарантированы.
**Обработка сбоев:** - **Падение leader:** Raft автоматически выбирает нового. Клиент повторно отправляет command (падение могло произойти до конвертации command→event). Idempotency key (transaction_id) предотвращает двойную обработку. - **Падение follower:** Raft повторяет бесконечно до перезапуска узла или его замены.
Распределённый event sourcing
Единственная Raft группа имеет два ограничения: 1. CQRS ответ медленный — клиент должен опрашивать статус выполнения. 2. Ёмкость одной Raft группы ограничена — нужны шардирование + распределённые транзакции в масштабе.
Pull vs push
**Pull модель (Рисунок 25):** Внешний пользователь периодически опрашивает read-only state machine. Не в реальном времени; может перегрузить wallet service.
**Pull + reverse proxy (Рисунок 26):** Пользователь отправляет command в reverse proxy, который опрашивает от имени пользователя. Упрощает логику клиента, но всё ещё не в реальном времени.
**Push модель (Рисунок 27):** Read-only state machine отправляет статус выполнения обратно в reverse proxy сразу при получении события. Пользователь получает ответ практически в реальном времени.
Распределённая транзакция
С push моделью, делающей каждую Raft группу синхронной, повторно использовать TC/C или Saga для координации между партициями.
**Схема партиционирования:** Хэш ключа на 2 → Партиция 1 (аккаунт A) и Партиция 2 (аккаунт C).
**Итоговый дизайн распределённого event sourcing (Рисунок 28):** Saga coordinator + несколько Raft partition групп.
**29-шаговый happy path Saga (Рисунок 29): Перевод $1 от A к C:** 1. Пользователь A отправляет распределённую транзакцию в Saga coordinator (A-$1, C+$1). 2. Saga coordinator создаёт запись в таблице статусов фаз. 3. Coordinator отправляет команду A-$1 в Партицию 1. 4. Raft leader Партиции 1 сохраняет command, валидирует, конвертирует в event, Raft синхронизирует по узлам, выполняет (списать $1 с A). 5. Партиция 1 CQRS синхронизируется с read path; read path восстанавливает state и статус выполнения. 6. Read path Партиции 1 отправляет статус в Saga coordinator. 7. Saga coordinator получает успех от Партиции 1. 8. Coordinator записывает успех Партиции 1 в таблицу статусов. 9. Coordinator отправляет команду C+$1 в Партицию 2. 10. Raft leader Партиции 2 сохраняет command, валидирует, конвертирует в event, Raft синхронизирует, выполняет (начислить $1 к C). 11. Партиция 2 CQRS синхронизируется с read path; восстанавливает state. 12. Read path Партиции 2 отправляет статус в Saga coordinator. 13. Saga coordinator получает успех от Партиции 2. 14. Coordinator записывает успех Партиции 2 в таблицу статусов. 15. Все операции завершены; coordinator отвечает вызывающему с успехом.
Шаг 4 — Итог
**Эволюция дизайнов:** 1. **Redis шардирование в памяти** — быстро, но нет атомарности между узлами. 2. **Распределённые транзакции (2PC / TC/C / Saga)** — атомарность достигнута, но нет audit trail. 3. **Event sourcing (удалённый Kafka + БД)** — воспроизводимость, но медленно (сеть). 4. **Файловый event sourcing** — высокая производительность через локальный mmap + RocksDB. 5. **Raft репликация** — устраняет единственную точку отказа. 6. **CQRS + reverse proxy (push)** — синхронный ответ для внешних пользователей. 7. **Saga по Raft partition группам** — итоговый масштабируемый, надёжный, аудитируемый дизайн.
**Достигнутые ключевые свойства:** 1 млн+ TPS, транзакционные гарантии, надёжность 99,99%, полная воспроизводимость для аудитов.

Фондовая биржа
Проектируем систему электронной фондовой биржи. Основная функция — эффективно сопоставлять покупателей и продавцов. Сегодня ордера обрабатываются суперкомпьютерами на NYSE (миллиарды совпадений в день) и HKEX (~200 миллиардов акций в день). Масштаб имеет значение — уточните у интервьюера размер и характеристики.

Шаг 1 — Понять задачу и определить масштаб
**Функциональные требования:** - Ценные бумаги: **только акции** (без опционов/фьючерсов). - Операции: **размещение** и **отмена** limit orders. - Типы ордеров: **только limit orders** (нет market или условных ордеров). - Часы торгов: только стандартные (нет внебиржевых). - Поддержка десятков тысяч одновременных пользователей, ≥100 символов, **миллиарды ордеров/день**. - Стакан заявок (order book) в реальном времени виден клиентам. - **Проверки рисков** (например, максимум 1 млн акций одного эмитента на пользователя в день). - **Управление кошельком**: требуются достаточные средства; средства резервируются для открытых ордеров.
Нефункциональные требования
- **Доступность**: ≥99,99%. - **Отказоустойчивость**: быстрый механизм восстановления для минимизации последствий производственных инцидентов. - **Задержка**: миллисекундный round-trip (ордер поступает на биржу → возвращается исполненный). Акцент на **99-м перцентиле** задержки. - **Безопасность**: KYC (Know Your Client) для новых аккаунтов; защита от DDoS для публичных endpoints.
Оценка нагрузки
- 100 символов, 1 миллиард ордеров/день. - NYSE работает 6,5 часов/день (9:30 — 16:00 ET). - **QPS**: 1 млрд / 6,5 / 3600 ≈ **43 000**. - **Пиковый QPS**: 5× = **215 000** (высокий объём при открытии/закрытии рынка).
Шаг 2 — Предложить высокоуровневый дизайн
Ключевые понятия: брокеры (Charles Schwab, Robinhood), институциональные клиенты (пенсионные фонды, хедж-фонды/маркет-мейкеры), limit orders, market orders, уровни рыночных данных, candlestick charts, FIX protocol.
Основы фондового рынка
**Брокер** — розничные клиенты торгуют через брокеров (веб/мобильный UI). Институциональные клиенты используют специализированное ПО.
**Limit order** — покупка/продажа по фиксированной цене; может не исполниться немедленно или быть частично исполнен.
**Market order** — цена не указывается; исполняется по текущей рыночной цене немедленно; жертвует ценой ради гарантированного исполнения.
**Уровни рыночных данных:** - **L1** — лучшая цена bid, цена ask, объёмы (Рисунок 2). - **L2** — больше ценовых уровней, чем в L1 (Рисунок 3). - **L3** — ценовые уровни с объёмом в очереди на каждом уровне (Рисунок 4).
**Candlestick chart** — показывает цены открытия, закрытия, максимум и минимум за временной интервал (1 мин, 5 мин, 1 час, 1 день, 1 неделя, 1 месяц). Рисунок 5 показывает одну свечу.
**FIX protocol** — Financial Information eXchange (1991), нейтральный протокол для операций с ценными бумагами.
Высокоуровневый дизайн
Три потока (Рисунок 6):
**Trading flow (критический путь, строгая задержка):** 1. Клиент размещает ордер через приложение брокера. 2. Брокер отправляет ордер на биржу. 3. Client gateway: валидация ввода, ограничение частоты, аутентификация, нормализация → пересылка в order manager. 4–5. Order manager выполняет проверки рисков. 6. Order manager проверяет достаточность средств в кошельке. 7–9. Ордер отправляется в matching engine через sequencer (с присвоением sequence ID); совпадение создаёт два исполнения (fills) для стороны покупки и продажи; outbound sequencer проставляет метки исполнениям. 10–14. Исполнения возвращаются клиенту через client gateway.
**Market data flow (некритический):** - M1: Matching engine генерирует поток исполнений → market data publisher (MDP). - M2: MDP строит candlestick charts и order books → data service. - M3: Рыночные данные сохраняются в специализированном хранилище; брокеры передают клиентам.
**Reporting flow (некритический):** - R1–R2: Reporter собирает поля ордеров + исполнений (client_id, price, quantity, order_type, filled_quantity, remaining_quantity) → записывает в базу данных.

Trading flow
**Matching engine** (также называемый cross engine): - Ведёт order book для каждого символа. - Сопоставляет ордера покупки/продажи → два исполнения на каждое совпадение (сторона покупки + сторона продажи). - Распространяет поток исполнений как рыночные данные. - Должен быть **детерминированным**: одна и та же входная последовательность → одна и та же выходная (позволяет воспроизводить для HA).
**Sequencer** — делает matching engine детерминированным (Рисунок 7): - **Inbound sequencer**: проставляет входящим ордерам последовательные ID до обработки matching engine. - **Outbound sequencer**: проставляет исходящим исполнениям последовательные ID. - Последовательные ID → пропущенные числа легко обнаруживаются. - Функции: message queue, event store, гарантия exactly-once, быстрое воспроизведение. - Как два Kafka потока, но с меньшей и более предсказуемой задержкой.
**Order manager**: - Принимает входящие ордера от client gateway. - Отправляет на проверку рисков (например, объём торгов пользователя < $1 млн/день). - Проверяет достаточность средств в кошельке. - Отправляет ордер в sequencer (только необходимые атрибуты). - Получает исполнения от sequencer → возвращает брокерам. - Использует **event sourcing** для управления состоянием (обрабатывает множество вариантов переходов).
**Client gateway** (Рисунки 8 и 9): - Привратник: валидация ввода, ограничение частоты, аутентификация, нормализация. - Остаётся лёгким — передаёт ордера адресатам максимально быстро. - Разные gateway для розничных и институциональных клиентов (задержка, объём, безопасность). - **Colocation (colo)**: торговое ПО биржи, работающее на сервере брокера в дата-центре биржи; задержка = скорость света между серверами.
Market data flow
Market data publisher (MDP) принимает исполнения от matching engine → строит order books и candlestick charts (совместно: рыночные данные) → отправляет в data service для подписчиков.
Reporting flow
Reporter не на критическом пути, но критичен для бизнеса: история торгов, налоговая отчётность, compliance, расчёты. - Объединяет атрибуты входящих ордеров + исходящих исполнений. - Менее чувствителен к задержке; ключевые требования — **точность и compliance**.
API Design
REST API между брокерами и client gateway (институциональные клиенты могут использовать специализированные низколатентные протоколы).
**Разместить ордер:** `POST /v1/order` - Параметры: `symbol` (string), `side` (buy/sell), `price` (Long), `orderType` (limit/market), `quantity` (Long). - Ответ: `id`, `creationTime`, `filledQuantity`, `remainingQuantity`, `status` (new/canceled/filled).
**Получить исполнения:** `GET /execution?symbol={}&orderId={}&startTime={}&endTime={}` - Ответ: массив исполнений с `id`, `orderId`, `symbol`, `side`, `price`, `orderType`, `quantity`.
**Order book:** `GET /marketdata/orderBook/L2?symbol={}&depth={}` - Ответ: массивы `bids` и `asks` (цена + объём).
**Candlestick charts:** `GET /marketdata/candles?symbol={}&resolution={}&startTime={}&endTime={}` - Ответ: массив `candles` с `open`, `close`, `high`, `low` на каждый интервал.
Модели данных
Три основных типа данных: product/order/execution, order book, candlestick chart.
Product, order, execution
- **Product** — атрибуты символа (тип, торговый символ, отображаемый символ, валюта, лот, шаг цены). Редко меняется; хорошо кэшируется. - **Order** — входящая инструкция покупки/продажи. - **Execution (fill)** — исходящий результат совпадения. Matching engine создаёт два исполнения на каждое совпадение (сторона покупки + продажи). Не каждый ордер имеет исполнение.
**Хранение:** - Критический trading path: в памяти + диск/shared memory (не БД). Хранится в sequencer для быстрого восстановления; архивируется после закрытия рынка. - Reporter: записывает в БД для reconciliation/налоговой отчётности. - MDP: использует исполнения для восстановления order book и candlestick данных.

Order book
Список ордеров покупки/продажи по ценной бумаге, организованный по ценовым уровням. Ключевая структура данных в matching engine.
**Требования:** O(1) добавление/отмена/исполнение, O(1) поиск по ценовому уровню, быстрый запрос лучших bid/ask, итерация ценовых уровней.
**Реализация:** ``` class PriceLevel { Price limitPrice; long totalVolume; List<Order> orders; } class Book<Side> { Side side; Map<Price, PriceLevel> limitMap; } class OrderBook { Book<Buy> buyBook; Book<Sell> sellBook; PriceLevel bestBid; PriceLevel bestOffer; Map<OrderID, Order> orderMap; } ```
**Ключ:** Использовать **doubly-linked list** для `orders` (не обычный список) для достижения O(1) для всех операций: - **Размещение** (новый ордер) → добавить в конец: O(1). - **Исполнение** → удалить из начала: O(1). - **Отмена** → использовать `orderMap` для поиска ордера O(1); doubly-linked list позволяет удаление без обхода (не нужен указатель на предыдущий узел).
Также используется в MDP для восстановления L1/L2/L3 данных из потока исполнений.
Candlestick chart
Ключевая структура данных в MDP наряду с order book. ``` class Candlestick { long openPrice; long closePrice; long highPrice; long lowPrice; long volume; long timestamp; int interval; } class CandlestickChart { LinkedList<Candlestick> sticks; } ``` **Оптимизации памяти:** - Предварительно выделенные **ring buffers** для хранения свечей (без создания новых объектов). - Ограничить свечи в памяти; остальные сохранять на диск.
Рыночные данные сохраняются в **in-memory columnar database** (например, KDB) для аналитики в реальном времени; исторические данные сохраняются на диск после закрытия рынка.
Шаг 3 — Deep Dive
Современные крупные биржи запускают почти всё на одном гигантском сервере. Ключевые области оптимизации: производительность, event sourcing, высокая доступность, отказоустойчивость.
Производительность
`Latency = ∑executionTimeAlongCriticalPath`
**Критический путь:** `gateway → order manager → sequencer → matching engine`
**Сократить задачи на критическом пути:** Даже логирование убирается с критического пути.
**Сократить время на задачу — устранить сетевой и дисковый доступ:** - Сетевой round-trip: ~500 мкс на хоп → несколько компонентов = единичные мс суммарно. - Последовательные записи на диск (sequencer): десятки мс. - Итого: десятки мс — недостаточно для современных низколатентных бирж.
**Решение: Разместить всё на одном сервере (Рисунок 15).** - Компоненты общаются через **mmap** (`mmap(2)`) поверх `/dev/shm` (файловая система с backing в памяти) — нет сети, нет дискового I/O. - mmap message bus: передача сообщений за субмикросекунды.
**Application loop (Рисунок 16):** - Однопоточный цикл, опрашивающий задачи для выполнения. - **CPU pinning**: каждый поток application loop закреплён за фиксированным ядром CPU. - Преимущества: нет переключения контекста, нет конкуренции за блокировки (один писатель) → низкий 99-й перцентиль задержки. - Компромисс: более сложное кодирование; инженеры должны тщательно распределять время на задачу.
Event sourcing
**Традиционный подход с БД** (Рисунок 17, левая часть): хранит только текущее состояние (статус ордера) — нет истории достижения этого состояния.
**Event sourcing** (Рисунок 17, правая часть): неизменяемый журнал всех изменяющих состояние событий. Events — золотой источник истины. Состояние восстанавливается воспроизведением events.
**Дизайн event sourcing с mmap (Рисунок 18):** - Внешний домен использует FIX; внутренний — FIX поверх SBE (Simple Binary Encoding) для компактного/быстрого кодирования. - Gateway трансформирует FIX → SBE → отправляет `NewOrderEvent` через Event Store Client. - Order manager (встроен в matching engine): получает `NewOrderEvent`, валидирует, обновляет внутренние состояния ордеров, отправляет в matching core. - Matching engine генерирует `OrderFilledEvent` → event store. - MDP и reporter подписываются на event store.
**Order manager как библиотека:** Встроен в каждый компонент, которому нужно состояние ордера. Каждый компонент ведёт собственное состояние ордеров — нет общего order manager во избежание потерь задержки. Event sourcing гарантирует идентичность и воспроизводимость всех состояний.
**Sequencer в дизайне event sourcing (Рисунок 19):** - Единственный писатель — только один sequencer на event store (несколько вызовут конкуренцию за блокировки). - Читает события из локального ring buffer каждого компонента, проставляет sequence ID, отправляет в event store. - Простой и быстрый (нет функции хранения сообщений — только проставляет ID). - Резервные sequencers для HA.

Высокая доступность
Цель: 4 девятки (99,99%) = максимум 8,64 секунды простоя/день → требуется немедленное восстановление.
**Hot-warm дизайн (Рисунок 20):** - **Hot (primary) matching engine**: активен; отправляет события в event store. - **Warm (backup) экземпляр**: получает и обрабатывает те же события, но не отправляет их наружу. - При отказе primary: warm экземпляр немедленно берёт на себя роль primary. - При отказе/рестарте warm: восстанавливает все состояния воспроизведением event store.
**Heartbeats**: matching engine отправляет heartbeats; отсутствие heartbeat запускает проверку failover.
**Масштабирование hot-warm на несколько машин:** Реплицировать весь event store на машины и дата-центры через **reliable UDP** для эффективной широковещательной рассылки всем warm серверам (см. дизайн Aeron).
Отказоустойчивость
Hot-warm дизайн с междата-центровой репликацией для аварийного восстановления (землетрясение, отключение электроэнергии).
**Ключевые вопросы:** - **RTO** (Recovery Time Objective): уровень секунд → требуется автоматический failover + стратегия деградации сервиса. - **RPO** (Recovery Point Objective): потеря данных близка к нулю → Raft гарантирует множество копий через консенсус.
**Выборы лидера с Raft (Рисунки 21 и 22):** - 5-узловой Raft кластер; у каждого узла собственный mmap event store. - Лидер отправляет события всем followers по RPC; followers сохраняют в event stores. - Минимум голосов для операции: N/2 + 1 (3 из 5). - **Heartbeat**: лидер отправляет `AppendEntries` (без контента) followers. - **Election timeout**: follower становится кандидатом при отсутствии heartbeat; отправляет `RequestVote` другим. - Первый follower с большинством голосов побеждает; при ничье → раздельное голосование → новые выборы. - **Term** (Рисунок 22): время разделено на произвольные интервалы, представляющие нормальную работу vs выборы.
**Рекомендации по обработке сбоев:** - Начать с ручного failover; автоматизировать только после накопления достаточных операционных сигналов. - Использовать **chaos engineering** для быстрого выявления граничных случаев.
Алгоритмы совпадения
По умолчанию: **FIFO matching** — ордера на одном ценовом уровне сопоставляются в порядке поступления.
**Другие алгоритмы (используются во фьючерсной торговле):** - **FIFO с LMM (Lead Market Maker)**: предварительно выделяет долю LMM перед FIFO очередью. - **Dark pool**: альтернативная торговая система с другими правилами совпадения.
**Ключевые моменты псевдокода:** - `handleOrder`: валидирует sequence ID, валидирует ордер, передаёт в `handleNew` или `handleCancel`. - `handleNew`: маршрутизирует в `match(sellBook, order)` для BUY или `match(buyBook, order)` для SELL. - `handleCancel`: использует `orderMap` для поиска ордера за O(1); удаляет и устанавливает статус CANCELED. - `match`: итерирует doubly-linked list на ценовом уровне, заполняет с головы до `leavesQuantity == 0`.
Детерминизм
**Функциональный детерминизм**: одна входная последовательность → один выход (обеспечивается sequencer + event sourcing).
**Детерминизм задержки**: стабильная задержка по всем сделкам. Измеряется **99-м перцентилем** (или 99,99-м) с использованием **HdrHistogram**.
**Время в event sourcing (Рисунок 23)**: фактическое время события не имеет значения — важен только порядок. Дискретные неравномерные метки времени событий преобразуются в непрерывные последовательные точки → время воспроизведения/восстановления существенно сокращается.
**Проблема задержки в Java**: JVM Stop-the-World GC (HotSpot) вызывает паузы в safe-point — частый источник скачков 99-го перцентиля задержки.
Оптимизации market data publisher
MDP принимает результаты совпадений от matching engine → перестраивает order book и candlestick charts → публикует подписчикам.
**Дизайн использует ring buffers (Рисунок 24):** - Ring buffer (кольцевой буфер): очередь фиксированного размера с соединением начала и конца. - Предварительно выделенное пространство — нет создания/освобождения объектов. - **Lock-free** структура данных. - **Cache line padding**: гарантирует, что номер последовательности ring buffer никогда не находится в одной cache line с другими данными → устраняет false sharing.
**Многоуровневый доступ:** Розничные клиенты получают 5 уровней L2 по умолчанию; дополнительные уровни — за доплату.

Равенство в распределении рыночных данных
Для регулируемых бирж: все подписчики должны получать рыночные данные одновременно.
**Проблема:** Первый подписчик в списке всегда получает данные первым; умные клиенты наперегонки подключаются при открытии рынка.
**Решения:** - **Multicast с reliable UDP**: широковещательная рассылка обновлений множеству участников одновременно. - **Случайный порядок**: назначать случайную позицию подписчику при подключении.
Multicast
Три протокола транспортировки данных: - **Unicast**: один источник → один адресат. - **Broadcast**: один источник → вся подсеть. - **Multicast**: один источник → группа хостов в разных подсетях.
Получатели multicast в одной группе теоретически получают данные одновременно. UDP ненадёжен — использовать обработку повторной передачи (например, NACK-Oriented Reliable Multicast).
Colocation
Биржи предлагают colocation сервисы: серверы брокера/хедж-фонда размещаются в том же дата-центре, что и биржа. Задержка ∝ длина кабеля. Считается платной VIP-услугой, не нарушением справедливости.
Сетевая безопасность
DDoS — реальная угроза для бирж с публичными интерфейсами.
**Меры защиты:** - Изолировать публичные сервисы от приватных; несколько копий только для чтения. - Слой кэширования для редко обновляемых данных. - Усиление URL: использовать `/data/recent` вместо `/data?from=123&to=456` (кэшируется на CDN; сложнее перебирать). - Механизмы allowlist/blocklist через сетевые gateway продукты. - **Ограничение частоты запросов**.
Итог
Идеальный деплой для крупной биржи: всё на одном гигантском сервере или в одном процессе. Некоторые биржи спроектированы именно так.
**Криптовалютные биржи** используют облачную инфраструктуру; некоторые DeFi проекты используют AMM (Automatic Market Making) вообще без order books.
**Ключевые темы дизайна:** matching engine + sequencer, order book (doubly-linked list O(1)), candlestick charts, client gateway, mmap message bus, application loop с CPU pinning, event sourcing с SBE, hot-warm HA, Raft отказоустойчивость, FIFO matching, HdrHistogram для задержки, ring buffer MDP, multicast для равного распределения данных.

Обучение продолжается
Проектирование хороших систем требует многолетнего накопленного опыта. Один из способов ускорить его — изучать реальные архитектуры систем. Обращайте внимание как на общие принципы, так и на лежащие в основе технологии — понимайте, какие проблемы каждая технология решает.
Реальные системы
Материалы, охватывающие общие идеи дизайна реальных системных архитектур разных компаний:
**Facebook** - Facebook Timeline: Brought To You By The Power Of Denormalization - Scale at Facebook - Building Timeline: Scaling up to hold your life story - Erlang at Facebook (Facebook chat) - Finding a needle in Haystack: Facebook's photo storage - Serving Facebook Multifeed: Efficiency, performance gains through redesign - Scaling Memcache at Facebook - TAO: Facebook's Distributed Data Store for the Social Graph
**Amazon** - Amazon Architecture - Dynamo: Amazon's Highly Available Key-value Store
**Netflix** - A 360 Degree View Of The Entire Netflix Stack - It's All A/Bout Testing: The Netflix Experimentation Platform - Netflix Recommendations: Beyond the 5 stars (Part 1 & 2)
**Google** - Google Architecture - The Google File System - Differential Synchronization (Google Docs) - Bigtable: A Distributed Storage System for Structured Data
**YouTube** - YouTube Architecture - Seattle Conference on Scalability: YouTube Scalability
**Twitter** - The Architecture Twitter Uses To Deal With 150M Active Users - Scaling Twitter: Making Twitter 10000 Percent Faster - Timelines at Scale - Announcing Snowflake (unique ID generation at high scale)
**Другие** - Instagram Architecture: 14 Million Users, Terabytes Of Photos - How Uber Scales Their Real-Time Market Platform - Scaling Pinterest / Pinterest Architecture Update - A Brief History of Scaling LinkedIn - Flickr Architecture - How We've Scaled Dropbox - The WhatsApp Architecture Facebook Bought For $19 Billion
Инженерные блоги компаний
Чтение инженерного блога компании перед собеседованием даёт неоценимое понимание их технологического стека и архитектурных решений.
| Компания | Блог | |---------|------| | Airbnb | medium.com/airbnb-engineering | | Amazon | developer.amazon.com/blogs | | Docker | blog.docker.com | | Dropbox | blogs.dropbox.com/tech | | eBay | ebaytechblog.com | | Facebook | code.facebook.com/posts | | GitHub | githubengineering.com | | Google | developers.googleblog.com | | Highscalability | highscalability.com | | Instagram | engineering.instagram.com | | LinkedIn | engineering.linkedin.com/blog | | Netflix | medium.com/netflix-techblog | | PayPal | paypal-engineering.com | | Pinterest | engineering.pinterest.com | | Reddit | redditblog.com | | Shopify | engineering.shopify.com | | Slack | slack.engineering | | Spotify | labs.spotify.com | | Stripe | stripe.com/blog/engineering | | System Design Primer | github.com/donnemartin/system-design-primer | | Twitter | blog.twitter.com/engineering | | Uber | eng.uber.com | | Yelp | engineeringblog.yelp.com | | Zoom | medium.com/zoom-developer-blog |
Поздравляем! Вы дошли до конца этого руководства по подготовке к собеседованиям. Вы накопили навыки и знания для проектирования систем. Практика делает совершенным — получение работы мечты — это долгий путь, требующий многих часов труда.