Основные понятия

Основы сетей

Важные аспекты работы сетей, необходимые для собеседований по System Design

Сети - это фундаментальная часть System Design: вы практически всегда будете проектировать системы, состоящие из независимых устройств, общающихся по сети. Но область сетевых технологий обширна и сложна, и в ней легко потеряться.

В этом руководстве мы рассмотрим наиболее важные части сетевого взаимодействия, которые вам понадобятся для собеседований по системному дизайну. В дальнейших разборах, паттернах и задачах мы будем опираться на эти основы для решения проблем, с которыми вы столкнетесь при проектировании систем.

Мы начнем с основ работы сетей, затем рассмотрим ключевые протоколы на различных уровнях сетевого стека. Для каждой концепции мы разберем ее назначение, принцип работы и то, когда ее следует применять в ваших системах.

На сетях чаще фокусируются во время собеседований по инфраструктуре и распределенным системам. Для ролей Full Stack Engineer и Product Manager вам, вероятно, потребуется лишь поверхностное понимание сетевых концепций. Понимание этих основ поможет вам принимать лучшие решения, даже если мельчайшие детали не будут проверяться на собеседовании.

Каждый интервьюер уникален, и если ваш собеседник только что закончил дежурство (on-call), разбираясь с проблемами балансировщика нагрузки или CDN, вам стоит быть готовым ответить на его уточняющие вопросы!

Основы сетей

По своей сути, сети - это соединение устройств и обеспечение возможности их общения. Сети построены на многоуровневой архитектуре (модель OSI), которая значительно упрощает жизнь нам, разработчикам приложений.

Фактически, сетевые уровни - это просто абстракции, позволяющие нам рассуждать о коммуникации между устройствами в более простых терминах, релевантных нашему приложению. Когда вы запрашиваете веб-страницу, вам не нужно знать, какие значения напряжения представляют 1 или 0 на сетевом проводе (современное сетевое оборудование еще сложнее!) - вам просто нужно знать, как использовать следующий уровень модели OSI. Думайте об этом так же, как вы используете функцию open в вашем языке программирования вместо того, чтобы вручную инструктировать диск, как читать байты.

Сетевые уровни

Хотя полный сетевой стек увлекателен, есть три ключевых уровня, которые чаще всего встречаются на собеседованиях по System Design. Мы углубимся в каждый из них чуть позже, но сначала давайте поговорим о том, что делают эти уровни и как они работают вместе.

Сетевой уровень (Layer 3)

На этом уровне находится IP - протокол, отвечающий за маршрутизацию и адресацию. Он отвечает за разбивку данных на пакеты, пересылку пакетов между сетями и обеспечение доставки «по мере возможности» (best-effort) на любой целевой IP-адрес в сети. Хотя на этом уровне есть и другие протоколы (например, InfiniBand, который широко используется для нагруженного ML обучения), IP является безусловно самым распространенным для собеседований по System Design.

Транспортный уровень (Layer 4)

На этом уровне работают протоколы, которые обеспечивают сквозную (end-to-end) связь: TCP, QUIC и UDP. Думайте о них как о слое, который обеспечивает такие функции, как надежность, упорядочивание и управление потоком поверх сетевого уровня.

Прикладной уровень (Layer 7)

На последнем уровне находятся протоколы приложений, такие как DNS, HTTP, Websockets, WebRTC. Это распространенные протоколы, которые строятся поверх TCP (или UDP в случае WebRTC), чтобы предоставить уровень абстракции для различных типов данных, обычно связанных с веб-приложениями. Мы рассмотрим их подробно.

Эти уровни работают вместе, чтобы обеспечить все сетевые коммуникации. Чтобы увидеть, как они взаимодействуют на практике, давайте разберем конкретный пример работы простого веб-запроса.

Уровни сетевой модели OSI

Пример: Простой веб-запрос

Когда вы вводите URL в браузер, в действие вступают несколько уровней сетевых протоколов. Сначала используется DNS для преобразования доменного имени, такого как nowinterview.ru, в IP-адрес, например 12.34.56.78. Затем мы устанавливаем TCP-соединение поверх IP, отправляем HTTP-запрос, получаем ответ и разрываем соединение.

Подробнее:

  1. Разрешение DNS (DNS Resolution): Клиент начинает с преобразования доменного имени веб-сайта в IP-адрес с помощью DNS (Domain Name System).
  2. TCP - рукопожатие (TCP Handshake): Клиент инициирует TCP-соединение с сервером, используя трехстороннее рукопожатие:
    • SYN: Клиент отправляет пакет SYN (Synchronize) серверу для запроса соединения
    • SYN-ACK: Сервер отвечает пакетом SYN-ACK (Synchronize-Acknowledge) для подтверждения запроса
    • ACK: Клиент отправляет пакет ACK (Acknowledge) для установления соединения
  3. HTTP-запрос: Как только TCP-соединение установлено, клиент отправляет HTTP GET запрос серверу для получения веб-страницы.
  4. Обработка на сервере: Сервер обрабатывает запрос, получает запрошенную веб-страницу и подготавливает HTTP-ответ. (Обычно это единственная задержка, о которой думают и которой управляют большинство разработчиков!)
  5. HTTP-ответ: Сервер отправляет HTTP-ответ обратно клиенту, который включает содержимое запрошенной веб-страницы.
  6. Разрыв TCP (TCP Teardown): После завершения передачи данных клиент и сервер закрывают TCP-соединение, используя четырехстороннее рукопожатие:
    • FIN: Клиент отправляет пакет FIN (Finish) серверу для завершения соединения.
    • ACK: Сервер подтверждает пакет FIN пакетом ACK.
    • FIN: Сервер отправляет пакет FIN клиенту для завершения своей стороны соединения.
    • ACK: Клиент подтверждает пакет FIN сервера пакетом ACK.

Раньше в BigTech было популярно просить кандидатов углубиться в детали вопроса: "Что происходит, когда вы вводите nowinterview.ru в браузер и нажимаете Enter?". Сейчас это встречается реже.

Как браузер выполняет HTTP-запрос

Хотя конкретные детали рукопожатий и разрывов TCP не часто обсуждаются на интервью по System Design, есть несколько моментов, которые стоит отметить:

Во-первых, как разработчики приложений, мы можем значительно упростить нашу ментальную модель. Приложение может принимать как должное, что данные передаются с определенной степенью надежности и упорядоченности: уровень TCP гарантирует, что данные будут доставлены корректно и по порядку, и сообщит приложению, если они не придут. Нам также никогда не придется беспокоиться о том, чтобы найти конкретный сервер и направить туда последовательность электронов. С помощью DNS мы можем найти IP-адрес, а с помощью IP различное сетевое оборудование между нами, нашим интернет-провайдером, магистральными провайдерами и т. д. может направлять данные к месту назначения.

Во-вторых, хотя у нас здесь один концептуальный «запрос» и «ответ», между серверами было передано гораздо больше пакетов. Все они вносят задержку, которую мы можем игнорировать... пока это не станет проблемой. Чем выше мы поднимаемся по стеку, тем больше накладных расходов и вспомогательных операций. Это будет важно для обсуждения балансировщиков нагрузки чуть позже.

Наконец, обратите внимание, что соединение между клиентом и сервером - это состояние, которое должны поддерживать и клиент, и сервер. Если мы не используем такие функции, как HTTP keep-alive или мультиплексирование HTTP/2, нам нужно повторять процесс установки соединения для каждого запроса - это потенциально значительные накладные расходы. Это станет важным при проектировании систем, требующих постоянных соединений, например, которые обрабатывают обновления в реальном времени.

Протоколы сетевого уровня

Первая остановка в нашем путешествии - протоколы сетевого уровня. На этом уровне доминирует протокол IP, который отвечает за маршрутизацию и адресацию. IP-адреса обычно присваиваются узлам DHCP-сервером при их загрузке. Эти IP-адреса произвольны и что-то значат лишь в той мере, в какой мы рассказываем о них. Мы можем создать частную сеть со своими серверами и дать им любой IP-адрес, который захотим, но если нам нужно, чтобы интернет-трафик мог их находить, мы должны использовать IP-адреса, которые маршрутизируются и выделяются RIR.

