Инженерный кейс: Масштабирование экосистемы Discord-ботов до миллионов пользователей и тысяч серверов
Инженерный кейс: Масштабирование экосистемы Discord-ботов до миллионов пользователей и тысяч серверов
Разработка ботов для Discord кажется тривиальной задачей лишь на этапе учебного прототипа из десятка серверов. Однако при органическом росте аудитории до тысяч гильдий и миллионов участников стандартные монолитные подходы неминуемо упираются в жесткие инфраструктурные ограничения: утечки памяти в V8 Heap, исчерпание WebSocket-шлюзов (Discord Gateway), нехватку привилегированных интентов, перегрузку пула соединений базы данных и каскадные отказы процессов.
В данном материале представлен подробный технический разбор архитектурных вызовов, оптимизации ресурсов и эволюции стека четырех проектов: Desires, Niako, Wind и Rushia.
1. Хронология проектов и метрики нагрузки
Каждый проект создавался как ответ на конкретные продуктовые вызовы и эксплуатационные нагрузки:
-
Desires (3.5 млн пользователей · 15 000 серверов):
- Стек: Vanilla JS,
discord.js, отдельный микросервис API на Express, статический веб-интерфейс на HTML/CSS (вторая версия проектировалась на Vue + Nuxt). - Особенности: Первый масштабный проект. Сам бот Desires монолитно совмещал публичные функции и поддержку на собственном сервере. Столкновение с проблемами базового шардинга, перегрузкой событийного цикла Node.js и первыми критическими утечками оперативной памяти.
- Стек: Vanilla JS,
-
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.
- Стек: TypeScript,
-
Wind (1 млн пользователей · 1 000 серверов):
- Стек: TypeScript, модульная архитектура, интеграция Open Source решений.
- Интерфейс: Дашборд управления серверами на базе дизайн-системы Yandex Gravity UI.
-
Rushia (законченная архитектурная итерация, pre-release):
- Стек: TypeScript,
discord.js v14, встроенный сверхбыстрый API на Hono (@hono/node-server), Mongoose 8, музыкальный движок Shoukaku v4, межпроцессное взаимодействие через Socket.io. - Интерфейс: Собственный дашборд на Next.js.
- Инфраструктура: Выделенный изолированный support-бот Osaka.
- Контекст: Изначально ветка проектировалась как NiakoV2, но в ходе переработки была полностью выделена в самостоятельный завершенный проект Rushia.
- Стек: TypeScript,
Сравнительный срез архитектурных стеков
| Проект | Аудитория / Серверы | Стек ядра бота | 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) и дуальный обработчик команд
Для обхода ограничений была внедрена многослойная стратегия обработки сущностей:
-
Graceful Fallback Pipeline: Сначала поиск производится в локальном L1-кэше шарда. Если сущность отсутствует, запускается контролируемый
fetchс локальным дебаунсом и дедупликацией параллельных запросов на один и тот же ID. -
Гибридный роутер команд: В переходный период (до повсеместного закрепления 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— правила автоматической очистки каналов.
Внутри каждого менеджера реализован двухуровневый паттерн кэширования:
- L1 In-Memory Collection: На уровне каждого шарда хранится коллекция активных гильдий (
cache: Collection<string, TModuleSetting>). При обработке ивента чтение происходит синхронно из памяти заO(1). - Lazy-инициализация и Auto-Create: Если гильдия обращается впервые, документ атомарно создается в базе и помещается в локальный кэш шарда.
- Периодический 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 и веб-панелей отражала рост требований к скорости и плотности отображения данных:
- Desires (v1): Монолитный Express API + легковесный HTML/CSS интерфейс.
- Niako (v1): Микросервисный REST API на NestJS со Swagger-документацией и дашборд на React 18 с собственным кастомным UI Kit.
- Wind: Дашборд управления серверами на базе дизайн-системы Yandex Gravity UI (
@gravity-ui/uikit), обеспечивающий удобную визуализацию сложных аналитических графиков и настроек. - Rushia: Высокоскоростной embedded API на Hono (
@hono/node-server,hono-rate-limiter) с минимальными накладными расходами на роутинг и дашборд на Next.js.
8. Ключевые инженерные выводы
- Осознанное квотирование кэша: Ограничение
makeCacheи периодические свиперы — единственный способ победить утечки памяти в Node.js при больших объемах WebSocket-трафика. - Отказ от предположений о наличии данных: Проектирование с расчетом на отсутствие привилегированных интентов и ленивая дозагрузка сущностей избавляют от непредвиденных падений.
- Многоуровневое кэширование БД: L1 In-Memory коллекции на уровне каждого шарда спасают базу данных от тысяч идентичных запросов в секунду.
- Изоляция критических контуров: Вынесение служебных ботов (Eral, Osaka) в автономные процессы обеспечивает бесперебойную работу техподдержки при любых авариях на основном кластере.
- Эволюция стека под реальную нагрузку: Переход от Express к NestJS, а затем к Hono и кастомному шардингу позволил платформе обслуживать миллионы пользователей с предсказуемым временем отклика.