Сообщений на вызов ввода-вывода.

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

Итак, как бы вы спроектировали и построили систему, обеспечивающую производительность и масштабируемость?

Системы сообщений предназначены для передачи сообщений через различные сетевые транспортные средства (например, TCP / IP, UDP, WebSockets, Bluetooth, LoRa). У них может быть брокерский или серверный компонент, или клиенты могут напрямую общаться друг с другом. Большая часть моей истории связана с серверными системами, где клиенты общаются с сервером, а сервер рассылает сообщение одному или нескольким получателям. Хотя некоторые могут предпочесть, чтобы системы общались напрямую от одного клиента с другим, я считаю, что подход сервер / брокер работает лучше, учитывая, что серверы просты в эксплуатации и эксплуатации, хорошо кластеризованы, поэтому они могут работать где угодно, устойчивы и хорошо работают. . Я расскажу об этих темах со ссылкой на проект NATS, который я создал, чтобы служить плоскостью управления для CloudFoundry, проекта, который я создал в VMWare.

Маршрутизация сообщений

Это первое и самое важное, что нужно сделать правильно. Перемещение между пользовательским пространством и пространством ядра - дорогое удовольствие. Если вы вызываете ядро ​​для каждого отправленного или полученного сообщения, это убьет вашу производительность. Я всегда измеряю, сколько сообщений на один вызов ввода-вывода я получаю при входе и выходе. Для входа это немного проще, поскольку вы можете предварительно выделить буферы и делать то, что я называю слепым чтением. Чтение всего, что есть в ядре, за один вызов. Как правило, это не влияет на задержку входящих сообщений. Следует обратить внимание на то, чтобы эти буферы динамически увеличивались и уменьшались в размере, чтобы соответствовать трафику, в противном случае они будут тратить память при масштабировании до большого количества подключений.

Для выхода или отправки сообщений обратно это становится балансирующей игрой между пропускной способностью и задержкой. Получение сообщений обратно с вашего сервера сообщений очень важно. Я обычно подхожу к этой проблеме, имея отдельный поток или сопрограмму, фактически выполняющую вызовы ввода-вывода в ядро. Это позволяет пользовательскому пространству объединять несколько сообщений вместе, снова пытаясь максимизировать количество сообщений на один вызов ввода-вывода. Я не хочу, чтобы эта внутренняя буферизация оставалась неотмеченной, и поэтому пользовательское пространство может достигнуть предела, когда он намеренно принимает на себя ответственность за вызов ввода-вывода, чтобы фактически замедлить работу потенциально восходящего потока и избежать массового потребления памяти из-за внутренней буферизации. Если кто-то не успевает, быстро откажитесь и отключите его, иначе вы рискуете, что одно плохое яблоко испортит обслуживание других. Затраты на сигнализацию и переключение для потока ввода-вывода или сопрограммы также должны быть измерены, чтобы достичь хорошего баланса между пропускной способностью и задержкой. Вы также должны измерить, как объединение буферов и использование семантики Scatter и Gather (writev, если поддерживается) влияют на вашу производительность.

Также ведется довольно много интересной работы по устранению перехода от пользователя к пространству ядра с такими вещами, как dpdk и другими сетями, предназначенными только для пользовательского пространства. Что меня еще больше интересует, так это возможность передавать обработку в ядро ​​с помощью таких механизмов, как eBPF, или даже на такие сетевые элементы, как карты NIC.

Парсер протокола

В системе обмена сообщениями, которая поддерживает несколько шаблонов сообщений, таких как pub / sub или 1: N, запрос / ответ и динамические очереди с балансировкой нагрузки, маршрутизация сообщений становится очень важной с точки зрения производительности. В системе, где темы и / или темы являются многофункциональными и включают несколько уровней и поддержку подстановочных знаков, сопоставление входящей темы с группой получателей может стать нетривиальным. Кроме того, если скорость изменения предметного пространства высока, это отрицательно скажется на производительности распределения без правильной архитектуры. Я, вероятно, потратил большую часть времени в своей карьере на обмен сообщениями об этой проблеме, и я уверен, что все еще не совсем правильно. В NATS предметы состоят из жетонов, разделенных знаком . Или точкой. Для издателей все темы должны быть буквальными, однако в подписках могут быть подстановочные знаки для группировки различных тем публикации в один поток обратного вызова / обработки подписчика. В NATS у нас есть токен или частичный подстановочный знак * и полный подстановочный знак терминала . Например, я могу подписаться на foo.bar. *, И это будет доставлено для сообщений на foo.bar.baz или foo.bar.22, но а не foo.bar. Если я подписался на foo.› в NATS, то любая публикация в foo.1 , foo.bar.baz и т. Д. Будет работать. Для получения дополнительной информации о возможностях использования подстановочных знаков см. Нашу документацию NATS.io по Субъектной адресации.

