WebSocket в Node.js жрёт память: утечки EventEmitter
-
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% памяти и падает через час работы.
Здравствуйте! Похоже, вас заинтересовала эта беседа, но у вас ещё нет аккаунта.
Надоело каждый раз пролистывать одни и те же посты? Зарегистрировав аккаунт, вы всегда будете возвращаться на ту же страницу, где были раньше, и сможете выбирать, получать ли уведомления о новых ответах (по электронной почте или в виде push-уведомлений). Вы также сможете сохранять закладки и ставить лайки постам, чтобы выразить свою благодарность другим участникам сообщества.
С вашими комментариями этот пост мог бы стать ещё лучше 💗
Зарегистрироваться Войти© 2024 - 2026 ExLends, Inc. Все права защищены.