Як виправити помилку у Node.js і ненавмисно підняти продуктивність в 2 рази

Почалося все з того, що я оптимізував віддачу помилки HTTP 408 Request Timeout в сервері додатків Impress, працюючому на Node.js. Як відомо, у нодовского http.Server є подія timeout, яке повинно викликатися для кожного відкритого сокета, якщо той не закрився за вказаний час. Хочу уточнити, що не для кожного запиту тобто не для кожної події request, функція якого має два аргументи (req, res), а саме для кожного сокета. Через один сокет може послідовно вчинити багато запитів у режимі keep-alive. Якщо ми задаємо це подія, через server.setTimeout(2 * 60 * 1000, function(socket) {… }) то повинні самі знищувати сокет socket.destroy(). Але якщо не встановити свій обробник, то http.Server має вбудований, який знищить сокет через 2 хвилини автоматично. На цьому самому таймауте можна віддати помилку 408 і вважати інцидент вичерпаним. Якби не одне але… З подивом я виявив, що подія timeout викликається і для тих сокетів, які підвисли і для вже отримали відповідь і для закритих клієнтської стороною, взагалі для всіх, що знаходяться в режимі keep-alive. Це дивна поведінка виявилося досить складним, і я розповім про це нижче. Можна було б вставити одну перевірку подія timeout, але зі своїм ідеалізмом я не втримався і поліз виправляти баг на рівень глибше. Виявилося, що в http.Server режим keep-alive реалізований не те що не по RFC, а відверто не дописаний. Замість окремого timeout для з'єднання і окремого keep-alive timeout, там все на одному таймауте, який реалізований на найшвидших псевдо-таймери (enroll/unenroll), але заданий за замовчуванням в 2 хвилини. Це було б не так страшно, якби браузери добре працювали з keep-alive і переиспользовали його ефективно або закривали б невикористовувані з'єднання.

Спочатку результати

Після 12 рядків змін подія timeout почало спрацьовувати тільки коли сервер не віддав відповіді клієнту і клієнт його чекає. Таймаут з'єднання залишився зі значенням за замовчуванням 2 хвилини, але з'явився ще http.Server.keepAliveTimeout зі значенням за замовчуванням 5 секунд (як у Apache). Репозиторій з виправленнями: tshemsedinov/node (для node.js 0.12) і tshemsedinov/io.js (для io.js). Скоро я відправлю пул-реквесты відповідно до joyent/node і nodejs/node (колишній io.js а зараз у ньому вже склеєні проекти).

Суть виправлення в тому, що таймаут з'єднання повинен спрацьовувати якщо з'єднання зависло, залишивши запит без відповіді, а якщо сокет відкрито, але всі запити отвечены, то потрібно чекати набагато менше, даючи можливість прислати ще запит в режимі keep-alive.

Побічний ефект вже можна здогадатися, звільнилося дуже багато пам'яті і дескрипторів сокетів, що відразу викликало у моїх поточних високонавантажених проектах підвищення загальної продуктивності більш ніж в 2 рази. А тут я покажу маленький тест з кодом, результати якого видно на графіках нижче і дає уявлення про те, що відбувається.
Суть тесту: створити 15 тис сполук HTTP/1.1 (які вважаються keep-alive, навіть без спеціальних заголовків) і перевірити інтенсивність створення і закриття сокетів і витрати пам'яті. Тест виконувався 200 секунд, кожні 10 секунд записувалися дані. Графіки зліва — це Node.js 0.2.7 без виправлень, а праворуч — пропатченних і пересобранный Node.js. Синя линяючи — кількість відкритий сокетів, а червона — закриті сокети. Для цього, мені, звичайно ж, довелося записати всі сокети в масив, що не дозволяло повністю звільняти пам'ять. Тому є два варіанти клієнтської частини тесту, з масивом сокетів, та без нього, щоб перевірити пам'ять. Як і очікувалося, що сокети звільняються в 2 рази швидше, а це значить, що вони не займають дескрипторів і не навантажують TCP/IP стек операційної системи, яка крім ноди тримає структури даних і буфери для кожного дескриптора.
Синя линяючи — RSS (resident set size) — скільки займає процес все, червона — heap total — виділена пам'ять для програми, зелена — heap used — використовувана пам'ять. Природно, що вся звільнена пам'ять може переиспользоваться для інших сокетів, ще швидше, ніж при першому виділення.

Код тестів:Клієнтська частина тесту
var net = require('net');
var count = 0;

keepAliveConnect();

