MAATRIX / Блог / Потолок WebSocket-соединений: 10 000 висящих клиентов и что кончится первым

Потолок WebSocket-соединений: 10 000 висящих клиентов и что кончится первым

MAATRIX

Держать 10 000 открытых WebSocket-соединений — это не то же самое, что обслуживать 10 000 запросов в секунду по HTTP. Клиенты почти всё время молчат, но сервер обязан помнить о каждом: держать сокет, буфер и кусок состояния в памяти, и делать это часами, а иногда днями подряд. Разберём, какой ресурс кончается первым при росте числа таких соединений, почему архитектура сервера решает больше, чем железо, и как посчитать свой реальный потолок, а не ориентироваться на чужие цифры.

Чем WebSocket-нагрузка отличается от обычного HTTP

Обычный HTTP-запрос живёт коротко: соединение открылось, сервер отдал ответ, соединение закрылось или ушло в пул keep-alive на несколько секунд. Нагрузку в этой модели меряют в запросах в секунду, и ресурсы освобождаются почти сразу после обработки.

WebSocket-соединение открывается один раз через HTTP-хендшейк (Upgrade: websocket) и дальше живёт как постоянный дуплексный TCP-поток — чат, торговый терминал, дашборд реального времени, игра. Клиент может ничего не отправлять минутами: соединение всё равно открыто, и сервер обязан быть готов принять данные в любой момент. Отсюда и другой профиль нагрузки:

  • считать нужно не запросы в секунду, а одновременно удержанные соединения — метрика, которая растёт монотонно, пока клиенты не отключаются;
  • почти всё время соединение простаивает, но ресурсы под него выделены так же, как под активное;
  • сервер хранит состояние на соединение — подписки, авторизацию, последний присланный оффсет — а не только сетевой сокет;
  • живость соединения нужно проверять принудительно (ping/pong), потому что TCP сам по себе не сообщает о том, что клиент отвалился без явного закрытия.

Именно эта комбинация — долгая жизнь плюс простой большую часть времени — и определяет, какой ресурс закончится первым. Разберём по порядку: дескрипторы, память, CPU.

Файловые дескрипторы: первый лимит, о который вы споткнётесь

Каждое открытое TCP-соединение — это один файловый дескриптор процесса. Дефолтный мягкий лимit в большинстве дистрибутивов — 1024 на процесс, и это первое, во что вы упрётесь, если вообще не трогали лимиты: уже на паре сотен активных WebSocket-клиентов процесс начнёт получать EMFILE при попытке принять новое соединение.

Проверить текущий лимит уже запущенного процесса:

cat /proc/$(pgrep -f node)/limits | grep "Max open files"

Поднять лимит нужно в нескольких местах одновременно, иначе он не применится к реальному процессу:

# /etc/security/limits.conf
appuser soft nofile 65536
appuser hard nofile 65536
# systemd unit — /etc/security/limits.conf сюда НЕ прокидывается
[Service]
LimitNOFILE=65536

Отдельно есть системный потолок на все процессы разом — fs.file-max, его тоже стоит проверить и при необходимости поднять через /etc/sysctl.conf. Дескрипторы — это тот лимит, который поднять проще всего: одна строка в конфиге и рестарт сервиса. Поэтому на практике это не столько ваш настоящий потолок, сколько первая стена, о которую спотыкаются те, кто вообще не готовился к нагрузке — за подробным разбором самих лимитов и того, сколько дескрипторов реально уходит на одно соединение (не всегда один, если под капотом есть пул к базе или прокси-сокет), стоит заглянуть в настройку лимитов открытых файлов и методику расчёта расхода дескрипторов на запрос.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Память: буферы держатся даже когда соединение молчит

Дескрипторы поднять легко, а вот память под простаивающие соединения — это уже архитектурный вопрос, и здесь появляются два независимых слоя расхода.

Буферы ядра. У каждого TCP-сокета есть буфер приёма и отправки, размер которых регулируется net.ipv4.tcp_rmem и tcp_wmem. Ядро умеет автотюнинг — под простаивающий сокет буфер сжимается, но не до нуля: какой-то минимум держится всегда, чтобы не тормозить обработку следующего пакета. На один сокет это, как правило, единицы-десятки килобайт, а не мегабайты — но при 50 000 одновременных соединений даже скромный минимум в пересчёте на все сокеты складывается в заметный кусок RAM, который стоит закладывать в расчёт, а не считать "почти бесплатным".

