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

Давайте снова посмотрим на упрощенную схему архитектуры Centrifugo:

Т.е. Centrifugo - это отдельный независимый сервер, к которому подключаются клиенты приложения (по протоколу Websocket или SockJS) и подписываются на интересные темы (каналы). Затем, как только серверная часть приложения получает новое событие - оно публикует событие как сообщение JSON в Centrifugo через API. Затем Centrifugo рассылает его всем заинтересованным клиентам. Эта схема, в частности, позволяет использовать Centrifugo вместе с приложением, написанным на любом языке / фреймворке. Например, на работе мы используем его вместе с нашим любимым фреймворком - Django.

С первых выпусков Centrifugo работал с форматом JSON - JSON был повсюду. Вы отправляете запросы API в JSON (из серверной части приложения в Centrifugo, зеленая стрелка на картинке выше), клиентский протокол основан на JSON (связь между пользователями приложения и Centrifugo - соединение Websocket или SockJS, пунктирная стрелка на схеме).

И в этих двух местах выше кодировка JSON вполне оправдана. Поскольку он позволяет всем отправлять запросы API, поиграйте с ними с консоли, используя такие инструменты, как CURL или HTTPie. Это удобно, просто в реализации и поддержке. Клиентский протокол в основном используется клиентами веб-браузера, поэтому, опять же, JSON - довольно логичный выбор. Если бы я представил что-то вроде формата MsgPack для клиентского протокола или API, это немного усложнило бы ситуацию (и дополнительную зависимость).

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

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

Вот еще немного готовой к эксплуатации установки центрифуги:

Т.е. множество узлов Centrifugo, подключенных через механизм Redis PUB / SUB. Это позволяет балансировать нагрузку клиентов между узлами. Redis Sentinel может использоваться для обеспечения высокой доступности экземпляра Redis в этой схеме.

Поэтому, если вы хотите аварийное переключение или выросли из одного узла Centrifugo, вам необходимо использовать Redis Engine и клиентов балансировки нагрузки между запущенными узлами Centrifugo (например, мы используем Nginx с модулями восходящего потока и липких сеансов). Прикрепленные сеансы важны для резервных вариантов опроса на основе HTTP, предоставляемых SockJS - поскольку он поддерживает клиентские сеансы в течение некоторого времени во время повторных подключений, важно подключиться к тому же узлу.

А до версии 1.5 Centrifugo также использовал JSON для всех коммуникаций Redis. Это взаимодействие включает публикацию сообщений PUB / SUB, хранение кеша истории, поддержание информации о присутствии клиента в каналах.

Давайте немного подумаем об архитектуре проекта (учитывая Redis Engine). Любой клиент может быть подключен к любому узлу Centrifugo. Поэтому, если один узел получил сообщение, опубликованное в каком-либо канале, он должен доставить это сообщение всем другим узлам, у которых есть клиенты, подписанные на этот канал (через Redis PUB / SUB). Также Centrifugo поддерживает кэш истории сообщений настроенного размера и настроенного времени жизни для механизма восстановления сообщений (например, возможность восстановления некоторых сообщений, потерянных во время сбоя в сети). Таким образом, узел публикует каждое новое сообщение в Redis. Затем Redis отправляет его всем заинтересованным (подписанным на этот канал) узлам.

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

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

А теперь представьте, сколько работы по кодированию / декодированию JSON требуется при обработке каждого нового опубликованного сообщения. Centrifugo имеет историю и данные о присутствии, которые также были закодированы в JSON перед сохранением в структурах данных Redis. Более того, Centrifugo является сервером потока сообщений - это означает, что каждое сообщение в канале должно проходить через Centrifugo, даже если в данный момент нет подключенных подписанных клиентов. Давайте посмотрим, сколько времени требуется для кодирования сообщения в JSON:

BenchmarkMsgMarshalJSON  2022 ns/op    432 B/op     5 allocs/op

