Все статьи

Инженерный кейс: Масштабирование экосистемы Discord-ботов до миллионов пользователей и тысяч серверов

highloaddiscordtypescriptarchitectureshardingnestjshonoredismongodb

Инженерный кейс: Масштабирование экосистемы Discord-ботов до миллионов пользователей и тысяч серверов

Разработка ботов для Discord кажется тривиальной задачей лишь на этапе учебного прототипа из десятка серверов. Однако при органическом росте аудитории до тысяч гильдий и миллионов участников стандартные монолитные подходы неминуемо упираются в жесткие инфраструктурные ограничения: утечки памяти в V8 Heap, исчерпание WebSocket-шлюзов (Discord Gateway), нехватку привилегированных интентов, перегрузку пула соединений базы данных и каскадные отказы процессов.

В данном материале представлен подробный технический разбор архитектурных вызовов, оптимизации ресурсов и эволюции стека четырех проектов: Desires, Niako, Wind и Rushia.


1. Хронология проектов и метрики нагрузки

Каждый проект создавался как ответ на конкретные продуктовые вызовы и эксплуатационные нагрузки:

  1. Desires (3.5 млн пользователей · 15 000 серверов):

    • Стек: Vanilla JS, discord.js, отдельный микросервис API на Express, статический веб-интерфейс на HTML/CSS (вторая версия проектировалась на Vue + Nuxt).
    • Особенности: Первый масштабный проект. Сам бот Desires монолитно совмещал публичные функции и поддержку на собственном сервере. Столкновение с проблемами базового шардинга, перегрузкой событийного цикла Node.js и первыми критическими утечками оперативной памяти.
  2. Niako (2.5 млн пользователей · 10 000 серверов):

    • Стек: TypeScript, discord.js v14, discord-hybrid-sharding, Redis, MongoDB (Mongoose), полноценный REST API на NestJS со Swagger-документацией.
    • Интерфейс: Веб-панель на React 18 с собственным UI Kit.
    • Инфраструктура: Выделенный изолированный support-бот Eral.
  3. Wind (1 млн пользователей · 1 000 серверов):

    • Стек: TypeScript, модульная архитектура, интеграция Open Source решений.
    • Интерфейс: Дашборд управления серверами на базе дизайн-системы Yandex Gravity UI.
  4. Rushia (законченная архитектурная итерация, pre-release):

    • Стек: TypeScript, discord.js v14, встроенный сверхбыстрый API на Hono (@hono/node-server), Mongoose 8, музыкальный движок Shoukaku v4, межпроцессное взаимодействие через Socket.io.
    • Интерфейс: Собственный дашборд на Next.js.
    • Инфраструктура: Выделенный изолированный support-бот Osaka.
    • Контекст: Изначально ветка проектировалась как NiakoV2, но в ходе переработки была полностью выделена в самостоятельный завершенный проект Rushia.

Сравнительный срез архитектурных стеков

Проект Аудитория / Серверы Стек ядра бота API & Бэкенд Web Dashboard Support-бот
Desires 3.5M / 15K JavaScript, Discord.js Отдельный сервис на Express Чистый HTML/CSS (v2: Vue + Nuxt) Сам Desires (монолит)
Niako 2.5M / 10K TypeScript, Discord.js v14, hybrid-sharding Микросервис на NestJS + Swagger React 18 + Собственный UI Kit Eral (изолированный)
Wind 1.0M / 1K TypeScript, Open Source модули Встроенный REST API Дашборд на Gravity UI
Rushia Pre-release TypeScript, Discord.js v14, Socket.io Hono API (@hono/node-server) Next.js + Собственный UI Kit Osaka (изолированный)

2. Ловушка Gateway Intents и обработка частичных событий

Один из самых коварных барьеров при масштабировании ботов — система Privileged Gateway Intents в Discord API. Начиная со 100 серверов, бот обязан проходить верификацию для получения прав на чтение содержимого сообщений (MESSAGE_CONTENT), полного состава гильдий (GUILD_MEMBERS) и статусов (GUILD_PRESENCES).

Проблема: Неполные структуры данных (Uncached Entities)

В высоконагруженной среде работа без полного кэша участников ломает классический код, привыкший к синхронному доступу:

  • guild.members.cache.get(userId) в 90% случаев возвращает undefined, так как участник еще не отправлял сообщений в текущей сессии шарда.
  • При голосовых обновлениях (voiceStateUpdate), модерации или выдаче ролей объект member может приходить неполным (Partials) или отсутствовать вовсе.
  • Массовый вызов guild.members.fetch(userId) на каждое событие мгновенно упирается в глобальный HTTP Rate Limit (429 Too Many Requests) со стороны Discord REST API.

Инженерное решение: Ленивая дозагрузка (Lazy Fetching) и дуальный обработчик команд

