Оптимизации в 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 $ в мес.
Проект опен сорс. Глянуть можно тут.