WebSocket-соединения в Node.js — это мина под performance вашего сервера. Казалось бы, простая подписка на события, переподключение при падении соединения, обработка данных. Но где-то между client.on('error') и рекурсивным вызовом функции подключения память начинает расти как на дрожжах. За пару часов работы 90% оперативки занято, приложение тупит, а в логах весело мигает предупреждение про EventEmitter.
Проблема не в самом Node.js и даже не в WebSocket. Проблема в том, как мы обрабатываем события. EventEmitter держит все подписчики в памяти, пока они не удалены явно. И если вы забыли про removeAllListeners() в нужном месте — поздравляем, у вас классическая утечка памяти. Разберёмся, почему это происходит и как это вообще предотвратить.
Как EventEmitter тихо душит вашу память
На первый взгляд код выглядит безобидно. Есть функция переподключения, которая срабатывает каждую секунду, если соединение упало. Тайм-аут, снова попытка, и так по кругу. Но вот что происходит под капотом: каждый раз, когда вы вызываете client.on('error', callback), вы добавляете новый обработчик в внутренний массив слушателей объекта client.
Этот массив живёт столько же, сколько живёт сам объект. И если объект долго живущий (например, socket, который переиспользуется для переподключений), то все эти обработчики накапливаются в памяти как снежный ком. Первая итерация — один обработчик, вторая — два, третья — три. Через час таких переподключений у вас уже тысячи мёртвых обработчиков.
Вот типичный сценарий:
var net = require('net');
const client = new net.Socket();
let _connect_ = function () {
client.connect(port, host, function (err) {
if (err) {
console.log("error");
console.log(err);
} else {
console.log("connected");
}
});
client.on('error', function () {
console.log("connection error");
setTimeout(function(){_connect_();}, 1000);
});
};
_connect_();
Выглядит нормально, не так ли? Но нет. Каждый вызов _connect_() добавляет новый обработчик 'error'. Если сервер падает часто, обработчиков станет столько же, сколько попыток переподключения. И память будет расти линейно, пока не станет критичной.
Проблема здесь двойная: во-первых, обработчики накапливаются. Во-вторых, если вы добавили ещё и обработчики на 'data', 'close', 'timeout' — их количество растёт кратно. Пять событий по тысяче обработчиков каждое — это уже 5000 функций в памяти, которые никогда не будут вызваны.
Когда Node.js начинает кричать о проблеме
Node.js имеет встроенный детектор утечек памяти через EventEmitter. По умолчанию максимальное количество обработчиков на один объект — 10. Как только вы превышаете этот лимит, система выплёвывает вот такое предупреждение:
(node) warning: possible EventEmitter memory leak detected. 11 listeners added.
Use emitter.setMaxListeners() to increase limit.
Звучит как помощь, но это ловушка. Новички видят это предупреждение, гуглят и находят совет: просто вызови setMaxListeners() и забей. Вот так:
client.setMaxListeners(100);
Ой, проблема решена! Предупреждение исчезло. Но утечка-то остаётся. Вы просто заткнули рот детектору, но инструмент продолжает жрать память так же интенсивно. Это как отключить датчик масла в двигателе машины — проблема не исчезла, просто вы её не видите.
Предупреждение — это сигнал, что что-то не так с вашей архитектурой. А не сигнал к тому, чтобы увеличить лимит:
- Предупреждение означает, что вы привязали коротко живущие объекты к долго живущим.
- Это признак того, что обработчики где-то не удаляются после использования.
- Это подсказка, что нужно переписать логику переподключения или управления lifecycle’ом.
Как правильно управлять переподключениями
Решение простое — очищайте слушателей перед добавлением новых. Используйте removeAllListeners() перед тем, как снова вызывать on():
var net = require('net');
const client = new net.Socket();
let _connect_ = function () {
client.connect(port, host, function (err) {
if (err) {
console.log("error");
console.log(err);
} else {
console.log("connected");
}
});
client.on('error', function () {
console.log("connection error");
reconnect();
});
};
function reconnect() {
client.removeAllListeners();
client.on('error', function () {
console.log("connection error");
});
setTimeout(function(){_connect_();}, 1000);
}
_connect_();
Теперь каждый раз, когда вы переподключаетесь, все старые обработчики удаляются. Масив слушателей очищается, и память не растёт бесконечно.
Лучший подход — минимизировать переиспользование объектов. Вот альтернатива:
- Создавайте новый socket для каждого переподключения вместо переиспользования старого.
- Явно закрывайте соединение перед новой попыткой:
client.destroy().
- Используйте паттерн с Promise или async/await, где lifetime обработчиков ограничен одной операцией.
async function connectWithRetry(host, port, maxRetries = 5) {
for (let i = 0; i < maxRetries; i++) {
try {
return await new Promise((resolve, reject) => {
const socket = new net.Socket();
const timeout = setTimeout(() => {
socket.destroy();
reject(new Error('Connection timeout'));
}, 5000);
socket.once('connect', () => {
clearTimeout(timeout);
resolve(socket);
});
socket.once('error', reject);
socket.connect(port, host);
});
} catch (err) {
if (i === maxRetries - 1) throw err;
await new Promise(r => setTimeout(r, 1000));
}
}
}
Здесь каждая попытка создаёт новый socket и использует once() вместо on(). Это автоматически удаляет обработчик после первого срабатывания. Нет накопления, нет утечек.
Что ещё создаёт скрытые утечки
EventEmitter-утечки — это не единственная проблема. Есть и другие подводные камни:
Забытые таймауты и интервалы. Если вы создаёте setInterval() или setTimeout() в обработчике события, но забываете их очищать, они тоже накапливаются в памяти. Каждый таймер — это ссылка на функцию, которая удерживает scope в памяти.
Замыкания с большими объектами. Если ваш обработчик события сохраняет в замыкании большой объект (например, весь запрос или весь буфер данных), этот объект не будет освобождён, пока жив обработчик.
Неудалённые слушатели в цепи. Если вы привязываете слушателей к слушателям (слушатель добавляет нового слушателя), и это происходит во вложенном коде, получится пирамида утечек.
Socket-ы без явного закрытия. Если не вызвать socket.destroy() или socket.end() при обработке timeout-события, сокет останется в памяти в полу-мёртвом состоянии.
Как отследить эти утечки? Есть несколько способов:
- Heapdump. Модуль, который делает снимок памяти Node.js и позволяет анализировать его в Chrome DevTools. Можно сравнить два снимка и увидеть, какие объекты растут.
- Node.js --inspect. Встроенный инструмент для профилирования. Запустите приложение с флагом
--inspect и подключитесь к inspector’у.
- Простой мониторинг. Выводите
process.memoryUsage() каждые 5 секунд. Если числа растут монотонно и никогда не падают — утечка.
За пределами EventEmitter
Меморная оптимизация WebSocket-соединений в Node.js — это целая область. Вы можете глубже упасть в кроличью нору и наткнуться на проблемы типа утечек в буферах данных, неправильного управления потоками (streams), или неудачной компиляции V8. Но если вы начали с проблемы расхода памяти — 90% вероятности, что виноват именно EventEmitter и забытые обработчики.
Суть простая: каждый on() должен иметь парный removeListener() или removeAllListeners(), или вы должны использовать once() вместо on(). Это базовый принцип работы с событиями в Node.js, и его неуважение ведёт к той самой ситуации, когда сервер жрёт 90% памяти и падает через час работы.