AMQP и очереди в веб-приложениях

This post is available on english language here: Where are the queues coming from in web?

Эта история началась с того, что меня дважды спросили, как построить такой HTTP-гейт (api endpoint), который проксирует запросы к группе нижестоящих серверов, при этом не задумываясь ни об их точных IP-адресах, ни об их количестве, ни о нагруженности и здоровье (health) в целом. При этом каждый запрос должен быть обработан наименее нагруженным сервером в данный момент, а ответ должен быть послан синхронно обычным HTTP -респонсом (не вторичным коллбэком).

Не претендую на оригинальность, но расскажу как мы делали:

На входе ставится обычный веб-сервер на любом доступном вам языке программирования, хоть PHP. Он принимает HTTP-запрос и назначает ему уникальный UID. Затем он создаёт временную очередь в AMQP-сервере, подписывая её на обменник с ключом (routing key) равным UID. Затем он и кладёт содержимое запроса в очередь приёмки, сопровождая его тем же UID, и начинает висеть и слушать свою временную очередь.

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

Наш инстанс входного скрипта, висящий на временной очереди, ловит это сообщение с результатом и паблишит его в качестве HTTP response в ответ на исходный запрос. Затем инстанс просто завершает своё выполнение, а временная очередь уничтожается.

Вот и всё. Не надо ходить на бэкэнд-сервера, не надо заниматься окрестрацией их адресов, спрашивать их статусы, быть в курсе их падений/восстановлений и тэ дэ. Минус схемы, конечно, в оверхеде на работу с очередями.

Впоследствии оказалось, что даже умудрённые опытом коллеги не понимают основного фундаментального отличия: AMQP – это протокол гарантированной доставки (и обработки), а HTTP – нет.

Disclaimer: я знаю, что в документации RabbitMQ написано not guaranteed. Однако раббит хотя бы обещает гарантию – и делает это реально хорошо, в то время как протокол HTTP не гарантирует вообще ничего by design.

Как работает HTTP в вебе: клиент посылает запрос, и “висит” в ожидании ответа. Если в этот момент прервать выполнение, разорвать соединение, обесточить систему – ответ будет потерян навсегда (а в некоторых системах выполнение на серверной стороне также прервётся на полпути).

Если на серверной стороне произошла фатальная ошибка, то мы можем как получить код ответа вроде 500/503, так и не получить ничего. Причём мы не знаем, успел ли запрос обработаться, или обработался полностью и умер при отсылке ответа, или обработался на половину, или не успел вообще, или это дедлок от двух “параллельных” запросов, или это просто такой длинный запрос по природе, или это бэкэнд сильно занят и нас отбил балансер.

Ни один из известных мне HTTP-клиентов, не говоря уже об AJAX в браузерах, не повторяет автоматически тот же запрос в случае неуспешного ответа от предыдущего. И это поведение – правильное: мы с клиентской стороны не знаем, что “там” произошло, и повторный запрос мог бы привести (например) к повторному списанию средств со счёта.

Иное дело – очереди AMQP. Здесь я пишу про реализацию RabbitMQ, потому что если вы используете что-то другое, то вы сами себе злобные буратины. Так вот, в AMQP у вас есть гарантированное помещение сообщения (запроса) в очередь.

Система не “отпустит” клиента, пока сообщение не будет заперсищено (persisted) в надёжное хранилище, это быстрая операция и она не связана ни с какой нагрузкой – в отличие от HTTP, где приём запроса и его обработка связаны в одном приложении физически.

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

По правилам функционального программирования, каждая функция должна иметь строго один выход и не должна иметь побочных эффектов. Также, в сервисе, единственный результат обработки сообщения – это выходное сообщение. Если сообщение было выпущено – это гарантия, что выполнение задачи произошло.

Так вот, протокол AMQP by design подразумевает, что сервис, когда берёт сообщение в обработку, не удаляет его из очереди, а лишь маркирует как занятое. Если он упадёт – оно автоматически освободится. Когда он успешно завершает обработку, он публикует выходное сообщение, оно персистится, и потом, сразу же, удаляется входное. После этого сервис переходит в режим ожидания следующего сообщения.