Это мой №3, но все же очень важный. NATS - это текстовый протокол, и многие утверждают, что только двоичные протоколы могут работать хорошо. Я разработал и реализовал оба. На мой взгляд, текстовый протокол не страдает от производительности, но парсер протокола должен быть эффективным. Первоначальный сервер NATS был очень быстро написан для CloudFoundry, чтобы использовать его в качестве плоскости управления для системы. Я написал исходную версию на Ruby, хотя сегодняшняя версия написана на Go. Хотя я все еще люблю C, я сомневаюсь, что буду часто использовать его в будущей работе. Go был моим предпочтительным языком для сетевой инфраструктуры с самого начала.

Масштабируемость

Хотя NATS в наши дни довольно сложен, и у меня нет планов переписывать сервер на другом языке, как это было при переносе его с Ruby на Go, Rust привлек мое внимание, и я наблюдаю за ним, как и многие другие. Сообщение Брайана Кэнтрилла - хорошее прочтение. Что меня больше всего волнует, так это WebAssembly, но это совсем другой пост.

Итак, вернемся к синтаксическому анализатору протокола, исходный на Ruby был написан с использованием регулярных выражений. Не потому, что я был ленив, а потому, что RegEx в Ruby был очень быстрым по сравнению с проходом входящего потока с использованием самого Ruby. Конечно, когда мы перешли на компилируемый язык, такой как Go, это преимущество в производительности больше не было очевидным. Синтаксический анализатор протокола сервера NATS - это ручной анализатор с почти нулевым распределением, который может работать с разделенными буферами, большими сообщениями, преобразованиями текста в целые числа, и он работает очень хорошо. В первые дни Go утилиты преобразования текста не отличались высокой производительностью, поэтому вы все равно увидите множество наших собственных процедур для перемещения текста к числам и обратно и т. Д. Чтобы понять некоторые из первых проблем с производительностью с Go в самом начале, не стесняйтесь смотреть презентацию, которую я провел на первом GopherCon в Денвере. В нем подробно описаны многие ключевые области производительности, представленные в этом посте. Golang на протяжении многих лет творит чудеса с точки зрения производительности в отношении базовых структур данных, таких как карты и сборка мусора. За этим стоит невероятная команда. Для нас это был отличный выбор.

И последнее, но не менее важное - это масштабируемость или производительность в масштабе. Важно понимать, есть ли у вас проблемы с производительностью или масштабируемостью. У вас проблемы с производительностью, если ваша система работает медленно для одного пользователя или, в данном случае, для подключения одного клиента. У вашей системы есть проблема масштабируемости, если ваша система работает быстро для одного клиентского соединения, но замедляется при большом количестве соединений. Мы уже видели намек на это, когда обсуждали маршрутизацию сообщений и несколько уровней кеширования в распределителе субъектов, чтобы избежать конкуренции на уровне масштабов. Сервер NATS на современном оборудовании с множеством соединений с высокой скоростью передачи сообщений может обрабатывать почти 100 млн сообщений в секунду. Если вы думаете об отдельных потоках сообщений, где нет кроссовера, мы должны масштабироваться как можно ближе к линейному, пока не достигнем ЦП / количества ядер. На самом деле это сложно, поскольку мы начинаем обрабатывать кеши и блокирующие структуры принудительно синхронизируются. В нашем примере, приведенном выше, совместное использование единого предметного пространства и структуры данных для маршрутизации сообщений серьезно подорвало бы нашу масштабируемость даже при умеренной частоте обновления подписок. Области, на которых я концентрируюсь, чтобы убедиться, что они могут работать одновременно и параллельно, если у оборудования есть ресурсы, следующие.

