Убьет ли HTTP/2 лонг поллинг и вебсокеты?
Привет, меня зовут Александр Уланов и я пишу на Ruby с 2012 года и, в основном, занимался бэкендом веб приложений, несколько лет назад начал заниматься еще и фронтендом и администрированием. В настоящее время я работаю независимым Full-Stack разработчиком и консультантом с компанией Learning Tapestry, которая занимается заказной разработкой и работает над проектами в сфере образования и транспортировки.
Веб до 2004 года — это книга
Каких-то 18 лет назад веб был максимально простым и был больше похож на обычную книгу. Мы заходили на веб-страницы и перелистывали их ссылками как страницы обычной книги. На этом интерактив заканчивался. Все изменилось в 2004 году с появлением AJAX-запросов — Asynchronous JavaScript and XML. AJAX-запросы были продвинуты в массы Google и Gmail. После этого веб стал «приложением», и получил название Web 2.0.
В вебе появились интерактивные элементы, с которыми пользователи могли взаимодействовать, а самое главное, интерфейс стал перестраиваться без перелистывания. Кроме того, веб-приложения научились отправлять пользователям нотификации, например, уведомления, какие-то сообщения или прогресс с бэкенда. Или например, как в нашем случае, положение точек на карте.
В нашем текущем проекте автобусы перевозят детей из дома в школу и из школы домой. Специальные люди отслеживают, например, какую остановку проехал автобус, какую пропустил, где остановился чтобы подобрать детей и где высадить, и на основании этого контактирует со школами и родителями.
Нотификации от сервера клиенту
Возникает вопрос: как уведомлять клиента о том, что изменилось положение автобуса на карте? Возможны несколько вариантов.
Схема работы Short Polling
Начнем с самого простого — Short Polling или обычная кнопка «Обновить», которую нажимает пользователь. Это очень ресурсоемкий вариант, который дает большую нагрузку на сервер и в настоящее время его использование нежелательно.
Long Polling.
Server-sent events.
WebSockets, поговорим о них подробнее
Websockets
Вебсокеты — это не HTTP, а долгоживущее TCP-соединение от клиента к серверу по отдельному протоколу, который так и называется — WebSockets.
Это двунаправленный канал, и сообщение могут отправляться в обоих направлениях — от клиента к серверу и от сервера к клиенту. Соответственно, WebSockets должны хранить состояние (быть stateful), то есть клиент должен «помнить» о сервере, а сервер должен «помнить» о клиенте. Однако исторически сложилось, что веб-фреймворки не хранят состояние (являются stateless), то есть каждый раз, когда выполняется запрос, сервер отдает ответ, закрывает соединение и «забывает» о клиенте.
ActionCable
Веб фреймворки масштабируются процессами и потоками и не готовы к сотням тысяч висящих (idle) websocket-соединений в силу своей архитектуры. Однако в 2016 году в Rails был добавлен ActionCable, который теперь поставляется с каждым Rails-приложением по умолчанию. Чтобы обойти stateless-формат веб-фреймворков ActionCable использует rack Hijacking API.
Соответственно, когда запрос приходит в rack, он перехватывается, затем возвращаются хедеры ответа, т. е. занятый запросом поток веб сервера освобождается, и сервер как будто забывает о запросе, однако это соединение хранится в отдельном пуле, к которому всегда можно обратиться.
ПРИМЕЧАНИЕ. Не все веб-серверы готовы к websocket-ам из коробки. Например, Rails v. 5 пришлось перейти с WEBrick на Puma. При этом Puma не могла хранить больше 1024 соединений, что было исправлено в обновлении до четвертой версии только в 2019 году.
Для синхронизации между процессами ActionCable использует Redis Publish/Subscribe (также может использовать Postgres). К сожалению, ActionCable не работает с Hanami и Sinatra, но можно использовать LiteCable (облегченную версия ActionCable), который является клеем между вашим веб-приложением и сервером AnyCable, к которому мы вернемся немного позже.
ActionCable. Отправка с сервера на клиенты
Для отправки сообщения сервера на клиенты:
Метод Broadcast в ActionCable помещает задачу в Redis.
В каждом процессе rack вызывается код ActionCable.
ActionCable отсылает сообщения по списку сокетов.
Сервер постоянно пингует клиенты и, если клиент не получает пинг в течение шести секунд, сервер пытается переподключиться в JS на браузере.
ActionCable. Получение сообщения от клиента
При получении данных через rack Hijack:
ActionCable вызывает callback.
В каждом процессе Puma используется threadpool для чтения сокетов.
Callback срабатывают в тех процессах, на которые мы подписались.
Проблемы ActionCable
Долгая отправка. Рассылка 10000 нотификаций (то есть одного сообщения по 10000 websocket-соединений) может занять до 10 секунд. В таком случае, смысл использования websocket-ов немного теряется.
Проблемы с памятью. Одно неактивное websocket-соединение в ActionCable «сжирает» где-то 190 килобайт памяти. Соответственно, если висит 20000 таких соединений (что легко достижимо в большинстве проектов), теряется уже 3,5 Гб.
Трудности с балансировкой. Необходимо помнить, что Load Balancer должен быть правильно настроен, чтобы нормально балансировать нагрузку, т. е. запросы по websocket-ам. Например, мы столкнулись с проблемой, когда неправильная настройка привела к тому, что все websocket-соединения падали на один единственный инстанс Passenger, где очень быстро забивали очередь запросов, и сервер становился недоступен.
Мониторинг websocket-соединений как таковой отсутствует. Websocket-соединения очень трудно профилировать, разве что через счета за сервер. В народе популярно «правило буравчика»: после 10000 соединений на проекте можно переходить на AnyCable.
AnyCable
AnyCable — это проект, который был написан Владимиром Дементьевым. Основное преимущество AnyCable — нативный сервер, написанный на Go или Erlang (2 готовые версии, которые можно использовать). Go и Erlang намного быстрее, чем Ruby. Это обеспечивает высокую производительность и на отсылку нотификаций по 10000 сокетов уходит ~1 секунда. Плюс, AnyCable масштабируется отдельно от Rails.
Получается, что с AnyCable можно спокойно использовать websocket-ы и Rails, и не испытывать никаких трудностей. Однако проблемы остаются, хотя касаются уже самих websocket-ов. Например:
В Chrome может быть только 255 активных websocket-соединений (в Firefox — 200). При этом веб-страница может иметь несколько таких соединений, потому что используются разные виджеты, разные компоненты, которые необходимо обновлять. При этом пользователи очень любят открывать много вкладок, и поэтому лимит соединений быстро исчерпывается. Это не очень серьезная проблема, однако, если лимит соединений исчерпан, новые соединения не создаются.
В AnyCable и в ActionCable отсутствует какой-либо fallback-механизм. Если websocket-ы по какой-либо причине недоступны, пользователь не получает обновлений.
Нет поддержки очереди сообщений. Например, если в чате отправляются одно за другим несколько сообщений, они могут быть доставлены не по порядку. Если интернет соединение было на какое-то время потеряно — так же будут потеряны сообщения, которые были отправлены в это время, нельзя их «подцепить».
Отсутствуют гарантии доставки, то есть мы не знаем, дошло ли сообщение от клиента до приложения или от сервера до клиента. Это проблема двух генералов, армии которых могут захватить крепость вместе, но будут разбиты по-отдельности. При этом договориться генералы не могут, потому что неизвестно, добрался ли гонец.
Long polling старый, но не бесполезный
Long polling использовался до того как появились websockets.
Схема работы Long Polling
Принцип действия long polling похож на принцип действия short polling. Но когда от клиента приходит запрос на сервер, мы это соединение не закрываем и оно остается висеть и ждать новых данных от сервера. После получения новых данных от сервера, ответ приходит на клиент и соединение закрывается. Через заданный промежуток времени, приходит новый запрос, который также не закрывается и ждет данные.
Long polling был не очень популярен из-за HTTP/1 и ограничения подключений к домену (всего 6 на браузер). Соответственно, использовать его было не практично. Проблема была решена с появлением HTTP/2, где появился мультиплексинг, возможность использования одного TCP соединения на несколько таких long polling-подключений.
Приятный бонус — гем message_bus для Rails, на который мы перешли с websocket-ов, когда столкнулись с различными проблемами, в том числе проблемой мониторинга websocket-ов. Гем message_bus хорошо подошел для решения задачи по отслеживанию автобусов, поскольку нет необходимости обновлять каждый автобус по очереди, а достаточно обновлять данные по всем автобусам на карте раз в 20 секунд.
discourse/message_bus:
использует hijack, чтобы не занимать потоки сервера, и оставлять их свободными для обычных запросов. Как и с ws-соединениями в ActionCable, long polling запросы складываются в отдельный пул, откуда впоследствии читаются;
использует Redis Pub/Sub для синхронизации между процессами;
предлагает много удобных возможностей из коробки, которые отсутствуют в ActionCable/AnyCable, например:
— гарантии доставки и очереди. Каждое сообщение, которое мы посылаем по этому каналу, имеет идентификатор. В случае разрыва интернет-соединения, можно подгрузить пропущенные сообщения.
— автореконнект для балансировки, поскольку каждое соединение через какое-то время закрывается, а новый запрос балансируется как обычно.
Server-sent events
Возвращение server-sent event
Схема работы Server-Sent Events
Server-sent events так же были не популярны на HTTP/1 из-за ограничения количества соединений (6 на домен в браузере). С появлением HTTP/2 стали хорошей альтернативой WS и LP, поскольку в данный момент одно TCP-соединение может отвечать за несколько SSE-соединений. Это обычный http-запрос, который приходит на сервер и остается открытым, пока не закроется на клиенте.
Server-sent events похожи на long polling. Однако, когда приходит http-запрос, он не закрывается при получении данных или по таймауту, а является долгоживущим, как и websocket-ы. Соответственно, мы можем эти события (events) постоянно отправлять, пока не закроем соединение сами.
К сожалению, готовые популярные решения для Rails, такие как ActionCable или AnyCable, отсутствуют. В Rails есть ActionController: Live, который приходится допиливать. Если использовать ActionController: Live из коробки, будут блокироваться Puma-треды, и нам потребуется все больше и больше серверов. Поэтому нужно самостоятельно использовать rack hijacking, освобождать поток и хранить соединение где-то в другом пуле.
Фреймворки-агрегаторы
Можно использовать фреймворки-агрегаторы, которые могут помочь в работе с websocket-ами благодаря наличию, например, fallback-ов, очередей (в некоторых) и т. д.
Во всех есть fallback, по меньшей мере на Long Polling, библиотеки для сервера и клиента и другие фичи, которых может не хватать для ваших конкретных задач в ActionCable/AnyCable. Выбор остается за вами.