Состояние приложения. Это обычно больший расход, чем буферы ядра, и именно он определяет реальный потолок. Каждое соединение в приложении — это не просто сокет, а объект: сессия, список подписанных каналов, идентификатор пользователя, буфер недособранных фреймов протокола, иногда очередь исходящих сообщений на случай временного затора. В средах с управляемой памятью (Node.js/V8, JVM) эти объекты живут долго и остаются в старшем поколении кучи — сборщик мусора вынужден регулярно проходить по ним, и чем больше живых объектов висит одновременно, тем дороже становятся паузы GC, даже если каждый объект сам по себе крошечный.

Сколько именно уходит на соединение — вопрос вашей конкретной реализации, и это надо мерить, а не брать из статьи: ориентировочно счёт может идти от единиц до нескольких десятков килобайт на соединение в зависимости от того, сколько состояния вы туда кладёте. При 50 000-100 000 соединений это уже сотни мегабайт — единицы гигабайт RSS процесса ещё до того, как через сокеты прошёл хоть один байт полезной нагрузки. Долгоживущий процесс с таким объёмом постоянно живых объектов заодно чаще страдает от роста RSS без утечек как таковых — механика этого разобрана в статье про фрагментацию памяти в долгоживущем процессе: плановый рестарт время от времени — не костыль, а рабочий приём именно для таких сервисов.

CPU: цена heartbeat на тысячах соединений

TCP сам по себе не скажет вам, что клиент пропал без явного закрытия соединения — обрыв Wi-Fi, таймаут NAT на промежуточном роутере, зависший телефон. Поэтому WebSocket-серверы шлют периодический ping и ждут pong; не дождались за таймаут — закрывают и чистят соединение. Частота обычно в диапазоне десятков секунд, конкретное значение — компромисс между быстрым обнаружением мёртвых клиентов и лишней нагрузкой; универсального правильного числа нет, это ориентир, который стоит подбирать под свой профиль клиентов.

Наивная реализация — отдельный таймер на каждое соединение (setInterval на каждый сокет) — не бесплатна даже при простое: чем больше таймеров висит в куче событий event loop, тем чаще процесс просыпается и тем больше накладных расходов на планирование, особенно если у каждого таймера свой замыкание-колбэк, которое лишний раз нагружает GC. Практичнее группировать проверки — тикать одним общим интервалом и обходить все активные соединения пачкой, либо раскладывать клиентов по нескольким "вёдрам" с разным сдвигом фазы, чтобы не будить процесс на пинг всех соединений одновременно.

Отдельная ловушка — массовый обрыв связи. Если у клиентов случился сетевой сбой (отвалился аплинк, перезагрузился балансировщик), они начинают переподключаться одновременно — это резкий всплеск хендшейков, авторизаций и восстановления состояния разом, который по CPU может быть заметно тяжелее, чем спокойный heartbeat тех же соединений. Клиентам стоит закладывать джиттер в задержку переподключения, чтобы размазать этот всплеск по времени, а не бить в сервер синхронной волной.

Event loop против thread-per-connection: почему Node.js держит больше

Архитектура обработки соединений определяет, где именно окажется потолок, сильнее, чем железо под сервером.

Thread-per-connection — модель, где на каждое соединение выделяется отдельный поток ОС, блокирующийся на чтении сокета. У каждого потока — свой стек (резервируется адресное пространство, обычно на уровне единиц мегабайт, хотя физически коммитится по мере использования), запись в таблице планировщика ядра и накладные расходы на переключение контекста. Пока соединений сотни — модель работает нормально и код проще писать. Но когда их тысячи, две вещи начинают давить одновременно: растёт RSS от закоммиченных страниц стека каждого потока, и планировщик ОС тратит всё больше времени просто на переключение между потоками, большинство из которых ничего не делают. Именно поэтому классические thread-per-connection серверы упираются в потолок на сравнительно небольшом числе одновременных соединений.

Event loop (Node.js, nginx, Python asyncio, Java NIO/Netty) — один или несколько потоков используют epoll/kqueue, чтобы ядро само уведомляло процесс только тогда, когда конкретный сокет реально готов к чтению или записи. Простаивающее соединение не занимает поток и почти не тратит CPU — оно просто лежит в таблице ядра до следующего события. Поэтому такая модель кладётся на профиль WebSocket-нагрузки гораздо естественнее: тысячи молчащих клиентов почти ничего не стоят, пока не начали слать данные одновременно.