Заключение

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

  1. Маршрутизация сообщений. Это обсуждалось, но в эту часть архитектуры было вложено так много усилий, чтобы она работала и масштабировалась.
  2. Сообщение на выходе. Это также должно иметь возможность работать в масштабе, даже когда несколько источников входящего трафика объединяются в один исходящий поток.
  3. Резервное хранилище для предметного дистрибьютора в NATS представляет собой модифицированное «Дерево Патрисии или Радиксное дерево». Из Википедии Дональд Р. Моррисон впервые описал то, что он назвал «деревьями Патрисии» в 1968 году; «[4]» название происходит от «аббревиатуры» PATRICIA, что означает «Практический алгоритм». Для получения информации, закодированной в буквенно-цифровом формате ». Эта структура хорошо работает для экономии места, когда иерархия субъектов имеет много листовых узлов. Ранняя работа в TIBCO с Rendezvous (и EMS) и финансовым пространством (подумайте о распределении котировок акций) показала, что общая древовидная структура может быть хорошо подходящей. Например, с предметным пространством типа «foo.bar.baz. [1–1,000,000]» только 1M конечных узлов будут отличаться и будут иметь общие узлы для foo и bar. В современных компьютерных архитектурах, где более важны предсказуемый доступ к данным и удобство кеширования, древовидные структуры со ссылками не так популярны, но все же чрезвычайно эффективны. Структура, которую я использовал в NATS, использует ссылки и хэши для уровней, так что это их комбинация. Вы даете ему тему, и он возвращает список результатов обычных подписчиков и подписчиков очереди. Для NATS они обрабатываются по-другому, поэтому результирующий набор разделяет их. Несмотря на эффективность, производительность NATS не была бы такой хорошей, как в этом отделе, если бы это была единственная структура данных, которую мы использовали. Многие потоки / сопрограммы могут обращаться к этой структуре. Использование блокировок чтения / записи помогает, а также атомики в некоторых областях, и у меня действительно есть своего рода кеш L2, встроенный в структуру данных, которая находится перед деревом Патрисии. Бывший коллега посчитал, что мы должны переделать структуру, чтобы она была свободна от блокировок и использовала атомику и CAS. На бумаге это всегда выглядит привлекательно, но редко дает ожидаемые результаты. Вместо этого то, что я пытался достичь, похоже на то, как работают процессоры и кеши, и в лучшем случае каждый входящий процессор имеет свободную от блокировок и неконкурентную структуру данных, которая обрабатывает большую часть распределения сообщений. Конечно, аннулирование кэша здесь важно как для общего кэша L2, так и для независимых кешей L1 для входящей обработки. Для L2 мы стараемся быть более умными и использовать меньшие строки кэша для аннулирования. Это делается по традиционным схемам блокировки. Для независимого L1 я провожу атомарную проверку идентификатора поколения для данного дистрибьютора. Если он был изменен, вместо того, чтобы быть слишком умным, я просто сдуваю весь кеш L1 и позволяю ему повторно заполняться из общего L2 или самого дерева поддержки. Для одного входящего потока сервер NATS на современном оборудовании может обрабатывать около 20 миллионов сообщений в секунду, что неплохо. И это всего лишь один поток! Но опять же, на это начинает влиять, если скорость изменения дистрибьютора увеличивается и вызывает больше недействительности кеша. Если у меня будет немного свободного времени, я могу вернуться к столу для рисования на этом, но пока я достаточно доволен пропускной способностью для умеренно меняющегося предметного пространства.

Если вы зашли так далеко, поздравляю! Я занимаюсь этим уже почти 30 лет, но до сих пор получаю огромное удовольствие от проектирования и создания систем, которые просты и БЫСТРЫЕ! Я надеюсь, что некоторые из приведенных выше вопросов помогут вам спроектировать и построить собственную быструю и эффективную сетевую службу.

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

Если вам это понравилось, нажмите ниже, чтобы другие люди увидели это здесь, на Medium.

Twitter: «@derekcollison»

Хотите сверхбыструю систему обмена сообщениями?