Для обхода ограничений была внедрена многослойная стратегия обработки сущностей:

  1. Graceful Fallback Pipeline: Сначала поиск производится в локальном L1-кэше шарда. Если сущность отсутствует, запускается контролируемый fetch с локальным дебаунсом и дедупликацией параллельных запросов на один и тот же ID.

  2. Гибридный роутер команд: В переходный период (до повсеместного закрепления Slash Commands) ядро поддерживало дуальный контур: MessageCommandHandler (для серверов с префиксом) и SlashCommandHandler (для взаимодействия через Discord Interactions), не требующий интента на чтение текста сообщений.

export class CommandDispatcher {
    public async resolveMember(guild: Guild, userId: string): Promise<GuildMember | null> {
        const cached = guild.members.cache.get(userId)
        if (cached && cached.roles.cache.size > 0) {
            return cached
        }

        try {
            return await guild.members.fetch({ user: userId, force: false })
        } catch {
            return null
        }
    }
}

3. Главная проблема: Cache Bloat и оптимизация памяти в Discord.js

По умолчанию библиотека discord.js сохраняет в оперативной памяти (V8 Heap) практически все поступающие Gateway-сущности — сообщения, пользователей, реакции, голосовые состояния, эмодзи и присутствия.

Суть проблемы

При десятках тысяч гильдий в реальном времени через WebSocket прокачиваются сотни событий в секунду:

  • Тысячи серверов × сотни каналов = миллионы объектов в кэше.
  • Потребление RAM одного воркера быстро превышало стандартный лимит Node.js (1.4–2.0 ГБ), приводя к аварийным падениям с ошибкой JavaScript heap out of memory.
  • Попытка решить проблему простым увеличением V8 Heap (--max-old-space-size) лишь оттягивала крах и приводила к многосекундным фризам из-за работы сборщика мусора (Garbage Collector Stop-the-World).

Инженерное решение: Тонкая настройка cacheWithLimits и агрессивные свиперы

В ядрах Niako и Rushia была реализована стратегия жесткого квотирования памяти. Все неиспользуемые менеджеры были полностью обнулены, а для критических коллекций заданы строгие лимиты и предикаты удержания:

makeCache: Options.cacheWithLimits({
    ...Options.DefaultMakeCacheSettings,
    MessageManager: {
        maxSize: 50,
        keepOverLimit: message => message.author.id === this.user.id
    },
    ReactionManager: 0,
    ReactionUserManager: 0,
    AutoModerationRuleManager: 0,
    ApplicationCommandManager: 0,
    StageInstanceManager: 0,
    VoiceStateManager: {
        maxSize: 100,
        keepOverLimit: state => !state?.mute
    },
    GuildMemberManager: {
        maxSize: 100,
        keepOverLimit: member => member.user.bot || member.permissions.has('Administrator')
    },
    PresenceManager: {
        keepOverLimit: presence => !['invisible', 'offline'].includes(presence.status) && !presence?.member?.user?.bot
    },
    ThreadManager: {
        maxSize: 100,
        keepOverLimit: thread => thread.type === ChannelType.PrivateThread
    }
}),
sweepers: {
    ...Options.DefaultSweeperSettings,
    messages: {
        interval: 1_800,
        lifetime: 1_800
    },
    guildMembers: {
        interval: 1_800,
        filter: () => member => 1 >= member.roles.cache.size
    },
    users: {
        interval: 1_800,
        filter: () => user => user.id !== user.client.user.id
    }
}

Результаты оптимизации:

  • Снижение базового потребления RAM на 30–40% на каждый рабочий процесс.
  • Полное устранение Out-Of-Memory сбоев даже во время пиковых рассылок и массовых ивентов.

4. Двухуровневое кэширование БД (MongoDB + L1 In-Memory Collection)

Прямые запросы в MongoDB на каждое входящее событие (проверка авторолей, префиксов, прав модерации, триггеров автоудаления) создавали колоссальную нагрузку на дисковую подсистему и приводили к исчерпанию пула соединений (Connection Pool Exhaustion).

Архитектура модульных менеджеров

Для каждой функциональной подсистемы был создан изолированный менеджер данных (src/db/):

  • ModuleSettingManager — глобальные конфигурации серверов.
  • ModuleTrackerManager — трекинг голосового и текстового онлайна.
  • ModuleRatingManager — экономика, опыт и ранги.
  • AutoDeleteManager — правила автоматической очистки каналов.

Внутри каждого менеджера реализован двухуровневый паттерн кэширования:

  1. L1 In-Memory Collection: На уровне каждого шарда хранится коллекция активных гильдий (cache: Collection<string, TModuleSetting>). При обработке ивента чтение происходит синхронно из памяти за O(1).
  2. Lazy-инициализация и Auto-Create: Если гильдия обращается впервые, документ атомарно создается в базе и помещается в локальный кэш шарда.
  3. Периодический Sweeper БД: Фоновый процесс раз в 10 часов сканирует и удаляет «пустые» документы неактивных серверов, предотвращая разрастание объема коллекций в MongoDB.
export default class ModuleSettingManager {
    private cache: Collection<string, TModuleSetting> = new Collection()

    constructor(private db: Database) {
        setInterval(() => this.sweeper(), 36_000_000)
    }

