Разработка высокопроизводительных сервисов на Node.js

This post is also available in: Английский


В данной статье речь пойдет о разработке с использованием сравнительно молодой технологии Node.js. Она стремительно набирает популярность — и не безосновательно. Эта технология позволяет реализовывать быстрые веб-сервисы, способные выдерживать колоссальные нагрузки, что крайне важно для видеосервисов. Например, на Node.js удобно делать сборщики статистики в реальном времени, масштабные чаты и т.п. Конечно, сервисы, написанные на C++, обладают большим потенциалом производительности, но стоимость их реализации слишком высока. Нода же позволяет быстро создавать сложные системы. В данной статье мы рассмотрим использование Node.js для создания простого приложения, способного выдерживать очень высокую нагрузку.

Особенности разработки на Node.js

В основе этой технологии лежит язык JavaScript, что создает свои особенности и парадигмы разработки. В первую очередь следует иметь в виду, что в Node.js функции базовых модулей асинхронны. Это означает, что функции ноды не блокируют поток, а исполняются в фоновом режиме. Отсюда сразу возникает понятие callback-функции — процедуры, которая выполняется после завершения исполнения кода в потоке. Node.js прекрасно показывает себя в задачах с большим числом операций ввода/вывода. Например чтение файлов, запись в БД.

Установка

Чтобы начать использование Node.js, нужно ее сначала установить. Пожалуй, в любом современном дистрибутиве Linux можно найти нужные пакеты. При использовании Debian или Ubuntu для установки достаточно выполнить следующие команды:

sudo aptitude update
sudo aptitude install node

Модульная структура

Еще одним преимуществом Ноды является наличие множества модулей на все случаи жизни. Модули играют ту же роль, что и библиотеки в других языках. Некоторые модули уже включены в установку Node.js, другие нужно инсталлировать отдельно. Для этого существует специальная утилита — npm:

npm install

Модули могут быть установлены как локально (только для текущего проекта), так и глобально (для всех сразу).
Настоятельно рекомендуем производить исключительно локальную установку модулей, т.к. они активно развиваются и совместимость между версиями часто при этом теряется.

Первое приложение

Не будем оригинальны и просто поприветствуем мир в своей первой ноде. Для этого создадим новый файл index.js. В него внесем следующий код:

console.log('Hello, World!');

Для выполнения скрипта достаточно затем запустить этот файл командой:

node index.js

Усложняем

Конечно, ‘Hello, World’ — это прекрасно, — но хочется чего-то большего.
Лучше всего — создать настоящий highload проект. Чат отлично подходит для этих целей.

Для начала выберем стек технологий, на основе которых мы напишем систему обмена сообщениями.
Работа Node.js требует отдельного хранилища для сообщений. Для этого отлично подойдет Redis. Данное хранилище хорошо еще и тем, что имеет встроенный механизм времени жизни коллекций. Его мы будем использовать для своевременной очистки истории. Кроме того, механизм Pub/Sub пригодится нам непосредственно для мгновенной доставки сообщений.

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

var webSocketServer = require('websocket').server;

Заставляем ноду слушать подключения:

var http = require('http');

var server = http.createServer();

server.listen(config.port, function() {
    console.log('Chat started on port ' + config.port);
});

var wsServer = new webSocketServer({
    httpServer: server,
    autoAcceptConnections:false
});

wsServer.on('request', function(req) {
    var connection = req.accept();

    connection.on('open', function(con) {

    });

    connection.on('message', function(message) {

    });

    connection.on('close', function() {

    });
});

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

По приходу сообщения в чат необходимо:
1) сохранить его в историю
2) передать другим пользователям

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

Масштабируемость?

Сервисы Node.js имеют отличную особенность — их можно запускать кластерно, сразу несколько процессов. Каждый из них занимает ядро процессора. Для того, чтобы задействовать данную особенность, достаточно использовать встроенный модуль cluster.

var cluster = require('cluster');
var numCPUs = require('os').cpus().length;

cluster.setupMaster({
    exec:'./chat.js'
});

for (var i = 0; i < numCPUs; i++) {
    cluster.fork();
}

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

Решение, тем не менее, есть. Это механизм Redis Pub/Sub. Заключается он в том, что мы создаем в хранилище канал и подписываемся на него. Теперь по приходу сообщения в канал на всех нодах-подписчиках вызовется callback-метод, который разошлет пользователям сообщения. Как вы уже поняли, сервис Node.js является транспортом для Redis, т.к. всего лишь сохраняет в него сообщения и рассылает их пользователям.

Несмотря на то, что Redis работает быстро, все равно может возникнуть ситуация, когда он станет «бутылочным горлышком» в нашей системе. Чтобы этого не случилось, задействуем механизм hashring.

Суть hashring заключается в том, что несколько инстансов Redis объединяются в единую систему и коллекции распределяются между ними. Hashring знает местонахождение каждой коллекции, так что нам достаточно отправить на hashring запрос, и в ответ мы получим ссылку на нужный нам Redis.

