В данной статье производится сравнение работы простейших сервисов реализованных с помощью фреймворка 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 прекратятся.
sshikov
Одно мелкое замечание — указывайте версию Camel, с которой работаете. Их много разных, Camel очень древний уже проект, и у кого-то вполне может быть более старая, чем у вас. И в любом случае, пост могут найти поиском и прочитать через несколько лет после публикации.
P.S. Версия Java кстати тоже не помешает. Много потоков, много создаваемых объектов, влияние GC вполне может быть сильным. Не говоря уже про то, что если вы что-то меряете — то потребление ресурсов, таких как память и процессор, тоже интересно было бы поглядеть. Поэтому — как можно более полное описание тестового стенда :)