Платформа Pyrus состоит из нескольких десятков микросервисов и поставляется как в облачной версии, которую обслуживают наши Site Reliability Engineers (SRE), так и в безоблачной в виде набора docker образов на базе linux, которые обслуживают ИТ-департаменты наших клиентов.

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

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

А что, можно терять пакеты?

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

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

Другой пример - сообщение от сервиса-планировщика, который периодически «толкает» другие сервисы, может быть пропущено, так как через определенный интервал придёт новое такое же.

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

Очередь на базе данных

Нам не хотелось вводить новый брокер сообщений, чтобы не плодить стек технологий, поэтому первое время мы использовали для хранения сообщений просто таблицу в БД: отправители (publishers) добавляют строки, а получатели (consumers) периодически читают и удаляют эти строки по мере обработки. Такой подход хорошо работает пока сообщений мало, но по мере масштабирования увеличивается число отправителей и получателей и в таблице-очереди происходит столпотворение (congestion). Много транзакций одновременно пытаются взять блокировку последней страницы индекса и держат друг друга, в результате с таблицей становится невозможно проводить никакие регламентные операции - она все время занята.

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

Новый брокер сообщений?

На собеседовании мы иногда задаем вопрос «Какой менеджер очередей вы выберете для высоконагруженного проекта?» Если кандидат отвечает «RabbitMQ» - это верный признак, что человек никогда реально не работал с очередями по-серьезному. Дело в том, что в ситуации split brain кластер RabbitMQ не только теряет сообщения, но и не может самостоятельно восстановиться, когда сетевая связность возвращается.

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

Чтобы сохранить работоспособность кластера серверов при отказе произвольной машины, часто используют консенсус-протоколы - Paxos или Raft. Оба они обеспечивают выбор машины-лидера в кластере до тех пор, пока большинство машин живы и способны коммуницировать друг с другом. Выиграв выборы, лидер выполняет координацию всей активности в кластере пока «видит» большинство машин (не обязательно именно тех, которые его выбрали). Если большинство машин потеряет связь с лидером, они сами организуют перевыборы. На практике время «безвластия», когда кластер не способен функционировать, обычно занимает несколько секунд.

В Kafka нативную поддержку Raft выпустили совсем недавно, 3 октября 2022 года, со второй попытки. Как только пройдет обкатку и станет зрелым решением, эксплуатация Kafka упростится, поскольку использование Zookeeper станет необязательным.

Гарантии доставки

Системы обмена сообщениями / шины данных разделяются на два класса - «at most once delivery» и «at least once delivery» - по принципу обработки ошибок доставки. 

Системы первого класса при потере сообщения не делают повтор (и таким образом доставляют каждое сообщение 0 или 1 раз). 

Системы второго класса ожидают от получателей подтверждение (Ack, Acknowledgment), а если его нет - по истечении определенного таймаута пытаются доставить сообщение еще раз (и в итоге доставляют 1 или более раз). 

При использовании систем второго класса («at least once delivery») прикладному приложению нужно самостоятельно заботиться об обработке дубликатов - 2я, 3я и любая последующая обработка сообщения должна приводить ваши бизнес-данные в то же состояние, что и первая. А вот технологии «exactly once delivery», гарантирующей ровно одну доставку, формально говоря, не существует и те, кто уверен, что использует таковую, просто еще не сталкивались либо с потерей сообщений, либо с повторной доставкой.

Используемая нами библиотека NATS относится к системам первого класса, «at most once delivery». Когда нам понадобилась технология второго класса, с гарантией доставки, мы начали смотреть решение NATS JetStream, тоже open source, от тех же авторов, которое умеет собирать Raft-кластер поверх NATS и сохранять копии каждого сообщения на нескольких нодах сразу. Первый релиз JetStream вышел 12 апреля 2021 года, но ранее авторы много лет развивали предшественника - NATS Streaming. Фактически JetStream родился в результате встраивания NATS Streaming в ядро NATS, то есть технология выглядит проверенной временем.

Как устроен NATS JetStream

Идеологически (и технически) сервера NATS образуют full mesh топологию (каждый сервер соединен по TCP напрямую с каждым) и полная информация о кластере - сервера, отправители, получатели - хранится на каждой ноде. Это безумно удобно, поскольку подключение ноды в кластер не требует конфигурации - достаточно при запуске указать новому серверу адрес любого другого сервера в кластере, и вся конфигурация подтянется за секунду, а весь кластер узнает о новом участнике. Система крайне живучая, при потере ноды сервера таким же образом узнают об этом и перестают слать ей сообщения. Для управления кластером не нужно никаких внешних heartbeats и мониторингов, инициирующих автоматическое отключение нод по недоступности, - все это встроено в протокол NATS. Выделенный сервер в качестве лидера - кластеру NATS тоже не нужен.

Кластер NATS JetStream - это подмножество серверов кластера NATS, которым назначена роль выбирать лидеров через протокол Raft. Отправители публикуют сообщения в один из каналов (streams) по определенной теме (topic), получатели подписываются на все или часть сообщений (по префиксу темы). Лидеры в JetStream выбираются:

  • для всего кластера - координирует конфигурацию;

  • для каждого канала - сохраняют новые сообщения;

  • для каждого получателя (consumer) - сохраняют факты доставки сообщений из канала этому получателю.

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

Для включения JetStream на существующем кластере NATS достаточно у части серверов включить соответствующую настройку в конфигурационном файле. Вообще, настроек в NATS JetStream много для самых разных сценариев. Чтобы контролировать нагрузку на ваши ресурсы, рекомендуем обратить внимание на:

  • Retention - по умолчанию канал сохраняет все сообщения в пределах установленных лимитов (число сообщений, общий размер памяти на канал и возраст сообщения), чтобы удалять сообщение после обработки (режим очереди) нужно выбрать WorkQueuePolicy;

  • MaxAge - сколько времени хранить сообщения до автоматической очистки;

  • Storage - где хранить сообщения, на диске - надежнее, в памяти - производительнее;

  • Replicas - число реплик, оно включает главную машину, если нужно иметь 2 копии помимо основного сервера, то значение нужно установить в 3;

  • AckWait - сколько времени ждать между попытками доставки;

  • MaxDeliver - максимальное число попыток доставки, потом сообщение просто будет висеть до истечения периода MaxAge, но если появится новый получатель (consumer), сообщение будет доставляться ему заново.

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

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

Опыт использования JetStream на проде

Мы включили JetStream на 3 серверах пару месяцев назад и с тех пор уже успели сделать rolling upgrade всего кластера NATS до актуальной версии. Все прошло гладко, без даунтайма, в течение 5 минут все ноды были перезапущены на новой версии без потери сообщений. Приятно, когда авторы поддерживают совместимость между разными релизами с возможностью горячей замены, привет разработчикам PostgreSQL, попробуйте обновить базу Postgres с 13 на 14 версию, например.

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

Временами проскакивает сообщение уровня warning о задержке в репликации на одну из нод JetStream:

[7] [WRN] Healthcheck failed: "JetStream stream 'js > STREAM_NAME' is not current"

[7] [WRN] Healthcheck failed: "JetStream has not established contact with a meta leader"

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

Мы расширяемся! Если вы хотите разрабатывать высоконагруженный сервис на современном стеке для тысяч компаний-клиентов, посмотрите на наши вакансии или просто пришлите резюме на job@pyrus.com с пометкой HABR

Комментарии (0)