Оптимизации в Netty. 10 советов по улучшению производительности

Всем привет. Вот уже третий год работаю с Netty. За 3 года узнал очень многое, даже начал контрибьютить и хочу поделиться советами по тюнингу, так как у себя в проекте я проделывал это довольно часто.

Нетти в топе бенчмарков

Итак, поехали.

1. Нативный epoll транспорт для Linux

Первая и самая мощная оптимизация — это переключение на нативный epoll транспорт под Linux вместо Java реализации. В нетти сделать это довольно просто — достаточно лишь добавить одну зависимость в проект:

<dependency>
   <groupId>io.netty</groupId>
   <artifactId>netty-transport-native-epoll</artifactId>
   <version>${netty.version}</version>
   <classifier>linux-x86_64</classifier>
</dependency>

и автозаменой по коду осуществить замену следующих классов:

  • NioEventLoopGroup → EpollEventLoopGroup
  • NioEventLoop → EpollEventLoop
  • NioServerSocketChannel → EpollServerSocketChannel
  • NioSocketChannel → EpollSocketChannel

В нашем случае мы получили прирост в 30 % сразу после переключения. Детали.

2. Нативный OpenSSL

Безопасность — ключевой фактор для любого коммерческого проекта. Поэтому все, так или иначе, у себя в проектах используют https, ssl/tls. Раньше в java.security пакете все было плохо и, что самое главное, медленно (да и сейчас не намного лучше). Поэтому классический сетап продакшн сервера в яве часто включал в себя nginx, который обрабатывает ssl/tls и отдает дешифрованный трафик уже в конечные приложения. С нетти этого делать не нужно. Так как в нетти есть готовые биндинги на нативные OpenSSL либы.

Более того, нетти предлагает несколько разных реализаций этих биндингов. Мы, например, используем биндинги на boringssl — форк OpenSSL, который был оптимизирован командой из гугла для лучшей производительности.

Для подключения нужно добавить 1 зависимость:

<dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-tcnative-boringssl-static</artifactId>
            <version>${netty.boring.ssl.version}</version>
            <classifier>${epoll.os}</classifier>
        </dependency>

Указать в качестве провайдера SSL — OpenSSL:

return SslContextBuilder.forServer(serverCert, serverKey, serverPass)
                .sslProvider(SslProvider.OPENSSL)
                .build();

Добавить еще один обработчик в pipeline, если еще не добавили:

new SslHandler(engine)

Для нас прирост производительности составил ~15%. Детали.

3. Экономим на системных вызовах

Довольно часто в коде приходится слать несколько сообщений подряд в один и тот же сокет. Например, в нашем случаем — когда пользователь хочет получить последнее состояние с железки при открытии приложения. Выглядеть это может так:

for (PinState pinState : pinStates) {
    ctx.writeAndFlush(pinState);
}

Этот код можно оптимизировать:

for (PinState pinState : pinStates) {
    ctx.write(pinState);
}
ctx.flush();

Во втором случае при write нетти не будет сразу отсылать сообщение по сети, а, обработав в пайплайне, положит его в буфер (в случае если сообщение меньше буфера). Таким образом уменьшая количество системных вызовов для отправки данных по сети.

4. Алоцируем меньше с помощью ByteBuf

Как вы знаете, в нетти есть своя реализация байт буферов. Их можно использовать для повышения производительности вашего приложения, а именно — для снижения количества создаваемых объектов. Например, такой код:

ctx.writeAndFlush(
    new ResponseMessage(messageId, OK)
);

можно ускорить, избежав создания ява объекта и создав буфер вручную без необходимости передавать объект ResponseMessage в конкретный обработчик дальше в пайплайне. Например, так:

ByteBuf buf = ctx.alloc().buffer(3); //direct pooled buffers
buf.writeByte(messageId);
buf.writeShort(OK);
ctx.writeAndFlush(buf);

Итог, вы создаете меньше объектов и таким образом снижаете нагрузку на сборщик (не забываем, что в нетти по умолчанию используются пулы direct буферов).

Pooled Direct Buffer очень оправданы, хоть и увеличивают сложность

5. Переиспользуем ByteBuf

В нашем приложении есть фича — шаринг доступа к железке. Это когда вы можете дать любому человеку доступ к управлению вашим устройством. Например, вы хотите, чтобы ваша жена могла открывать двери гаража с телефона или получать информацию о температуре в доме. В случае температуры для дома — если 2 телефона онлайн — с железки нужно отсылать одинаковое сообщение на оба телефона. Выглядеть это может так:

for (Channel ch : targets) {
   ch.writeAndFlush(hardwareState);
}

