Все статьи

Инженерный кейс: Эволюция архитектуры Discord-ботов от монолита до кластера с Hot-Reload

highloaddiscordtypescriptarchitectureshardingnestjshonoredismongodbmysql

Инженерный кейс: Эволюция архитектуры Discord-ботов от монолита до кластера с Hot-Reload

Разработка ботов для Discord кажется тривиальной задачей лишь на этапе учебного прототипа из десятка серверов. Достаточно открыть документацию discord.js, повесить слушатель на interactionCreate, написать пару команд и запустить процесс в фоне.

Однако реальность высоконагруженного продакшена кардинально иная. Когда бот перешагивает планку в тысячи серверов и миллионы участников, разработчик сталкивается с полным спектром классических проблем распределенных систем:

  • Перегрузка WebSocket-шлюзов (Discord Gateway): тысячи входящих JSON-пакетов в секунду, которые рантайм Node.js должен успевать десериализовать без просадки FPS событийного цикла (Event Loop).
  • Катастрофический Cache Bloat в V8 Heap: стандартные структуры discord.js жадно аллоцируют в память каждого встреченного пользователя, канал, роль и сообщение, стремительно исчерпывая лимиты оперативной памяти.
  • Ограничения Privileged Gateway Intents: жесткие лимиты Discord API на чтение состава участников гильдий (GUILD_MEMBERS) и содержимого сообщений (MESSAGE_CONTENT), вынуждающие строить неблокирующие механизмы ленивой дозагрузки (Lazy Fetching).
  • Исчерпание пула соединений БД: прямые запросы в базу данных (MySQL/MongoDB) на каждый чих в чате мгновенно парализуют реляционные пулы и забивают дисковый I/O.
  • Проблема доступности поддержки: падение монолитного бота во время роллинг-рестарта или сбоя полностью лишает сервер технической поддержки возможности принимать тикеты от разгневанных пользователей.

В этом материале представлен подробный хронологический разбор эволюции архитектуры трех ключевых поколений проектов: Desires, Niako и Rushia & Osaka. Мы проследим путь от олдскульного JavaScript-монолита до распределенного WebSocket-кластера с нулевым временем простоя и горячей заменой кода на лету.


1. Эпоха Desires: Монолит на Vanilla JS и 15 000 серверов

Discord-бот Desires

Проект Desires стал первым масштабным боевым полигоном. На пике своего развития бот обслуживал аудиторию свыше 3.5 миллионов пользователей на 15 000 серверов, выполняя функции модерации, развлечений, экономики и администрирования.

Стек и архитектурный контекст

Архитектура Desires проектировалась по классическим канонам ранней экосистемы Node.js:

  • Ядро: чистый JavaScript (Vanilla JS), библиотека discord.js v12 (эпоха до появления гранулярного makeCache и до внедрения нативных Slash Commands).
  • Интерактивность: классические текстовые префиксные команды через событие message со сплошным строковым парсингом.
  • API & Веб-интерфейс: отдельный микросервис на Express, отдающий легковесные статические страницы (HTML/CSS), а позже прототипировавшийся на Vue + Nuxt.
  • База данных: MySQL с прямыми SQL-запросами из обработчиков событий без какого-либо слоя кэширования.
  • Шардинг: базовый ShardingManager из состава discord.js, спавнящий дочерние процессы через стандартный child_process.fork.

Архитектура монолита Desires: 15 000 серверов и единая точка отказа

Суровые вызовы монолита «на любителя»

Архитектура Desires была полностью рабочей и долгое время успешно держала нагрузку, однако сам по себе подход был типичным олдскульным монолитом. Главные конструктивные компромиссы заключались в следующем:

  1. Встроенный контур саппорта — критическая точка отказа: Служебные функции сервера технической поддержки (прием тикетов, авто-роли, верификация участников) находились непосредственно внутри кодовой базы основного бота Desires. Когда на 15 000 серверов начинались пиковые нагрузки или бот уходил в плановый перезапуск для обновления конфигураций, официальный Discord-сервер проекта полностью терял автоматизацию. Пользователи, пришедшие сообщить о проблеме, натыкались на «мертвые» кнопки тикетов.

  2. Неуправляемый Cache Bloat в discord.js v12: В discord.js v12 еще не существовало механизма тонкого квотирования Options.cacheWithLimits (появившегося только в v13). По умолчанию клиент сохранял в кучу V8 каждого встреченного участника, эмодзи, сообщение и канал. Единственным встроенным средством были примитивные свиперы сообщений, но структуры 15 000 гильдий продолжали непрерывно раздувать память вплоть до 1.8–2.0 ГБ на воркер, упираясь в лимит max-old-space-size и вызывая изнурительные паузы сборщика мусора (Garbage Collector Stop-the-World).

  3. Прямые SQL-запросы в MySQL без L1-кэша: При обработке каждого входящего события ядро выполняло прямой SQL-запрос (SELECT ... WHERE guild_id = ?). При тысячах входящих сообщений в секунду пул соединений к MySQL мгновенно переполнялся, создавая блокировку потоков и каскадные задержки ответов.

