Всем привет. Эта статья продолжение 10к на ядро с конкретными примерами оптимизаций, которые были проделаны для повышения производительности сервера. С написания первой части прошло уже 5 мес и за это время нагрузка на наш продакшн сервер выросла с 500 рек-сек до 2000 с пиками до 5000 рек-сек. Благодаря netty, мы даже не заметили это повышение (разве что место на диске уходит быстрее).
(Не обращайте внимание на пики, это баги при деплое)
Эта статья будет полезна всем тем кто работает с netty или только начинает. Итак, поехали.
Нативный Epoll транспорт для Linux
Одна из ключевых оптимизаций, которую стоит использовать всем — это подключение нативного Epoll транспорта вместо реализации на java. Тем более, что с netty это означает добавить лишь 1 зависимость:
<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
Дело в том, что java реализация для работы с не блокирующими сокетами реализуется через класс Selector, который позволяет вам эффективно работать с множеством соединений, но его реализация на java не самая оптимальная. Сразу по трем причинам:
- Метод selectedKeys() на каждый вызов создает новый HashSet
- Итерация по этому множеству создает iterator
- И ко всему прочему внутри метода selectedKeys() огромное количество блоков синхронизации
В моем конкретном случае я получил прирост производительности около 30%. Конечно же, эта оптимизация возможна только для Linux серверов.
Нативный OpenSSL
Не знаю как на просторах СНГ, но ТАМ — безопасность ключевой фактор для любого проекта. “What about security?” — неминуемый вопрос, который Вам обязательно зададут, если заинтересуются Вашим проектом, системой, сервисом или продуктом.
В аутсорс мире, из которого я пришел, в команде всегда обычно был 1-2 DevOps на которых я всегда мог переложить данный вопрос. Например, вместо добавлять поддержку https, SSL/TLS на уровне приложения, всегда можно было попросить администраторов настроить nginx и с него уже прокидывать обычный http на свой сервер. И быстро и эффективно. Сегодня, когда я и швец и жнец и на дуде игрец — мне все приходится делать самому — заниматься разработкой, деплоить, мониторить. Поэтому подключить https на уровне приложения гораздо быстрее и проще чем разворачивать nginx.
Заставить openSSL работать с netty немного сложнее чем подключить нативный epoll транспорт. Вам понадобится подключить в проект новую зависимость:
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-tcnative</artifactId>
<version>${netty.tcnative.version}</version>
<classifier>linux-x86_64</classifier>
</dependency>
Указать в качестве провайдера SSL — openSSL:
return SslContextBuilder.forServer(serverCert, serverKey, serverPass)
.sslProvider(SslProvider.OPENSSL)
.build();
Добавить еще один обработчик в pipeline:
new SslHandler(engine)
И наконец, собрать нативный код для работы с openSSL на сервере. Инструкция тут. По сути, весь процесс сводится к:
- Выкачать исходники
- mvn clean install
Для меня прирост производительности составил ~15%.
Полный пример можно глянуть тут и тут.
Экономим на системных вызовах
Очень часто приходится отправлять несколько сообщений в один и тот же сокет. Это может выглядеть так:
for (Message msg : messages) {
ctx.writeAndFlush(msg);
}
Этот код можно оптимизировать
for (Message msg : messages) {
ctx.write(msg);
}
ctx.flush();
Во втором случае при write нетти не будет сразу отсылать сообщение по сети, а обработав положит его в буфер (в случае если сообщение меньше буфера). Таким образом уменьшая количество системных вызовов для отправки данных по сети.
Лучшая синхронизация — отсутствие синхронизации.
Как я уже писал в предыдущей статье — netty асинхронный фреймворк с малым количеством потоков обработчиков логики (обычно n core * 2). Поэтому каждый такой поток-обработчик должен выполнятся как можно быстрее. Любого рода синхронизация может этому помешать, особенно при нагрузках в десятки тысяч запросов в секунду.
С этой целью netty каждое новое соединение привязывает к одному и тому же обработчику (потоку) чтобы снизить необходимость кода для синхронизации. Например, если пользователь присоединился к серверу и выполняет некие действия — допустим, изменяет состояние модели, которая связана только с ним, то никакой синхронизации и volatile не нужно. Все сообщения этого пользователя будут обрабатываться одним и тем же потоком. Это отлично и работает для части проектов.
Но что, если состояние может изменятся из нескольких соединений, которые вероятней всего будут привязаны к разным потокам? Например, для случая, когда мы делаем игровую комнату и команда от пользователя должна менять окружающий мир?
Для этого в netty существует метод register, который позволяет перепривязать соединение из одного обработчика к другому.
ChannelFuture cf = ctx.deregister();
cf.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture channelFuture) throws Exception {
targetEventLoop.register(channelFuture.channel()).addListener(completeHandler);
}
});
Этот подход позволяет обрабатывать события для одной игровой комнаты в одном потоке и полностью избавится от сихронизаций и volatile для изменения состояния этой комнаты.
Пример перепривязки на логин в моем коде тут и тут.
Переиспользуем EventLoop
Netty довольно часто выбирают для серверного решения, так как сервера должны поддерживать работу разных протоколов. Например, мое скромное IoT облако поддерживает HTTP/S, WebSockets, SSL/TCP сокеты для разного hardware и собственного бинарного протокола. Это значит, что для каждого из этих протоколов должен быть IO поток (boss group) и потоки обработчики логики (work group). Обычно создание нескольких таких обработчиков выглядит так:
//http server
new ServerBootstrap().group(new EpollEventLoopGroup(1), new EpollEventLoopGroup(workerThreads))
.channel(channelClass)
.childHandler(getHTTPChannelInitializer(())
.bind(80);
//https server
new ServerBootstrap().group(new EpollEventLoopGroup(1), new EpollEventLoopGroup(workerThreads))
.channel(channelClass)
.childHandler(getHTTPSChannelInitializer(())
.bind(443);
Но в случае netty чем меньше лишних потоков вы создаете, тем больше вероятность создать более производительное приложение. К счастью, в netty EventLoop можно переиспользовать:
EventLoopGroup boss = new EpollEventLoopGroup(1);
EventLoopGroup workers = new EpollEventLoopGroup(workerThreads);
//http server
new ServerBootstrap().group(boss, workers)
.channel(channelClass)
.childHandler(getHTTPChannelInitializer(())
.bind(80);
//https server
new ServerBootstrap().group(boss, workers)
.channel(channelClass)
.childHandler(getHTTPSChannelInitializer(())
.bind(443);
Off-heap сообщения
Ни для кого уже не секрет, что для высоконагруженных приложений одним из узких мест является сборщик мусора. Netty быстра, в том числе, как раз за счет повсеместного использования памяти вне java heap. У netty есть даже своя экосистема вокруг off-heap буферов и система обнаружения утечек памяти. Так можете поступить и Вы. Например:
ctx.writeAndFlush(new ResponseMessage(messageId, OK, 0));
изменить на
ByteBuf buf = ctx.alloc().directBuffer(5);
buf.writeByte(messageId);
buf.writeShort(OK);
buf.writeShort(0);
ctx.writeAndFlush(buf);
//buf.release();
В этом случае, правда, Вы должны быть уверены, что один их обработчиков в pipeline освободит этот буфер. Это не значит, что вы должны сразу же бежать и изменять свой код, но про такую возможность оптимизиции Вы должны знать. Несмотря на более сложный код и возможность получить утечку памяти. Для горячих методов это может идеальным решением.
Надеюсь эти простые советы позволят Вам ускорить ваше приложение.
Напомню, что мой проект open-source. Поэтому если Вам интересно как эти оптимизации выглядят в существующем коде — смотрите тут.
Автор: doom369