Меня зовут Алексей Комаров, я — старший frontend-разработчик в SuperJob. Хочу поделиться опытом реализации механизма обновления данных в реальном времени у нас на сайте. Под катом — подробности о выборе подхода, о проблемах, с которыми мы столкнулись при разработке, о наших кейсах оптимизации клиентской стороны и, конечно, немного кода и наглядных схем.
Выбор технологии для real-time
Все началось с того, что перед нами встала задача реализовать в браузере чат; обмен сообщениями должен происходить в реальном времени.
Для знакомых с web людей не является секретом, что протокол HTTP предполагает клиент-серверную архитектуру, в которой клиент всегда инициирует передачу данных, а сервер только отвечает на запросы клиентов. В режиме реального времени необходимо, чтобы сервер инициировал отправку данных.
Давайте рассмотрим, какие есть решения этой проблемы.
Способы организации real time в web
Polling
Клиент по таймеру опрашивает сервер: «А не появилось ли чего-нибудь новенького?» Это самый старый и прямолинейный способ организации real-time. Минусов у этого подхода больше, чем плюсов: нагрузка на сеть и сервер; данные приходят не в реальном времени, а с задержкой между наступлением события и отправкой данных.
Long Polling
Клиент открывает соединение, а сервер держит его до наступления события, потом отправляет данные, после чего клиент переоткрывает соединение. Это уже настоящий real-time — нагрузка на сеть и сервер снижается. Но остается необходимость самостоятельно организовывать непрерывное соединение, следить за его обрывами и тем, чтобы передаваемые данные не потерялись в этот момент.
Server Sent Events (SSE)
Поддерживаемая браузерами технология непрерывного HTTP-соединения, в котором данные передаются потоком от сервера к клиенту.
WebSocket
Независимый протокол поверх TCP. Это самое современное и популярное решение задачи организации передачи данных между клиентом и сервером в реальном времени.
Polling и Long Polling мы не рассматривали, потому что это устаревшие и не оптимальные подходы. Поэтому выбор был между SSE и WebSocket.
SSE vs WebSocket
Давайте кратко пройдемся по плюсам и минусам обеих технологий.
Плюсы SSE:
SSE использует HTTP, поэтому на сервере не нужна поддержка дополнительных протоколов и нет проблем с сетевыми экранами.
Простой браузерный API и широкая поддержка браузерами.
Встроенный механизм переподключения при обрыве соединения и защита от потери данных.
Минусы SSE:
Однонаправленная передача данных от сервера к клиенту; передавать можно только текст.
SSE позволяет открыть не более шести соединений для одного домена в браузере.
Плюсы WebSocket:
Поскольку WebSocket — это независимый протокол, то с его помощью можно передавать бинарные данные и организовать двунаправленную передачу данных.
Количество соединений в браузере не ограничено.
Минусы WebSocket:
WebSocket является отдельным протоколом, поэтому необходима его поддержка на сервере и возможны проблемы с сетевыми экранами.
Необходима самостоятельная реализация протокола на стороне клиента, хотя существуют библиотеки, решающие эту задачу.
В целом можно сказать, что SSE является более простой для реализации и понимания технологией. WebSocket — более сложный, но при этом гибкий подход.
Так как WebSocket является более современной и популярной технологией, которая позволяет более гибко решать задачи, вначале мы начали прорабатывать решение с ее использованием. Однако вскоре вскрылось одно обстоятельство, заставившее нас пересмотреть решение: если использовать WebSocket, то необходимо дублировать авторизацию или придумывать «костыли» для использования осуществленной по HTTP авторизации. В SSE такой проблемы нет, потому что он реализован на основе HTTP, и при его использовании все заголовки авторизации проставляются без дополнительных телодвижений.
Авторизация на нашем сайте является довольно сложным механизмом, и делать его еще сложнее нам совсем не хотелось, поэтому в итоге мы остановились на SSE.
Реализация real-time с помощью SSE
Рассмотрим, как можно реализовать соединение SSE в браузере и на сервере. На клиенте нам нужно создать объект браузерного API для SSE и подписаться на событие сообщения onmessage. Данные от сервера приходят в параметре коллбека в виде строки.
eventSource = new EventSource('/SSE/', options);
eventSource.onmessage = ({ data }) => {
if (typeof data !== 'string') {
log.warn('Unsupported sse data type (should be string)’, { data, dataType: typeof data });
return;
}
try {
const {type, data: message} = JSON.parse(data);
///…
}
});
Так как SSE — это технология непрерывного HTTP-соединения, то на сервере необходимо обрабатывать обычный HTTP-запрос.
Мы используем фреймворк Express на Node.js-сервере, поэтому создаем новый роут для клиентского SSE-запроса и в поток ответа отправляем два переноса строки в начале соединения, свидетельствующие о начале передачи данных, а потом и сами данные, каждая порция которых отделяется переносом строки.
export const sendSSEEvent = (res, data) => {
if (!data) {
res.write('\n\n');
return;
}
res.write('data: ${JSON.stringify(data)}\n');
res.write('\n');
};
Это все, что нужно сделать на сервере, чтобы заработала передача данных.
Клиент-серверная архитектура
Таким образом у нас сформировалось архитектурное решение передачи данных с сервера в реальном времени:
Серверная часть SSE реализуется на Node.js-сервере.
В качестве транспорта между PHP-сервером и Node.js используется Redis.
Для каждого авторизованного пользователя создается Redis-канал.
SSE используется для нотификации о новых данных, а не для передачи данных.
Давайте рассмотрим схему, которая иллюстрирует работу сервера с клиентом.
На клиенте есть браузерное API для SSE и диспетчер сообщений, реализованный в функции processServerMessage. На сервере — express route, библиотека для работы с Redis и пул SSE-каналов для каждого пользователя и его устройства.
Работу схемы можно представить как два потока. Вначале SSE API инициирует соединение, которое обрабатывается на сервере, и создается канал для прослушивания сообщений Redis и канал в пуле SSE-каналов, если они еще не были созданы.
Отмечу, что имя канала Redis включает тип пользователя и его ID, что позволяет идентифицировать, для кого приходит сообщения из системы.
Пул каналов — это хэш-таблица, ключами которой являются тип пользователя и ID пользователя, а значение — это массив из идентификаторов клиентских устройств и открытых соединений express.
После того как закончилась инициализация, возможна передача данных. Backend записывает в Redis нотификацию о том, что произошло некое событие и, возможно, вспомогательные данные (например, ID чата, в который пришло сообщение). Redis передает это в канал, который связан с Node.js-сервером. По имени канала серверная часть нашей системы определяет, какому пользователю предназначено сообщение, и находит в пуле каналов соответствующие открытые соединения для всех устройств, которые в этот момент соединены с сервером. Далее сообщение передается в каждое открытое соединение на клиентские устройства. На клиентском устройстве API SSE получает данные и вызывает диспетчер сообщений, который в зависимости от типа сообщения и вспомогательных данных вызывает API бэкенда для получения данных по HTTP.
Таким образом, мы не используем SSE-соединение для передачи данных — только для нотификации о том, что они появились на сервере. Дальше запрос данных происходит по обычному API бэкенда.
Собственно, так мы организовали доставку сообщений с сервера на клиент в реальном времени. Реализация системы оказалась достаточно простой, так как мы выбрали подходящую технологию и использовали уже имеющиеся механизмы приложения.
От хорошего к лучшему: оптимизация системы
Итак, наш чат работает, и на этом можно было бы остановиться, но нам захотелось оптимизаций (шутка!) — в них была потребность.
Для оптимальной работы системы необходимо:
Уменьшение объема данных, передаваемых по SSE-соединению, для повышения скорости оповещения клиентов о новых данных.
Уменьшение количества открытых SSE-каналов.
С уменьшением передаваемых данных все понятно, а уменьшение количества SSE-соединений необходимо, во-первых, чтобы уменьшить нагрузку на сервер, а во-вторых, из-за лимита в шесть подключений, накладываемого браузерами. Для нас эта оптимизация была особенно важна, так как наши клиенты любят открывать множество вкладок, когда просматривают вакансии, а каждая вкладка — это SSE-соединение.
Уменьшение передаваемых через SSE-соединение данных мы реализовали, решив пересылать по SSE только нотификации, а данные получать по обычному API backend.
Для уменьшения количества соединений по SSE мы использовали два приема.
Первый — режим реального времени доступен только для авторизованных пользователей. На самом деле, это изначально закладывалось в архитектуру, так как имена каналов Redis содержат данные авторизованных пользователей. Все гости сайта живут без режима реального времени.
Второй способ уменьшить количество SSE соединений — для каждого браузера создавать только одно соединение (а не для каждой вкладки сайта). Рассмотренная выше система так не умеет, поэтому нам необходимы некоторые доработки.
Один браузер — одно SSE-соединение
Итак, чтобы одна вкладка открывала SSE-соединение, а остальные получали сообщения от нее, нам нужны: среда обмена сообщениями, обеспечение контроля работоспособности вкладки с SSE-соединением и механизм выбора вкладки, открывающей соединение. Рассмотрим подробнее, как реализовать каждое требование.
Среда обмена сообщениями
В качестве транспорта мы используем локальное хранилище браузера (LocalStorage). Оно позволяет записывать значения с некоторым ключом и подписываться на изменения. Вкладка-отправитель записывает сообщение с помощью функции setItem. Другие вкладки получают сообщение, подписавшись на браузерное событие «storage». Таким образом, мы можем общаться между вкладками.
Контроль работоспособности
Контроль за работоспособностью активной вкладки осуществляется с помощью двух взаимосвязанных механизмов:
Heartbeat — периодический сигнал контролируемой системы, который оповещает о ее работоспособности. Если этот сигнал не приходит, то все заинтересованные части системы понимают, что произошла исключительная ситуация и надо ее обработать.
const heartbeatIntervalId = setInterval(() => {
setSseId(genUuid()) ;
}, SSE_CONNECTION_HEARTBEAT_INTERVAL) ;
Сторожевой таймер — схема контроля за зависанием, состоящая из таймера, который периодически сбрасывается контролируемой системой.
const setConnectionWatcher = () => {
if (sseConnectionWatchTimer) {
clearTimeout (sseConnectionWatchTimer);
}
sseConnectionWatchTimer = setTimeout(() => {
openConnection();
}, SSE_CONNECTION_WATCH_INTERVAL);
};
Механизм выбора активной вкладки
Для определения того, какая вкладка будет открывать соединение, используется лотерея. В каждой вкладке задается случайная задержка установки соединения. Вкладка с наименьшей задержкой выигрывает.
const delayBase = SSE_CONNECTION_WATCH_INTERVAL;
const delayRandom = Math.random() * 1000;
sseConnectionWatchTimer = setTimeout(() => {
openConnection();
}, delayBase + delayRandom);
Клиентская архитектура
Теперь рассмотрим полную схему работы приложения на клиенте. Схему можно разделить на две части: синхронизация вкладок и обработка SSE-сообщения от сервера.
Синхронизацию вкладок можно описать с помощью трех возможных сценариев:
Пользователь открывает браузер, и запускается сразу несколько вкладок. Для каждой вкладки запускается сторожевой таймер и срабатывает лотерея. Вкладка, которая выиграла лотерею, вызывает менеджер соединений, а он, в свою очередь, открывает SSE-соединение и запускает HeartBeat. В остальных вкладках сторожевой таймер начинает регулярно сбрасываться по сигналу HeartBeat, который приходит из local storage. Система приходит в согласованное состояние.
Пользователь открывает новую вкладку при уже установленном SSE-соединении. В этом случае для открытой вкладки запускается сторожевой таймер, но он начинает сбрасываться уже работающим сигналом HeartBeat. Таким образом, вкладка становится синхронной со всеми остальными.
Пользователь закрывает вкладку с SSE-соединением. В этом случае менеджер соединений закрывает SSE-соединение и останавливает HeartBeat. Срабатывают сторожевые таймеры остальных вкладок, и снова запускается лотерея.
Схема обработки SSE-сообщений похожа на уже рассмотренную ранее схему, за исключением того, что вкладка с установленным SSE-соединением записывает полученное сообщение в local storage, а остальные вкладки, получив его в обработчике события новых данных в хранилище, вызывают свой диспетчер сообщений.
С такой архитектурой достаточно иметь одно SSE-соединение, и все вкладки будут получать сообщения от сервера в реальном времени.
Итоги
Несмотря на то что некоторые считают SSE устаревшей технологией, это далеко не так, и во многих случаях она является оптимальным решением. Это решение позволяет минимизировать трудозатраты и не увеличивать сложность системы. К тому же ряд оптимизаций позволил добиться стабильной работы системы и снизить нагрузку на сеть и сервер.
В качестве бонуса мы получили возможность обновлять в реальном времени любые данные на сайте, так как механизм оповещения сервером клиентов в реальном времени был реализован независимо от чата.
Надеюсь, наш опыт окажется для вас полезным.
Комментарии (26)
Alexandroppolus
01.02.2022 17:45В качестве транспорта мы используем локальное хранилище браузера (LocalStorage).
Тиньки это запилили через сервис-воркер - 471718
Вы не пробовали такую схему?
komarov_a_87 Автор
01.02.2022 18:04Нет, у нас не возникло таких проблем как у Тинькофф, поэтому мы дальше localStorage не изучали вопрос
aleks_raiden
01.02.2022 18:23А вместо всего этого взяли бы Centrifugo - и все бы работало, тем более, коллеги по рынку делали )
komarov_a_87 Автор
01.02.2022 19:19первый коммит - 2020 год. мы сделали нашу реализацию немного раньше.
при этом клиент для Node.js в npm лежит как альфа версия и скачиваний не больше 20 в сутки. вряд ли такое стоит в продакшне использовать
aleks_raiden
01.02.2022 19:41А зачем ему клиент для ноды? Учитывая что клиентская либа универсальная а сервер имеет несколько транспортов. Ну и почитайте блог, они здесь, включая автора, есть на хабре. В суровом продакшине многие годы.
И да, первый коммит - 5 марта 2015-го года. Пруф: https://github.com/centrifugal/centrifugo/commit/0f439cbe244f38cd6037e33a8b1e1b1250478fb2
komarov_a_87 Автор
02.02.2022 11:55+1мой комментарий был основан на 5 минутном рисерче информации о данной технологии, поэтому вы наверное правы.
здорово, что есть разные подходы решить проблему и что есть готовые решения. мы выбрали одно из них и описали его в статье. если Centrifugo - лучшее, на ваш взгляд, решение - замечательно, используйте его.
LborV
01.02.2022 18:51Интересное решение, но мне кажется оверкил. На сколько я понял есть бэк на PHP который и должен тригерить ноду, чтобы отправить сообщение? У меня был похожий опыт только с веб сокетами, а конкретнее socket.io, нода в моём кейсе выступала в качестве прокси, хронила внутри себе структуру [{id: авторизационый хеш токена пользователя, sokcet: ссылка на сам сокет }], пыха пуляля по udp на ноду сообщение с авторизационым хешем, а нода проксировала это на фронт. Перформанс и аптайм превзошёл все ожидания, такая система споеойно держит более 400 рпс
komarov_a_87 Автор
01.02.2022 19:27Если вы про то, что оверкил - использовать Redis (а вместо него лучше общаться по udp), то мы просто использовали то, что бэкенд и так уже использовал для общения между некоторыми сервисами
mitya_k
01.02.2022 22:17Включение HTTP2 на стороне балансировщика разве не решает проблему ограничения кол-ва подключений?
pfffffffffffff
01.02.2022 22:24А как настроено проксирование запроса на sse на ноду?
komarov_a_87 Автор
02.02.2022 12:00Не очень понятен вопрос.
сервер на Node.js проксирует запросы обычного API бэкенда. но соединения SSE обрабатываются на ноде и дальше не проксируются.
kawena54
02.02.2022 12:37я так и не понял у вас что и клиент решает лезть ему в редис или нет ? и серверные прослойки? В общем в схеме не очень понятно как вы работаете с кэшем
komarov_a_87 Автор
04.02.2022 11:15клиент не решает лезть в Redis, клиент открывает SSE соединение. По этому событию сервер Node.js подключается к каналу Redis. Дальше при появлении сообщения в канале редиса нода отдает эти данные в уже открытое SSE соединение.
Безусловно, если клиент не инициировал SSE соединение, то сообщение отправленное в канал редиса ему отправлено не будет.
Glomberg
02.02.2022 13:46Интересно было ознакомиться.
Пару вопросов, позвольте: какой период срабатывания у hearthbeat? Сильно грузит браузер система контроля (hearthbeat и сторожевой таймер)?
komarov_a_87 Автор
04.02.2022 11:10У нас интервал хартбита 5 секунд, а период сторожевого таймера 10 секунд. это имперически и интуитивно подобранное значение. вопрос даже не в нагрузке на браузер, а в том, что слишком маленькие значения могут приводить к ошибкам гонки хартбита и сторожевого таймера. Мы посчитали, что для наших пользователей отсутствие SSE соединения в течение 10-15 секунд не будет проблемой.
Nnnnoooo
04.02.2022 14:08Мы посчитали, что для наших пользователей отсутствие SSE соединения в течение 10-15 секунд не будет проблемой.
Кстати это тоже можно было бы указать в статье. Потому что для кого-то это можжет быть минусом. SSE это хорошая технология, но как везде есть куча нюансов. И как раз нюансы наиболее полезны и интересны в статьях.
zkrvndm
03.02.2022 12:58Есть еще один способ организации Real Time соединения, как таковой у него нет названия, но фактически это обычный Long Polling реализованный на базе протокола JSONP. Для получения данных используется тег script и длинные опросы, никаких fetch и XHR, чистый хардкор)
Кстати, а как вы победили заморозку вкладки браузером? Потому, что если SSE соединение бы улет висеть в какой-нибудь фоновой вкладки, то рано или поздно эта вкладка заморозиться хромом и тогда остальные ваши вкладки (включая активную) перестанут получать уведомления.
komarov_a_87 Автор
04.02.2022 11:17В этом случае замороженная вкладка перестанет отправлять хартбит, и остальные вкладки поймут, что пора решать, кто следующий откроет SSE соединение.
Nnnnoooo
04.02.2022 14:13А если вкладка не полностью заморозилась, а у нее значительно понизился приоритет (например так себя firefox ведет). Т.е. любое событие по таймеру будет происходить со значительной задержкой (иногда до нескольких минут).
dlesnikov
03.02.2022 12:59+1SSE позволяет передавать текст, но он может быть в gzip, например, что сильно облегчает жизнь.
Nnnnoooo
Проблема с SSE, в том что если соединение разорвалось со стороны сервера и бекенд перестал отвечать (например временная проблема или тормоза сервера), то браузер продолжает долбить с попытками подключения к SSE бекенду с невероятной скоростью без каких-либо задержек (до сотен запросов в секунду). Т.е. получаем такой маленький ддос если клиентов подключено много одновременно. Но обычно это не проблема, т.к. объем трафика не сильно большой по современным меркам, за исключением случаев когда включена какая либо анти ддос зашита (cloudflare/ovh/incapsula и т.д.). Т.е. клиент блокируется полностью на какой-то период времени только из-за временной недоступности SSE бекенда
komarov_a_87 Автор
В API SSE есть колбэк onerror, который помогает разруливать подобные ситуации. мы установили переподключение с прогрессивно увеличивающейся задержкой и проблем с ддосом от SSE у нас нет
Nnnnoooo
Так надо было об этом в статье написать. Просто это не совсем явное поведение SSE, которое нигде не описывается (никому заренее в голову не придет что браузер будет генерить столько запросов на ровном месте).
komarov_a_87 Автор
В статье написано о том, что SSE поддерживает переподключение. Ну и об этом написано много где - на mdn, в спецификации, у Кантора
Nnnnoooo
Бекенд не Ванга и не знает когда он упадет. Т.е. как сервер заранее может выставить большую задержку прямо перед падением?
Т.е. или сразу или всегда выставлять какую-то задержку, например в 1 сек при любых ответах сервера, что иногда не вариант (если вдруг нужно моментальное переподключение) или вот это:
да, это оба решения проблемы, но это очень неявные и в доках насчет этого ничего нет. Вот как раз о таких неявных моментах хорошо бы было написать в статье.