    async get(guild: Guild, options: { fetch?: boolean } = { fetch: true }) {
        if (this.cache.has(guild.id)) {
            return this.cache.get(guild.id)!
        }
        return !options.fetch ? null : (await this.find(guild.id))
    }

    async find(guildId: string) {
        const doc = await ModuleSettingSchema.findOne({ guildId })
        if (doc) {
            this.cache.set(guildId, doc)
            return doc
        }
        return await this.create(guildId)
    }

    async save(doc: TModuleSetting) {
        const saved = await doc.save()
        this.cache.set(saved.guildId, saved)
        return saved
    }
}

5. Эволюция шардирования: От hybrid-sharding к кастомному WebSocket-кластеру

Discord накладывает строгое ограничение: один WebSocket-шард может обслуживать не более 2 500 серверов. При масштабе в 10 000–15 000 серверов требовалось минимум 16–24 шардов.

Оркестрация шардов через WebSocket Cluster Manager

Стандартные библиотеки запускают шарды как дочерние процессы одной ноды. Для полного контроля над жизненным циклом и отказоустойчивостью был разработан собственный оркестратор NiakoCluster:

  • Центральный мастер-сервер распределяет пулы шардов по отдельным воркерам через WebSocket (Socket.io).
  • При падении или перезапуске воркера мастер-контроллер автоматически перенаправляет пул шардов на резервный узел без разрыва общего SLA.
  • Реализована плавная задержка старта (staggered spawn), предотвращающая превышение Discord Identify Rate Limits (максимум 1 IDENTIFY в 5 секунд на сессию).
export default class WebSocketManager {
    public readonly url: string = `ws://${internal.originalIp}:${internal.ports.clusterWs}`
    public readonly shardUrl: string = `ws://${internal.ip}:${internal.ports.shardWs}`

    constructor() {
        if(!debug) {
            this.socket.emit('process')
            this.socket.on('respawn', (res: ResponseShardRespawn) => {
                this.respawn(res)
            })
        }
    }

    private async respawn(res: ResponseShardRespawn) {
        if(this.shardManager) {
            this.shardManager.shards.forEach(c => c.kill())
            this.shardManager.isRespawn = true
            delete this.shardManager
        }

        this.shardManager = new ShardingManager(res)
        this.sendCluster(res)

        if(!res.shardList.includes(0)) {
            await new Promise((resolve) => setTimeout(resolve, 30_000))
        }

        return this.shardManager.generateShards()
    }
}

6. Архитектурная изоляция: Роль support-ботов (Eral и Osaka)

Один из важнейших эксплуатационных уроков, вынесенных из раннего опыта: никогда не размещать критическую инфраструктуру поддержки внутри высоконагруженного основного кластера.

В эпоху Desires сам основной бот выполнял задачи модерации, верификации и тикетов на support-сервере. Когда под нагрузкой в 15 000 серверов или во время роллинг-рестартов бот падал, сервер техподдержки полностью терял автоматизацию — пользователи не могли создать тикет или получить помощь.

Для устранения этой точки отказа, начиная с Niako, саппорт-функционал был навсегда вынесен в автономные легковесные боты:

  • Eral — выделенный бот для support-сервера Niako.
  • Osaka — выделенный бот для support-сервера Rushia.

Они функционировали в полностью изолированных процессах со своей базой данных, гарантируя 99.99% доступность (SLA) тикет-системы и модерации даже в моменты глобального техобслуживания или сбоев основного кластера.


7. Эволюция API и дашбордов: Собственные UI Kit и Gravity UI

Эволюция серверного API и веб-панелей отражала рост требований к скорости и плотности отображения данных:

  1. Desires (v1): Монолитный Express API + легковесный HTML/CSS интерфейс.
  2. Niako (v1): Микросервисный REST API на NestJS со Swagger-документацией и дашборд на React 18 с собственным кастомным UI Kit.
  3. Wind: Дашборд управления серверами на базе дизайн-системы Yandex Gravity UI (@gravity-ui/uikit), обеспечивающий удобную визуализацию сложных аналитических графиков и настроек.
  4. Rushia: Высокоскоростной embedded API на Hono (@hono/node-server, hono-rate-limiter) с минимальными накладными расходами на роутинг и дашборд на Next.js.

8. Ключевые инженерные выводы

  1. Осознанное квотирование кэша: Ограничение makeCache и периодические свиперы — единственный способ победить утечки памяти в Node.js при больших объемах WebSocket-трафика.
  2. Отказ от предположений о наличии данных: Проектирование с расчетом на отсутствие привилегированных интентов и ленивая дозагрузка сущностей избавляют от непредвиденных падений.
  3. Многоуровневое кэширование БД: L1 In-Memory коллекции на уровне каждого шарда спасают базу данных от тысяч идентичных запросов в секунду.
  4. Изоляция критических контуров: Вынесение служебных ботов (Eral, Osaka) в автономные процессы обеспечивает бесперебойную работу техподдержки при любых авариях на основном кластере.
  5. Эволюция стека под реальную нагрузку: Переход от Express к NestJS, а затем к Hono и кастомному шардингу позволил платформе обслуживать миллионы пользователей с предсказуемым временем отклика.