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

Конспект в 3 частях

  1. Распределенные Системы. Брендан Бёрнс. Введение
  2. Распределенные Системы. Брендан Бёрнс. Одноузловые паттерны проектирования
  3. Распределенные Системы. Брендан Бёрнс. Паттерны проектирования обслуживающих систем

Реплицированные сервисы с распределением нагрузки

This is an image

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

Балансировщик обычно распределяет нагрузку либо по карусельному (round-robin) принципу, либо с применением некоторой разновидности закрепления сессий.

Сервисы без внутреннего состояния

This is an image

Сервисы без внутреннего состояния (stateless-сервисы) не требуют для своей работы сохранения состояния.

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

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

This is an image

По мере роста сервиса ему требуются дополнительные экземпляры для поддержки большего количества одновременно подключенных пользователей. Горизонтально масштабируемые системы поддерживают растущее количество пользователей путем добавления дополнительных копий сервиса. Это происходит благодаря использованию паттерна Load-balanced Replicated Serving (обслуживание с репликацией и балансировкой нагрузки).

Датчики готовности для балансировщика нагрузки

This is an image

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

Датчик готовности определяет, готово ли приложение обслужить запрос пользователя.

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

При построении приложения, использующего паттерн Replicated Service, не забывайте предусмотреть специальный URL, реализующий проверку готовности.

Сервисы с закреплением сессий

This is an image

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

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

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

Добавляем кэширующую прослойку

This is an image

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

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

Простейшая форма кэширования в веб-приложениях — применение кэширующего веб-прокси. Кэширующий прокси представляет собой HTTP-сервер, хранящий в памяти состояние запросов пользователей. Если два пользователя запросят одну и ту же веб-страницу, только один из запросов будет адресован приложению, второй будет обслужен из памяти кэша.

Развертывание кэширующего сервера

This is an image

Простейший способ развертывания веб-кэша — рядом с каждым экземпляром сервиса при использовании паттерна Sidecar.

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

Это не всегда желательно. Для кэша следует использовать наименьшее количество экземпляров с наибольшим количеством памяти (например, не десять копий с 1 Гбайт памяти у каждой, а две копии с 5 Гбайт памяти у каждой).

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

This is an image

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

Шардированные сервисы

This is an image

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

Узел балансировки нагрузки (корневой узел) отвечает за изучение каждого запроса и перенаправление его соответствующему узлу (или узлам) для обработки.

Репликация сервиса обычно используется для построения stateless-сервисов, а шардирование — для сервисов, хранящих состояние (stateful-сервисов).

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

Шардинг позволяет масштабировать сервис в зависимости от объема обслуживаемых данных.

Шардирование кэша

This is an image

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

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

Зачем вам нужен шардированный кэш

Как уже упоминалось во введении, шардирование в первую очередь необходимо для увеличения объема хранимых в сервисе данных.

Роль кэша в производительности системы

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

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

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

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

Реплицированный и шардированный кэш

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

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

Указанные причины могут подтолкнуть вас к развертыванию одновременно реплицированного и шардированного сервиса.

Шардированный сервис с репликацией совмещает паттерн построения реплицированного сервиса с паттерном шардирования. По сути, каждый шард кэша в таком случае реализуется не одним сервером, а реплицированным сервисом.

Такая архитектура более сложна в реализации и развертывании, но имеет определенные преимущества перед просто шардированным сервисом.

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

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

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

Шардирующие функции

Дан пользовательский запрос Req. Как выяснить, какой из шардов S с номерами от 0 до 9 должен обработать запрос?

Задача шардирующей функции — определить данное соответствие. Шардирующие функции похожи на хеш-функции, с которыми вы наверняка уже сталкивались, например, при изучении ассоциативных массивов. Действительно, хеш-таблицу в виде массива цепочек можно считать примером шардированного сервиса. Для заданных Req и Shard шардирующая функция должна установить между ними соответствие вида:

Shard = ShardingFunction(Req)

Шардирующая функция часто реализуется с использованием хеш-функции и оператора взятия остатка от деления (%). Хешфункции преобразуют произвольные цепочки байтов в целые числа фиксированной длины.

Хеш-функция имеет две важные с точки зрения шардинга характеристики:

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

Равномерность — значения хеш-функции должны иметь равномерное распределение.

Для шардированного сервиса первостепенное значение имеют детерминированность и равномерность хеш-функции.

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

