Почему мы выбрали Node.js в 2011 году
В ноябре 2011 у нас была конкретная проблема: BI-дашборд, опрашивающий сервер каждые 5 секунд от 150+ одновременных пользователей. PHP-бэкенд генерировал 1800+ запросов к базе данных в минуту, большинство возвращало идентичные данные. Нагрузка на сервер была 70% в рабочее время. Решение выглядело как кэширование — но клиенту нужны были свежие данные в течение 10 секунд после закрытия сделки.
Мы могли добавить Memcached. Мы могли настроить MySQL. Мы выбрали Node.js.
Вот точное рассуждение, которое мы использовали, включая внутренние возражения.
Техническая проблема
Наш дашборд был циклом опроса:
// Клиентская сторона (jQuery) — что у нас было
setInterval(function() {
$.get('/api/dashboard-data', function(data) {
updateCharts(data);
});
}, 5000);
150 пользователей × опрос каждые 5 секунд = 30 запросов/сек к PHP-бэкенду. Каждый запрос: PHP-FPM порождал воркера, тот подключался к MySQL, выполнял 4-6 запросов, возвращал JSON.
Сами запросы были быстрыми — менее 20мс каждый. Накладные расходы — управление процессами PHP-FPM + накладные расходы соединения MySQL × 30 запросов/сек.
Данные на дашборде менялись когда закрывалась сделка — примерно 20-40 раз в час в рабочее время. Значит, 98% из тех 1800 запросов в минуту возвращали те же данные, что были возвращены 5 секунд назад.
Фундаментальное несоответствие: pull-модель для данных, которые менялись всего 30-40 раз в час.
Почему Node.js подходил для этой задачи
1. WebSockets делали архитектуру правильной.
Вместо того чтобы 150 клиентов спрашивали «есть новые данные?» каждые 5 секунд, сервер сам сообщал клиентам когда появлялись новые данные. Push вместо pull. WebSockets давали постоянное соединение; Socket.io 0.9 обрабатывал совместимость с браузерами и переподключение.
Новая архитектура:
- Одно WebSocket-соединение на пользователя: 150 соединений всего
- Сервер отправляет обновление всем клиентам при изменении данных: 30-40 раз/час
- Запросы MySQL: 30-40 в час вместо 1800 в минуту
Улучшение соотношения: в 2700 раз меньше запросов к базе данных при реальной нагрузке.
2. Node.js эффективно обрабатывает постоянные соединения.
150 открытых WebSocket-соединений с одним процессом. В Apache/PHP 150 одновременных соединений означали до 150 PHP-воркеров, каждый занимал 2-8МБ стека. В Node.js 150 соединений обрабатывает один event loop — менее мегабайта накладных расходов на соединение.
Внутренние возражения
Мы были PHP-командой. Никто не выпускал Node.js в production. Возражения были реальными:
Возражение 1: «Node.js не готов к production»
Версия 0.6 была первым релизом с заявленной гарантией стабильности. Аргумент против: даже в 0.6 были отчёты об утечках памяти, модель процесса (перезапуск при падении) ещё развивалась.
Наш ответ: запустим Node.js за nginx, с PM2 как менеджером процессов. Если Node упадёт, PM2 перезапустит его через 1-2 секунды. Дашборд, ненадолго ставший пустым — приемлемо для BI-инструмента. Неприемлемо для оформления заказа.
Мы намеренно выбирали первый деплой с низкими ставками: дашборд, не транзакционный эндпоинт. Если Node.js нас подведёт, пользователи увидят пустой график, а не потеряют покупку.
Возражение 2: «Никто в команде не знает Node.js»
Правда. Мы выделили одного разработчика учиться во время разработки. Кривая обучения была ниже ожидаемой: JavaScript мы уже знали; новой ментальной моделью был event loop и асинхронный I/O. Две недели вечеров и выходных.
Настоящий риск был не в изучении Node.js — а в отладке незнакомой среды выполнения в production. Мы смягчили это обильным логированием:
var logger = new winston.Logger({
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: '/var/log/dashboard.log' })
]
});
io.on('connection', function(socket) {
logger.info('Клиент подключён', { id: socket.id, timestamp: new Date().toISOString() });
});
function broadcastUpdate(section, data) {
logger.info('Broadcast обновления', {
section: section,
clients: io.sockets.clients(section).length
});
io.to('dashboard:' + section).emit('update', data);
}
Возражение 3: «PHP работает нормально, зачем рисковать?»
Это было самым тяжёлым возражением, потому что оно не было неправильным. PHP бы справился. Memcached + PHP + опрос раз в 10 секунд снизил бы нагрузку на 50%. Это был проект на 2 дня против 3-недельного build на Node.js.
Наш ответ был ориентирован на будущее: у нас было ещё два клиентских проекта с реал-тайм функциями. Если мы проверим Node.js здесь, у тех проектов будет проверенная среда выполнения. 3 недели — это инвестиция в возможности, а не просто решение одной проблемы.
Реализация: что мы построили
var pool = mysql.createPool({ host: 'db.internal', connectionLimit: 5 });
// Опрос MySQL на изменения — только бродкастим когда данные реально изменились
var lastDataHash = {};
setInterval(function() {
['sales', 'agents', 'recent'].forEach(function(section) {
getDashboardData(section, function(err, data) {
if (err) return;
var hash = JSON.stringify(data);
if (hash !== lastDataHash[section]) {
lastDataHash[section] = hash;
socketServer.to('dashboard:' + section).emit('update', data);
}
});
});
}, 3000);
Мы всё ещё опрашивали MySQL каждые 3 секунды — в 2011 не было уведомлений об изменениях MySQL. Но вместо 150 клиентов, опрашивающих PHP каждые 5 секунд (750 PHP-запросов/минуту), у нас был 1 Node.js-процесс, опрашивающий MySQL каждые 3 секунды (9 запросов/минуту) и рассылающий всем подключённым клиентам.
Результаты через 60 дней
| Метрика | До (PHP polling) | После (Node.js WebSockets) |
|---|---|---|
| Запросов MySQL/час при 150 пользователях | ~108 000 | ~540 |
| CPU сервера (рабочее время) | 68-74% | 8-12% |
| Задержка обновления дашборда | 0–5 секунд | <0.5 секунды |
| Память (серверный процесс) | 350-480МБ (PHP-FPM воркеры) | 42МБ (Node.js) |
| Инцидентов за 60 дней | — | 1 (OOM через 18 дней) |
Один инцидент: утечка памяти в объекте lastDataHash. Мы добавляли в него новые сравнения без очистки старых. Исправлено за 20 минут, 2-секундный downtime. PM2 автоперезапустился раньше, чем нас успели оповестить.
Что бы мы сделали иначе
Мы бы всё равно выбрали Node.js. Добавили бы:
--max-old-space-size=256для ограничения heap с самого начала- Профилирование heap с первого дня
- Автоперезапуск PM2 при превышении памяти:
max_memory_restart: '200M' - Redis для состояния вместо in-process хеша
Решение было правильным. Реализация имела утечку памяти, проявившуюся через 18 дней. Оба утверждения верны.
Более широкий урок: новая технология в production всегда рискованнее, чем ты оцениваешь. Смягчение — не избегать её, а выбирать первые деплои с низкими ставками, чрезмерно инструментировать и иметь путь отката. У нас было всё три. Инцидент был управляемым.