Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Практически во всех проектах есть такой сервис как оркестратор. Называться он может по разному, но суть одна: всё что от него требуется, это сходить в N сервисов, забрать у них необходимую информацию, как-то агрегировать, и отдать наверх клиенту ответ. И у нас, в Леруа Мерлен, он тоже есть. На данный момент наш оркестратор - это рабочая лошадка, которую используют все кому не лень, например, фронт сайта получает через него данные по товарам для того чтобы показать их пользователю. На данный момент это один из самых высоконагруженных сервисов в нашем контуре.
Как правило, подобные сервисы не особо кого волнуют ровно до того момента, пока они не становятся узким горлышком на пути к добру, которое несут сервисы ниже уровнем. Когда же ваши доменные сервисы держат больше 2к rps, а оркестратор, который агрегирует из них информацию - всего 200, у бизнеса начинает дёргаться правый глаз, а у разработчика - чесаться руки провести рефакторинг. Давайте разбираться каким образом мы можем это сделать.
Типовая архитектура оркестратора
Классическая схема оркестратора для ритейла выглядит примерно так:
Фронт запрашивает id одного или нескольких товаров, которые хочет отобразить + id маски. Маска - это alias для списка атрибутов, необходимых для отображения. На разных страницах сайта необходимо показывать разный набор атрибутов, информация об этом лежит в отдельном микросервисе. Далее, имея список товаров и необходимых атрибутов, оркестратор идёт в микросервисы, которые информацией по этим атрибутам обладают. Плюс, надо зайти в рекомендательную систему, которая вернёт список id товаров которые мы можем порекомендовать купить вместе с основным (заменители основного и ему сопутствующие). Этот список тоже нужно обогатить атрибутами по маске, которую запросит фронт.
В общем, логика не бог весть какая, но она есть.
Варианты оптимизации
Первое, что можно тут заметить: оркестратор совершает очень много запросов. По этому, первое, о чём нужно подумать когда стоит задача разогнать сервис - это уменьшить их количество в единицу времени. Сделать это можно несколькими способами:
Прикрутить кэш
Самый выгодный способ оптимизировать запрос - это не делать его вообще. Или делать, но редко. Не каждый запрос можно кешировать - иногда нужна актуальная информация из источника. Тем не менее, мы знаем, что маски меняются довольно редко, по этому смело можем ходить в сервис раз в 5 минут, забирать всю базу (она не такая большая) и вот у нас минус один последовательный запрос.
Распараллелить запросы
Как показывает практика, большинство времени подобного рода сервисы просто ждут ответа от их доменных коллег, так что если они могут "ждать" эти ответы не по очереди, а одновременно, то хорошая идея это организовать. Если мы берём стандартный сервер tomcat или undertow, то можно сделать так, чтобы каждый запрос, принимаемый сервером порождал N потоков, каждый из которых ходил бы за своим атрибутом, а поток-родитель потом это всё бы собирал и выплёвывал наверх.
Соединить запросы
Если до первых двух пунктов мы догадались в самой первой итерации сервиса, то до возможности соединять несколько запросов в один мы дошли не сразу.
Что имеется в виду: в сервис приходит массив основных продуктов и их атрибутов, по которым нужно собрать информацию. Помимо этого, к продуктам нужно подобрать рекомендации. Сервис рекомендаций, естественно, возвращает только список продуктовых id, которые также нужно обогатить своим набором атрибутов. Атрибуты эти, отличаются от атрибутов основного продукта и тоже приходят как параметр запроса нашего оркестратора.
Фактически, в оркестратор приходит список основных продуктов и 2 маски: одна для основных запрашиваемых продуктов, и вторая для продуктов, которые мы к ним рекомендуем:
{
"products": ["product1", "product2", ..., "productN"],
"mask" : "product_detail_page_attributes",
"recommendation_mask": "name_and_photo_only"
}
Проблема в том, что список id основных продуктов мы знаем сразу, а за списком id продуктов-рекомендаций ещё нужно сходить. Возникает несколько вариантов того, как это сделать:
Вариант 1:
Идём с известными основными продуктами и их атрибутами по микросервисам собирать о них информацию;
Параллельно с этим, идём в сервис рекомендаций, который возвращает нам список id рекомендуемых продуктов;
После того, как сервис рекомендаций вернул список товаров, аналогично первому пункту, идём с уже другим набором атрибутов по сервисам и получаем всю необходимую информацию.
Тут 1 и 2 выполняются параллельно, 3 же придётся подождать первых двух. Как видно, число последовательных запросов: 2, что не так много, но, как показывает практика, проблема может заключаться в их количестве. Это то, что у нас было с самого начала, после этого мы сделали следующую оптимизацию:
Вариант 2:
Сначала идём в сервис рекомендаций и получаем id продуктов-рекомендаций;
После этого добавляем к основным продуктам рекомендованные товары, и к атрибутам основных товаров атрибуты товаров-рекомендаций, и уже со всем этим добром идём к доменным сервисам.
После того, как мы получили информацию по всем атрибутам общего списка товаров, нам нужно в памяти разделить этот список на основные и рекомендованные, и почистить лишние атрибуты т.к. клиенту необходимы разные наборы атрибутов у разного типа товаров, а мы от доменных сервисов получили всё.
В этом варианте, как и в пером, число последовательных запросов равно 2-м. Но, число запросов совершаемых сервисом, уменьшается вдвое. С другой стороны, делая 1 жирный запрос по всем продуктам и атрибутам мы по сети гоняем информацию, которая нам не нужна - нас не же не интересуют атрибуты основных товаров у рекомендаций. Этого можно избежать, если спроектировать грамотное API, но, скорее всего, в доменных сервисах запрос в базу уйдёт всё равно всё со всем.
Тем не менее, второй вариант всё же по моему мнению лучше, т.к.
меньше запросов => меньше потоков => сервис больше таких запросов сможет обработать. Также, доп запрос - это доп затраты на маршаллинг и демаршаллинг, которые могут быть значительными.
Тем не менее 2.0, нужно понимать, что если раньше сервис рекомендаций, который, положа руку на сердце, является второстепенным для бизнеса, начнёт тормозить и отваливаться по таймауту, в первом варианте, мы сразу же возвращаем критичные для бизнеса данные наверх, во втором же, нам нужно сначала обрабатывать исключение, и поход за критичными данными произойдёт только после того, как мы увидим таймаут от сервиса рекомендаций. Т.е. это будет однозначно дольше.
Выводы:
Если попытаться суммировать вышесказанное, то несмотря на то, что для нас второй вариант дал ощутимый прирост производительности и мы остановились на нём, можно представить ситуацию, когда он не будет таким хорошим - я бы рассматривал каждый случай отдельно, смотрел на специфику запросов, сколько и какие данные гоняются по сети и так далее.
Хотя мне всё-таки кажется, что в большинстве случаев чем меньше запросов - тем лучше.
Едем дальше
Итак, мы провели некие оптимизации, и да, сервис действительно стал держать больше нагрузки, но что делать, если этого всё ещё не достаточно?
Во-первых, сразу нужно сказать об ограничениях, которые у нас были.
Потому что если доменные сервисы хорошо держат нагрузку, а оркестратор - нет, то чисто теоретически можно просто поднять побольше этих оркестраторов, тогда на каждый будет меньше нагрузки и в целом всё будет работать.
Однако, это:
Не всегда возможно из-за ограничений инфраструктуры. Это сейчас у нас кубер и со скалированием проблем нет, но перед пандемией у нас был всего десяток серверов для приложений и на одном сервере мы мы могли поднять только 1 инстанс оркестратора. Т.е. максимальное количество инстансов, которые мы могли себе позволить - 10;
Как показывает практика, это не оптимальный подход. Да, прирост производительности есть, но до для того, чтобы получить необходимые 60ms на 95-й персентиле нужно было этими инстансами прямо таки обмазаться, чего мы себе позволить не могли.
Анализ проблемы
На самом деле проблему "как разогнать сервис до определённого rps?" можно переформулировать в вопрос "почему на нужном rps сервис не держит?". Для того, чтобы понять в чём проблема, даём нарастающую нагрузку, и, в момент когда сервису плохеет смотрим на состояние.
К сожалению, идея написать статью появилась после того, как мы решили проблему, по этому графики не сохранились, но, если в двух словах, мы увидели, что количество потоков постоянно росло и в какой-то момент garbage collector просто сходит с ума. Причём, ни увеличение возможного количества потоков, ни добавление памяти картину не меняло.
Чтобы разобраться почему так, давайте взглянем на то, что происходит с приложением при запросе:
Сервер принимает входящее http соединение. Занимаются этим потоки пула
WORKER_IO_THREADS
, их обычно по количеству ядер (в нашем примере 4). Допустим, наш запрос попал наXNIO-1 I/O 2
. Он установил соединение, и отправил запрос дальше, освобождаясь для принятия новых запросов.От потока из пула
WORKER_IO_THREADS
запрос попадает уже на непосредственный обработчик - в нашем случае этоXNIO-1 task-2
из пулаWORKER_TASK_CORE_THREADS
Это тот поток, который будет выполнять всё, что мы напишем внутри нашего контроллера.Как уже говорилось выше, зная список атрибутов,
XNIO-1 task-2
создаёт n потоков, каждый из которых параллельно должен добыть какую-то часть информации о продуктах. Потоки берутся из кастомного пула (пулыWORKER_IO_THREADS
иWORKER_TASK_CORE_THREADS
- это сущности undertow)Каждый кастомный поток, идёт к клиенту доменного сервиса и делает запрос. Т.к. под капотом используется
RestTemplate
, поток блокируется и ждёт пока доменный сервис не ответит.
И, на самом деле, всё что описано выше - это стандартная схема, и кучу времени подобные сервисы на java писали именно так. Подход рабочий, но проблема заключается в том, что чтобы обработать всего лишь 1 запрос необходимо N потоков из кастомного пула, как результат потоков этих будет очень много и на их поддержку будут уходить ресурсы. Именно по этому при такой стандартной схеме взаимодействия сервис будет довольно таки прожорлив.
Переход на неблокирующий подход
Как мы можем заметить, проблема возникает только в CUSTOM_THREAD_POOL - там очень много потоков и большинство времени они занимаются тем, что просто ждут ответа. Существует подход, используя который, мы можем сократить число подобных потоков. Зовётся он неблокирующим.
Чтобы понять в чём суть, мне бы хотелось привести аналогию с фастфудом
Блокирующий подход:
Вы решили устроить праздник живота и порадовать себя парочкой бургеров.
Вы подходите к свободной кассе и делаете заказ.
Кассир принимает заказ. Заказ отправляется на кухню.
Вы стоите перед кассой и ждёте заказ.
Вы смотрите на кассира. Кассир смотрит на вас.
Вы смотрите на кассира. Кассир смотрит на вас.
Проходит время, минуты складываются в часы, неловкое молчание, и, наконец, заказ готов - вы отправляетесь на свой заслуженный обед.
Неблокирующий подход:
Вы всё так же хотите бургер.
Вы подходите к свободному электронному терминалу и начинаете выбирать себе еду.
Вы оплачиваете заказ, берёте чек с номером и локатор.
Вы садитесь за свободный стол, поставив на него локатор.
Пока вы ждёте заказ вы можете заниматься чем угодно полезным (или не очень) - и это основное преимущество неблокирующего подхода.
Когда ваш заказ готов, вам его принесут и вы приступаете к трапезе.
Разница в подходах заключается в том, что во втором вы ничего не ждёте. Вернее ждёте, но в момент ожидания вы можете ещё чем-то заниматься. Например, сделать ещё один заказ в другом ресторане. Ваш локатор - это временная замена вашей будущей еды, если хотите, это Future
вашего заказа.
Вернёмся к нашему серверу
Если используется блокирующий RestTemplate
, то поток, который получает информацию от доменного сервиса делает следующее:
Он стучится к сервису, говорит ему "здрасьте", я такое-то приложение, мне нужна такая-то информация, я буду ждать от вас ответ на таком-то порту, и начинает ждать, что на этот порт от сервиса придёт ответ. Пока ответ не придёт, или пока не наступит таймаут поток ничем полезным заниматься не будет.
Вместо того, чтобы иметь кучу потоков, каждый из которых ничем полезным не занимается, ожидая ответа от сервиса, предлагается создать пул потоков, который будет состоять всего из 4-х (по количеству CPU) потоков, но которые постоянно будут заниматься полезной работой.
Потоки из этого нового пула будут принимать "заказы" на общение с сервисами моментально возвращать управление вместе с каким-то аналогом Future
и избавляя от необходимости заказчика ждать.
Они поделят между собой диапазон портов, и когда к одному из них придут с "заказом", он выберет свободный порт из диапазона, на котором он будет получать ответ, и, отправит запрос в доменный сервис. После того, как запрос отправлен, этому потоку в отличие от блокирующего подхода будет чем заняться. У него ранее "заказывали" другую информацию, и она уже могла прийти на другой порт, а значит надо сходить проверить - не ответил ли один из доменных сервисов, которых он опрашивал ранее. Если ответил, то нужно нотифицировать поток заказчика, что его заказ выполнен.
Таким образом, наши потоки либо принимают заказы и отправляют запросы, либо в бесконечном цикле обходят порты, на которых ожидается ответ. Таким образом, они всегда при деле.
Теория - это классно, как применить-то?
C 11-й Java доступен httpClient, который реализует описанный выше неблокирующий подход. А с Spring 5 есть его обёртка - WebClient, которым можно заменить RestTemplate. WebClient
- часть spring-webflux, по этому как результат запроса в доменный сервис возвращает Mono/Flux
. Наш основной же код приложения работает с CompletableFuture
.
Возникает вопрос: можно ли использовать WebClient
как замену RestTemplate
, получив преимущества неблокирующего приложения, но при этом не переписывая с CompletableFuture
на Mono/Flux
весь проект?
Ответ: можно, но нужно очень хорошо понимать что делаешь.
Как положить прод, если не любишь читать документацию.
У Mono/Flux
есть метод toFuture()
, который возвращает как раз CompletableFuture
.
Может возникнуть соблазн ни о чём не задумываясь просто использовать этот метод. Так мы и сделали. И уже через несколько секунд нам пришлось откатываться, т.к. наш прод в прямом смысле этого слова встал - все запросы к нему просто тупо начали висеть.
Начинаем разбираться и видим следующее:
Потоки WebClient
(по умолчанию они зовутся reactor-http-epoll-число
), которые должны были постоянно работать оббегая каждый свой список портов, заблокированы.
Всё дело оказалось в методе CompletableFuture thenApply(work)
который встретился у нас выше по стеку.
Этот метод, "докидывает" работы в текущий CompletableFuture
на котором вызывается. И всё бы ничего, но у нас так получилось, что эта "работа" оказалась блокирующей.
Получилось следующее: через thenApply
потоку reactor-http-epoll-1
из WebClient
было "накинуто" задание сходить в ещё один сервис. Пока он этого не сделает и не вернёт управление наверх он не может выполнять свою основную работу - смотреть не ответили ли доменные сервисы. И причём, в конце этого задания у стояла блокировка, т.е. нужно было обязательно дождаться от этого сервиса ответа. И так исторически сложилось, что библиотека WebClient
, распределяющая работу типа "сходить в ещё один сервис" выбрала именно reactor-http-epoll-1
для организации взаимодействия в этом запросе. То есть получилась ситуация, когда поток заблокировался, потому что ждал ответа от сервиса, в то время как он же и должен был этот ответ в получить. Но он не мог бегать и смотреть что ему пришло, т.к. был заблокирован. Вот такой вот deadlock.
Решение в данном случае: использовать thenApplyAsync, чтобы дополнительная работа выполнялась не в потоке WebClient
.
Мораль же следующая:
Никогда не позволяйте потокам WebClient
блокироваться - они постоянно должны работать.
Именно потому что так просто выстрелить себе в ногу, я бы рекомендовал воздержаться от использования CompletableFuture
и Mono/Flux
в одном проекте. Если используете Mono/Flux
, то лучше перепишите всё на Netty, благо у CompletableFuture
и Mono/Flux
очень похожее API. Вот тут в документации написано почему использовать метод toFuture()
конвертирующий одно в другое - не очень хорошая практика.
Хэппи-энд
Тем не менее, после правок, всё завелось. После деплоя видим следующую картину:
Где-то на 17:26 произошёл деплой новой версии. Из графика видно что:
количество потоков сократилось больше чем в 4 раза (это сократилось количество потоков в
CUSTOM_THREAD_POOL
с 1000+ до 4-х);потоки в статусе
timed-waiting
исчезли как класс;количество потоков в статусе
waiting
не изменилось, но это понятно - нас всё ещё есть нативные воркеры undertow. Можно использовать Netty, тогда не было бы и их - у приложения тогда бы вообще было бы всего 4 универсальных потока, которые занимались бы всем.
Результаты по перфомансу превзошли все ожидания: 0 ошибок и 80 ms 75-й персентиль, при всего 4-х инстансах приложения.
Необходимая производительность в 60 ms на 95 персентиле достигается путём поднятия единственного дополнительного инстанса.
Выводы
Мы очень рады, что мы успели провести эту оптимизацию до пандемии. Именно переход на неблокирующий веб-клиент позволил нам фактически не заметить двукратное увеличение нагрузки во время локдауна, что для системы нашего размера не так уж тривиально.
Общие рекомендации по проектированию оркестраторов могут быть следующие:
Для начала следует задуматься о:
кэшировании;
параллельном выполнении запросов;
объединении запросов.
Именно эти шаги могут уменьшить время ответа запроса.
Далее, использование неблокирующего клиента в микросервисах типа "оркестратор" позволяет повысить пропускную способность сервиса, экономя на тредах.
Именно повысить пропускную способность, т.е. те же результаты, но меньшими ресурсами. Однако, если у вас куча серверов, возможно, вам проще будет поднять побольше инстансов.
В конце концов, Вы же наверняка рассчитывали, что это решит ваши проблемы с нагрузкой, не так ли?)
Не советую использовать CompletableFuture
и Mono/Flux
в одном проекте. Анализ описанных в данной статье проблем при их появлении не тривиален. Как минимум потому, что их не так просто воспроизвести на тестовых стендах. А если используете, то используйте с умом.
Не допускайте блокировки потоков WebClient
. Как только кто-то из них заблокируется, это будет означать, что он уже не сможет выполнять свою основную функцию.
В чём минусы подхода?
Обсуждая неблокирующее программирование, мы должны понимать, что идея подобной организации потоков не нова. Может быть, она нова в Java
, но в Node.js
с самого начала всё так и работало. И тем не менее, когда меня пригласили в Леруа Мерлен распиливать монолит, у которого были проблемы с производительностью, то, что мы распиливали, было как раз Node.js
приложением. Это говорит о том, что данный подход не является панацеей. Он хорошо работает, когда потоки не нагружены лишней работой и плохо, когда есть много тяжелых вычислений. Но это уже следующий шаг после внедрения неблокирующего клиента).
Люди строили highload на Java и до jdk11
/Spring 5
, неблокирующее программирование - это всего лишь один из подходов к организации, который хорошо бы иметь в своём распоряжении и применять когда в нём возникнет необходимость.
Спасибо за внимание!