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, но я же писал вам в начале, что стартап провальный.

© Habrahabr.ru