Топ 6 оптимізацій для netty

Всім привіт. Ця стаття продовження 10к на ядро з конкретними прикладами оптимізацій, які були виконані для підвищення продуктивності сервера. З написання першої частини минуло вже 5 місяців і за цей час навантаження на наш продакшн сервер зросла з 500 річок-сек до 2000 з піками до 5000 річок-с. Завдяки netty, ми навіть не помітили це підвищення (хіба що місце на диску йде швидше).

Blynk load
(Не звертайте увагу на піки, це баги при деплое)

Ця стаття буде корисна всім тим хто працює з 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 install clean
Для мене приріст продуктивності склав ~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. Тому якщо Вам цікаво як ці оптимізації виглядають в існуючому коді — дивіться тут.

Джерело: Хабрахабр
  • avatar
  • 0

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.