Опыт Desires дал фундаментальный урок: высоконагруженный бот не может быть монолитом, а критически важная инфраструктура поддержки обязана жить в изолированном независимом контуре.


2. Эпоха Niako: Строгий TypeScript, L1/L2 кэш и изоляция Eral

Discord-бот Niako

Проект Niako создавался как масштабная работа над ошибками Desires. Обслуживая 2.5 миллиона пользователей и 10 000 серверов, проект перешел на полностью типизированную модульную архитектуру.

Ключевые нововведения стека Niako

  • Язык: Полная миграция на TypeScript со строгой валидацией интерфейсов.
  • Шардинг: Использовался стандартный ShardingManager от discord.js (без вебсокет-кластеров, работающий через классическое дерево процессов Node.js).
  • Специализированный саппорт-бот Eral: Первое в истории экосистемы выделение автономного процесса для сервера поддержки.
  • Бэкенд & Панель: Полноценный микросервисный REST API на NestJS со Swagger-документацией и веб-панель на React 18 с собственным кастомным UI Kit.

Архитектура Niako: Стандартный шардинг, L1-кэш и изоляция Eral

Изоляция инфраструктуры поддержки: бот Eral

Главным стратегическим решением в Niako стало создание Eral — автономного легковесного бота, предназначенного исключительно для сервера технической поддержки.

  • Eral запускался как полностью самостоятельный процесс со своим токеном авторизации и выделенным пулом БД.
  • Eral не обрабатывал публичные гильдии и не подвергался внешним всплескам трафика.
  • В результате SLA тикет-системы и модерации сервера поддержки вырос до 99.99%: даже во время глобального обновления или перезапуска шардов Niako саппорт-команда продолжала непрерывно принимать обращения пользователей.

Укрощение памяти: makeCache и агрессивные Sweepers

В Niako была впервые внедрена жесткая политика квотирования памяти V8 Heap через опцию makeCache. Все неиспользуемые сущности отключались на уровне рантайма:

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 одного воркера снизилось с 1.8 ГБ до стабильных 280–320 МБ, полностью исключив аварийные падения по Out-Of-Memory.

Двухуровневое кэширование БД (Mongoose L2 + RAM L1)

Чтобы избавить MongoDB от тысяч повторных обращений в секунду, для каждой подсистемы (ModuleSettingManager, ModuleTrackerManager, ModuleRatingManager) был реализован шаблон двухуровневого кэша:

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
    }

    private async sweeper() {
        const emptyDocs = await ModuleSettingSchema.find({ isDefault: true })
        for (const doc of emptyDocs) {
            if (!this.cache.has(doc.guildId)) {
                await doc.deleteOne()
            }
        }
    }
}

Преимущества реализации:

  1. Синхронный доступ за O(1): 98% обращений к настройкам гильдии возвращаются мгновенно из локальной Collection шарда без ожидания сетевого I/O.
  2. Lazy Auto-Create: Если сервер запускает команду впервые, документ создается атомарно и тут же помещается в кэш.
  3. 10-часовой фоновый Sweeper: Процесс регулярно находит и удаляет из базы неактивные документы с дефолтными настройками, предотвращая раздувание объема MongoDB.

3. Эпоха Rushia & Osaka: Вершина инженерной мысли

Discord-бот Rushia

Проект Rushia (изначально проектировавшийся как ветка NiakoV2) вобрал в себя весь накопленный опыт и стал наиболее технологически совершенной итерацией платформы.

Топология кластера Rushia: Socket.io, Hono API и Lavalink

1. Переход на встроенный сверхбыстрый Hono API

Несмотря на мощь NestJS в Niako, для взаимодействия воркеров с дашбордом требовалась максимальная производительность с минимальным оверхедом по памяти. В Rushia выбор пал на Hono (@hono/node-server):

  • Embedded REST API поднимается прямо внутри каждого рабочего процесса бота.
  • Роутинг на Hono выполняется на порядки быстрее классического Express/NestJS за счет Regexp-дерева и легковесной архитектуры.
  • Встроенные middleware (hono-rate-limiter, валидаторы параметров) защищают локальный API от перегрузок со стороны дашборда на Next.js.

2. WebSocket-кластер NiakoCluster на Socket.io

