В данной статье производится сравнение работы простейших сервисов реализованных с помощью фреймворка Camel и двух его компонентов: HTTP и AHC. Углубляться в структуру и работу с самим фреймворком не будем, предполагается что читатель уже немного знаком с ним.

Рассматривать будем простой сервис на Camel, который получает запросы от jetty-компонента, обрабатывает их, например, выводит в лог, вызывает другой сервис через http, и обрабатывает ответ от него, например, пишет в лог.

Для тестирования использовались скрипты JMeter для вызова нашего сервиса в соответствии с задуманной частотой и интервалами, а так же небольшой Http-сервис, играющий роль внешнего по отношению к нашему сервису, и выполняющий задержку 5 секунд. Все взаимодействие происходит по локальной петле (127.0.0.1), так что сетевые задержки не учтены, но для сравнительного анализа они и не нужны.

HTTP-компонент

В данном разделе будет рассматриваться стандартный HTTP-компонент для взаимодействия по HTTP. Код простого сервиса:

from("jetty:http://localhost:8080/test")
     .log("receive request body ${body}")
     .removeHeaders("CamelHttp*")
     .to("http://{{another.url}}")
     .log("finish process body ${body}");

Примечание: удаление заголовков, начинающихся на "CamelHttp" необходимо потому, что они выставляются в Jetty-компоненте и могут повлиять на работу Http-компонента.

Для проверки работы данного сервиса запустим скрипт JMeter, который отправляет 25 одновременных запросов.

Samples

Min

Max

Error %

25

5012

7013

20.000%

В результате видим, что 20% или 5 из 25 запросов обработались с ошибкой(Read timed out). Связано это с тем, что у http-компонента по умолчанию установлено ограничение в 20 соединений к одному хосту. Изменяется это ограничение параметром connectionsPerRoute

from("jetty:http://localhost:8080/test")
     .log("receive request body ${body}")
     .removeHeaders("CamelHttp*")
     .to("http://{{another.url}}?connectionsPerRoute=200")
     .log("finish process body ${body}");

После этого исправления все 25 сообщений обрабатываются без ошибок. Но есть еще одно ограничение – это ограничение пула потоков jetty-компонента, по умолчанию 200. Для проверки этого ограничения запустим следующие 4 сценария JMeter:

  1. 200 одновременных запросов

  2. 300 одновременных запросов

  3. 300 запросов равномерно распределенных в течении 5 секунд, с повтором 5 раз

  4. 200 запросов равномерно распределенных в течении 5 секунд, с повтором 5 раз

После запуска 1 сценария в JVM произошел рост потоков до 214 штук, и далее количество потоков не менялось для всех сценариев.

Результаты выполнения тестовых сценариев:

 

Процент ошибок

200 запросов единовременно

0%

300 запросов единовременно

34.667%

300 запросов с повтором 5 раз

71.733%

200 запросов с повтором 5 раз

0%

Первый и четвертый сценарии демонстрируют нормальную работу с допустимой нагрузкой

Второй сценарий с 300 одновременными запросами демонстрирует резкое превышение возможностей настроенного сервиса, 200 запросов обрабатываются потоками jetty-компонента, а остальные 100 дожидаются в пуле задач jetty, и в результате обрабатываются не 5 секунд, а 10. Соответственно 34% ошибок – это примерно эти 100 запросов.

Третий сценарий демонстрирует продолжительную работу сервиса по нагрузкой, превышающей его возможности – 300 запросов равномерно распределяются в 5 секундный интервал, и каждый из них повторяется 5 раз, т.е. каждую секунду в сервис поступает 60 запросов, а так как сервис не может обрабатывать более 200 запросов в один момент времени лишние запросы хранятся в пуле задач и для клиента обрабатываются дольше положенных 5 секунд, в результате клиенты отваливаются по таймауту.

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

Для того чтобы увеличить количество одновременно обрабатываемых запросов, можно увеличить пул потоков jetty-компонента, однако следует помнить что каждый поток в JVM по умолчанию потребляет 1 МБ ОЗУ для хранения стэка, и бесконечно плодить потоки в современном мире Docker-контейнеров и микросервисов невозможно, лимиты по памяти не позволят это сделать. Лучше рассмотрим другой подход в следующем разделе.

AHC-компонент

AHC-компонент - это еще один компонент фреймворка Camel для взаимодействия по HTTP. Основан он на библиотеке AsyncHttpClient, позволяющей реализовывать асинхронное (реактивное) взаимодействие. За счет этого компонента попытаемся добиться реактивной работы сервиса – в обычном синхронном режиме с http-компонентом наши потоки просто стояли и ждали, пока внешний сервис нам ответ, т.е. в пустую тратили 5 секунд времени. В асинхронном же компоненте они сразу после отправки запроса освобождаются и готовы принимать новые запросы, а ответы на эти запросы при их получении обрабатываются другим пулом потоков. Изменения в нашем сервисе будут совсем небольшие:

from("jetty:http://localhost:8080/test")
     .log("receive request body ${body}")
     .removeHeaders("CamelHttp*")
     .to("ahc:http://{{another.url}}")
     .log("finish process body ${body}");

Сценарий, в котором 300 запросов запускаются единовременно выполнился без ошибок. Что уже плюс, так как синхронный http-компонент не мог его вообще осилить. Рассмотрим состояние потоков JVM:

Потоков, если сравнить с предыдущим вариантом тоже сравнительно меньше. Рассмотри результаты других сценариев:

 

Процент ошибок

300 запросов с повтором 5 раз

0%

800 запросов с повтором 5 раз

0%

1200 запросов с повтором 5 раз

1.533%

1600 запросов с повтором 5 раз

15.02%

За счет того, что запросы идут не одновременно, общее количество потоков меньше чем в первом сценарии.

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

Возможные проблемы с AHC-компонентом

Если в сервисе используется динамическое создание AHC-эндпоинтов, то это может случайно выстрелить вам ногу. Рассмотрим пример:

from("jetty:http://localhost:8080/test")
     .log("receive request body ${body}")
     .removeHeaders("CamelHttp*") 
     .setHeader("rand", ()->new Random().nextInt(10000) )
     .toD("ahc:http://{{another.url}}?rand=${headers.rand}")
     .log("finish process body ${body}");

После запуска сценария с единовременным стартом 300 запросов состояние потоков в JVM:

Как видим, поток слишком много. Дело в том, что по умолчанию AHC-компонент для каждого эндпоинта создает свою инстанцию объекта AsyncHttpClient, у каждой из которых свой пул соединений и потоков, в результате для каждого запроса создается по 2 потока – один поток ввода/вывода, другой поток-таймер для контроля таймаутов и поддержания соединений в состоянии KeepAlive. Чтобы этого избежать необходимо настроить инстанцию AsyncHttpClient на уровне компонента, которая будет передаваться в эндпоинт при его создании.

AhcComponent ahc = getContext().getComponent("ahc", AhcComponent.class);
ahc.setClient(new DefaultAsyncHttpClient());

После этого создание множества инстанций AsyncHttpClient’a прекратятся.