function keepAliveConnect() {
var c = net.connect({ port: 80, allowHalfOpen: true }, function() {
c.write('GET / HTTP/1.1\r\nHost: localhost\r\n\r\n');
if (count++ < 15000) keepAliveConnect();
});
}
Серверна частина з лічильниками сокетів
var http = require('http');
var pad = ";
for (var i = 0; i < 10; i++) pad+= '- - - - - - - - - - - - - - - - - ';
var sockets = [];

var server = http.createServer(function (req, res) {
var socket = req.socket;
sockets.push(socket);
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(pad + 'Hello, World\n');
});

setInterval(function() {
var destroyedSockets = 0;
for (var i = 0; i < sockets.length; i++) {
if (sockets[i].destroyed) destroyedSockets++;
}
var m = process.memoryUsage(),
a = [m.rss, m.heapTotal, m.heapUsed, sockets.length, destroyedSockets];
console.log(a.join(','));
}, 10000);

server.listen(80, '127.0.0.1');
Серверна частина без лічильників сокетів
var http = require('http');
var pad = ";
for (var i = 0; i < 10; i++) pad+= '- - - - - - - - - - - - - - - - - ';

var server = http.createServer(function (req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(pad + 'Hello, World\n');
});

setInterval(function() {
var m = process.memoryUsage();
console.log([m.rss, m.heapTotal, m.heapUsed].join(','));
}, 10000);

server.listen(80, '127.0.0.1');

Подробиці проблеми

Якщо клієнтська сторона не запитує keep-alive, то Node.js закриває сокет відразу за викликом res.end() і жодної витоку ресурсів не відбувається. Тому всі тести в яких ми масово робимо http.get('/').on('error', function() {}) або curl domain.com/ або через ab (apache benchmark), показують що все добре. А браузери завжди хочуть keep-alive, з яким погано працюють, як і нода. Проблема keep-alive в тому, що через нього можна відправляти кілька запитів тільки послідовно, в ньому немає пакетного механізму, який би маркував на конкурентних запитів відповідає кожен з відповідей. Згоден, це дико незручно. SPDY і HTTP/2 такої проблеми немає. Коли браузери завантажують сторінку з безліччю ресурсів, то вони іноді використовують keep-alive, але частіше відправляють правильні заголовки, вселяючи сервера, що потрібно тримати відкриті з'єднання, а самі використовують його зовсім мало або взагалі ігнорують, керуючись незрозумілої мені логікою. Ось Firebug і DevTools показують, що запити завершені, а сокети висять. Навіть якщо сторінка вже завантажилася повністю, при цьому було створено кілька сокетів, вони не закриті, і нам потрібно зробити один нещасний запит до API, то мої спостереження показують, що браузери завжди створюють нове підключення, а сокети так і тримають, поки сервер їх не закриє. Такі підвисли сокети і не вважаються паралельними запитами, тому не впливають на обмеження браузерів (я так розумію, що вони маркуються як half-open, що не використовуються і виключаються з лічильника). Це можна перевірити, якщо закрити браузер, то на сервері ноди відразу закриється ціла пачка сокетів, не встигли почекати свої 2 хвилини таймауту.

З боку ноди встановлено таймаут в 2 хвилини, незалежно від того, відправлений відповідь на клієнтську сторону чи ні. Знижувати цей таймаут, наприклад до 5 секунд — не вихід, в результаті будуть обриватися сполуки, які об'єктивно займаю більше, ніж 5 секунд. Потрібен окремий таймаут для keep-alive, відлік якого починається не відразу, а після останньої активності в сокеті, тобто це реальний час очікування чергового запиту від клієнта.

Взагалі, для повної реалізації keep-alive потрібно зробити набагато більше, брати бажаний час таймауту з HTTP заголовків, що надсилаються клієнтом, відправляти клієнту фактичне встановлений час таймауту в заголовках твета, обробляти параметр max і Keep-Alive Extensions. Але сучасні браузери не використовують всі ці речі, у всякому разі, з проведених мною експериментів вони ігнорували ці HTTP заголовки. Тому я заспокоївся малими правками, дали великі результати.

Виправлення в Node.js

Спочатку я вирішив залатати проблему із зайвими таймаутами простим способом, запобігаючи emit події: ae9a1a5. Але для цього довелося ознайомитися з кодом і мені не сподобалося, як він написаний. Місцями є коментарі, що так писати не можна, що великі замикання потрібно декомпозировать, позбутися вкладеності функцій, але ніхто не чіпає ці бібліотеки, тому, що потім тести не збереш і можна зіпсувати безлічі людей весь залежний код. Гаразд, все правити не вийде, але витік інформації не давала мені спокою. І я задумав вирішити проблему, знищуючи сокет після ServerResponse.prototype.detachSocket, коли один res.end() вже послано, але це зламало багато корисної поведінки, пов'язаного з keep-alive: 9d9484b. Після експериментів, читання RFC та документації з інших серверів, стало очевидно, що потрібно реалізовувати keep-alive timeout, і що він відрізняється від просто тайм-аут з'єднання.

