«Издатель-подписчик» Redis

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

Подробно изучим концепцию «Издатель-подписчик» и то, как в Redis реализована эта модель взаимодействия. А также нюансы подхода Redis, особенно детали реализации вплоть до уровня блоков памяти. Цель этого анализа  —  полное понимание механизмов «Издатель-подписчик» и их практического применения с Redis.

Введение

«Издатель-подписчик»  —  это модель, по которой компоненты распределенной системы обмениваются сообщениями. Сообщения отправляются издателями в тему, а оттуда получаются подписчиками. При этом издатели остаются анонимными, хотя идентифицируются по соответствующей информации, если она включается в полезную нагрузку сообщения.

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

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

Модели «Издатель-подписчик»

«Издатель-подписчик»  —  простая модель взаимодействия, где сообщения получаются брокером от издателя и отправляются одному или нескольким подписчикам. Затем доставленные сообщения интерпретируются подписчиками в соответствии с задачами своих конкретных сценариев.

Исходя из количества издателей и подписчиков, сообщения относят к одной из четырех моделей: «один к одному», «один ко многим», «многие к одному» и «многие ко многим».

Размерности модели «Издатель-подписчик»

«Издатель-подписчик»: основные понятия

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

Компоненты системы «Издатель-подписчик»

«Издатель-подписчик» Redis

Изучив высокоуровневые компоненты модели «Издатель-подписчик», переходим к ее реализации в Redis. Разберемся со взаимодействием системы «Издатель-подписчик» при опубликовании сообщения издателем и завершении ее работы на уровне получателя.

Шаблон «Издатель-подписчик» реализуется в Redis простой и эффективной системой обмена сообщениями между клиентами. Здесь одни клиенты «публикуют» сообщения в именованном канале. А другие, чтобы получать сообщения, «подписываются» на этот канал.

Публикуемое клиентом на канале сообщение доставляется в Redis всем клиентам, подписанным на этот канал. Так осуществляются взаимодействие в реальном времени и обмен информацией между отдельными компонентами приложения.

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

Синхронное взаимодействие

На Redis «Издатель-подписчик» отличается синхронностью. Чтобы сообщение было доставлено, подписчики и издатели должны подключаться одновременно.

Это как радиостанция: чтобы слушать, на нее нужно настроиться; если же радио выключено, послушать сообщения не получится. В системе Redis «Издатель-подписчик» сообщения доставляются только подключенным подписчикам.

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

Отправил и забыл

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

«Издатель-подписчик» Redis является системой обмена сообщениями «Отправил и забыл», поскольку механизма явного подтверждения, что сообщение получено получателем, здесь не предоставляется. Сообщения отправляются всем активным подписчикам, задача которых  —  получить их и обработать.

Только разветвление

«Издатель-подписчик» Redis ориентирована исключительно на разветвление. То есть от издателя сообщение отправляется всем активным подписчикам. Ими получается копия сообщения независимо от того, интересно оно им или нет.

«Издатель-подписчик» Redis изнутри

Redis известен в первую очередь как сервер «ключ-значение». Когда клиент подключается к redis-server, инициируются TCP-соединение с сервером и отправка команд на него.

Базовое клиентское взаимодействие Redis

Но Redis  —  это еще и сервер сообщений. Клиентом, заинтересованным в теме topicA, устанавливается TCP-соединение с сервером Redis, отправляется команда подписаться SUBSCRIBE TopicA, после чего им ожидаются связанные с topicA новости. Затем к серверу Redis подключается новостное агентство, которым отправляется PUBLISH topicA message-data, и подписанный клиент уведомляется об этом заманчивом предложении:

Базовая модель «Издатель-подписчик»

Конкретизируем происходящее в виде процесса Redis, которым отслеживается набор подписок каждого сокета:

Подробнее о происходящем в Redis

В исходной реализации «Издатель-подписчик» клиентами отправляются три новые команды: PUBLISH, SUBSCRIBE, UNSUBSCRIBE. Подписки в Redis отслеживаются при помощи глобальной переменной pubsub_channels. Названия каналов сопоставляются ею с наборами объектов подписанного клиента. Клиентский объект  —  это подключенный по TCP клиент с отслеживанием файлового дескриптора этого соединения.

Когда клиентом отправляется команда SUBSCRIBE, его клиентский объект добавляется в набор клиентов этого названия канала.
Когда отправляется команда PUBLISH, в Redis выполняется поиск подписчиков в карте pubsub_channels и для каждого клиента планируется задание отправки опубликованного сообщения в сокет клиента.

Обработка отключений

Клиентские подключения обрываются из-за закрытия их клиентами либо из-за выдернутого сетевого кабеля. Когда это случается, клиентские подписки в Redis очищаются. Допустим, отключается клиент A. Чтобы удалить его из структуры pubsub_channels, в Redis пришлось бы удалить клиента из набора подписок каждого канала  —  и topicA, и topicB.