Важная оговорка: у event loop есть обратная сторона. Если единственный поток занят тяжёлым синхронным вычислением — сериализацией большого JSON, криптографией, сложной агрегацией — вся очередь событий стоит, и это бьёт по всем соединениям сразу, а не по одному. Решение — выносить тяжёлые куски в worker_threads или отдельные процессы (cluster в Node.js), оставляя основной loop только для ввода-вывода. И тут всплывает нюанс специфичный для WebSocket: если соединения размазаны по нескольким процессам или инстансам за балансировщиком, состояние конкретного клиента живёт только в том процессе, к которому он подключился — для рассылки сообщения между всеми клиентами нужен либо sticky-балансинг, либо общая шина (Redis pub/sub и аналоги), которую нужно спроектировать отдельно.

Как замерить свой потолок вместо доверия чужим цифрам

Число "N тысяч соединений" в чужой статье посчитано на чужом железе, с чужим объёмом состояния на соединение и чужой частотой heartbeat — переносить его на свой сервер бессмысленно. Рабочая методика — нагрузить свой стенд волнами и смотреть, что откажет первым.

  1. Напишите простой генератор нагрузки (Python с websockets, Node с ws), который открывает соединения пачками — например, по 500-1000 за раз — и держит их так же, как вели бы себя реальные клиенты: с тем же интервалом ping/pong, примерно той же долей активных против молчащих.
  2. Между волнами снимайте показания на сервере:
ss -s                                   # сводка по сокетам
cat /proc/net/sockstat                  # то же на уровне ядра
ls /proc/$(pgrep -f node)/fd | wc -l    # реально открытые дескрипторы процесса
ps -o rss,vsz -p $(pgrep -f node)       # память процесса
pidstat -p $(pgrep -f node) 1           # CPU по времени
  1. Останавливайтесь, когда увидите один из признаков предела: ошибки EMFILE/ECONNREFUSED на сервере, рост задержки ответа на ping/pong, нелинейное ускорение роста RSS (признак давления на GC), либо стабильно высокий CPU уже на простаивающих соединениях без полезной нагрузки.
  2. Берите за рабочий потолок не точку первого отказа, а заметный запас до неё — половину или две трети от найденного числа, с учётом того, что в проде поверх фонового простоя будет ещё и всплеск активности.

Отдельно стоит проверить сам генератор нагрузки: если вы гоните тысячи соединений с одной машины, она сама может упереться в лимит исходящих портов (net.ipv4.ip_local_port_range) или собственный ulimit, и тогда вы измерите потолок клиента, а не сервера. Лучше распределить нагрузку с нескольких машин. Общая методика поиска реального потолка сервера — не только применительно к WebSocket — подробно разобрана в статье про сервер, который держал 10 000 соединений и умер на 10 001-м: тот же принцип "минимум из независимых лимитов" здесь работает один в один, просто с другим набором доминирующих ресурсов.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Сколько WebSocket-соединений выдержит обычный VPS?

Единого числа нет — при небольшом объёме состояния на соединение и грамотном event-loop сервере современный VPS средней конфигурации способен держать порядки от нескольких тысяч до нескольких десятков тысяч простаивающих соединений, но это ориентир, а не гарантия: конкретное число зависит от объёма состояния на клиента, частоты heartbeat и доли активных соединений. Меряйте на своём стенде.

Хватит ли памяти, если соединения просто висят и молчат?

Сами по себе буферы ядра под простаивающий сокет невелики — обычно единицы-десятки килобайт. Основной расход почти всегда даёт состояние приложения на соединение (сессия, подписки, буферы фреймов), а не сетевой стек — считайте именно его.

Почему Node.js хвалят за WebSocket, а классический thread-per-connection сервер — нет?

Дело не в языке, а в модели ввода-вывода: event loop на epoll/kqueue почти не тратит ресурсы на простаивающее соединение, тогда как выделенный поток на каждое соединение упирается в память стеков и накладные расходы планировщика уже на нескольких тысячах клиентов. Java с Netty/NIO или Go с netpoller под капотом работают по той же логике, что и Node.js.

Нужно ли заранее поднимать ulimit, если ожидается рост?

Да, это дешёвая профилактика — поднимите LimitNOFILE в systemd-юните и fs.file-max заранее, до того как процесс начнёт получать EMFILE. Но не считайте это решением проблемы потолка целиком: дескрипторы почти никогда не оказываются настоящим узким местом после того, как их подняли, реальный предел обычно в памяти состояния.

Как понять, что упираемся именно в CPU от heartbeat, а не от полезной нагрузки?

Замерьте CPU процесса в период, когда соединения открыты, но клиенты ничего не делают, кроме ping/pong — это и есть фоновая цена простоя. Если она уже заметна на графике, наивная реализация heartbeat (отдельный таймер на соединение) — первый кандидат на пересмотр, до того как разбираться с остальной логикой.

Обсудить статью, задать вопрос или начать новую тему

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

Перейти в сообщество →