Привет, меня зовут Александр Уланов и я пишу на Ruby с 2012 года и, в основном, занимался бэкендом веб приложений, несколько лет назад начал заниматься еще и фронтендом и администрированием. В настоящее время я работаю независимым Full-Stack разработчиком и консультантом с компанией Learning Tapestry, которая занимается заказной разработкой и работает над проектами в сфере образования и транспортировки.

Веб до 2004 года — это книга

Каких-то 18 лет назад веб был максимально простым и был больше похож на обычную книгу. Мы заходили на веб-страницы и перелистывали их ссылками как страницы обычной книги. На этом интерактив заканчивался. Все изменилось в 2004 году с появлением AJAX-запросов — Asynchronous JavaScript and XML. AJAX-запросы были продвинуты в массы Google и Gmail. После этого веб стал «приложением», и получил название Web 2.0.

В вебе появились интерактивные элементы, с которыми пользователи могли взаимодействовать, а самое главное, интерфейс стал перестраиваться без перелистывания. Кроме того, веб-приложения научились отправлять пользователям нотификации, например, уведомления, какие-то сообщения или прогресс с бэкенда. Или например, как в нашем случае, положение точек на карте.

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

Нотификации от сервера клиенту

Возникает вопрос: как уведомлять клиента о том, что изменилось положение автобуса на карте? Возможны несколько вариантов.

Схема работы Short Polling
Схема работы 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

Принцип действия 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

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. Выбор остается за вами.