Виправлення:
  1. Додано параметр server.keepAliveTimeout, який можна задавати вручну /lib/_http_server.js#L259
  2. Перейменував функцію події prefinish, щоб використовувати її в іншому місці /lib/_http_server.js#L455,L456
  3. Навісив подія finish, щоб зловити момент, коли вже все отвечено. На немає видаляю з EventEmitter обробники, повішені на подію timeout сокета і вещаю подія, що руйнує сокет /lib/_http_server.js#L483,L491
  4. Для сервера https додаємо параметр keepAliveTimeout, тому, що він успадковує все інше з прототипу /lib/https.js#L51
Impress Application Server всі ці зміни реалізовані всередині, у вигляді красивої латочки і ефект доступний навіть без патча на Node.js у його вихідні коди можна подивитися, як це зроблено. Крім цього, на останніх проектах ми домоглися та інших, вражаючих результатів, наприклад, 10 млн постійних з'єднань на 4 серверах, об'єднаних у кластер (2.5 млн на 1 сервер) на базі протоколу SSE (Server-Sent Events), а зараз готуємося зробити те ж саме для вебсокетов. Реалізували прикладну балансування для кластера Impress, зв'язали вузли кластера своїм протоколом на базі TCP, замість використовуваного раніше ZMQ від чого отримали відчутне прискорення. Результати цієї роботи я збираюся частково опублікувати в наступних статтях. Багато говорять мені, що нікому не потрібна ця оптимізація і продуктивність, всім байдуже. Але, як мінімум, на чотирьох живих високонавантажених прикладах, для моїх замовників з КНР і для інтерактивного телевізійного формату «Сьоме почуття», я спостерігаю загальне підвищення продуктивності від 2-3 разів до 1 порядку, а це вже суттєво. Для цього мені довелося відмовитися і від принципу middleware, і переписати межпроцессовое взаємодія, і реалізувати прикладну балансування (апаратні балансировщики не справляються) і т. д. Про це буде окрема стаття, про жахи продуктивності при використанні middleware: «Що нода дала, то middleware забрав». Для чого я вже підготував достатньо фактів, статистики і прикладів, і маю що запропонувати натомість.

А хочете все і відразу, прямо зараз?

Тоді потрібно протестувати ось таку латку і не на базі свого білду, а показати її вплив на офіційну версію Node.js 0.12.7. Зараз перевіримо, що буде, якщо додати на подію request додаткових 7 рядків коду. Сокети будуть закриватися як потрібно і навіть помилка з гаком подією timeout теж зникає, це зрозуміло. А от з пам'яттю, ситуація звичайно значно краще, але не настільки, як це при пересборке Node.js.
http.createServer(function (req, res) {
var socket = req.socket;
res.on('finish', function() {
socket.removeAllListeners('timeout');
socket.setTimeout(5000, function() {
socket.destroy();
});
});
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello, World\n');
});

Порівняємо результати на графіках: зліва — початковий стан Node.js 0.12.7, посередині — додавання 7 рядків у request і запущено на офіційному 0.12.7, праворуч — пропатченних Node.js з мого репозиторію. Причини цього зрозумілі, я склонировал не 0.12.7, а трохи більш нову версію і від неї відштовхнувся. Звичайно, всі тести крім останнього проведені на моєму репозиторії, з патчем і без патча. А останній тест я порівняв з офіційною версією 0.12.7, щоб було зрозуміло, як це вплине на Ваш код вже зараз.
Версія V8 в моєму репозиторії така ж, як і в 0.12.7, але очевидно, що в ноді трапилися оптимізації. Якщо почекати зовсім небагато, то можна буде користуватися або наведеної вище латкою або виправлення потраплять в ноду. Результати цих двох варіантів майже збігаються. Взагалі, я збираюся і далі займатися експериментами і оптимізацією в цьому напрямку, а якщо у Вас будуть ідеї, то прошу — не соромтеся пропонувати і підключатися до приведення коду самих критичних вбудованих бібліотек ноди в пристойний вигляд. Повірте, там багато роботи для фахівця будь-якого рівня. Крім того, вивчення джерел — це найкращий з відомих мені способів освоєння платформи.

Джерело: Хабрахабр

0 коментарів

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