Кодирование / декодирование стандартной библиотеки JSON в языке Go выполняется довольно медленно. Использует рефлексию, много выделяет. Похоже, если бы мы могли это ускорить, это было бы огромным преимуществом.

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

Первый подход, о котором я подумал, заключался в использовании инструментов генерации JSON: таких как ffjson или easyjson. Вы скармливаете им файл со своими типами структур и получаете быстро сгенерированный код маршалинга / демаршалинга, который не зависит от пакета отражения. Я попробовал оба, и они неплохо себя ведут для декодирования / кодирования JSON (доказали числа из их файлов README репо). Хотя у easyjson есть одна проблема с полем json.RawMessage, с которым я столкнулся. Я также поиграл с библиотекой jsonparser - и получил довольно хорошие результаты. Это действительно самый быстрый из доступных парсеров JSON с единственной ценой - он довольно низкоуровневый. И он может только демаршалировать данные - что не очень подходит для задачи, которую я решал - быстрое кодирование и декодирование внутри движка. Так что, возможно, я буду использовать его в будущем, чтобы демаршалировать входящие запросы JSON API и клиентского протокола.

Решение, к которому я пришел, было довольно радикальным. Почему бы не полностью избавиться от кодировки JSON изнутри? Не каждое сообщение, опубликованное в Centrifugo, следует доставлять клиентам. Более того, в большинстве случаев текущих подписчиков не будет. Поэтому не каждое сообщение нужно кодировать в JSON. Нам все еще нужно опубликовать сообщение в Redis (возможно, есть клиенты на других узлах или просто для целей кеширования истории), но для этого мы можем использовать самый быстрый из доступных форматов сериализации.

Итак, какой у нас есть выбор формата сериализации данных? На самом деле симпатичные из них. Вот репо с тестами самых популярных реализаций формата сериализации данных Go. Как сказано в заголовке поста, я решил использовать формат protobuf и, в частности, библиотеку gogoprotobuf. Просто потому, что это один из самых быстрых в списке, и я слышал несколько историй успеха от членов сообщества Go. И он используется в проектах-монстрах Go, таких как Etcd, Cockroachdb, Kubernetes и других.

Protobuf требует написать дополнительный файл схемы для типов и запустить дополнительную команду для генерации кода, но результат невероятно быстр. Вот эталонный тест сериализации Message с использованием библиотеки gogoprotobuf:

BenchmarkMsgMarshalGogoprotobuf  168 ns/op   48 B/op   1 allocs/op

В 12 раз быстрее, чем стандартная библиотека кодирования JSON! И похоже, что с выпуском Go 1.7 он будет примерно на 30% быстрее - посмотрите, тот же тест на той же машине с go1.7rc5:

BenchmarkMsgMarshalGogoprotobuf  124 ns/op   48 B/op   1 allocs/op

Одна из проблем, которую мне нужно было решить, заключалась в том, чтобы сделать переход между новым внутренним форматом protobuf и старым внешним форматом клиентского протокола JSON безболезненным и непрозрачным. Итак, вот как я это решил.

Сначала рассмотрим схему protobuf для двух типов (ClientInfo и Message), показанных выше:

Чтобы сгенерировать код Go на основе этого прото-файла, мы должны запустить команду protoc с настраиваемым генератором:

protoc --gogofaster_out=. message.proto

Я выбрал наиболее подходящий для моего случая генератор кода gogofaster_out. На самом деле есть и другие варианты, каждая из которых включает / исключает некоторые функции в сгенерированном коде. Также я разрабатываю для Mac OS и установил protobuf поверх homebrew через:

brew install homebrew/versions/protobuf260

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

protoc --proto_path=/Users/fz/gopath/src/:/usr/local/Cellar/protobuf260/2.6.0/include/:. --gogofaster_out=. message.proto

Интересная часть схемы protobuf выше - это то, как мы заменили поле json.RawMessage. Библиотека Gogoprotobuf позволяет определять пользовательские типы, и это именно то, что я сделал для поля bytes - см. Эту строку:

(gogoproto.customtype) = "github.com/centrifugal/centrifugo/libcentrifugo/raw.Raw"

