دراسة هندسية: توسيع منظومة روبوتات Discord إلى ملايين المستخدمين وآلاف الخوادم

highloaddiscordtypescriptarchitectureshardingnestjshonoredismongodb

دراسة هندسية: توسيع منظومة روبوتات Discord إلى ملايين المستخدمين وآلاف الخوادم

قد يبدو بناء روبوتات لمنصة Discord مهمة بسيطة خلال المراحل التجريبية الأولى. ولكن مع النمو المتسارع ليصل المشروع إلى آلاف الخوادم وملايين المستخدمين النشطين، تصطدم المعمارية الأحادية بحدود قاسية: تسرب ذاكرة V8 Heap، اختناق قنوات WebSocket، غياب أذونات Privileged Gateway Intents، استنزاف اتصالات قواعد البيانات، وانهيارات العمليات المتتالية.

يقدم هذا المقال استعراضًا هندسيًا تفصيليًا لتطور أربعة مشاريع تقنية متتالية: Desires و Niako و Wind و Rushia.


1. التسلسل الزمني للأجيال ومقاييس الحمل

تم تصميم كل مشروع كاستجابة هندسية مباشرة لاحتياجات المنتج وتجاوز اختناقات البنية التحتية:

  1. Desires (3.5 مليون مستخدم · 15,000 خادم):

    • حزمة التقنيات: لغة Vanilla JS، مكتبة discord.js، خدمة مصغرة مستقلة لـ API بـ Express، واجهة HTML/CSS ثابتة (تخطيط v2 بـ Vue + Nuxt).
    • السياق: نقطة الانطلاق في المشاريع عالية التحميل. كان الروبوت يتولى بشكل أحادي وظائف الخوادم العامة وخادم الدعم الفني الخاص به، مما كشف لأول مرة عن مشاكل التجزئة الكلاسيكية واختناق معالجة الأحداث وتسرب الذاكرة.
  2. Niako (2.5 مليون مستخدم · 10,000 خادم):

    • حزمة التقنيات: TypeScript، مكتبة discord.js v14، تقنية discord-hybrid-sharding، Redis، قاعدة بيانات MongoDB (Mongoose)، واجهة REST API كاملة بـ NestJS مع توثيق Swagger.
    • لوحة التحكم: لوحة إدارة خوادم مبنية بـ React 18 مع مكتبة UI Kit مخصصة بالكامل.
    • البنية التحتية: روبوت دعم فني مستقل ومعزول Eral.
  3. Wind (1.0 مليون مستخدم · 1,000 خادم):

    • حزمة التقنيات: TypeScript، معمارية معيارية، دمج حلول مفتوحة المصدر.
    • لوحة التحكم: لوحة تحكم لإدارة الخوادم مبنية على نظام التصميم Yandex Gravity UI.
  4. Rushia (إصدار معماري مكتمل، Pre-release):

    • حزمة التقنيات: TypeScript، مكتبة discord.js v14، واجهة Hono API مدمجة فائقة السرعة (@hono/node-server)، Mongoose 8، محرك الصوت Shoukaku v4، وتواصل بين العمليات عبر Socket.io.
    • لوحة التحكم: لوحة تحكم مخصصة بـ Next.js مع UI Kit مخصص.
    • البنية التحتية: روبوت دعم فني مستقل ومعزول Osaka.
    • سياق التطوير: صُمم المشروع في البداية كنسخة NiakoV2 ثم تطور لاحقًا إلى مشروع Rushia المستقل والمكتمل.

مقارنة معمارية لحزم التقنيات

المشروع الجمهور / الخوادم النواة الأساسية للروبوت طبقة API والخلفية لوحة التحكم روبوت الدعم المعزول
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 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 والتعامل مع الكيانات غير المخزنة مؤقتًا

من أصعب العقبات في روبوتات Discord الكبيرة نظام Privileged Gateway Intents. فبمجرد وصول الروبوت إلى 100 خادم، تفرض المنصة التحقق الصارم للوصول إلى محتوى الرسائل (MESSAGE_CONTENT)، قائمة الأعضاء الكاملة (GUILD_MEMBERS)، وحالات الاتصال (GUILD_PRESENCES).

