SSE, нотификации, Node.js и при чём тут C#?

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

До этого я никогда не занимался уведомлениями, но был осведомлен, что есть для этого несколько путей: WebSocket, SSE и Long Polling.

https://imgflip.com/i/30mye1 Сначала я был Базом, а потом всё же стал Вуди
https://imgflip.com/i/30mye1 Сначала я был Базом, а потом всё же стал Вуди

Планирование

Изучив статистику по браузерам наших пользователей, я принял решение использовать SSE (Server Sent Events). Приложению всё равно на вряд ли понадобится отправлять по WebSocket данные, для этого есть наш API над HTTP. А Long Polling никак не стандартизирован.

SSE по своей технологии очень прост. Это долговисящий HTTP запрос, но, благодаря заголовку о MIME-типе Content-Type: text/event-stream , сервер и клиент не обрывают соединение по тайм-ауту. И в ответ по этому потоку нам приходят данные о событиях в специальном формате по стандарту. 

event: task-updated
data: {"id":"236c2259-a5f4-4f87-bc5d-2c6d00bd0875","title":"New title"}

Разработка

Написал я этот микросервис, наверное, за часа 2-3, как-никак писать приложения на Node.js получается довольно быстро. По логике всё очень просто.

  1. Принимаем запрос.

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

  3. Слушаем очередь RabbitMQ, по прибытии сообщения отправляем событие по тем самым сохраненным соединениям пользователя, взяв их из словаря по идентификатору. 

Есть уже куча туториалов о том, как использовать SSE на стороне бэкенда. Поэтому не вижу смысла показывать конкретный код. Не обижайтесь.

Первые проблемы

Всё в проде работало хорошо и не предвещало беды. Но программирование, оно такое - без проблем никогда не получается. Произошёл наплыв пользователей и наш микросервис не мог физически быстрее обрабатывать сообщения из очереди. Вследствие чего, уведомления стали приходить с заметной задержкой. Ладно, давайте масштабировать, сделаем кластер из инстансов - подумал я. Всё было просто до того момента, когда я вспомнил, что так называемые сессии пользователей (сокеты, держащие SSE потоки) хранятся в словаре. И при репликации процессов соединения делятся между инстансами, а как именно - только одному процессору известно. Немного погуглив, понял, что можно через IPC канал отправлять сообщения процессам, чтобы хоть как-то синхронизировать соединения пользователей. Но есть небольшое НО, передавать можно в сообщении только те данные и объекты, которые можно сериализовать.

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

MDN Web Docs

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

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

Что мы имеем на данном этапе? В одном инстансе Node.js приложения у нас держатся подключения пользователей (SSE), прослушивается очередь RabbitMQ, и к этому добавилась прослушка IPC канала. Инстансов - N штук. Вроде всё заработало, но ненадолго...

Проблема усиливается

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

На собеседованиях некоторые компании спрашивают про многопоточность на нашей любимой Node.js. Мы все смело отвечаем, что да, она есть - worker threads. Но многопоточность всё же не заканчивается на параллельности. Помимо неё нам нужна общая память между потоками, хоть это и корень всех бед в работе многопоточных приложений. Но, как назло, в Node.js потоки изолированы друг от друга. Да, мы можем слать сообщения, но как и при реализации с кластером инстансов, там можно отправлять только сериализуемые данные, а нам это не подходит: проходили, знаем, не решает проблему. 

И, наконец, решение

Не долго думая, я просто переписал этот микросервис на C# (ASP.NET Core). Но, на самом деле, вместо C# мог быть любой другой язык программирования, в котором есть возможность работать с HTTP и RabbitMQ, а главное - возможность управления потоками: Java, C++, Go и т.д. C# меня заманил тем, что TPL (Task Parallel Library) использует libuv в потоках, а волшебный ThreadPoolManager сам решает, когда создавать поток, а когда накинуть работу уже созданному. Можно считать это как Node.js на максималках (строгая типизация, нормальная многопоточность и т.д.), да и я очень люблю этот язык, чего таить греха. После перехода на новый ЯП, проблема исчезла, а проблем с конкурентостью вроде не было. В принципе, не так страшно, если на один из открытых браузеров пользователю не придёт уведомление. 

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

P.S. Теоретически, проблема бы решилась, если бы я увеличил мощность CPU, но я же писал вам в начале, что стартап провальный.

@hello_my_name_is_dany
14.08.2021 17:54 UTC
Первоисточник

Комментарии

@kuznetsovkd
14.08.2021 13:53 UTC
0
одно уведомление для пользователя будет обрабатываться всеми инстансами нашего микросервиса

Мастер поток коннектится к rabbit, форки держат подключения пользователей, мастер пересылает сообщения форкам, а те уже определяют подключен к ним нужный пользователь или нет.
Почему это будет работать так же медленно?
@amakhrov
15.08.2021 02:12 UTC
0

Собственно, у автора примерно так и работало в первой версии, насколько я понял. Через IPC.

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

@ninJo
14.08.2021 14:04 UTC
0

А почему было бы не взять готовое решение? Есть немало работающих из коробки и требующих минимум времени.

Если бывают наплывы пользователей - то значит стартап не такой уж и провальный =)

@DeniSix
14.08.2021 14:21 UTC
+2
Зачем городить костыли с IPC внутри node.js приложения, если можно перед группой воркеров воткнуть балансер? Раз у нас уже «микросервис» и RabbitMQ предоставляет необходимые гарантии.
@x893
14.08.2021 15:11 UTC
0

Аппратный или программный балансировщик с передачей запроса на нужную ноду кластера (на которую самый первый запрос отправлялся) + любая in-memory база для инстансов внутри одной ноды. Со входящими совсем всё просто - даже писать лень. Работает с 2003 года.

@mitya_k
14.08.2021 16:29 UTC
0

Простите, но все это можно сделать не хватаясь за другой язык:

  • Сообщения складываются в какую-нибудь БД, например, Redis. Откуда worker читает сообщения для пользователей, которые к нему подключены

  1. Все worker получают сообщения из RabbitMQ, но только worker, который имеет соединение с конкретным пользователем делает acknowledgment для сообщения, иначе игнорит.

  2. Сообщения складываются в какую-нибудь БД, например, Redis. Откуда worker читает сообщения для пользователей, которые к нему подключены.

А то что в потоках нельзя шарить сокеты это не зря сделано. Многопоточное программирование двигается в сторону общения сообщения как в Erlang/Elixir, а не в шаринге всего подряд, что ведет к сложноуловимым ошибкам и использованию блокировок, которые убивают перфоманс

@derikn_mike
14.08.2021 17:07 UTC
+4

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

Добро пожаловать в цивилизованный мир .net

@monochromer
15.08.2021 07:25 UTC
+1

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

И SharedArrayBuffer не подходит?

@dsrk_dev
15.08.2021 15:14 UTC
+1

он не решает проблему с сериализацией

@phis
15.08.2021 20:07 UTC
+1

а ведь можно было просто установить пару плагинов на кролика и не мучаться: webstomp и oauth jwt и уже поверх этого масштабироваться инстансами ноды

@Bassardes
16.08.2021 13:54 UTC
+1

Ну раз уж взяли дотнет, еще можно было взять и SignalR. И получить сразу из коробки кучу транспортов (WebSocket, Long poling etc) + потенциальную возможность масштабирования на несколько серверов (через редис)

@gameplayer55055
16.08.2021 13:57 UTC
0

Тем, кто пишет на десктопе намного легче. Берешь маслаешь UDP multicast.

Надеюсь, что с продвижением http3 такие штуки возможно будет проворачивать

@sergsergsergserg
15.09.2021 15:02 UTC
0