Ограничение Discord API на 2 500 гильдий на один WebSocket-шард требует строгой оркестрации. В Rushia был разработан собственный мастер-контроллер NiakoCluster:

  • Мастер-сервер оркестрирует воркеры через постоянное WebSocket-соединение на Socket.io.
  • При падении или зависании любого воркера мастер-контроллер инициирует событие respawn.
  • Внедрен механизм плавного старта (Staggered Spawn) с задержкой в 30 секунд для не-нулевых шардов, гарантирующий соблюдение глобального Discord Identify Rate Limit (не более одного 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()
    }
}

3. Революция BaseHandler: Динамический Hot-Reload без перезапуска шардов

Одной из самых инновационных разработок стал BaseHandler — универсальный загрузчик компонентов бота на базе файлового наблюдателя chokidar.

Пайплайн динамического Hot-Reload BaseHandler

Проблема продакшена

В классических ботах для добавления новой команды или исправления опечатки в тексте требовался полный перезапуск процесса. При перезапуске рвались WebSocket-сессии с Discord Gateway, тысячи голосовых каналов замолкали, а после рестарта на шлюз обрушивался шквал запросов на повторную синхронизацию (Resync Storm).

Инженерное решение

BaseHandler отслеживает файловую систему в реальном времени, вычисляет контрольные суммы содержимого через md5 и выполняет сброс кэша рантайма delete require.cache:

import { IBaseModule } from '#types/base/BaseHandler';
import { RushiaClient } from '../client/RushiaClient';
import { readFileSync, readdirSync } from 'fs';
import { Collection } from 'discord.js';
import chokidar from 'chokidar';
import md5 from 'md5';

export default class BaseHandler {
    public readonly paths: Collection<string, string> = new Collection()
    public readonly cache: Collection<string, any> = new Collection()

    constructor(
        public client: RushiaClient,
        public directory: string,
        private options?: { usePathNames: boolean }
    ) {}

    public async loadAll(directory = this.directory) {
        const commons = readdirSync(directory)
        const directorys = this.getDirectorys(commons)
        const files = this.getFiles(commons)

        for (let i = 0; directorys.length > i; i++) {
            await this.loadAll(`${directory}/${directorys[i]}`)
        }

        for (let i = 0; files.length > i; i++) {
            await this.load(directory, files[i])
        }
    }

    public async load(directory: string, file: string) {
        const path = `${directory}/${file}`

        if (['ttf', 'otf'].some((f) => file.endsWith(f))) {
            this.chokidar(directory, file)
            return this.cache.set(path, file)
        }
        
        delete require.cache[require.resolve(path)]

        const module = (await import(path))?.default as IBaseModule
        if (!['object', 'function'].includes(typeof module)) return

        this.paths.set(path, md5(readFileSync(path).toString('utf-8')))

        switch (typeof module) {
            case 'object':
                if (module?.options?.disabled) return
                if (!module?.options) {
                    return this.cache.set(file.split('.')[0], module)
                }
                module.options.dir = directory
                this.chokidar(directory, file)
                return this.cache.set(this?.options?.usePathNames ? path : (module.options?.name || path), module)
            case 'function':
                const pull = new (module as any)()
                if (!pull?.options || pull?.options?.disabled) return
                pull.options.dir = directory
                this.chokidar(directory, file)
                return this.cache.set(this?.options?.usePathNames ? path : (pull.options?.name || path), pull)
        }
    }

    private chokidar(dir: string, file: string) {
        const path = `${dir}/${file}`
        chokidar.watch(path).on('add', path => {
            if (this.checkUpdate(path)) return
            this.load(dir, file)
        }).on('change', path => {
            if (this.checkUpdate(path)) return
            this.load(dir, file)
        }).on('unlink', path => {
            if (this.checkUpdate(path)) return
            this.load(dir, file)
        })
    }

    private checkUpdate(path: string) {
        const current = md5(readFileSync(path).toString('utf-8'))
        if (current !== this.paths.get(path)) {
            this.paths.set(path, current)
            return false
        } else {
            return true
        }
    }
}

Анатомия работы конвейера:

  1. Защита от дребезга через MD5: Операционные системы часто генерируют несколько событий change подряд при сохранении файла в IDE. Метод checkUpdate(path) считывает файл и сверяет MD5-хеш с предыдущим сохраненным в this.paths. Если хеш идентичен, событие мгновенно отбрасывается.
  2. Очистка require.cache: Вызов delete require.cache[require.resolve(path)] удаляет старый скомпилированный модуль из внутреннего реестра Node.js.
  3. Атомарный ре-импорт: Вызов await import(path) подгружает свежую версию модуля и помещает экземпляр команды, кнопки или модального окна в this.cache.
  4. Поддержка шрифтов для Canvas: Если файл имеет расширение .ttf или .otf, он регистрируется в кэше шрифтов для графического движка карточек профилей.