المشكلة: هياكل بيانات منقوصة

العمل بدون كاش كامل للأعضاء يعطل الشيفرة البرمجية المعتادة:

  • استدعاء guild.members.cache.get(userId) يُرجع undefined في أكثر من 90% من الحالات لعدم إرسال المستخدم لرسائل في الجلسة الحالية للتجزئة.
  • أحداث الصوت (voiceStateUpdate) والتحقق والإشراف تصل كبيانات جزئية (Partials) بدون ملفات تعريف أو أدوار مكتملة.
  • استدعاء guild.members.fetch(userId) العشوائي مع كل حدث يؤدي مباشرة إلى تجاوز معدل الطلبات (HTTP 429 Too Many Requests) في Discord REST API.

الحل الهندسي: التحميل الكسول المتدرج (Lazy Fetching) وتوجيه الأوامر المزدوج

تم بناء مسار متعدد المراحل لمعالجة الكيانات:

  1. مسار الاسترجاع المتدرج (Graceful Fallback Pipeline): يتم فحص ذاكرة L1 المحلية للتجزئة أولاً. وفي حال عدم وجود العضو، يتم إطلاق استدعاء fetch غير متزامن مع دمج الطلبات المتزامنة لنفس المعرف (Deduplication & Debounce).

  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 بجميع الكيانات الواردة — الرسائل، المستخدمين، التفاعلات، الحالات الصوتية، والرموز التعبيرية.

المشكلة

مع عشرات الآلاف من الخوادم، تتدفق مئات الأحداث في كل ثانية عبر WebSocket:

  • آلاف الخوادم × مئات القنوات = ملايين الكائنات المخزنة في الذاكرة.
  • تجاوز استهلاك RAM السقف الافتراضي لـ Node.js (1.4–2.0 جيجابايت)، مما أدى إلى انهيار متكرر بخطأ JavaScript heap out of memory.
  • زيادة تخصيص الذاكرة عبر --max-old-space-size لم تكن حلاً حقيقيًا، بل سببت تجمد النظام لعدة ثوانٍ أثناء تنظيف الذاكرة (Garbage Collection Stop-the-World).

الحل الهندسي: ضبط دقيق عبر cacheWithLimits وعمليات مسح استباقية (Sweepers)

في نواة 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% لكل عملية معالجة.
  • القضاء التام على حوادث نفاد الذاكرة (OOM) حتى أثناء أوقات الذروة.

4. التخزين المؤقت لقواعد البيانات على مستويين (MongoDB + L1 In-Memory Collection)

أدى إجراء استعلامات مباشرة في MongoDB مع كل حدث وارد (الأدوار التلقائية، البادئات، فحص الصلاحيات، الحذف التلقائي) إلى ضغط هائل على القرص ونفاد اتصالات قاعدة البيانات.

معمارية المديرين المعياريين

تم تفكيك المنطق إلى مديري مجالات مستقلين (src/db/):

  • ModuleSettingManager — تكوينات الخوادم العامة.
  • ModuleTrackerManager — تحليلات النشاط الصوتي والكتابي.
  • ModuleRatingManager — النظام الاقتصادي ومستويات الخبرة.
  • AutoDeleteManager — قواعد التنظيف التلقائي للقنوات.

يطبق كل مدير نمط تخزين مؤقت على مستويين:

  1. ذاكرة L1 In-Memory Collection: تحتفظ كل تجزئة بذاكرة محلية للخوادم النشطة (cache: Collection<string, TModuleSetting>) لتنفيذ القراءة بشكل متزامن بزمن O(1).
  2. التحميل الكسول والإنشاء التلقائي: عند تفاعل خادم جديد، يُنشأ المستند ذريًا في MongoDB ويُسجل في ذاكرة التجزئة المحلية.
  3. تنظيف دوري لقاعدة البيانات (Sweeper): عملية خلفية تعمل كل 10 ساعات لمسح المستندات الفارغة للخوادم غير النشطة، مما يمنع تضخم حجم قاعدة البيانات.
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 قيدًا صارمًا: لا يمكن للتجزئة الواحدة خدمة أكثر من 2,500 خادم. وتطلب دعم 10,000–15,000 خادم تشغيل 16–24 تجزئة نشطة.