Но это неэффективно: в Redis достаточно посетить канал topicA, ведь клиент A подписан только на него. Для этого в Redis каждый клиент помечается своим набором каналов, на которые подписан, и синхронизируется с основной структурой pubsub_channels. Поэтому перебирать приходится не все каналы, а только те, на которые клиент подписан. Нарисуем эти наборы в виде кружочков:

Конкретизируем дальше

Я описал структуры данных как карты maps и наборы sets: логически глобальная переменная pubsub_channels  —  это Map<ChannelName, Set<Client>>, а набор подписок каждого клиента  —  Set<ChannelName>. Но это абстрактные структуры данных: по ним не скажешь, как представлять их в памяти. Продолжим конкретизировать в выделенных блоках памяти.

Карта pubsub_channels  —  это на самом деле хеш-таблица. Название канала хешируется в позицию массива размерности 2^n так:

Массив pubsub_channels с элементами от 0 до 7  —  это единственный выделенный блок памяти. Чтобы опубликоваться на канале, хешируем название канала и находим его элемент, а затем перебираем набор клиентов этого канала. Но разные названия каналов хешируются в один элемент. В Redis эти коллизии обрабатываются цепочкой хеширования: каждым элементом указывается на связный список каналов.

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

Ключи в хеш-таблице каналов  —  это строки зеленого цвета, а значения  —  наборы клиентов красного цвета. Но «набор» тоже абстрактная структура данных. Как она реализуется на Redis? Набор клиентов  —  еще один связный список.

Велик соблазн считать строки topicA и topicB встроенными в объекты цепочки хеширования. Но это неверно, под каждую строку выделяется отдельная память. В Redis строки применяются активно, здесь для них имеется собственное представление: простые динамические строки. Это массив символов, к которому приставляются его длина и количество свободных байтов. Нарисуем это так:

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

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

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

Наконец-то подобрались к истине вплотную, каждый блок на схеме  —  это выделение памяти в процессе redis-server. Кратко подытожим алгоритмы PUBLISH и SUBSCRIBE:

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

Хеш-таблицы в реальном времени

Хеш-таблицы различаются по размеру и примерно пропорциональны количеству их элементов. Размер хеш-таблиц в Redis изменяется в зависимости от количества элементов в них. Но Redis рассчитан на низкую задержку, а изменение размера хеш-таблицы  —  операция времязатратная. Как изменение размера хеш-таблицы обходится здесь без скачков задержки?

Ответ: размер хеш-таблицы изменяется в Redis постепенно. Здесь хранятся две базовые хеш-таблицы: старая и новая.

Рассмотрим хеш-таблицу pubsub_channels во время изменения размера:

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

Дорогостоящая отписка

В системе Redis «Издатель-подписчик» имеется еще одна важная команда: UNSUBSCRIBE. Ею делается обратное команде SUBSCRIBE: получение клиентом сообщений, публикуемых на этом канале, прекращается.
Как написать UNSUBSCRIBE с приведенными выше структурами данных? Вот как это делается в Redis:

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

То есть UNSUBSCRIBE  —  это операция O(n), где n  —  количество подписанных на канал Redis клиентов. Если их очень много, UNSUBSCRIBE обходится дорого. Нужно ограничить клиентов или количество доступных им подписок. Важная оптимизация Pusher  —  устранение дублированных подписок: вместо миллионов их остается на Redis гораздо меньше.

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

Redis оптимизируется под операции PUBLISH, поскольку они распространеннее, чем изменения подписок.

Подписки сопоставления с образцом

Исходным API «Издателя-подписчика» Redis предоставляются PUBLISH, SUBSCRIBE и UNSUBSCRIBE. Довольно скоро в Redis появились подписки сопоставления с образцом. Благодаря им клиент подписывается не на одно-единственное буквенное название канала, а на все каналы, которые соответствуют образцу вроде регулярного выражения.

Новая важная команда  —  PSUBSCRIBE. Теперь, если клиентом отправляется PSUBSCRIBE food.donuts.*, а новостным агентством  —  PUBLISH food.donuts.glazed 2-for-£2, подписанный клиент уведомляется, потому что food.donuts.glazed соответствует образцу food.donuts.*.

Система подписок сопоставления с образцом полностью отделена от обычной системы подписки на каналы. Наряду с глобальной хеш-таблицей pubsub_channels имеется глобальный список pubsub_patterns. Это связный список объектов pubsubPattern, каждым из которых один образец приводится в соответствие с одним клиентом. Аналогично у каждого объекта клиента имеется связный список образцов, на которые он подписан.

Вот как выглядит память redis-server после того, как клиент B подписывается на drink?, а клиенты A и B  —  на food.*:

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

Теперь, когда клиентом отправляется PUBLISH food.donuts 5-for-$1, в Redis перебирается глобальный список pubsub_patterns и строка food.donuts проверяется на соответствие каждому образцу. В Redis при каждом совпадении привязанному клиенту отправляется сообщение 5-for-$1.