Вот исходный код этого пользовательского типа Raw. Идея этого необработанного типа заключается в том, что это именованный тип, производный от [] byte, и он работает таким образом, чтобы не трогать полезную нагрузку сообщения (предоставляемую приложением, публикующим новые данные в канал). Т.е. такое же поведение, как у json.RawMessage, у . У этого настраиваемого типа должно быть несколько методов, которые будут вызываться из кода, созданного командой protoc - Marshal, MarshalTo, Unmarshal, Size, Equal, Compare и экспортированная функция NewPopulatedX (где X - имя type, поэтому в моем случае NewPopulatedRaw). Если вы хотите больше примеров нестандартных типов, посмотрите Реализация типа UUID в репозитории gogoprotobuf.

Более того, я определил два дополнительных метода для соответствия интерфейсам JSON Marshaler / Unmarshaler:

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

(gogoproto.jsontag) = "data"

- в схеме выше.

Все это делает результат, сгенерированный структурами gogoprotobuf, совместимым с клиентским протоколом JSON, что-то вроде:

json.Marshal(message)

- вернет те же байты, что и раньше. Итак, мы используем сериализацию protobuf между узлами и совместим с клиентским протоколом JSON.

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

На самом деле первоначальная цель этого рефакторинга заключалась не только в повышении производительности, но и в том, чтобы сделать внутренний интерфейс движка Centrifugo более чистым. Описанные здесь оптимизации были побочным эффектом, возникшим во время тяжелого внутреннего рефакторинга. Этот рефакторинг затронул не только связь между узлами при использовании движка Redis, но и движок памяти (которому не нужно использовать protobuf). Чтобы продемонстрировать, как изменения между версиями 1.4 и 1.5 повлияли на производительность Centrifugo с точки зрения пользователя, давайте рассмотрим простой пример.

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

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

Вот пример кода Python для выполнения этого запроса API (с использованием библиотеки API Cent):

Теперь давайте запустим его много раз для разного количества каналов (от 1 до 5000) и посмотрим на медианное время отклика центрифуги. Сначала для движка памяти:

Как я уже сказал выше, Memory Engine не требует protobuf. Так почему у нас такая разница между версиями? Ответ заключается в одной строке кода, который я добавил в Memory Engine - не кодируйте сообщение в JSON, если в канале нет подписчиков. Не знаю, почему я этого раньше не делал :)

А теперь о Redis Engine:

Здесь узел Centrifugo не может знать, есть ли клиенты, подписанные на канал (потому что они могут быть подключены к другому узлу), поэтому нам нужно кодировать каждое сообщение и публиковать его в Redis. Итак, разница здесь связана с заменой JSON на protobuf. И чем больше требуется работы по кодированию, тем больше разница.

Я думаю, это неплохая скорость. Обратите внимание, что в приведенном выше примере Centrifugo действительно много работает. В случае механизма Redis: он декодирует входящий запрос JSON API, создает N сообщений для каждого канала, применяет правила конфигурации каналов, сериализует их и вызывает команду publish Redis для каждого из этих сообщений. Таким образом, 30 мс для широковещательного сообщения на 5000 каналов выглядит достаточно быстро. Конечно совершенству нет предела :)

Также просто из интереса я решил сравнить Centrifugo с его предшественником - Centrifuge, написанной на Python (поверх Tornado). К сожалению, в нем нет команды broadcast, поэтому мы будем публиковать много разных сообщений в разные каналы за один запрос. Итак, код выглядит так:

Для механизма памяти это 50 мс для Centrifugo (при использовании GOMAXPROCS = 1) и 7800 мс для Centrifuge - разница более чем 100 x! Конечно, это несправедливое сравнение - у Python есть свои преимущества, о которых мы все знаем. И PyPy. Просто любопытно.

Это все на сегодня! Любые отзывы приветствуются. Не стесняйтесь писать мне электронное письмо (можете найти его на моей странице профиля github) с любыми вопросами о Centrifugo.