Комментарии (16)


  1. Dmitry2019
    09.01.2023 12:01
    +3

    Странно слышать про такие ограничения по вебсокетам. Вот пример миллионов соединений

    https://josephmate.github.io/2022-04-14-max-connections/


    1. Mayurifag
      09.01.2023 12:28

      Кажется, вы про ограничения на сервере. А в тексте указано ограничение от браузера.

      Если открыть много вкладок и в каждой по вебсокет соединению, то видимо на 200+ соединения открываться перестанут, это что из текста я понял, не было вроде про сервер сказано ничего, только про клиент.


      1. Dmitry2019
        09.01.2023 16:00
        +1

        Это вроде про серверы

        ПРИМЕЧАНИЕ. Не все веб-серверы готовы к websocket-ам из коробки. Например, Rails v.5 пришлось перейти с WEBrick на Puma. При этом Puma не могла хранить больше 1024 соединений, что было исправлено в обновлении до четвертой версии только в 2019 гогоду.

        Не могу себе представить сценарий, где в браузере нужно открывать сотни вебсокет соединений. Обычно открывается одно на вкладку. Может кто-то подскажет зачем?


        1. AstarothAst
          10.01.2023 10:50

          Насчет сотен не скажу, но сразу несколько вебсокет-соединений могут иметь смысл — одновременно в вебсокете может находиться только одно сообщение, либо «туда», либо «оттуда», поэтому при большой интенсивности может иметь смысл открыть два соединения, одно, например, для интенсивного получения данных, а второе для относительно редкой отправки своих реакций на сервер. А может еще и третье — для пушей сервера других событий. Больше ничего в голову не приходит.


          1. mayorovp
            10.01.2023 12:36

            Э-э-э, вообще-то там дуплексное соединение, и одновременно в нём может находиться как сообщение "туда", так и "оттуда".


            1. AstarothAst
              10.01.2023 16:53

              Именно одновременно? Интересно.


              1. mayorovp
                10.01.2023 17:17

                Да, именно. А что тут удивляет? Это ж просто нарезанный на сообщения TCP. А TCP всегда был дуплексным.


                1. AstarothAst
                  10.01.2023 21:59

                  Просто думаю, почему ж у меня в памяти отложилось, что в вебсокете одновременно может быть только одно сообщение? Очевидно что-то же заставило меня так думать…


    1. Shado_vi
      09.01.2023 13:41
      +5

      если рассматривать статью не по названию а понимая что всё это в контексте руби то получится она не такой "странной".
      не все умеют так неплохо работать c websocket навроде erlang и node.js, среди них например ruby и php.


      Начнем с самого простого — Short Polling или обычная кнопка «Обновить», которую нажимает пользователь. Это очень ресурсоемкий вариант, который дает большую нагрузку на сервер и в настоящее время его использование нежелательно.

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


  1. space2pacman
    09.01.2023 12:18
    +9

    После этого веб стал «приложением», и получил название Web 2.0.

    Нет. Веб 2.0 это когда пользователи стали генерировать контент.


  1. morsic
    09.01.2023 12:19
    +2

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

    Вебсокеты то как раз по упорядочены

    Гарантии доставки - обычно решается в зависимости от задачи, таймауты/пинги/сообщение что ответ принял/идемпотентность/слежение за tcp сокетом под вебсокетами/игнорирование


  1. boopiz
    09.01.2023 15:39
    +2

    до всего этого аякса неплохо работали скрытые iframe в качестве асинхронных запросов для получения данных (и без всяких ограничкний на количество) . что вкупе с яваскриптом отлично генерирвало контент. просто в то время в интернеты не всех пускали. особенно всяких маркетологв. а тем, кто там обитал все эти свистоперделки ее нужны.

    все эти гигантскик объемы данных можно смело резать в 2 раза минимум. и качество информации в 90% не ухудшится (ибо нечему)


  1. Suvitruf
    09.01.2023 17:00
    -1

    Вебсокеты — это не HTTP, а долгоживущее TCP-соединение от клиента к серверу по отдельному протоколу, который так и называется — WebSockets.

    Ну...HTTP — это тоже TCP, а если требуется лишь request/response, то http с keep-alive весьма похож на вебсокеты)


  1. BigDflz
    09.01.2023 18:46

    Если интернет соединение было на какое-то время потеряно - так же будут потеряны сообщения, которые были отправлены в это время, нельзя их "подцепить".

    не верно. брал модуль w5500, отправлял через него короткие данные, отображаемые в реальном времени в браузере, вынимал , во время передачи, коннектор из w5500 секунд на 10-15, и втыкал снова, никаких потерь не происходило, в w5500 есть буфер, в котором и накапливались эти данные.
    если посмотреть через wireshark, то можно заметить , что передача по ws идет с квитирование.
    как по мне, то использование в лоб json и ему подобных убивает достоинство ws, если разделить передаваемую инфу по ws на части : "команда", "разделитель", "данные", то можно более полно использовать возможности js для ws, а именно "рефлексию",
    типа xxx24®ddddddddd , где dddddd может быть и json и строка html
    тогда

    con.onmessage = function (response) {
                if (typeof (response.data) === 'string')
                {
                   var rg = /^([a-z_0-9.]{1,})\|([\s\S]*)/i;
                    var r = rg.exec(response.data);
                    try {
                        if (r[1].includes('.'))
                        {
                            var d = r[1].split('.');
                            window[d[0]][d[1]](r[2]);
                        } else
                        {
                            window[r[1]](r[2]);
                        }
                    } catch (er) {
                        console.log('ошибка ' + er.stack);
                        console.log('вызов ' + r[1]);
                        console.trace();
                    }
                }
    

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


    1. mayorovp
      09.01.2023 18:55

      Квитирование в ws можно заметить из без wireshark, достаточно знать что веб-сокеты идут поверх TCP. Собственно, и буфер есть в w5500 не просто так, а потому что протокол TCP его требует.


      Только вот попробуйте в своём эксперименте продержать связь выключенной достаточное время для устаревания соединения в NAT. Или просто возьмите достаточно "умную" ОС, которая заметит что коннектор вынут и сбросит все сокеты.


  1. gmtd
    09.01.2023 20:52

    То есть, если своей сервер, то можно настроить HTTP/2 на Apache, заюзать Server-sent events (как наиболее простое для развертывания и использования решение) и не париться?