Еще про работу с Redis из Node.js

Помните, что в Node.js код выполняется асинхронно? Так вот: это создает определенные особенности по работе с хранилищем данных.

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

В таком случае можно использовать такие команды как zadd, incr и decr. Они позволяют менять коллекции без считывания данных из их, что сводит число коллизий на нет.

Разработка протокола обмена сообщениями.

Если вы хотите расширить возможности своего чата, то вы должны не просто обеспечить обмен сообщениями между клиентами, но и активно взаимодействовать с ними через служебные сообщения. Для этого необходимо разработать протокол обмена. За его основу стоит взять простой способ записи объектов — JSON. Теперь вместо простого текста мы можем слать объекты, содержащие в себе различные поля. К примеру, добавив поле type, можно организовать обработку различных служебных сообщений от сервера. Здесь все ограничивается только вашей фантазией.

Очистка коллекций Redis

Рано или поздно Вы столкнетесь с проблемой, что коллекции в Redis растут. Количество неактуальных данных будет постоянно увеличиваться. К примеру навряд ли кто-то захочет просматривать сообщения чата недельной давности.

Конечно, можно чистить сообщения как самой нодой чата (это лишняя нагрузка на нее), так и сторонним сервисом (нагрузка + время на его реализацию). Однако Redis предоставляет инструмент установки времени жизни коллекции посредством команды expire. К указанному времени коллекция полностью удалится, что уничтожит все сообщения. Чтобы этого не произошло, необходимо разделять историю по коллекциям с дискретизацией по времени. Допустим, с шагом в 5 минут. Идентификаторы коллекций будут отличаться суффиксом равным времени, ближайшему к текущему и нацело делящемуся на шаг дискретизации. Каждой этой коллекции уже устанавливается время жизни. Теперь сообщения будут удаляться не все сразу, а небольшими группами; при этом актуальные всегда будут присутствовать в хранилище.

Почему это Highload?

Итак, написать — это половина дела. Теперь нужно убедиться, что сервис действительно эффективно выдерживает нагрузку.

В своих проектах на вебсокетах мы используем удобный бенчмарк wsbench. Он позволяет эмпирическим путем измерить стойкость сервиса к нагрузке. С помощью него можно создавать подключения к сервису, рассылать сообщения. (Подробнее читайте на странице бенчмарка.)

Wsbench поможет нам измерить следующие показатели сервиса:

  1. максимальное колличество подключений,
  2. требуемый объем оперативной памяти,
  3. время отклика системы на приходящее сообщение,
  4. способность держать нагрузку.

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

1) Для определения максимального числа пользователей необходимо просто попытаться инициировать их с помощью бенчмарка. В нем можно задать число создаваемых пользователей и скорость наращивания объема подключений к сервису. Как показала практика, здесь важен процессор сервера. 4-ядерный Intel Core i5 2.5 GHz держал до 30.000 одновременных подключений. И это притом, что сервис был запущен в виртуальной машине!

2) Для определения требуемого объема оперативной памяти подключаем 10000 пользователей к сервису и измеряем занятый объем до и после этой процедуры. Затем делим разность этих двух значений на число подключений. Затем спокойно умножаем это значение на 2 — ваш сервис должен не только «держать» пользователей, но и рассылать им сообщения.  Сервисы на Node.js очень любят использовать много оперативной памяти, поэтому не жалейте ее. При нехватке памяти начнется процесс своппинга, что серьезно замедлит работу Node.js. Значение 1-2 МБ на 1 пользователя не должно вас смущать.

3) Для определения времени отклика поднимем 1 инстанс сервиса, создадим максимально возможное число подключений, отправим в канал Redis 1 сообщение, засечем время отправки и сравним его со временем рассылки всем пользователям.

4) Чтобы оценить общую способность сервиса к удерживанию нагрузки, нужно создать максимальное число подключений к Node.js и заставить их интенсивно обмениваться сообщениями. Пожалуй, раз в 5 секунд будет достаточно. Параллельно подключаем 1 реального пользователя к сервису, и смотрим на появления новых сообщений в браузере. Тут важно ограничить объем отправляемых сообщений, т.к. оперировать большими тестами сервис не должен. На указанном в п.1 процессоре с длинной сообщения 140 символов поток в 6000 сообщений в секунду легко  обрабатывался сервисом. Правда, у реального клиента зависал браузер, но скорее всего такой нагрузки в одной комнате чата не будет. (Операции по добавлению сообщений в DOM не быстры. Подробнее читайте в статье «Оптимизация HTML5 приложений».)

Заключение

В данной статье описаны основные подходы к реализации highload-приложений на Node.js в связке с Redis. Следуя им, Вы без труда построите нагрузостойкий сервис. На Github выложен готовый чат, работающий по приведенным в статье принципам.

DChat Demo

Полезные ссылки