Pull to refresh

Простой и быстрый фреймворк для стресс-тестирования приложений

Reading time 4 min
Views 13K
[ english version ]

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

Поэтому было решено набросать свой, ведь это всего 3-5 классиков в данном случае. Основные требования: быстрота и динамическая генерация запросов. При этом быстрота это не просто тысячи RPS, а в идеале — когда стресс упирается только в пропускную способность сети и работает с любой свободной машины.

Движок


С требованиями ясно, теперь нужно решить на чем это все будет работать, т.е. какой http/tcp клиент использовать. Конечно мы не хотим использовать устаревшую модель thread-per-connection (нить на соединение), потому что сразу упремся в несколько тысяч rps в зависимости от мощности машины и быстроты переключения контекстов в jvm. Т.о. apache-http-client и им подобные отметаются. Здесь надо смотреть на т.н. неблокирующие сетевые клиенты, построенные на NIO.

К счастью в java мире в этой нише давно присутствует стандарт де-факто опенсорсный Netty, который к тому очень универсален и низкоуровневый, позволяет работать с tcp и udp.

Архитектура


Для создания своего отправщика нам понадобится ChannelUpstreamHandler обработчик в терминах Netty, из которого и будет посылать наши запросы.

Далее нужно выбрать высокопроизводительный таймер для отправки максимально возможного количества запросов в секунду (rps). Здесь можно взять стандартный ScheduledExecutorService, он в принципе с этим справляется, однако на слабых машинах лучше использовать HashedWheelTimer (входит в состав Netty) из-за меньших накладных расходов при добавлении задач, только требует некоторого тюнинга. На мощных машинах между ними практически нет разницы.

И последнее, чтобы выжать максимум rps с любой машины, когда неизвестны какие лимиты по соединениям в данной ОСи или общая текущая нагрузка, надежней всего воспользоваться методом проб и ошибок: задать сначала какое-нибудь запредельное значение, например миллион запросов в секунду и далее ждать на каком количестве соединений начнутся ошибки при создании новых. Опыты показали что предельное количество rps обычно чуть поменьше этой цифры.
Т.е. берем эту цифру за начальное значение rps и потом если ошибки повторяются уменьшаем ее на 10-20%.

Реализация

Генерация запросов


Для поддержки динамической генерации запросов создаем интерфейс с единственный методом, который наш стресс будет вызывать чтобы получать содержимое очередного запроса:
public interface RequestSource {
    /**
     * @return request contents
     */
    ChannelBuffer next();
}


ChannelBuffer — это абстракция потока байтов в Netty, т.е. здесь должно возвращаться все содержимое запроса в виде потока байт. В случае http и других текстовых протоколов — это просто байтовое представление строки (текста) запроса.
Также в случае http необходимо ставить 2 символа новой строки в конце запроса(\n\n), это является признаком конца запроса и для Netty (не пошлет запрос в противном случае)

Отправка

Чтобы отправлять запросы в Netty — сначала нужно явно подключиться к удаленному серверу, поэтому на старте клиента запускаем периодические подключения с частотой в соответствие с текущим rps:
scheduler.startAtFixedRate(new Runnable() {
    @Overrid
    public void run() {
       try {
            ChannelFuture future = bootstrap.connect(addr);
            connected.incrementAndGet();
        } catch (ChannelException e) {
            if (e.getCause() instanceof SocketException) {
                processLimitErrors();            
            }
            ...
        }, rpsRate);


После успешного подключения, сразу посылаем сам запрос, поэтому наш Netty обработчик удобно будет наследовать от SimpleChannelUpstreamHandler где для этого есть специальный метод. Но есть один нюанс: новое подключение обрабатывается т.н. главном потоке («boss»), где не должны присутствовать долгие операции, чем может являться генерация нового запроса, поэтому придется перекладывать в другой поток, в итоге сама отправка запроса будет выглядеть примерно так:

private class StressClientHandler extends SimpleChannelUpstreamHandler {        
        ....
        @Override
        public void channelConnected(ChannelHandlerContext ctx, final ChannelStateEvent e) throws Exception {
            ...
            requestExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    e.getChannel().write(requestSource.next());
                }
            });
            ....
        }
    }

Обработка ошибок

Далее — обработка ошибок создания новых соединений когда текущая частота отправки запросов слишком большая. И это самая нетривиальная часть, вернее сложно сделать это платформонезависимо, т.к. разные операционные системы ведут себя по разному в этой ситуации. Например linux выкидывает BindException, windows — ConnectException, а MacOS X — либо одно из этих, либо вообще InternalError (Too many open files). Т.о. на мак-оси стресс ведет себя наиболее непредсказуемо.

В связи с этим, кроме обработки ошибок при подключении, в нашем обработчике тоже необходимо это делать (попутно подсчитывая количество ошибок для статистики):
private class StressClientHandler extends SimpleChannelUpstreamHandler {        
        ....
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception {
            e.getChannel().close();

            Throwable exc = e.getCause();
            ...
            if (exc instanceof BindException) {
                be.incrementAndGet();
                processLimitErrors();
            } else if (exc instanceof ConnectException) {
                ce.incrementAndGet();
                processLimitErrors();
            } 
            ...
        }
            ....
   }

Ответы сервера

Напоследок, надо решить что будем делать с ответами от сервера. Поскольку это стресс тест и нам важна только пропускная способность, здесь остается только считать статистику:

private class StressClientHandler extends SimpleChannelUpstreamHandler {
  @Override
        public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception {
            ...
            ChannelBuffer resp = (ChannelBuffer) e.getMessage();
            received.incrementAndGet();
            ...
        }
}


Здесь же может быть и подсчет типов http ответов (4xx, 2xx)
Весь код

Весь код с дополнительными плюшками вроде чтения http шаблонов из файлов, шаблонизатором, таймаутами и тп. лежит в виде готового maven проекта на GitHub (ultimate-stress). Там же можно скачать готовый дистрибутив (jar файл).

Выводы


Все конечно упирается в лимит открытых соединений. Например на linux при увеличении некоторых настроек ОС (ulimit и т.п.), на локальной машине удавалось добиться около 30K rps, на современном железе. Теоритечески кроме лимита соединений и сети больше ограничений быть не должно, на практике все же накладные расходы jvm дают о себе знать и фактический rps на 20-30% меньше заданного.
Only registered users can participate in poll. Log in, please.
Что вы используете для стресс-тестирования java приложений?
5.61% apache http client 6
56.07% jmeter 60
7.48% tsung 8
6.54% siege 7
12.15% apache ab 13
5.61% какой-либо опенсорсный 6
14.95% самописный 16
7.48% soapUI/loadUI 8
11.21% Yandex.Tank 12
4.67% Gatling Tool 5
107 users voted. 119 users abstained.
Only registered users can participate in poll. Log in, please.
Какую нагрузку удавалось создать (rps)?
20.83% <1K 15
16.67% 1-5K 12
22.22% 5-10K 16
11.11% 10-20K 8
12.5% 20-50K 9
11.11% 50-100K 8
18.06% >100K 13
72 users voted. 131 users abstained.
Tags:
Hubs:
+4
Comments 21
Comments Comments 21

Articles