Равномерность хеш-функции обеспечивает равномерное распределение нагрузки между шардами.

Консистентные хеш-функции

Первоначальная настройка шардов в новой распределенной системе довольно проста — достаточно настроить соответствующие шарды и шардированные сервисы.

А что случится, если вы захотите изменить количество шардов в шардированной системе? Повторное шардирование — часто довольно затратный процесс.

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

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

Например, при использовании консистентной хеш-функции для шардирования кэша из рассматриваемого примера переход с 10 шардов на 11 вызовет перенаправление менее 10 % запросов (K / 11). Это гораздо лучше, чем потерять весь шардированный сервис.

Шардирование реплицированных сервисов

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

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

Системы с «горячим» шардированием

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

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

This is an image

Сначала все три шарда обрабатывают одинаковое количество трафика. Затем трафик перераспределяется таким образом, что на шард А приходится в четыре раза больше трафика, чем на шарды Б и В. Система с «горячим» шардированием перемещает шард Б на машину с шардом В, а шард А реплицирует на вторую машину. Реплики теперь делят трафик поровну.

Паттерн Scatter/Gather

This is an image

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

Подобно паттернам реплицированных и шардированных систем, паттерн Scatter/Gather — древовидный паттерн, в котором корневой узел распределяет запросы, а терминальные узлы их обрабатывают.

This is an image

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

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

Scatter/Gather с распределением нагрузки корневым узлом

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

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

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

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

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

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

Scatter/Gather с шардированием терминальных узлов

Хотя при применении реплицированного варианта паттерна Scatter/Gather сокращается время обработки пользовательских запросов, он не позволит масштабировать сервис сверх объема данных, который можно хранить в памяти или на диске одной машины.

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

В случае шардирования Scatter/Gather запрос отправляется всем терминальным узлам (шардам) в системе. Каждый терминальный узел обрабатывает запрос, используя данные, содержащиеся в своем шарде. Частичный ответ возвращается корневому узлу — он объединяет все частичные ответы в один полный ответ, который и возвращается пользователю.

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

Может показаться, что в рамках паттерна Scatter/Gather всегда имеет смысл реплицировать вычисления на как можно большее количество узлов. Распараллеливая вычисления, вы сокращаете время обработки конкретного запроса. Увеличение степени распараллеливания несет с собой дополнительные расходы, поэтому для достижения максимальной производительности в распределенной системе чрезвычайно важно правильно выбрать количество терминальных узлов.

Обработка каждого конкретного запроса подразумевает определенные накладные расходы. Требуется время на анализ запроса, на его пересылку по сети и т. д. В общем случае накладные расходы на обработку запроса операционной системой постоянны и сравнительно невелики по отношению ко времени обработки запроса в пользовательском режиме. Соответственно, при оценке производительности реализации паттерна Scatter/Gather ими, как правило, можно пренебречь.

Важно, однако, понимать, что объем накладных расходов в реализации паттерна Scatter/Gather растет с увеличением количества терминальных узлов. Поэтому, несмотря на их небольшой объем, с ростом степени распараллеливания расходы на них со временем могут превысить расходы на реализацию бизнес-логики приложения. А это значит, что рост производительности за счет распараллеливания носит асимптотический характер.

Помимо того что добавление терминальных узлов может и не ускорить обработку запросов, Scatter/Gather-системы также подвержены «эффекту отстающего». Чтобы понять причину этого, важно помнить, что в Scatter/Gather системе корневой узел сможет отправить ответ конечному пользователю не ранее, чем дождется ответа от всех терминальных узлов. Поскольку необходимо получить данные от всех узлов, общее время обработки запроса определяется временем обработки запроса самым медленным узлом.

Масштабирование Scatter/Gather-систем с учетом надежности и производительности

Как и в случае с другими шардированными системами, наличие единственной копии шардированной Scatter/Gather-системы — не лучшее архитектурное решение.

Если в единственном экземпляре шарда происходит отказ, все Scatter/Gather-запросы будут отклоняться на время его недоступности, поскольку в рамках паттерна Scatter/Gather все запросы должны обрабатываться всеми терминальными узлами.

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

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

This is an image

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


Источники

  1. Распределенные системы. Паттерны проектирования. Бёрнс Б.
  2. https://azure.microsoft.com/ru-ru/resources/designing-distributed-systems/