Почему мы не построили это на WebSocket
Когда мы начинали Runnev, очевидным транспортом был WebSocket. Мы потратили на него три недели, а потом убрали. Вот что подтолкнуло нас к обычному HTTP и почему мы считаем это верным выбором для доставки событий в открытый интернет.
Рукопожатие апгрейда — это уязвимость на границе сети
WebSocket начинает жизнь как HTTP-запрос с Upgrade: websocket и
Connection: Upgrade. Если всё между клиентом и сервером добросовестно пробрасывает эти
заголовки и переключает протоколы, вы получаете сокет. Если что-то на пути этого не делает, вы
получаете обычный HTTP-ответ и сбитого с толку клиента.
В раннем тестировании мы видели, как рукопожатие падает в трёх повторяющихся местах. Корпоративные
прямые прокси, которые терминируют и заново порождают HTTP, срезали заголовки апгрейда и возвращали
200 с HTML-телом. Пара конфигураций CDN отвечала на апгрейд на тарифах, где проброс
WebSocket не был включён, так что сокет подключался к границе и затем молча ничего не делал. А один
enterprise-фаервол, против которого мы тестировали, разрешал рукопожатие, но убивал соединение в тот
же миг, как оно несло кадр, который он не мог проинспектировать.
Ни одно из этого — не баги, которые мы могли бы починить. Это среды, за которыми сидят пользователи наших пользователей. Транспорт, который работает на ноутбуке разработчика и падает внутри банка, — не тот транспорт, который мы можем выпустить.
Serverless не удержит сокет, но может потоково отдать ответ
Большая доля кода, желающего потреблять события, теперь работает в функциональных средах: короткоживущих, тарифицируемых по реальному времени, без понятия долгоживущего сокета, которым вы владеете. Нельзя удержать WebSocket открытым в функции, которую заморозят между вызовами.
Что эти среды умеют — так это потоково отдавать тело HTTP-ответа. Обработчик может запустить
fetch к эндпоинту подписки, читать из потока ответа и обрабатывать пакеты по мере их
прихода — всё в рамках одного вызова. Когда вызов завершается, курсор сохраняется, а следующий
возобновляется. Сторона подписки Runnev — это server-sent events именно потому, что SSE — это тело
ответа, а тело ответа проходит везде, куда проходит HTTP-запрос.
Мобильные сети наказывают простаивающие сокеты
В мобильной сети простаивающий сокет — это расход батареи, который оператор хочет вернуть. Радио переходит в энергосберегающие состояния, а привязки NAT истекают, поэтому сокет, молчавший несколько минут, может быть мёртв без уведомления любого из концов. Ping/pong WebSocket существует, чтобы это прикрывать, и работает, но означает, что каждый клиент крутит таймер, а каждый сервер отслеживает живость по большей части молчащих соединений.
С HTTP-потоком у той же проблемы более дешёвый ответ. Мы отправляем кадр-комментарий каждые 15 секунд,
чего достаточно, чтобы посредники не убирали соединение, а если соединение всё же умрёт,
возобновление — это новый GET с параметром запроса cursor. Нет сессии, которую нужно
восстанавливать, нет повторной авторизации сверх bearer-заголовка, уже бывшего в запросе. Нестабильный
канал превращается в череду коротких чтений, каждое из которых подхватывает там, где остановилось
предыдущее.
HTTP/2 даёт мультиплексирование, которое иначе пришлось бы строить
Сильнейший аргумент в пользу WebSocket — избежать блокировки в начале очереди (head-of-line) и накладных расходов на сообщение. Поверх HTTP/1.1 этот аргумент весомый. Поверх HTTP/2 и HTTP/3 он в основном испаряется: множество параллельных запросов делят одно соединение, заголовки сжаты, а управление потоком встроено. Клиент, подписанный на сорок потоков, держит сорок параллельных ответов GET на одном соединении, а транспорт разбирается с чередованием.
Мы измерили накладные расходы обрамления SSE на пакет против сырого кадра WebSocket для наших полезных нагрузок, и разница была на уровне шума рядом с затратами на TLS и JSON. Для системы, чьи пакеты — это десятки-сотни событий, обрамление — не туда, куда уходят байты.
Чем мы пожертвовали
Это не бесплатно. SSE однонаправлен: сервер стримит клиенту, а всё, что клиент хочет отправить, идёт отдельным запросом. Для потока событий это ровно та форма, которая нам нужна, но если ваша задача по-настоящему двунаправленная и низколатентная в обе стороны — например, совместный курсор или цикл сетевого кода игры, — WebSocket лучший инструмент, и мы бы вам так и сказали.
Мы также отказались от бинарных кадров на проводе для json-потоков; SSE — текст, поэтому сырые байты
идут в base64 в поле data для нашего режима raw при доставке по SSE. Повтор
пакета возвращает байты дословно, так что накладные расходы касаются только живого пути, а для
большинства raw-пользователей значим именно путь повтора.
Компромисс, на который мы пошли, намеренный: отдать двунаправленность и немного бинарной эффективности, а взамен транспорт переживает прокси, CDN, serverless и мобильные сети без особой настройки. Для доставки упорядоченного потока событий коду, средой которого мы не управляем, этот размен окупался каждый раз.