Итог: Любая правка в логике команд, кнопок интерактивности или локализации вступает в силу за 10–15 миллисекунд прямо в работающем продакшене без перезапуска шардов и без единой потери соединения!

4. Интеллектуальный парсер аргументов BaseArguments

Для обработки текстовых команд с префиксом был разработан типизированный AST-парсер BaseArguments, расширяющий нативный класс Array:

  • Автоматически резолвит упоминания пользователей, текстовые ID и никнеймы в объекты GuildMember.
  • Преобразует каналы, роли, HEX-цвета и временные интервалы (ms — например, "1d", "2h", "30m") в типизированные структуры.
  • Обеспечивает строгий синтаксический анализ аргументов команд модерации и экономики до передачи управления в исполняемый метод run().

Трансляция аудио в реальном времени — одна из самых тяжелых задач для Node.js из-за необходимости кодирования и декодирования аудио-пакетов Opus.

  • В Rushia аудио-подсистема вынесена в RushiaPlayer, построенный поверх библиотеки Shoukaku v4.
  • Воспроизведение треков делегируется независимому кластеру серверов Lavalink на Java.
  • Основной поток Node.js лишь обменивается управляющими WebSocket-командами, не расходуя процессорное время на манипуляции с аудиобуферами.

6. Изолированный бот Osaka

Как и в случае с парой Niako/Eral, для сервера поддержки Rushia был создан выделенный бот Osaka. Он унаследовал всю передовую инфраструктуру BaseHandler и строгой модульности, гарантируя 100% стабильность работы модерации и тикетов саппорт-сервера.


4. Сравнительный срез ключевых архитектурных эпох

Параметр 1. Desires 2. Niako 3. Rushia & Osaka
Аудитория / Серверы 3.5M / 15 000 2.5M / 10 000 Pre-release кластер
Язык разработки JavaScript (Vanilla JS) TypeScript (Strict) TypeScript (Strict, ESM/CJS)
Библиотека Discord Discord.js v12 (Text Commands) Discord.js v14 (Slash) Discord.js v14 (Slash + Context)
Оркестрация шардов Базовый ShardingManager Discord.js ShardingManager NiakoCluster (Socket.io Master)
Кэш данных (L1/L2) Без L1 (прямой MySQL I/O) L1 In-Memory + Mongo L2 L1 In-Memory + Mongo 8 + TTL
Обновление кода Полный рестарт процесса Рестарт шарда BaseHandler Hot-Reload (chokidar)
Служебный REST API Express NestJS + Swagger Embedded Hono (@hono/node-server)
Веб-панель HTML/CSS (v2: Vue/Nuxt) React 18 + Custom UI Kit Next.js + Custom UI Kit
Поддержка (Support) Встроенная (монолит) Изолированный Eral Изолированная Osaka
Аудио-подсистема — Lavalink v3 Shoukaku v4 + Lavalink

5. Ключевые выводы и архитектурные правила

Эволюция экосистемы Discord-ботов от монолита Desires до кластера Rushia позволила сформулировать 6 фундаментальных правил разработки распределенных ботов:

  1. Никогда не совмещайте саппорт-контур с публичным ботом: Падение основного сервиса под пиковой нагрузкой не должно парализовать тикеты технической поддержки. Создание автономных ботов (Eral, Osaka) — лучшее решение для сохранения SLA 99.99%.

  2. Квотируйте кэш памяти до первого байта: Дефолтный discord.js сохранит каждый входящий объект. Обнуляйте менеджеры ReactionManager: 0, AutoModerationRuleManager: 0 и задавайте предикаты сохранения (keepOverLimit) для пользователей и голосовых состояний.

  3. L1 In-Memory кэш обязателен для каждого воркера: Обращение к настройкам гильдии должно разрешаться синхронно в RAM за $O(1)$. До базы данных (MongoDB/PostgreSQL) должны доходить только мутации и редкие промахи кэша.

  4. Hot-Reload кода на лету спасает стабильность продакшена: Применение BaseHandler на базе chokidar с MD5-контролем и инвалидацией require.cache позволяет деплоить фиксы и добавлять команды за миллисекунды, сохраняя сессии со шлюзом и аудиопотоки.

  5. Изолируйте ресурсоемкие операции: Тяжелые задачи (кодирование звука в Opus, генерация сложных Canvas-карточек) должны делегироваться выделенным внешним сервисам (Lavalink, отдельные воркеры), освобождая событийный цикл Node.js для обслуживания WebSocket Gateway.

  6. Оптимизируйте внутренние API: Переход от тяжеловесных фреймворков к микророутерам вроде Hono внутри воркеров обеспечивает ультрабыстрый отклик дашборда при минимальном потреблении оперативной памяти.