Такие IP-адреса называются публичными IP и используются для идентификации устройств в интернете. Самое важное в них то, что инфраструктура маршрутизации интернета оптимизирована для маршрутизации трафика между публичными IP и знает, где они находятся. Любой адрес, начинающийся с 17 (например, 17.0.0.0), является частью Apple - магистральная сеть Интернета знает, что когда вы хотите отправить пакет на эти адреса, вам нужно отправить его на их маршрутизаторы. В сфере интернет-маршрутизации можно рассмотреть еще многое, но для наших целей это неважно, поэтому мы упростим задачу и перейдем вверх по стеку к следующему уровню: транспортному.

Протоколы транспортного уровня

На транспортном уровне мы устанавливаем сквозное (end-to-end) соединение между приложениями. Три основных протокола на этом уровне - TCP, UDP и QUIC, каждый из которых имеет свои особенности и подходит для определенных сценариев.

На большинстве собеседований по System Design реальный выбор, с которым вы столкнетесь, будет между TCP и UDP. QUIC - это новый протокол, который стремится предоставить некоторые преимущества перед TCP, такие как улучшенная производительность. Хотя QUIC становится все более популярным, для наших целей мы будем рассматривать его как лучшую версию TCP, но не очень широко распространенную.

Некоторые интервьюеры, которым интересна производительность, будут впечатлены вашими знаниями современных протоколов, таких как QUIC и HTTP/3, но большинство из интервьюеров захотят, чтобы вы потратили время на другие этапы проектирования.

UDP: Быстрый, но ненадежный

User Datagram Protocol (UDP) - это «пулемет» среди протоколов. Он предлагает мало функций поверх IP, но очень быстр. UDP работает на основе принципа "стреляй и молись" ("Spray and pray"). Он предоставляет более простой сервис без установления соединения, без гарантий доставки, упорядочивания или защиты от дубликатов.

Если вы напишите приложение, которое получает пакеты UDP, вы сможете увидеть, откуда они пришли (т. е. исходный IP-адрес и порт) и куда они направляются (т. е. IP-адрес и порт назначения). И на этом все, остальное - двоичный объект.

Ключевые характеристики UDP:

  • Без соединения (Connectionless): Нет рукопожатия или настройки соединения
  • Нет гарантии доставки: Пакеты могут быть потеряны без уведомления
  • Нет упорядочивания: Пакеты могут приходить в ином порядке, чем были отправлены
  • Низкая задержка: Меньше накладных расходов означает более быструю передачу

UDP идеально подходит для приложений, где скорость важнее надежности, таких как прямые видеотрансляции, онлайн-игры, VoIP и DNS-запросы. В этих случаях приложение или клиент готовы время от времени мириться с потерей пакетов или нарушением порядка. Например, в VoIP клиент может просто пропустить случайный пакет, что приведет к небольшому сбою в аудио, но в целом разговор останется разборчивым. Это предпочтительнее, чем повторная отправка потерянных пакетов и засорение сети ACK-ами.

Браузеры пока не имеют широкой поддержки UDP за пределами WebRTC (мы рассмотрим WebRTC подробнее чуть позже). Если вы рассматриваете дизайн, который мог бы использовать UDP (например, рассылка сердечек и реакций в Live Comments), подумайте, что вы будете делать для своих браузерных пользователей. Возможно, пользователи вашего приложения могут получать поток реакций UDP в реальном времени, а пользователи браузера - более медленный пакетный поток HTTP, который вы распределяете во времени в UI.

TCP: Надежный, но с накладными расходами

Transmission Control Protocol (TCP) - это рабочая лошадка интернета. Он обеспечивает надежную, упорядоченную и проверенную на ошибки доставку данных. Он устанавливает соединение через трехстороннее рукопожатие и поддерживает его на протяжении всего сеанса связи.

Это соединение называется «потоком» (stream) и является состоянием (stateful connection) между клиентом и сервером. Это также дает нам основание говорить об упорядочении: два сообщения, отправленные в одном потоке/соединении, прибудут в одном и том же порядке. TCP гарантирует, что получатели сообщений подтвердят их получение, а если они этого не сделают, будет повторно передавать сообщение до тех пор, пока оно не будет подтверждено.

Ключевые характеристики TCP:

  • Ориентирован на соединение (Connection-oriented): Устанавливает выделенное соединение перед передачей данных
  • Надежная доставка: Гарантирует, что данные придут по порядку и без ошибок
  • Управление потоком (Flow control): Предотвращает перегрузку получателей слишком большим объемом данных
  • Контроль перегрузки (Congestion control): Адаптируется к перегрузке сети, чтобы предотвратить коллапс

TCP идеален для приложений, где целостность данных критична - то есть практически для всего, где UDP не подходит.

Как выбрать протокол

На собеседованиях по System Design большинство интервьюеров будут ожидать, что вы используете TCP по умолчанию. Но вы можете заработать дополнительные очки, если сможете обосновать применение UDP и не запутаться в деталях. Вам следует задать себе вопрос: лучше ли UDP подходит для вашего сценария.

Вы можете выбрать UDP, когда:

  • Низкая задержка имеет решающее значение (приложения реального времени, игры)
  • Некоторая потеря данных допустима (потоковая передача мультимедиа)
  • Вы обрабатываете большие объемы данных телеметрии или журналов, случайная потеря которых допустима
  • Вам не требуется поддержка веб-браузеров

Современные приложения часто используют оба протокола. Например, веб-приложение для видеоконференций может использовать TCP/HTTP для аутентификации, но UDP/WebRTC для аудио/видеопотоков.

Сравнение UDP и TCP

ХарактеристикаUDPTCP
СоединениеБез соединенияОриентирован на соединение
НадежностьBest-effort доставкаГарантированная доставка
УпорядочиваниеНет гарантийСохраняет порядок
Управление потокомНетДа
Контроль перегрузкиНетДа
Размер заголовка8 байт20-60 байт
СкоростьБыстрееМедленнее из-за накладных расходов
ПрименениеСтриминг, игры, VoIPВсе остальное

Протоколы прикладного уровня

Большую часть времени разработчики проводят на прикладном уровне. Протоколы этого уровня определяют, как приложения общаются друг с другом, и строятся поверх протоколов транспортного уровня, которые мы только что обсудили.

Обычно прикладной уровень обрабатывается в "User Space", тогда как уровни под ним обрабатываются в ядре ОС в "Kernel Space". Это означает, что прикладной уровень более гибкий и внести изменения в него легче, чем в более низких уровнях. Это также означает, что обработка на прикладном уровне обычно медленнее, чем на более низких уровнях.

HTTP/HTTPS: Фундамент веба

Hypertext Transfer Protocol (HTTP) - это де-факто стандарт для передачи данных в вебе. Это протокол типа «запрос-ответ» (request-response), где клиенты отправляют запросы на серверы, а серверы отвечают запрошенными данными.

HTTP - это протокол без сохранения состояния (stateless), что означает, что каждый запрос независим, и серверу не нужно поддерживать какую-либо информацию о предыдущих запросах. Это, как правило, хорошая вещь. При проектировании распределенных систем следует минимизировать часть, которая должна быть stateful, где это возможно. Большинство простых HTTP-серверов можно описать как функцию параметров запроса - они stateless!

Вот пример простого HTTP-запроса и ответа:

HTTP-запрос и ответ

Здесь можно увидеть несколько ключевых концепций:

  1. Методы запроса (Request methods): GET, POST, PUT, DELETE и т.д.
  2. Коды состояния (Status codes): 200 OK, 404 Not Found, 500 Server Error и т.д.
  3. Заголовки (Headers): Метаданные о запросе или ответе
  4. Тело (Body): Фактический передаваемый контент