В этой системе, как ни странно, подписанные на один образец клиенты не объединяются. Если 10 000 клиентов подпишутся на food.*, получим связный список из 10 000 образцов, каждый из которых проверяется при каждом опубликовании. При таком проектировании предполагается, что набор подписок сопоставления с образцом будет небольшим и уникальным.

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

Вот как образец *a*a*b проверяется в Redis на соответствие строке aa:

stringmatch("*a*a*b", "aa")
stringmatch("a*a*b", "aa")
stringmatch("*a*b", "a")
stringmatch("a*b", "a")
stringmatch("*b", "")
stringmatch("b", "")
false
stringmatch("a*b", "")
false
stringmatch("a*a*b", "a")
stringmatch("*a*b", "")
stringmatch("a*b", "")
false
stringmatch("a*a*b", "")
false

Этот опасный образец с кучей «масок» чреват экспоненциальным увеличением времени выполнения сопоставления. Язык образцов Redis можно было бы скомпилировать в детерминированный конечный автомат, выполняемый за линейное время. Но образцы в него не компилируются.

В целом подписки сопоставления с образцом не следует предоставлять недоверенным клиентам, ведь имеется минимум два вектора атаки: многочисленные подписки сопоставления с образцом и созданные шаблоны. В Pusher очень внимательно относятся к этим подпискам Redis.

«Издатель-подписчик»: сценарии применения

Подробно изучив модель «Издатель-подписчик» и ее реализацию в Redis, рассмотрим сценарии применения.

Асинхронной интеграцией модели «Издатель-подписчик» в целом повышаются гибкость и надежность системы, становятся возможными различные варианты применения:

  1. Обмен сообщениями и чат в реальном времени. На основе системы «Издатель-подписчик» создаются приложения для обмена сообщениями и чата в реальном времени: платформы соцсетей, приложения мгновенных сообщений, среды совместной работы.
  2. Устройства интернета вещей. Моделью «Издатель-подписчик» устройства интернета вещей подключаются к облаку для взаимодействия с централизованным брокером, отправки и получения данных. Благодаря этому собираются и обрабатываются огромные объемы данных, которые впоследствии анализируются.
  3. Новостные сводки, сообщения. Подписчики в реальном времени получают последние сводки, новостные оповещения. Этот сценарий типичен для биржевых платформ, новостных приложений, систем аварийного реагирования.
  4. Распределенные вычисления и микросервисы. На основе модели «Издатель-подписчик» создаются распределенные системы и микросервисные архитектуры, в которых компоненты приложения независимо взаимодействуют, чем обусловливается увеличение масштабируемости и гибкости.
  5. Событийно-ориентированные архитектуры. Моделью «Издатель-подписчик» поддерживаются событийно-ориентированные архитектуры, в которых одни компоненты приложения реагируют на действия других. Этим обеспечивается гибкость проектирования приложений, упрощаются рабочие процессы.
  6. Разделение компонентов и уменьшение зависимостей. Моделью «Издатель-подписчик» разделяются компоненты приложения, уменьшаются зависимости между ними, благодаря этому сопровождение приложения со временем упрощается.
  7. Разветвленная обработка. Процесс отправки одного сообщения сразу нескольким подписчикам  —  это разветвленная обработка. Таким образом данные или события отправляются многочисленным получателям. Например, моделью «Издатель-подписчик» данные рассылаются подписчикам, каждым из которых эти данные независимо и параллельно обрабатываются и отправляются в последующие системы.
  8. Обработка, обратная разветвленной. Это процесс объединения нескольких сообщений в одно, приходится кстати при объединении обработки данных из различных источников. Например, моделью «Издатель-подписчик» данные собираются из каждого компонента и объединяются в единый поток для последующей обработки, при которой сгенерированные компонентами данные агрегируются и анализируются.
  9. Обновление распределенных кешей. Поддерживать согласованность экземпляров и распределенного кеша  —  задача непростая. Моделью «Издатель-подписчик» она решается при помощи механизма обновления и проверки действительности кеша. Сообщение публикуется в теме системы «Издатель-подписчик», когда данные в источнике данных бэкенда обновляются. Таким образом обновляются все экземпляры кеша. В итоге снижается вероятность предоставления пользователям устаревших данных, обеспечивается синхронизация всех экземпляров кеша.

Заключение

Благодаря масштабируемости, низкой задержке и легкости интеграции Redis  —  один из самых востребованных инструментов реализации системы «Издатель-подписчик». Мы подробно изучили его работу и даже добрались до уровня блоков памяти.

«Издатель-подписчик» Redis  —  это эффективный способ распространения сообщений. Однако нужно знать, под что он оптимизирован и где скрываются подводные камни. В этом мы и попытались разобраться. Если вкратце: используйте Redis только в доверенной среде, ограничивайте количество клиентов и обращайтесь с подписками сопоставления с образцом осторожно.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Joud W. Awad: Redis Pub/Sub In-Depth

Предыдущая статьяNelm — полноценная замена Helm
Следующая статьяC++: полное руководство по преобразованию строки в число двойной точности