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

Проект 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 была полностью рабочей и долгое время успешно держала нагрузку, однако сам по себе подход был типичным олдскульным монолитом. Главные конструктивные компромиссы заключались в следующем:
-
Встроенный контур саппорта — критическая точка отказа: Служебные функции сервера технической поддержки (прием тикетов, авто-роли, верификация участников) находились непосредственно внутри кодовой базы основного бота Desires. Когда на 15 000 серверов начинались пиковые нагрузки или бот уходил в плановый перезапуск для обновления конфигураций, официальный Discord-сервер проекта полностью терял автоматизацию. Пользователи, пришедшие сообщить о проблеме, натыкались на «мертвые» кнопки тикетов.
-
Неуправляемый 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). -
Прямые SQL-запросы в MySQL без L1-кэша: При обработке каждого входящего события ядро выполняло прямой SQL-запрос (
SELECT ... WHERE guild_id = ?). При тысячах входящих сообщений в секунду пул соединений к MySQL мгновенно переполнялся, создавая блокировку потоков и каскадные задержки ответов.
Опыт Desires дал фундаментальный урок: высоконагруженный бот не может быть монолитом, а критически важная инфраструктура поддержки обязана жить в изолированном независимом контуре.
2. Эпоха Niako: Строгий TypeScript, L1/L2 кэш и изоляция Eral

Проект Niako создавался как масштабная работа над ошибками Desires. Обслуживая 2.5 миллиона пользователей и 10 000 серверов, проект перешел на полностью типизированную модульную архитектуру.
Ключевые нововведения стека Niako
- Язык: Полная миграция на TypeScript со строгой валидацией интерфейсов.
- Шардинг: Использовался стандартный
ShardingManagerотdiscord.js(без вебсокет-кластеров, работающий через классическое дерево процессов Node.js). - Специализированный саппорт-бот Eral: Первое в истории экосистемы выделение автономного процесса для сервера поддержки.
- Бэкенд & Панель: Полноценный микросервисный REST API на NestJS со Swagger-документацией и веб-панель на React 18 с собственным кастомным UI Kit.
Изоляция инфраструктуры поддержки: бот 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()
}
}
}
}
Преимущества реализации:
- Синхронный доступ за O(1): 98% обращений к настройкам гильдии возвращаются мгновенно из локальной
Collectionшарда без ожидания сетевого I/O. - Lazy Auto-Create: Если сервер запускает команду впервые, документ создается атомарно и тут же помещается в кэш.
- 10-часовой фоновый Sweeper: Процесс регулярно находит и удаляет из базы неактивные документы с дефолтными настройками, предотвращая раздувание объема MongoDB.
3. Эпоха Rushia & Osaka: Вершина инженерной мысли

Проект Rushia (изначально проектировавшийся как ветка NiakoV2) вобрал в себя весь накопленный опыт и стал наиболее технологически совершенной итерацией платформы.
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.
Проблема продакшена
В классических ботах для добавления новой команды или исправления опечатки в тексте требовался полный перезапуск процесса. При перезапуске рвались 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
}
}
}
Анатомия работы конвейера:
- Защита от дребезга через MD5: Операционные системы часто генерируют несколько событий
changeподряд при сохранении файла в IDE. МетодcheckUpdate(path)считывает файл и сверяет MD5-хеш с предыдущим сохраненным вthis.paths. Если хеш идентичен, событие мгновенно отбрасывается. - Очистка require.cache: Вызов
delete require.cache[require.resolve(path)]удаляет старый скомпилированный модуль из внутреннего реестра Node.js. - Атомарный ре-импорт: Вызов
await import(path)подгружает свежую версию модуля и помещает экземпляр команды, кнопки или модального окна вthis.cache. - Поддержка шрифтов для Canvas: Если файл имеет расширение
.ttfили.otf, он регистрируется в кэше шрифтов для графического движка карточек профилей.
Итог: Любая правка в логике команд, кнопок интерактивности или локализации вступает в силу за 10–15 миллисекунд прямо в работающем продакшене без перезапуска шардов и без единой потери соединения!
4. Интеллектуальный парсер аргументов BaseArguments
Для обработки текстовых команд с префиксом был разработан типизированный AST-парсер BaseArguments, расширяющий нативный класс Array:
- Автоматически резолвит упоминания пользователей, текстовые ID и никнеймы в объекты
GuildMember. - Преобразует каналы, роли, HEX-цвета и временные интервалы (
ms— например,"1d","2h","30m") в типизированные структуры. - Обеспечивает строгий синтаксический анализ аргументов команд модерации и экономики до передачи управления в исполняемый метод
run().
5. Аудио-движок RushiaPlayer на Shoukaku v4 и Lavalink
Трансляция аудио в реальном времени — одна из самых тяжелых задач для 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 фундаментальных правил разработки распределенных ботов:
-
Никогда не совмещайте саппорт-контур с публичным ботом: Падение основного сервиса под пиковой нагрузкой не должно парализовать тикеты технической поддержки. Создание автономных ботов (Eral, Osaka) — лучшее решение для сохранения SLA 99.99%.
-
Квотируйте кэш памяти до первого байта: Дефолтный
discord.jsсохранит каждый входящий объект. Обнуляйте менеджерыReactionManager: 0,AutoModerationRuleManager: 0и задавайте предикаты сохранения (keepOverLimit) для пользователей и голосовых состояний. -
L1 In-Memory кэш обязателен для каждого воркера: Обращение к настройкам гильдии должно разрешаться синхронно в RAM за $O(1)$. До базы данных (MongoDB/PostgreSQL) должны доходить только мутации и редкие промахи кэша.
-
Hot-Reload кода на лету спасает стабильность продакшена: Применение
BaseHandlerна базеchokidarс MD5-контролем и инвалидациейrequire.cacheпозволяет деплоить фиксы и добавлять команды за миллисекунды, сохраняя сессии со шлюзом и аудиопотоки. -
Изолируйте ресурсоемкие операции: Тяжелые задачи (кодирование звука в Opus, генерация сложных Canvas-карточек) должны делегироваться выделенным внешним сервисам (Lavalink, отдельные воркеры), освобождая событийный цикл Node.js для обслуживания WebSocket Gateway.
-
Оптимизируйте внутренние API: Переход от тяжеловесных фреймворков к микророутерам вроде Hono внутри воркеров обеспечивает ультрабыстрый отклик дашборда при минимальном потреблении оперативной памяти.