Методы HTTP-запросов и коды состояния четко определены и стандартизированы (думайте о них как об enum'ах). Хорошо знать некоторые из распространенных, но большинство интервьюеров не будут углубляться в этот уровень детализации, кроме случаев, когда вы используете RESTful API.

Распространенные методы запроса

  • GET: Запросить данные с сервера. GET-запросы должны быть идемпотентными и не имеют тела.
  • POST: Отправить данные на сервер.
  • PUT: Обновить данные на сервере.
  • PATCH: Частично обновить ресурс.
  • DELETE: Удалить данные с сервера. DELETE-запросы должны быть идемпотентными.

Распространенные коды состояния

Успех (2xx)

  • 200 OK: Запрос был успешным
  • 201 Created: Запрос был успешным, и был создан новый ресурс

Перенаправление (3xx)

  • 302 Found: Запрошенный ресурс был временно перемещен
  • 301 Moved Permanently: Запрошенный ресурс был перемещен навсегда

Ошибка клиента (4xx)

  • 404 Not Found: Запрошенный ресурс не найден
  • 401 Unauthorized: Запрос требует аутентификации
  • 403 Forbidden: Сервер понял запрос, но отказывается его авторизовать
  • 429 Too Many Requests: Клиент отправил слишком много запросов за заданный период времени

Ошибка сервера (5xx)

  • 500 Server Error: Сервер столкнулся с ошибкой
  • 502 Bad Gateway: Сервер получил недопустимый ответ от вышестоящего сервера

Заголовки гораздо более гибкие (думайте о них как о парах ключ/значение). Эта гибкость демонстрирует прагматичную философию, которая лежит в основе большей части спецификации HTTP.

HTTP-заголовки - отличный пример того, как спроектировать интерфейс, который достаточно гибок для неизвестных будущих сценариев, и предоставляют хороший урок для проектирования API. Согласование контента (Content negotiation) - идеальный пример для изучения.

Заголовок HTTP Accepts-Encoding, например, предоставляет клиентам возможность указать, что они могут обрабатывать различные типы кодирования контента. Это позволяет серверам предоставлять ответы в кодировке (например) gzip или br (brotil), если они доступны. Серверы могут ответить наиболее эффективной кодировкой для этого клиента с помощью Content-Encoding: X.

HTTPS добавляет уровень безопасности (TLS/SSL) для шифрования коммуникаций, защищая от прослушивания и атак типа "человек посередине" (man-in-the-middle). Если вы создаете публичный веб-сайт, вы будете использовать только HTTPS, без исключений. Вообще говоря, это означает, что содержимое ваших HTTP-запросов и ответов зашифровано и безопасно при передаче.

Хотя содержимое ваших HTTPS-запросов зашифровано, не гарантируется, что оно было сгенерировано вашим клиентом! Ваш API никогда не должен доверять содержимому тела запроса без валидации.

Распространенной ошибкой является включение идентификатора пользователя в тело запроса и использование его для вызова базы данных. Если злоумышленник сможет изменить тело запроса, он сможет изменить идентификатор пользователя и прочитать произвольные пользовательские данные.

Это не означает, что вы не можете включать идентификаторы пользователей в свои запросы. Это просто означает, что вам нужно иметь возможность проверять их на сервере, и ваш API не должен запрашивать что-либо, чему вы не доверяете.

Это HTTP в двух словах! Теперь давайте поговорим о том, как использовать его для создания API.

REST: Простой и гибкий

Хотя HTTP можно использовать напрямую для создания веб-сайтов, часто проектирование систем связано с коммуникацией между сервисами через API. Для создания этих API у нас есть три основные парадигмы: REST, GraphQL и gRPC.

REST - наиболее распространенная парадигма API, которую вы будете использовать на собеседованиях по системному дизайну. Это простой и гибкий способ создания API. Основной принцип REST заключается в том, что клиенты часто выполняют простые операции над ресурсами (думайте о них как о таблицах базы данных или файлах на сервере).

В RESTful API дизайне основная задача - смоделировать ваши ресурсы и операции, которые вы можете выполнять над ними. RESTful API используют преимущества HTTP-методов или глаголов вместе с некоторыми устоявшимися соглашениями о путях и теле запроса. Они часто используют JSON для представления ресурсов как в теле запроса, так и в теле ответа - хотя это не является строго обязательным.

Если вы следовали нашей Структуре Интервью, вы уже знакомы с концепцией моделирования ресурсов и операций над ними. REST - это просто способ выразить это в терминах HTTP.

Простой RESTful API может выглядеть так (где User - это JSON-объект, представляющий пользователя):

GET /users/{id} -> User

Здесь мы используем HTTP-метод "GET" для указания того, что мы запрашиваем ресурс. {id} - это параметр (placeholder) для идентификатора ресурса, в данном случае идентификатор пользователя, которого мы хотим получить.

Когда мы хотим обновить этого пользователя, мы можем использовать HTTP-метод "PUT" для указания того, что мы обновляем уже существующий ресурс.

PUT /users/{id} -> User
{
  "username": "aerys_ii",
  "email": "aerys_ii@targaryen.com"
}

Мы также можем создавать новые ресурсы, используя HTTP-метод "POST". Мы включим в тело запроса содержимое ресурса, который хотим создать. Обратите внимание, что я не указываю ID здесь, потому что сервер назначит его.

POST /users -> User
{
  "username": "daenerys",
  "email": "daenerys@targaryen.com"
}

Наконец, ресурсы могут быть вложенными для представления отношений между ресурсами. Например, у пользователя может быть много постов, поэтому мы можем представить это отношение, вложив посты под ресурс пользователя.

GET /users/{id}/posts -> [Post]

Часто инженеры проектируют API как отдельные действия, такие как updateUser или startGame. Но это операции, а не ресурсы, и такой подход не соответствует принципам RESTful. В REST важно моделировать API в терминах ресурсов и стандартных операций над ними. Например: вместо updateUser - используйте PUT /users/{id} для обновления пользователя; вместо startGame - PATCH /games/{id} с телом { "status": "started" }.

Где использовать

В целом REST очень гибок для самых разных сценариев использования и приложений. ElasticSearch использует его для управления документами, настройки индексов и многого другого. Ознакомьтесь с этим разбором, если хотите увидеть отличный пример RESTful API.

REST не будет самым производительным решением для сервисов с очень высокой пропускной способностью, и, вообще говоря, JSON - довольно неэффективный формат для сериализации и десериализации данных.

Тем не менее, в большинстве приложений сериализация запросов и ответов не является узким местом. Как и TCP, мы рекомендуем использовать REST по умолчанию для ваших собеседований. Он понятен и является хорошей основой для создания масштабируемых систем. Вам следует использовать GraphQL, gRPC, SSE или WebSockets, если у вас есть особые потребности, которые REST не может удовлетворить. C практическими шаблонами проектирования REST API можно ознакомиться в нашем руководстве по Проектированию API.

GraphQL: Гибкая выборка данных

GraphQL - это более новая парадигма API (выложена в open-source в 2015 году), которая позволяет клиентам запрашивать именно те данные, которые им нужны.

GraphQL решает следующую проблему: часто команды разработки и системы разбиты на фронтенд и бэкенд. Например, фронтенд может быть мобильным приложением, а бэкенд

  • API на основе базы данных.

Когда команда фронтенда хочет отобразить новую страницу, они могут либо (a) собрать вместе кучу разных запросов к эндпоинтам бэкенда (представьте запрос к 1 API для получения списка пользователей и выполнение 10 вызовов API для получения их деталей), (b) создать огромные агрегирующие API, которые трудно поддерживать, или (c) написать совершенно новые API для каждой новой страницы, которую они хотят отобразить. Ни одно из этих решений не является идеальным, но легко столкнуться с ними при использовании стандартного REST API.

Проблема с недостаточной выборкой (under-fetching) заключается в том, что вам может потребоваться несколько запросов и циклов обмена данными. Это добавляет накладные расходы и задержку ко времени загрузки страницы.

Пример недостаточной выборки: для рендеринга страницы требуется много вызовов API.

Избыточная выборка (over-fetching) - это противоположный подход: когда мы упаковываем гораздо больше, чем нужно, в ответ API, чтобы поддержать сценарии использования, которых у нас нет сегодня. Это означает, что API могут занимать больше времени для загрузки и возвращать слишком много данных.

Пример избыточной выборки: результаты занимают слишком много времени и содержат гораздо больше данных, чем требуется

А написание совершенно новых API для каждой новой страницы - это кошмар.

GraphQL решает эти проблемы, позволяя команде фронтенда гибко запрашивать у бэкенда именно те данные, которые им нужны.

Вот пример GraphQL-запроса, который получает данные, которые нужны фронтенду для сложной страницы, показывающей как пользователей с их профилями, так и группы, в которых они состоят:

query GetUsersWithProfilesAndGroups($limit: Int = 10, $offset: Int = 0) {
  users(limit: $limit, offset: $offset) {
    id
    username
    //...

    profile {
      id
      fullName
      avatar
      // ...
    }

    groups {
      id
      name
      description
      // ...

      category {
        id
        name
        icon
      }
    }

    status {
      isActive
      lastActiveAt
    }
  }

  _metadata {
    totalCount
    hasNextPage
  }
}

Код GraphQL здесь в основном указывает, какие поля и вложенные объекты мы хотим получить. Бэкенд может интерпретировать этот запрос и ответить только теми данными, которые нужны фронтенду.

Где использовать

GraphQL отлично подходит для сценариев, когда команде фронтенда нужно часто вносить изменения в рамках коротких итераций разработки. С другой стороны, выполнение GraphQL-запросов может быть источником задержек и дополнительной сложности для бэкенда. На практике GraphQL находит свою нишу со сложными клиентами или когда несколько команд делают разнообразные запросы к общим данным.

Во время интервью мы рекомендуем использовать GraphQL в тех случаях, когда проблема явно сосредоточена на гибкости (например, интервьюер говорит нам, что нам нужно иметь возможность быстро адаптировать наше приложение к меняющимся требованиям) или когда требования на собеседовании намеренно неопределенны. Дополнительные рекомендации по GraphQL, ориентированные на собеседования, смотрите в нашей статье Проектирование API.

gRPC: Эффективное общение сервисов

gRPC - это высокопроизводительный RPC (Remote Procedure Call) фреймворк от Google (буква "g"), который использует HTTP/2 и Protocol Buffers.

Думайте о Protocol Buffers как о JSON, но с более жесткой схемой, которая позволяет добиться лучшей производительности и более эффективной сериализации. Вот пример определения Protocol Buffer для ресурса User:

message User {
  string id = 1;
  string name = 2;
}

Вместо громоздкого JSON-объекта со встроенной схемой (40 байт)...

{
  "id": "123",
  "name": "John Doe"
}

... у нас есть бинарное кодирование (15 байт) тех же данных. Меньше памяти и меньше CPU для парсинга!

0A 03 31 32 33 12 08 6A 6F 68 6E 20 64 6F 65

gRPC использует это для определений сервисов. Вот пример определения gRPC-сервиса для UserService:

message GetUserRequest {
  string id = 1;
}

message GetUserResponse {
  User user = 1;
}

service UserService {
  rpc GetUser (GetUserRequest) returns (GetUserResponse);
}

Код легко читается и говорит сам за себя!

Эти определения преобразуются в код клиентской и серверной заглушек (stub), которые многие языки и фреймворки могут использовать для создания сервисов и клиентов. gRPC включает множество функций, релевантных для работы с масштабируемыми микросервисными архитектурами (в конце концов, он был создан Google), таких как стриминг, клиентская балансировка нагрузки и многое другое.

Где использовать

gRPC идеально подходит для микросервисных архитектур, где сервисам нужно эффективно общаться. Его строгая типизация помогает ловить ошибки во время компиляции, а не во время выполнения, и его бинарный протокол более эффективен, чем JSON через HTTP. Рассмотрите gRPC для внутренней коммуникации между сервисами, особенно когда производительность критична или когда задержки определяются сетью, а не работой, которую выполняет сервер.

Тем не менее, вы обычно не будете использовать gRPC для публичных API, особенно для клиентов, которых вы не контролируете, потому что это бинарный протокол, и инструменты для работы с ним менее зрелые, чем простой JSON через HTTP. Использование внутренних API с gRPC и внешних API с REST - отличный способ получить преимущества бинарного протокола без сложности публичного API.

Пример использования gRPC для внутренних API и REST и HTTP для внешних

Иногда инженеры думают, что цель собеседования по проектированию систем - составить на доске оптимальное решение проблемы. Но интервьюеры обычно пытаются понять, как вы обдумываете проблему и как реагируете на ограничения, с которыми, возможно, раньше не сталкивались. Будьте осторожны с гипероптимизацией выбора протокола, прежде чем вы устраните другие существенные узкие места в вашем дизайне. Преждевременная оптимизация – корень всех зол!

Server-Sent Events (SSE): Push-уведомления в реальном времени

До сих пор мы говорили в основном об API в стиле запрос/ответ, но многим приложениям нужно "пушить" данные клиентам в потоковом режиме. Хотя gRPC поддерживает стриминг, он не идеален для внешних API из-за ограниченной поддержки (например, браузеры сегодня не поддерживают gRPC). Server-Sent Events (SSE) - это спецификация, определенная поверх HTTP, которая позволяет серверу отправлять множество сообщений клиенту через одно HTTP-соединение, в рамках одного ответа.

С большинством HTTP API вы получите один большой JSON как ответ от сервера, который обрабатывается после того, как весь ответ получен.

{
  "events": [
    { "id": 1, "timestamp": "2025-01-01T00:00:00Z", "description": "Event 1" },
    { "id": 2, "timestamp": "2025-01-01T00:00:01Z", "description": "Event 2" },
    ...
    { "id": 100, "timestamp": "2025-01-01T00:00:10Z", "description": "Event 100" }
  ]
}

Поскольку нам нужно дождаться полного ответа, прежде чем мы сможем его обработать, это не очень хорошо для push-уведомлений!

С другой стороны, с SSE сервер может отправлять множество сообщений как "куски" (chunks) в одном ответе от сервера:

data: {"id": 1, "timestamp": "2025-01-01T00:00:00Z", "description": "Event 1"}
data: {"id": 2, "timestamp": "2025-01-01T00:00:01Z", "description": "Event 2"}
...
data: {"id": 100, "timestamp": "2025-01-01T00:00:10Z", "description": "Event 100"}

Каждая строка здесь присылается как отдельное сообщение от сервера. Это все еще один большой HTTP-ответ (то же TCP-соединение), но он приходит через множество меньших пакетов, и клиенты должны обрабатывать каждую строку тела индивидуально, чтобы позволить им реагировать на данные по мере их поступления.

Но, как и все хаки, SSE имеет некоторые болезненные ограничения. Мы не можем держать SSE-соединение открытым слишком долго, потому что сервер (или балансировщик нагрузки, или промежуточный прокси) закроет соединение. Поэтому стандарт SSE определяет поведение объекта EventSource, который, как только соединение закрыто, автоматически переподключится с ID последнего полученного сообщения. Ожидается, что серверы будут отслеживать предыдущие сообщения, которые могли быть пропущены, пока клиент был отключен, и повторно отправлять их.

На практике также есть некоторые неприятные, неправильно работающие сети, которые будут группировать все SSE-ответы в один ответ, заставляя его вести себя очень похоже на то, чего мы пытаемся избежать.

Большинство интервьюеров не знакомы с этими ограничениями и с радостью позволят вам предположить, что их не существует. Но хорошо знать о них, потому что любой, кто фактически реализовал SSE, имеет постоянную головную боль от этих проблем.

Где использовать

Вы найдете SSE полезным на собеседованиях по System Design в ситуациях, когда вы хотите, чтобы клиенты получали уведомления или события, как только они происходят. Например, SSE - отличный вариант для поддержания участников торгов в курсе текущей цены аукциона.

Мы затрагиваем эту тему более подробно в нашем разборе Обновления в реальном времени, который также охватывает серверные аспекты реализации SSE.

WebSockets: Двунаправленное общение в реальном времени

Хотя SSE - отличный способ пушить от сервера к клиенту, многим приложениям нужно двунаправленное общение в реальном времени. И хотя gRPC поддерживает стриминг, он все еще (заезженная пластинка?) не идеален для внешних API из-за ограниченной поддержки (например, браузеры сегодня не поддерживают gRPC). Так что же делать кандидату на собеседовании?

Добавляем WebSockets! WebSockets обеспечивают постоянное TCP-подобное соединение между клиентом и сервером, позволяя двунаправленное общение в реальном времени с широкой поддержкой (включая браузеры). В отличие от модели запрос-ответ HTTP, WebSockets позволяют серверам пушить данные клиентам без нового запроса. Аналогично клиенты могут пушить данные обратно на сервер.

WebSockets инициируются через "обновление" HTTP протокола, которое позволяет существующему TCP-соединению изменять протоколы L7. Это очень удобно, потому что это означает, что вы можете использовать часть существующей информации HTTP-сессии (например, файлы cookie, заголовки и т. д.) в своих интересах.

Тот факт, что клиенты могут обновить соединение с HTTP до WebSocket, не означает, что инфраструктура будет поддерживать это. Каждый элемент инфраструктуры между клиентом и сервером должен поддерживать WebSocket-соединения.

Если вы когда-либо реализовывали WebSockets, вы знаете, что это может быть настоящей болью. Балансировщики нагрузки, прокси, файрволы (firewalls) - все они должны быть настроены для поддержки WebSocket-соединений. Это не всегда просто, и это одна из причин, почему WebSockets не всегда являются правильным выбором.

Как это работает

Вот как это работает:

  1. Клиент инициирует WebSocket-рукопожатие через HTTP (с поддерживающим TCP-соединением)
  2. Соединение обновляется до протокола WebSocket, WebSocket берет на себя TCP-соединение
  3. И клиент, и сервер могут отправлять сообщения друг другу через соединение
  4. Соединение остается открытым до явного закрытия

WebSockets не диктуют протокол приложения, у вас есть канал, где вы можете отправлять бинарные пакеты на сервер от клиента и наоборот. Это означает, что вам нужен какой-то способ определить, что именно пересылают ваш клиент и сервер. Для многих WebSocket-приложений простые сериализованные JSON-сообщения - отличный вариант!

Пример обмена сообщениями по WebSocket

Где использовать

WebSockets появляются на собеседованиях по системному дизайну, когда вам нужно высокочастотное, постоянное, двунаправленное общение между клиентом и сервером. Это может быть полезно в приложениях реального времени, играх и других случаях использования, где вам нужно отправлять и получать события, как только они происходят.

Для приложений, где вам просто нужно иметь возможность отправлять запросы и получать ответы, или ситуаций, где вы можете обойтись push-уведомлениями, предоставляемыми SSE, WebSockets - это, вероятно, переусложнение.

На собеседованиях по системному дизайну использование WebSockets без обоснования того, почему они нужны, может стать "желтым флагом" для вашего интервьюера. WebSockets мощные, но инфраструктура, необходимая для их поддержки, может быть дорогой, и накладные расходы stateful-соединений (особенно при масштабировании) могут быть значительными. Убедитесь, что вы можете обосновать, почему WebSockets необходимы для вашего дизайна.

WebRTC: Peer-to-Peer коммуникация

Последний протокол, который мы рассмотрим - самый уникальный. WebRTC позволяет осуществлять прямую peer-to-peer коммуникацию между браузерами без необходимости промежуточного сервера для обмена данными. WebRTC может быть идеальным для совместных приложений, таких как редакторы документов, и особенно полезен для аудио/видео звонков и конференц-приложений. И это единственный протокол прикладного уровня, из рассматриваемых нами, использующий UDP!

Спецификация WebRTC состоит из нескольких частей инфраструктуры и протоколов, которые необходимы для установления peer-to-peer соединения между браузерами. С точки зрения сетей, peer-to-peer соединения более сложны, чем клиент-серверные модели, которые мы обсуждали до сих пор, потому что большинство клиентов не разрешают входящие соединения по соображениям безопасности.

С WebRTC клиенты общаются с центральным сервером сигнализации (signaling server), который отслеживает, какие пиры доступны вместе с их информацией о соединении. Как только клиент получает информацию о соединении для другого пира, они могут попытаться установить прямое соединение без пересылки данных через какие-либо промежуточные серверы.

На практике большинство клиентов не разрешают входящие соединения по соображениям безопасности, и большинство пользователей находятся за NAT (Network Address Translation) устройством, которое не позволяет подключаться к ним напрямую. Так что если бы мы остановились на этом, большинство пиров не смогли бы коммуницировать друг с другом.

Стандарт WebRTC включает два метода для обхода этих ограничений:

  • STUN: "Session Traversal Utilities for NAT" - это протокол и набор техник, таких как "пробуждение отверстий" (hole punching), которые позволяют пирам устанавливать публично маршрутизируемые адреса и порты. Мы не будем вдаваться в детали, но как бы хакерски это ни звучало, это стандартный способ работы с обходом NAT, и он включает многократное создание открытых портов и обмен ими через сервер сигнализации с пирами.

  • TURN: "Traversal Using Relays around NAT" - это сервис ретрансляции (relay service), способный перенаправлять запросы через центральный сервер, который затем может быть маршрутизирован к соответствующему пиру.

Этапы установки WebRTC соединения

WebRTC-соединение устанавливается в 4 шага:

  1. Клиенты подключаются к центральному серверу сигнализации, чтобы узнать о своих пирах.
  2. Клиенты обращаются к STUN-серверу, чтобы получить публичный IP-адрес и порт.
  3. Клиенты делятся этой информацией друг с другом через сервер сигнализации.
  4. Клиенты устанавливают прямое peer-to-peer соединение и начинают отправлять данные.

Это если все идет гладко! В реальности эти соединения иногда терпят неудачу, и вам нужно иметь резервные варианты, такие как указанный выше TURN-сервер.

Где использовать

WebRTC идеален для аудио/видео звонков и конференц-приложений. Он также может подойти для совместных приложений, таких как редакторы документов, особенно если им нужно масштабироваться.

На практике большинство совместных редакторов не требуют масштабирования до тысяч клиентов. Кроме того, вам часто все равно нужен центральный сервер для хранения документа и координации между клиентами. Вот почему мы используем WebSockets в нашем разборе Google Docs. Но существует альтернатива, которая использует WebRTC и CRDTs (Conflict-free Replicated Data Types) для построения настоящего peer-to-peer взаимодействия.

Для собеседований мы предлагаем придерживаться WebRTC для аудио/видео звонков и конференц-приложений.

WebRTC - это абсолютная боль, и даже лучшие реализации все еще страдают от потери соединений. Это однозначно нишевое решение.

На собеседованиях кандидаты часто сбиваются с пути, пытаясь спроектировать peer-to-peer системы, используя WebRTC, когда это не нужно. WebRTC сложен, и его сложность часто не оправдана для большинства сценариев использования. Убедитесь, что вы можете обосновать, почему WebRTC необходим для вашего дизайна.

Балансировка нагрузки

Мы рассмотрели модель OSI и все релевантные протоколы, с которыми вы, вероятно, столкнетесь на собеседованиях по системному дизайну. Но при масштабировании наших проектов, конечно, есть серьезные последствия для сетевой архитектуры.

Для масштабирования у нас есть два варианта: более мощные сервера (вертикальное масштабирование) или увеличение количества серверов (горизонтальное масштабирование).

Вертикальное vs горизонтальное масштабирование

Наша настойчивая рекомендация - использовать вертикальное масштабирование везде, где это возможно. Современное оборудование невероятно мощное, и вместо тысячи небольших серверов, сегодня несколько более крупных могут справиться с нагрузкой (читайте больше о возможностях современного оборудования в нашем разборе Важные цифры).

Тем не менее, реальность для собеседований такова, что наиболее распространенный вариант масштабирования, который вы увидите - это горизонтальное масштабирование: мы добавляем больше серверов для обработки нагрузки. Но простое добавление прямоугольников на нашу доску не поможет, если мы не скажем клиентам, с каким именно сервером они должны коммуницировать.

Как мы маршрутизируем запросы к серверам?

Типы балансировки нагрузки

Нам нужно распределить входящие запросы (нагрузку), решив, какой сервер должен обрабатывать каждый запрос. Существует два способа балансировки нагрузки: на стороне клиента или на стороне сервера. У обоих есть свои плюсы и минусы.

Балансировка на стороне клиента (Client-Side Load Balancing)

В некоторых случаях клиент сам решает, к какому серверу обращаться. Это называется балансировкой нагрузки на стороне клиента. Клиент обычно использует реестр сервисов (service registry) для получения списка доступных серверов и затем выбирает один из них для каждого запроса.

Балансировка нагрузки на стороне клиента может быть очень быстрой и эффективной. Поскольку решение принимает клиент, он может выбрать самый быстрый сервер без каких-либо дополнительных задержек. Вместо маршрутизации на нужный сервер при каждом запросе нам нужно только (периодически) синхронизировать наш список серверов с реестром серверов.

Пример: Redis Cluster

Отличным примером этого является Redis Cluster (читайте больше в нашем разборе Redis). Узлы Redis Cluster поддерживают протокол gossip между собой для обмена информацией о кластере: какие узлы присутствуют, их статус и т.д. Каждый узел знает о каждом другом узле!

Для подключения к Redis Cluster клиент сделает запрос к любому из узлов в кластере и спросит как о узлах, участвующих в кластере, так и о шардах (shards) данных, которые они содержат. Когда приходит время читать или записывать данные, клиент хеширует ключ, чтобы определить, на какой шард отправить запрос, а затем использует локально полученную информацию об узлах, чтобы решить, с каким узлом общаться. Если вы отправите запрос на неправильный узел, Redis любезно отправит вам ответ MOVED, чтобы сообщить, что вы попали на неправильный узел.

Пример: DNS

Другой пример - это DNS. Когда вы отправляете запрос с вашим доменным именем, DNS-сервер может вернуть несколько IP-адресов для одного домена. При этом порядок адресов меняется, а в некоторых случаях каждый раз может возвращаться новое подмножество адресов. DNS-сервер, на самом деле, выполняет за нас балансировку нагрузки на стороне клиента!

Это поведение DNS также является способом избежать единой точки отказа с балансировщиком нагрузки. Вы настраиваете два балансировщика нагрузки (в разных дата-центрах или регионах, для отказоустойчивости) и используете DNS для ротации между ними. Если один упадет, клиенты автоматически начнут использовать другой.

Где использовать

Балансировка нагрузки на стороне клиента может отлично работать в двух разных сценариях: либо (1) у нас есть небольшое количество клиентов, которых мы контролируем (например, клиент Redis Cluster или балансировка нагрузки на стороне клиента gRPC для внутренних служб), либо (2) у нас большое количество клиентов, но для нас приемлемы медленные обновления (например, DNS).

Если мы контролируем небольшое количество клиентов, то для получение обновлений для них, когда мы добавляем или удаляем серверы достаточно просто.

В случае большого количества клиентов мы заботимся о задержке обновлений потому что время, которое потребуется, будет масштабироваться с количеством клиентов, которых нам нужно уведомить. В случае DNS записи имеют TTL (time to live), который представляет собой время, в течение которого запись действительна. Это позволяет удаленным DNS-серверам кэшировать записи для своих собственных клиентов, но означает, что наши обновления не могут быть быстрее, чем TTL.

Когда использовать балансировку нагрузки на стороне клиента

В условиях собеседования балансировка нагрузки на стороне клиента работает очень хорошо для внутренних микросервисов (на самом деле она встроена в gRPC).

Для всех остальных сценариев нам понадобится выделенный балансировщик нагрузки.

Выделенные балансировщики нагрузки (Dedicated Load Balancers)

Возможно мы не хотим, чтобы наши клиенты должны были обновлять свой список серверов или даже знать о существовании нескольких серверов на бэкенде. Или у нас может быть большое количество клиентов, которых мы не контролируем, но которым нужно быстро получать обновления.

В этих случаях мы будем использовать выделенный балансировщик нагрузки: сервер или аппаратное устройство, которое находится между клиентом и бэкенд-серверами и принимает решения о том, на какой сервер отправить запрос.

Выделенный балансировщик нагрузки

Эти балансировщики нагрузки могут работать на разных уровнях OSI, и ваш выбор будет частично зависеть от того, что нужно вашему приложению.

Наличие выделенного балансировщика нагрузки подразумевает дополнительное перенаправление (hop) для каждого запроса: сначала к балансировщику нагрузки, затем к серверу, который должен обслужить запрос. Но взамен мы получаем очень быстрые обновления нашего списка серверов и полный контроль над тем, как мы маршрутизируем запросы.

Балансировщики нагрузки транспортного уровня (Layer 4 Load Balancers)

Балансировщики нагрузки транспортного уровня принимают решения о маршрутизации на основе IP-адресов и портов, не заглядывая в содержимое пакетов. Эффект балансировщика нагрузки L4 такой же, как если бы вы случайно выбрали внутренний сервер и предположили, что TCP-соединения установлены непосредственно между клиентом и этим сервером.

Простой HTTP запрос с L4 балансировщиком нагрузки

Балансировщики нагрузки L4 имеют некоторые ключевые характеристики:

  • Поддерживают постоянные TCP-соединения между клиентом и сервером
  • Быстры и эффективны благодаря минимальной инспекции пакетов
  • Не могут принимать решения о маршрутизации на основе данных приложения
  • Обычно используются, когда приоритетом является производительность

Например, если клиент устанавливает TCP-соединение через L4 балансировщик нагрузки, тот же сервер будет обрабатывать все последующие запросы в рамках этого TCP-сеанса. Благодаря этому L4 балансировщики нагрузки особенно хорошо подходят для протоколов, которые требуют постоянных соединений, таких как WebSockets. На концептуальном уровне это как если бы у нас было прямое TCP-соединение между клиентом и сервером, которое мы можем использовать для коммуникации на более высоких уровнях OSI.

Где использовать

L4 балансировщики нагрузки отлично подходят для WebSocket-соединений и других протоколов, которые требуют постоянных соединений. Они также отлично подходят для высокопроизводительных приложений, которые не требуют сложной обработки на уровне приложения.

Если вы используете WebSockets на собеседовании, вы, вероятно, захотите использовать L4 балансировщик нагрузки. Для всего остального балансировщик нагрузки прикладного уровня L7, скорее всего, подходит лучше.

Балансировщики нагрузки прикладного уровня (Layer 7 Load Balancers)

Балансировщики нагрузки прикладного уровня могут просматривать фактическое содержимое каждого запроса и принимать более умные решения о маршрутизации.

В отличие от балансировщиков нагрузки L4, детали соединения не так важны. Балансировщики нагрузки L7 получают запрос уровня приложения (например, HTTP GET) и перенаправляют этот запрос на соответствующий внутренний сервер.

Простой HTTP запрос с L7 балансировщиком нагрузки

Ключевые характеристики балансировщиков нагрузки L7:

  • Завершают входящие соединения и создают новые к бэкенд-серверам
  • Могут маршрутизировать на основе содержимого запроса (URL, заголовки, куки и т.д.)
  • Более CPU-нагружены из-за инспекции пакетов
  • Предоставляют больше гибкости и функций
  • Лучше подходят для HTTP-трафика

Например, L7 балансировщик нагрузки может маршрутизировать все API-запросы на одно подмножество серверов, отправляя запросы веб-страниц на другое (предоставляя функциональность, аналогичную API Gateway). Или он может гарантировать, что все запросы от конкретного пользователя идут на один и тот же сервер на основе куки. Лежащее в основе TCP-соединение, которое установлено с вашим сервером через L7 балансировщик нагрузки, не так уж значимо. Это просто способ для балансировщика нагрузки пересылать L7-запросы, такие как HTTP, на ваш сервер.

Хотя L7 балансировщики нагрузки помогают нам не беспокоиться о деталях более низкого уровня, таких как TCP-соединения, мы не можем полностью игнорировать их, если нам нужны постоянные соединения с серверами.

Где использовать

Балансировщики нагрузки L7 отлично подходят для HTTP-трафика, который будет охватывать все протоколы, которые мы обсуждали до сих пор, кроме WebSockets.

Выбор между L4 и L7 балансировщиками нагрузки часто возникает на собеседованиях по системному дизайну при обсуждении функционала обновлений реального времени. Есть некоторые L7 балансировщики нагрузки, которые явно поддерживают протоколы, ориентированные на соединение, такие как WebSockets, но в целом L4 балансировщики нагрузки лучше для WebSocket-соединений, в то время как L7 балансировщики нагрузки предлагают больше гибкости для HTTP-решений, таких как long polling.

Health Checks и отказоустойчивость

Хотя основная задача балансировщиков это распределение нагрузки и трафика, они также отвечают за мониторинг состояния бэкенд-серверов. Если сервер падает, балансировщик нагрузки прекращает маршрутизацию трафика на него до тех пор, пока он не восстановится.

Эта способность автоматического переключения при отказе - то, что делает балансировщики нагрузки необходимыми для обеспечения высокой доступности. Они могут обнаруживать сбои и маршрутизировать трафик в обход них без вмешательства пользователя.

Для этого балансировщики нагрузки используют health checks. Health checks - это способ для балансировщика нагрузки определить, здоров ли сервер. Они могут быть настроены для проверки сервера с разными интервалами и с разными протоколами.

Распространенный подход - использовать TCP health check, который является простым и эффективным способом проверить, принимает ли сервер новые соединения. Layer 7 health check может сделать HTTP-запрос к серверу и убедиться, что ответ успешен (например, код состояния 200, а не 500, указывающий на внутренние сбои, или отсутствие ответа, указывающее на отказ сервера).

Алгоритмы балансировки нагрузки

Еще одно преимущество выделенного балансировщика нагрузки заключается в том, что мы можем выбирать алгоритм, используемый для распределения трафика.

Для большинства балансировщиков нагрузки доступны следующие алгоритмы:

  • Round Robin: Запросы распределяются последовательно по серверам
  • Random: Запросы распределяются случайным образом по серверам
  • Least Connections: Запросы идут на сервер с наименьшим количеством активных соединений
  • Least Response Time: Запросы идут на сервер с наименьшим временем отклика
  • IP Hash: IP клиента определяет, какой сервер получает запрос (полезно для сохранения сессии)

Часто бывает достаточно простых алгоритмов, таких как Round Robin или Random, особенно для stateless-приложений, где мы не ожидаем, что какой-либо конкретный сервер будет более популярным, чем любой другой. Когда новый сервер добавляется под балансировщик нагрузки (например, при масштабировании), эти алгоритмы естественным образом начнут распределять трафик на него без какой-либо специальной конфигурации.

Для сервисов, которые требуют постоянного соединения (например, те, которые обслуживают SSE или WebSocket-соединения), использование Least Connections - хорошая идея, потому что это позволяет избежать ситуации, когда один сервер постепенно накапливает все активные соединения.

Реализации в реальном мире

На практике вы столкнетесь с выделенными балансировщиками нагрузки в различных формах:

  • Аппаратные балансировщики нагрузки: Физические устройства, такие как F5 Networks BIG-IP
  • Программные балансировщики нагрузки: HAProxy, NGINX, Envoy
  • Облачные балансировщики нагрузки: AWS ELB/ALB/NLB, Google Cloud Load Balancing, Azure Load Balancer

Корпоративные аппаратные балансировщики нагрузки могут масштабироваться для поддержки сотен миллионов запросов в секунду, тогда как программные балансировщики нагрузки имеют более жесткие ограничения. Масштабирование балансировщиков нагрузки почти никогда не является частью собеседования по системному дизайну для инженеров (кроме некоторых специализаций по сетям), но если вы обнаружите, что вам нужна большая пропускная способность балансировщика нагрузки - упоминание аппаратных балансировщиков нагрузки - хороший выход.

Популярные темы для углубленного обсуждения

Отлично, мы разобрались с протоколами, балансировкой нагрузки, обработкой постоянных соединений и поддержкой высокой доступности. Что еще нам нужно знать?

Помимо основных протоколов и шаблонов, несколько практических соображений о сетях могут улучшить или разрушить конструкцию вашей системы.

Региональность и задержки (Latency)

Для глобальных сервисов у вас обычно будут серверы, распределенные по всему миру. Распространенный паттерн - иметь несколько дата-центров в одном регионе (Amazon называет их "availability zones"), чтобы, например, разрыв магистрального кабеля в одном здании не вывел из строя весь ваш сервис, а затем реплицировать эту модель в нескольких городах, разбросанных по всему миру.

Но хотя это глобальное развертывание - большая победа для человечества, оно добавляет новые сетевые проблемы. Физическое расстояние между клиентами и серверами значительно влияет на сетевую задержку. Ограничения скорости света означают, что запрос из Калининграда во Владивосток всегда будет иметь более высокую задержку, чем запрос к ближайшему серверу (< 1 мс против > 100 мс).

Свет проходит через оптоволоконные кабели примерно со скоростью 2/3 скорости света в вакууме, что составляет приблизительно 200 000 км/с. Это означает, что путь туда-обратно (round trip) между Калининградом и Владивостоком (около 10 000 км) имеет теоретическую минимальную задержку около 100 мс только из-за физики распространения сигнала, не считая времени обработки. Это физическое ограничение - вот почему географическое распределение необходимо для приложений с низкой задержкой.

Для решения этой проблемы нам нужно вернуться к локальности данных (data locality). Во всех вычислениях мы будем получать наилучшую производительность, когда данные находятся как можно ближе к узлам на которых выполняются вычисления.

Для регионального приложения мы хотим попытаться сохранить все данные, которые нам нужны для выполнения запроса, (a) как можно ближе друг к другу, и (b) как можно ближе к пользователю. Если наши пользовательские данные находятся в Москве, но наши веб-серверы находятся в Новосибирске, каждый запрос к базе данных будет иметь десятки миллисекунд сетевой задержки.

Некоторая часть этой задержки неизбежна. Если наши пользователи просто далеко друг от друга, мы ничего не можем сделать, чтобы это изменить. Но есть несколько стратегий, которые мы можем использовать для оптимизации в рамках физических ограничений.

Сети доставки содержимого (Content Delivery Networks, CDN)

Наиболее распространенная стратегия для снижения задержек сети - использовать сети доставки содержимого (Content Delivery Networks, CDN). CDN - это сети серверов, которые стратегически расположены по всему миру. CDN часто хвастаются сотнями или даже тысячами разных городов, где у них есть серверы. Эти серверы составляют то, что обычно называют "edge location" (имеется ввиду расположение на "краю сети", рядом с пользователем). Если такой сервер может ответить на запрос пользователя, пользователь получит молниеносно быстрое время отклика.

Это возможно только благодаря кэшированию. Если наши данные не меняются или не нуждаются в частом обновлении, мы можем кэшировать их на edge сервере и возвращать оттуда. Это особенно эффективно для статического контента, такого как изображения, видео и другие ресурсы.

На собеседованиях CDN часто используются, когда у нас есть данные, которые можно кэшировать и которые запрашиваются со всего мира. Использование CDN в качестве кэша для, например, результатов поиска в социальной сети позволяет нам как минимизировать задержку, так и снизить нагрузку на наши бэкенд-серверы.

Региональное разделение (Regional Partitioning)

Другая распространенная стратегия используемая, когда нам нужно иметь дело с региональностью - это региональное разделение. Если у нас много пользователей в одном регионе, мы можем разделить наши данные по регионам, чтобы каждый регион имел только данные, относящиеся к нему.

Давайте возьмем Uber в качестве примера. С приложением Uber мы заказываем поездки в конкретном городе. Если мы в Майами, мы никогда не захотим забронировать поездку с водителем, который сейчас в Нью-Йорке. Хотя в любой данный день у нас могут быть миллионы пассажиров и водителей, внутри одного конкретного города у нас может быть всего несколько тысяч. Наша физическая архитектура и сетевая топология могут отражать это!

Мы можем объединить близлежащие города в один локальный регион (например, "Северо-запад РФ" или "Дальний Восток РФ"). Каждый регион может иметь свою собственную базу данных, размещенную на отдельных серверах, расположенных в рамках этого региона. Серверы, обрабатывающие запросы, могут быть размещены рядом с базами данных, которые им нужно запрашивать. Когда пользователи хотят забронировать поездку или посмотреть свой статус, их запросы могут быть обработаны их региональными сервисами (быстро), и эти региональные сервисы могут использовать региональную базу данных для обработки запроса (очень быстро).

Обработка сбоев и режимы отказа (Handling Failures and Fault Modes)

Помимо региональности, еще один вопрос для более глубокого разбора, который часто возникает - как обрабатывать сбои в нашей системе. Часть этого - сбои серверов: серверы отказывают, питание может быть отключено и т.д. Но мы также можем иметь дело с сетевыми сбоями: кабели обрезаются, маршрутизаторы выходят из строя, пакеты теряются и т.д. Надежное проектирование систем требует защиты от этих сбоев.

Заблуждение о том, что "сеть надежна" - одно из самых опасных предположений в распределенных системах. Всегда проектируйте с учетом того, что сетевые вызовы будут терпеть неудачу, задерживаться или возвращать неожиданные результаты.

Рассмотрим несколько стратегий для обработки сбоев.

Таймауты и повторные попытки с выдержкой (Timeout and Retry with Backoff)

Самая элементарная гигиена для обработки сбоев - использовать таймауты и повторные попытки. Если мы ожидаем, что запрос займет определенное количество времени, мы можем установить таймаут, и если таймаут истек, мы можем сдаться и попробовать снова.

Повторные попытки запросов (retries) - отличная стратегия для работы с временными сбоями. Если сервер временно испытывает сложности, мы можем повторить запрос, и он, вполне вероятно, будет успешным. Построение идемпотентных API очень важно, чтобы мы могли повторить тот же запрос несколько раз, не вызывая проблем.

Выдержка (Backoff)

Повторные попытки могут быть обоюдоострым мечом. В некоторых случаях повторные попытки будут терпеть неудачу снова и снова. Если причина исходной проблемы в перегрузке сервера, то повторные попытки будут только усугублять ситуацию.

Вот почему обычно стратегии повторных попыток также включают backoff. Вместо немедленной повторной попытки мы ждем некоторое время. Если запрос все еще терпит неудачу, мы ждем еще дольше. Это дает системе время на восстановление и снижает нагрузку на систему.

Важно, чтобы в стратегии backoff была некоторая случайность (часто называемая "jitter"). Это предотвращает ситуацию, когда все наши неудачные запросы синхронизируются и повторяют попытки в одно и то же время снова и снова, как отбойный молоток.

На собеседованиях по системному дизайну интервьюеры часто ожидают услышать волшебную фразу "retry с exponential backoff". На собеседованиях уровня Senior и выше вас могут попросить подробнее рассказать о добавлении jitter.

У AWS есть отличный блог пост о таймаутах, повторных попытках и backoff, если вы хотите узнать больше.

Идемпотентность

Повторные попытки могут иметь побочные эффекты. Представьте платежную систему, где мы пытаемся списать с пользователя 10 рублей за подписку на nowinterview.ru. Если мы повторим тот же запрос несколько раз, мы можем списать 20 рублей (или 2 000 рублей) вместо 10.

Вот почему нам нужно убедиться, что наши API идемпотентны. Идемпотентность - это свойство операции при повторном применении ее к объекту давать тот же результат, что и при первом. HTTP GET-запросы - распространенные примеры идемпотентных API. Хотя содержимое, возвращаемое GET-запросом, может измениться, акт получения содержимого не изменяет состояние системы.

Но чтение данных это простой случай, а как насчет записи данных? Для этих случаев обычно в API добавляют ключ идемпотентности. Ключ идемпотентности - это уникальный идентификатор для запроса, который мы можем использовать, чтобы убедиться, что тот же запрос идемпотентен.

Для нашего примера с платежами, клиент создает уникальный ключ (например, UUID) для каждой операции, которую нужно сделать идемпотентной. Этот ключ отправляется на сервер, часто в заголовке запроса (например, x-idempotency-key) или как параметр URL (transactionId). На стороне сервера мы можем проверить, обработали ли мы уже (или обрабатываем ли мы в настоящее время) запрос с этим ключом идемпотентности, и обработать его только один раз. Дружественные API будут ждать завершения запроса, а затем отправлять результаты всем запрашивающим. Недружественные API просто вернут ошибку, говоря, что запрос уже существует. Но оба варианта защитят нас от двойного списания.

Предохранители (Circuit Breakers)

Последняя тема, которую часто обсуждают на собеседованиях - это то, как обрабатывать каскадные сбои в системе. Кандидатам часто задают вопросы типа "что происходит, когда этот сервис падает". Иногда ответ прост: "мы терпим неудачу и повторяем попытки, пока сервис не восстановится" - но иногда это создает новые проблемы для системы.

Если ваша база данных с несколькими экземплярами (репликами) полностью упала и вам нужно загрузить ее по одному экземпляру за раз, наличие большого потока повторных попыток и разгневанных пользователей может помешать экземпляру когда-либо запуститься. Если вы не можете запустить первый экземпляр, у вас нет надежды вернуть всю базу данных целиком.

Опытные инженеры, проводившие дежурства, будут иметь за плечами множество историй о каскадных сбоях. Это распространенная проблема, которая обычно остается незамеченной, пока не обнаруживается в 3 часа ночи.

Поэтому это отличный вопрос для собеседования! Это не только поможет вам найти кандидатов, которые понимают, как предотвратить некоторые из наиболее пагубных проблем, но и станет достойной проверкой опыта, который ищут многие интервьюеры.

Ключом к вашей подготовке является ознакомление со сценариями, в которых один сбой может привести к каскаду новых сбоев. Умение выявить эти паттерны и способы их устранения - отличный способ выделиться на собеседовании.

Предохранитель (Circuit breaker) - критический паттерн для надежного проектирования систем, который напрямую влияет на сетевую коммуникацию. Предохранители защищают вашу систему, когда сетевые вызовы к зависимостям терпят неудачу повторно. Вот как они работают:

  1. Предохранитель отслеживает сбои при вызове внешних сервисов
  2. Когда сбои превышают порог, предохранитель "размыкается" в открытое состояние
  3. Пока предохранитель открыт, запросы немедленно терпят неудачу без попытки фактического вызова
  4. После определенного периода таймаута предохранитель переходит в состояние полуоткрытое (half-open)
  5. Тестовый запрос определяет, закрыть ли предохранитель или оставить его открытым

Этот паттерн, вдохновленный электрическими предохранителями, предотвращает каскадные сбои в распределенных системах и дает падающим сервисам время на восстановление.

Предохранители предоставляют многочисленные преимущества:

  • Быстрое падение (Fail Fast): Быстро отклонять запросы к падающим сервисам вместо ожидания таймаутов
  • Снижение нагрузки (Reduce Load): Предотвращать перегрузку уже перегруженных сервисов большим количеством запросов
  • Самовосстановление (Self-Healing): Автоматически тестировать восстановление без полной нагрузки трафика
  • Стабильность системы (System Stability): Предотвращать сбои в одном сервисе от влияния на всю систему
Где использовать

Предохранители могут быть отличным ответом, когда интервьюер ожидает услышать погружение в надежность, режимы отказа или аварийное восстановление.

Некоторые примеры мест для применения предохранителей:

  • Внешние вызовы API к сторонним сервисам
  • Соединения и запросы к базе данных
  • Коммуникация между сервисами в микросервисах
  • Ресурсоемкие операции, которые могут превысить таймаут
  • Любой сетевой вызов, который может терпеть неудачу или стать медленным

Заключение

Поздравляем, вы сделали это! 🎉 Сети - это фундамент, который соединяет все компоненты в распределенной системе. Хотя эта тема обширна, сосредоточение на этих ключевых областях подготовит вас к собеседованиям по системному дизайну:

  1. Понимайте основы: IP-адресация, DNS и модель TCP/IP
  2. Знайте свои протоколы: TCP против UDP, HTTP/HTTPS, WebSockets и gRPC
  3. Освойте балансировку нагрузки: Балансировка нагрузки на стороне клиента и выделенные балансировщики нагрузки
  4. Планируйте практические реализации: Региональность и паттерны для обработки сбоев

Помните, что сетевые решения влияют на каждый аспект вашей системы - от задержки и пропускной способности до надежности и безопасности. Делая обоснованный выбор относительно сетевых компонентов и паттернов, вы будете проектировать системы, которые не просто функциональны, но надежны и масштабируемы.

На ваших собеседованиях будьте готовы обосновать свой выбор на основе конкретных требований системы, которую вы проектируете. Мы изложили кучу разумных доводов, но реальность для большинства проблем такова, что нет единственного правильного ответа, и интервьюер хочет увидеть, как вы рассуждаете и находите компромиссы.

Возможности для дальнейшего изучения

Чтение или просмотр видео иногда не лучший способ для овладения новыми знаниями. Один из самых простых способов узнать о сетях - это создать некоторый сетевой трафик и наблюдать, как он проходит через сеть. Скачайте Wireshark и попробуйте получить некоторый сетевой трафик на своей собственной машине. Это даст вам хорошее представление о всем стеке протоколов в действии.

После того, как вы это сделали, попробуйте симулировать некоторые распространенные сетевые сбои. Network Link Conditioner для Mac (доступен через XCode) - отличный инструмент, который позволяет вам имитировать, что происходит, когда в сети есть задержка или потеря пакетов. Попробуйте имитировать по-настоящему плохое соединение сотового телефона и посмотрите, как реагируют веб-сайты и приложения. Вы обязательно обнаружите некоторые сюрпризы (и много багов). Удачи!

Войдите чтобы отмечать прогресс