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

Планирование
Изучив статистику по браузерам наших пользователей, я принял решение использовать 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 получается довольно быстро. По логике всё очень просто.
Принимаем запрос.
Сохраняем соединение в массив, который находится в словаре с ключом, который в данной ситуации является идентификатором пользователя.
Слушаем очередь 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, но я же писал вам в начале, что стартап провальный.