Фреймворк Camel: сравнение компонентов HTTP и AHC
В данной статье производится сравнение работы простейших сервисов реализованных с помощью фреймворка 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:
200 одновременных запросов
300 одновременных запросов
300 запросов равномерно распределенных в течении 5 секунд, с повтором 5 раз
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 прекратятся.