إدارة العناقيد عبر WebSocket Manager

تقوم مكتبات التجزئة التقليدية بتشغيل التجزئات كعمليات فرعية على جهاز واحد. وللتحكم الكامل في دورة حياة العمليات والتعافي التلقائي، تم بناء المنسق العنقودي NiakoCluster:

  • يقوم خادم التحكم الرئيسي بتوزيع مجموعات التجزئة ديناميكيًا على خوادم المعالجة المستقلة عبر WebSockets (Socket.io).
  • في حال تعطل أي خادم معالجة، يُعيد المنسق توجيه التجزئات إلى خوادم احتياطية تلقائيًا دون الإخلال باتفاقية مستوى الخدمة (SLA).
  • تطبيق تأخير زمني تدريجي لتفادي تجاوز قيود معدل التعرف في Discord (حد أقصى 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. مبدأ العزل المعماري: روبوتات الدعم المستقلة (Eral و Osaka)

أهم درس تشغيلي: عدم دمج خدمات الدعم الفني الحساسة داخل العنقود الرئيسي عالي التحميل.

خلال حقبة Desires، كان الروبوت الرئيسي يدير التذاكر والتحقق والإشراف على خادم الدعم. وعندما توقف الروبوت تحت وطأة الأحمال العالية أو أثناء إعادة التشغيل، فقد خادم الدعم الفني أتمتته تمامًا — مما حرم المستخدمين من فتح التذاكر أو تلقي المساعدة.

ولإلغاء نقطة الفشل الفردية هذه، تم فصل وظائف الدعم بدءًا من Niako إلى روبوتات خفيفة ومستقلة:

  • Eral — روبوت الدعم المخصص لخادم Niako.
  • Osaka — روبوت الدعم المخصص لخادم Rushia.

تعمل هذه الروبوتات في بيئات معزولة تمامًا بقواعد بيانات خاصة، مما يضمن نسبة توفر 99.99% (SLA) لنظام التذاكر والإشراف حتى أثناء الصيانة الشاملة للعنقود الرئيسي.


7. تطور API ولوحات التحكم: UI Kit مخصص ونظام Gravity UI

تطورت الخدمات الخلفية ولوحات التحكم لتلبية متطلبات السرعة وكثافة عرض البيانات:

  1. Desires (v1): واجهة أحادية بـ Express API مع صفحة HTML/CSS بسيطة.
  2. Niako (v1): واجهة REST بـ NestJS و Swagger مع لوحة تحكم بـ React 18 مبنية بـ UI Kit مخصص بالكامل.
  3. Wind: لوحة إدارة خوادم مبنية على Yandex Gravity UI (@gravity-ui/uikit) توفر تمثيلاً مرئيًا غنيًا للرسوم البيانية والإعدادات.
  4. Rushia: واجهة Hono المدمجة فائقة السرعة (@hono/node-server و hono-rate-limiter) بأقل زمن استجابة ممكن مع لوحة بـ Next.js.

8. الخلاصات الهندسية

  1. الذاكرة المؤقتة المحسوبة بدقة: الضبط الصريح لـ makeCache وعمليات المسح الاستباقية هما الدرع الحقيقي لمنع تسرب الذاكرة في Node.js تحت أحمال WebSocket الهائلة.
  2. التصميم بدون افتراض مسبق للبيانات: الاستعداد لغياب أذونات Intents والتحميل الكسول الموجه يمنعان الانهيارات غير المتوقعة في الإنتاج.
  3. التخزين المؤقت متعدد المستويات لقواعد البيانات: مجموعات L1 In-Memory تحمي قواعد البيانات من آلاف الاستعلامات المتكررة في كل ثانية.
  4. المرونة عبر عزل الخدمات: فصل روبوتات الدعم (Eral و Osaka) يحمي قنوات التواصل والمساعدة في أوقات الأزمات.
  5. التطور المتكيف لحزمة التقنيات: الانتقال من Express إلى NestJS ثم إلى Hono والعنقدة المخصصة ضمن توسيع المنظومة لملايين المستخدمين دون تراجع في سرعة الاستجابة.