Проблема тут в том, что сообщение hardwareState будет обработано в пайплайне для каждого из сокетов. Это можно оптимизировать, создав массив байтов для отправки 1 раз:

ByteBuf msg = makeResponse(hardwareState);
msg.retain(targets.size() - 1);
for (Channel ch : targets) {
   ch.writeAndFlush(msg);
   msg.resetReaderIndex();
}

В коде выше мы создаем один ByteBuf, увеличиваем счетчик ссылок на на этот буфер, чтобы он не был очищен при отправке в первый же сокет и просто обнуляем индекс чтения в нем при записи в каждый новый сокет.

6. ChannelPromise

Так как нетти асинхронна и реактивна, каждая операция записи в сокет возвращает Future. В нетти это специальный расширенный класс — ChannelPromise. Всегда, когда вы используете:

ctx.writeAndFlush(
    response
);

внутри неявно создается новый DefaultChannelPromise. Если результат записи вам не нужен, этого можно избежать, передав существующий объект VoidChannelPromise:

ctx.writeAndFlush(
    response, ctx.voidPromise()
);

Экономя таким образом на создании лишнего объекта, который мы и не используем.

7. @Sharable

Многие хендлеры в нетти не хранят никакого состояния. Такие хендлеры обычно помечены через аннотацию @Sharable. Это означает, что вместо постоянного создания таких хендлеров для каждого соединения:

void initChannel(SocketChannel ch) {
    ch.pipeline().addLast(new HttpServerCodec());
    ch.pipeline().addLast(new SharableHandler());
}

вы можете переиспользовать один и тот же объект (как синглтон):

SharableHandler sharableHandler = new SharableHandler();
...
void initChannel(SocketChannel ch) {
    ch.pipeline().addLast(new HttpServerCodec());
    ch.pipeline().addLast(sharableHandler);
}

Это может быть особенно критично для не keep-alive соединений.

8. Используем контекст

Сразу рассмотрим небольшой пример «плохого» кода:

ctx.channel().writeAndFlush(msg);

Его недостаток в том, что у вас есть в наличии контекст, а значит, вы можете выполнить:

ctx.writeAndFlush(msg);

В первом случае сообщение пройдет от начала пайплайна, во втором — с места обработки текущего запроса. То есть во втором случаем выполняется меньше работы по обработке сообщения, так как сообщение не проходит все обработчики, которые есть в пайплайне.

9. Отключаем Leak Detection

Не все знают, но нетти ВСЕГДА, по умолчанию, использует дополнительные счетчики ссылок на объекты байт буферов (так как в нетти довольно легко выстрелить в ногу и написать код, который течет). Эти счетчики не бесплатны, поэтому для продакшн систем их желательно отключать в коде:

ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.DISABLED);

или через среду переменных:

-Dio.netty.leakDetection.level=DISABLED

10. Переиспользуем пулы событий

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

new ServerBootstrap().group(
    new EpollEventLoopGroup(1), 
    new EpollEventLoopGroup()
).bind(80);
new ServerBootstrap().group(
    new EpollEventLoopGroup(1), 
    new EpollEventLoopGroup()
).bind(443);

Его недостаток в том, что вы создаете больше потоков, чем вам на самом деле нужно. А значит, увеличиваете конкуренцию между потоками и потребляете больше памяти. К счастью, EventLoop можно переиспользовать:

EventLoopGroup boss = new EpollEventLoopGroup(1);
EventLoopGroup workers = new EpollEventLoopGroup();
new ServerBootstrap().group(
boss, 
   workers
).bind(80);
new ServerBootstrap().group(
boss, 
   workers
).bind(443);

Ну вот, собственно, и все. Это, конечно, не все советы. Но, думаю, для большинства проектов этих советов будет более чем достаточно.

О проекте

Наш проект Blynk — IoT-платформа с мобильными приложениями. Текущая нагрузка на систему 11000 рек-сек. 5000 девайсов постоянно в сети. Всего периодически подключается около 40K девайсов. Вся система обходится в 60 $ в мес.

Проект опен сорс. Глянуть можно тут.

Похожие статьи:
Компания ASUS представила новый смартфон, который, вопреки традиции последнего времени, не вошел в ее линейку Zenfone. Модель называется ASUS...
Компанія Stability AI випустила набір великих мовних моделей (LLM) із відкритим кодом під загальною назвою StableLM та оголосила, що вони...
Savvy IT School приглашает на курсы для начинающих программистов по специальности Frontend Developer. Для кого эта программа? Для...
Техногігант Google повідомив, що надає доступ до коду двох своїх технологій з дотримання конфіденційності. Як пише...
34-річний Тарас Покорний з Чернівців. До повномасштабного вторгнення працював Software Development Engineer у компанії DataRobot,...
Яндекс.Метрика