Резюме: с HTTP вы имеете дело с абсолютной анархией: вы не знаете, что там произошло, завершилась обработка или нет, можно посылать повторный запрос или нет. А с AMQP вы имеете дело с чётко структурированным life cycle с гарантированными переходами: вход, обработка, выход.

Более того, это позволяет вам писать код в стиле let it crash даже на обычном PHP, не окружая его лишними guard-ами и watchdog-ами, потому что вся коммуникация между компонентами вашего приложения – транзакционна по своей природе, и отслеживается любым удобным мониторингом.

Флэшбэк: откуда в вебе вообще есть пошли очереди? Глава для тех, кто прочитал всё вышенаписанное, но так и не понял зачем оно.

Раньше сайты делались так: есть серверное приложение, которое в ответ на запрос пользователя разбирает, чего он там хочет, и отрисовывает страничку (или выполняет требуемую операцию и таки тоже отрисовывает страничку в итоге). Ежу понятно, что в этом случае – чем больше RPS ты можешь сделать, тем больше посетителей обслужишь.

С высокой нагрузкой люди боролись типичными методами: ставили фронт-сервер с nginx, под ним N штук с копиями приложения (в случае php – с апачами), и раскидывали по ним нагрузку. Случайным образом (round robin) или чуть более хитрее: апстримы выстраивались лесенкой, на первый из них был таймаут секунда, на второй две секунды, на третий три секунды и так далее. Конечно, были и более умные схемы.

Так где-то лет 6 назад работали все. Даже Битрикс, будь он неладен, такое умеет.

Потом внезапно случилась революция SPA (single page applications). Понятие “серверная отрисовка страницы” в нём отсутствует как класс, большая часть бизнес-логики уехала в браузер (JS), а весь HTML/CSS/JS-код абсолютно всех страниц приложения стал отдаваться посетителю сразу, при старте, одним пакетом. И стал размещаться на публичных CDN а-ля CloudFlare.

И внезапно стало вообще не иметь никакого значения, за какое время у тебя отрисовываются странички, т.е. как быстро ты обслуживаешь HTTP-запросы, потому что это перестало быть характеристикой сайта вообще. Потому что никто не заддосит CloudFlare, а если и заддосят – то с ними ты точно не справился бы.

И вторая революционная вещь, которая возникла благодаря устройству JS и веб-сокетов – это тот факт, что между действием пользователя и реакцией на действие возникла асинхронность. Пусть даже запрос улетал на сервер по обычному HTTP, но ответ на него прилетал позже, по асинхронному веб-сокетному (или SSE) -соединению.

Поэтому акцент с многолетней позиции “обслужи как можно больше или сдохни” перевернулся на “не потеряй ни один запрос”. Когда интерфейс стал асинхронным, никого реально не волнует, что ваш лайк под этим постом доберётся до браузеров ваших друзей через 1 секунду или 1 минуту – вы в любом случае увидите мгновенный результат, потому что фейсбук для вас имитирует визуальный эффект “+1” к счётчику, в то время как реальный запрос в фоне (и ответ на него) может долго пробираться через дебри сети, и это нормально.

Внезапно стало вообще не иметь значения, обработал ли сервер этот запрос за 0.1 секунды или за 15 секунд – в любом случае, паттерн поведения всегда один и тот же: “ваша заявка принята, ответ вы увидите в вашем интерфейсе по его готовности”.

Потому что когда у тебя, например, сайт интернет-магазина – и клиент нажимает кнопку “Подтвердить заказ”, ты не можешь ему сказать “Подожди, пока у меня nginx найдёт подходящий upstream”. Ты не можешь ему, сцуко, сказать 503 – потому что он уйдёт на@уй, и будет прав. Ты должен ему сказать “Ок, заявка принята” тут же, сохранить её, и обработать по мере возможности.

Вот так в вебе появились очереди, которые являются ныне основой для построения отказоустойчивых систем. Посмотрите на Trello – одно из лучших веб-приложений, на мой взгляд. Посмотрите, как визуально оформлен поиск билетов на AviaSales. Посмотрите на фреймворк Rails – флагман мировой веб-разработки – который со следующей мажорной версии предлагает технологию ActiveCable для асинхронного веба из коробки. Посмотрите на Facebook, в конце концов.

Будьте в курсе, и не позволяйте стереотипам утащить вас в прошлое.