بازگشت به وبلاگ‌ها
کلاس‌های مجازی مقیاس‌پذیر: ساخت اتاق‌های ۱۰۰۰+ کاربری با LiveKit و NATS

کلاس‌های مجازی مقیاس‌پذیر: ساخت اتاق‌های ۱۰۰۰+ کاربری با LiveKit و NATS

۱۴۰۴/۹/۲۶5 دقیقه
livekitnatswebrtcnodejsreactscalability

مقدمه

ساخت اپلیکیشن‌های همکاری بلادرنگ در مقیاس بالا یکی از چالش‌برانگیزترین مسائل در مهندسی وب مدرن است. وقتی شروع به ایجاد یک پلتفرم کلاس مجازی با قابلیت میزبانی هزاران کاربر همزمان کردیم، به سرعت متوجه شدیم که WebRTC به تنهایی نمی‌تواند این مشکل را به خوبی حل کند. در حالی که WebRTC تجربه تعاملی با تأخیر کم را ارائه می‌دهد، فراتر از چند صد شرکت‌کننده در یک اتاق مقیاس‌پذیر نیست.

این داستان چگونگی ساخت یک معماری اتاق مقیاس‌پذیر است که از طریق مدیریت هوشمند بر اساس نقش، سیگنالینگ کانال جانبی و ارتقای بدون وقفه کاربر، از بیش از ۱۰۰۰ کاربر همزمان پشتیبانی می‌کند—و تصمیمات مهندسی که آن را ممکن ساخت.

صورت مسئله: سقف مقیاس‌پذیری WebRTC

WebRTC در ارتباطات دوطرفه با تأخیر کم عالی عمل می‌کند. این فناوری پشت Google Meet، Zoom و بی‌شمار اپلیکیشن ویدیو کنفرانس دیگر قرار دارد. با این حال، WebRTC برای ارتباطات گروه‌های کوچک طراحی شده است، نه سناریوهای مقیاس بزرگ.

یک SFU معمولی LiveKit می‌تواند ۲۰۰-۳۰۰ شرکت‌کننده را قبل از افت کیفیت مدیریت کند. فراتر از این نقطه، چندین مشکل ظاهر می‌شود:

  • اتمام CPU و پهنای باند در SFU هنگام فوروارد کردن استریم‌های مدیا به هر شرکت‌کننده

  • محدودیت‌های منابع سمت کلاینت وقتی مرورگرها برای دیکود کردن چندین استریم ویدیو تلاش می‌کنند

  • پیچیدگی شبکه وقتی مش اتصالات به صورت نمایی رشد می‌کند

  • مقیاس‌بندی هزینه وقتی نیازمندی‌های زیرساخت به صورت خطی با تعداد شرکت‌کننده رشد می‌کند
  • در همین حال، پلتفرم کلاس مجازی ما نیاز به پشتیبانی از بیش از ۱۰۰۰ بیننده همزمان داشت در حالی که تعامل بلادرنگ برای معلمان و دانشجویان فعال حفظ شود. یک سخنرانی معمولی ممکن است یک معلم، ۱۰-۲۰ شرکت‌کننده فعال که سؤال می‌پرسند، و صدها ناظر غیرفعال که به ارتباط دوطرفه نیاز ندارند داشته باشد.

    چالش واضح شد: چگونه اتاق‌های WebRTC را فراتر از محدودیت‌های طبیعی‌شان مقیاس‌بندی کنیم در حالی که تجربه تعاملی برای کسانی که به آن نیاز دارند حفظ شود؟

    راهکار: معماری بر اساس نقش

    ما یک معماری بر اساس نقش طراحی کردیم که کاربران را بر اساس نیازهای تعاملیشان جدا می‌کند:

    | نقش | نوع اتصال | مجوزها | ظرفیت | کاربرد |
    |------|-----------|--------|--------|--------|
    | تعاملی | LiveKit WebRTC | انتشار/اشتراک کامل | ~۲۰۰ کاربر | معلمان، دانشجویان فعال |
    | غیرفعال | بدون اتصال مستقیم | فقط مشاهده، بدون انتشار | نامحدود | ناظران، تازه‌واردها |

    بینش کلیدی این است که در اکثر سناریوهای آموزشی، تنها درصد کمی از کاربران در هر لحظه به ارتباط دوطرفه نیاز دارند. اکثریت ناظران غیرفعالی هستند که می‌توانند از طریق یک کانال سیگنالینگ جداگانه مدیریت شوند بدون مصرف منابع WebRTC.

    دیاگرام معماری

    ┌─────────────────────────────────────────────────────────────┐
    │ خوشه SFU لایوکیت │
    │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
    │ │ مدرس │ │ دانشجو A │ │ دانشجو B │ │
    │ │ (انتشار) │ │(انتشار/دریافت)│ │(انتشار/دریافت)│ │
    │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
    │ └────────────────┼────────────────┘ │
    │ │ │
    │ شرکت‌کنندگان تعاملی (WebRTC) │
    │ │ │
    └──────────────────────────┼──────────────────────────────────┘


    ┌─────────┴─────────┐
    │ │
    ▼ ▼
    ┌──────────────┐ ┌──────────────┐
    │ ha-api │ │ NATS JetStream│
    │ (هماهنگ‌کننده)│ │ (سیگنالینگ) │
    └──────┬───────┘ └──────┬───────┘
    │ │
    │ │
    ▼ ▼
    ┌───────────────────────────────────────┐
    │ بینندگان غیرفعال (۱۰۰۰+) │
    │ ┌──────────┐ ┌──────────┐ ┌──────┐│
    │ │ بیننده ۱ │ │ بیننده ۲ │ │ ... ││
    │ │ (HTTP) │ │ (HTTP) │ │ ││
    │ └──────────┘ └──────────┘ └──────┘│
    └───────────────────────────────────────┘

    سرویس ha-api: لایه هماهنگ‌سازی

    سرویس ha-api مغز معماری مقیاس‌پذیر ماست. ساخته شده با Node.js و Express، این سرویس مسئول موارد زیر است:

  • مدیریت چرخه حیات اتاق - ایجاد اتاق‌ها، مدیریت محدودیت شرکت‌کنندگان

  • تولید توکن - صدور توکن‌های مناسب LiveKit بر اساس نقش کاربر

  • ارتقا/تنزیل کاربر - جابجایی کاربران بین نقش‌های تعاملی و غیرفعال

  • پل سیگنال - اتصال بینندگان غیرفعال به لایه تعاملی از طریق NATS
  • نحوه مدیریت ایجاد اتاق و صدور توکن:

    import { AccessToken } from 'livekit-server-sdk';

    interface UserRole {
    userId: string;
    roomId: string;
    role: 'interactive' | 'passive';
    canPublish: boolean;
    canSubscribe: boolean;
    }

    async function generateToken(userRole: UserRole): Promise {
    const token = new AccessToken(
    process.env.LIVEKIT_API_KEY!,
    process.env.LIVEKIT_API_SECRET!,
    {
    identity: userRole.userId,
    ttl: '24h',
    }
    );

    token.addGrant({
    room: userRole.roomId,
    roomJoin: true,
    canPublish: userRole.canPublish,
    canSubscribe: userRole.canSubscribe,
    canPublishData: userRole.canPublish,
    });

    return token.toJwt();
    }

    // کاربر تعاملی مجوزهای کامل دریافت می‌کند
    const interactiveToken = await generateToken({
    userId: 'student-123',
    roomId: 'class-456',
    role: 'interactive',
    canPublish: true,
    canSubscribe: true,
    });

    // کاربر غیرفعال توکنی دریافت نمی‌کند (از طریق روش جایگزین مشاهده می‌کند)
    // اما همچنان می‌تواند از طریق HTTP → NATS → SSE سیگنال دهد

    بررسی عمیق: سیگنالینگ NATS JetStream

    جالب‌ترین چالش مهندسی که با آن مواجه شدیم این بود: چگونه یک بیننده غیرفعال (که هیچ اتصال WebRTC به اتاق ندارد) می‌تواند به مدیر سیگنال دهد که می‌خواهد صحبت کند؟

    در یک تنظیم WebRTC سنتی، شرکت‌کنندگان از کانال‌های داده یا سرور سیگنالینگ برای ارسال پیام استفاده می‌کنند. اما بینندگان غیرفعال ما کاملاً از زیرساخت LiveKit جدا هستند—آن‌ها اصلاً اتصال WebRTC ندارند.

    ما این مشکل را با یک سیستم سیگنالینگ کانال جانبی با استفاده از NATS JetStream حل کردیم.

    چرا NATS JetStream؟

    ما چندین گزینه را برای ستون فقرات سیگنالینگ خود ارزیابی کردیم:

    | فناوری | مزایا | معایب |
    |---------|-------|--------|
    | Redis Pub/Sub | ساده، سریع | بدون پایداری، بدون بازپخش |
    | Kafka | بادوام، مقیاس‌پذیر | سنگین، راه‌اندازی پیچیده |
    | RabbitMQ | بالغ، قابل اعتماد | با مدل event-streaming سازگار نیست |
    | NATS JetStream | سبک، پایدار، exactly-once | انتخاب عالی |

    NATS JetStream بهترین‌های همه دنیاها را به ما داد: سادگی Redis با دوام Kafka، همه در یک باینری سبک. این سیستم بیش از ۵۰,۰۰۰ پیام در ثانیه را با حداقل مصرف منابع مدیریت می‌کند.

    معماری جریان سیگنال

    ┌──────────────────┐                              ┌──────────────────┐
    │ بیننده غیرفعال │ │ مدیر │
    │ (کلاینت HTTP) │ │ (کلاینت WebRTC) │
    └────────┬─────────┘ └────────┬─────────┘
    │ │
    │ HTTP POST /api/rooms/{id}/raise-hand │
    ▼ │
    ┌──────────────────┐ │
    │ ha-api │ │
    │ (Express.js) │ │
    └────────┬─────────┘ │
    │ │
    │ js.publish('room.{id}.signal.raise-hand') │
    ▼ │
    ┌──────────────────┐ │
    │ NATS JetStream │ │
    │ (Stream) │ │
    └────────┬─────────┘ │
    │ │
    │ اشتراک Consumer │
    ▼ │
    ┌──────────────────┐ │
    │ ha-api │──────── SSE Push ─────────────────────▶
    │ (اندپوینت SSE) │ رویداد 'RAISE_HAND' │
    └──────────────────┘ ▼
    ┌──────────────────┐
    │ مدیر رابط │
    │ درخواست را │
    │ می‌بیند │
    └──────────────────┘

    جزئیات پیاده‌سازی

    پیکربندی NATS Stream:

    import { connect, JetStreamManager, RetentionPolicy, StorageType } from 'nats';

    async function setupNatsStreams() {
    const nc = await connect({ servers: process.env.NATS_URL });
    const jsm = await nc.jetstreamManager();

    // ایجاد stream برای سیگنال‌های اتاق
    await jsm.streams.add({
    name: 'ROOM_SIGNALS',
    subjects: ['room..signal.'],
    retention: RetentionPolicy.Limits,
    storage: StorageType.Memory,
    max_age: 3600 * 1e9, // یک ساعت به نانوثانیه
    max_msgs_per_subject: 1000,
    });

    return nc;
    }

    انتشار رویداد درخواست صحبت:

    app.post('/api/rooms/:roomId/raise-hand', authenticate, async (req, res) => {
    const { roomId } = req.params;
    const { userId, displayName } = req.user;

    const subject = room.${roomId}.signal.raise-hand;
    const payload = JSON.stringify({
    type: 'RAISE_HAND',
    userId,
    displayName,
    timestamp: Date.now(),
    metadata: {
    viewerType: 'passive',
    connectionId: req.headers['x-connection-id'],
    },
    });

    await js.publish(subject, payload, {
    msgID: raise-hand-${userId}-${Date.now()}, // حذف تکراری
    });

    res.json({ success: true, message: 'درخواست صحبت با موفقیت ارسال شد' });
    });

    اندپوینت SSE برای مدیران:

    app.get('/api/rooms/:roomId/events', authenticate, requireModerator, async (req, res) => {
    const { roomId } = req.params;

    // تنظیم هدرهای SSE
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    res.flushHeaders();

    // اشتراک در سیگنال‌های اتاق
    const consumer = await js.consumers.get('ROOM_SIGNALS', moderator-${roomId});
    const messages = await consumer.consume();

    for await (const msg of messages) {
    const event = JSON.parse(msg.data.toString());
    res.write(event: ${event.type}\n);
    res.write(data: ${JSON.stringify(event)}\n\n);
    msg.ack();
    }

    req.on('close', () => {
    messages.stop();
    });
    });

    فرآیند ارتقای بدون وقفه

    جواهر تاج معماری ما فرآیند ارتقا است—توانایی ارتقای آنی یک بیننده غیرفعال به یک شرکت‌کننده فعال WebRTC بدون هیچ بارگذاری مجدد صفحه یا از دست دادن زمینه.

    سفر کاربر

  • بیننده غیرفعال اتاق را مشاهده می‌کند - بدون اتصال WebRTC، مصرف منابع حداقل

  • بیننده درخواست صحبت می‌دهد - درخواست HTTP به ha-api → NATS → SSE به مدیر

  • مدیر تأیید می‌کند - روی دکمه "ارتقا" در رابط کاربری کلیک می‌کند

  • ha-api توکن LiveKit تولید می‌کند - با canPublish: true و canSubscribe: true

  • SSE رویداد PROMOTE را ارسال می‌کند - شامل توکن جدید و اطلاعات اتاق

  • کلاینت React کامپوننت‌ها را تعویض می‌کند - کامپوننت بیننده غیرفعال unmount می‌شود، اتاق LiveKit mount می‌شود

  • کاربر اکنون تعاملی است - می‌تواند صحبت کند، ویدیو به اشتراک بگذارد، کاملاً از طریق WebRTC مشارکت کند
  • پیاده‌سازی سمت کلاینت (React)

    import { useEffect, useState, useCallback } from 'react';
    import { LiveKitRoom, VideoConference } from '@livekit/components-react';
    import PassiveViewer from './PassiveViewer';

    type ViewerMode = 'passive' | 'interactive' | 'transitioning';

    interface RoomViewerProps {
    roomId: string;
    userId: string;
    }

    export function RoomViewer({ roomId, userId }: RoomViewerProps) {
    const [viewerMode, setViewerMode] = useState('passive');
    const [livekitToken, setLivekitToken] = useState(null);
    const [isHandRaised, setIsHandRaised] = useState(false);

    // اتصال SSE برای دریافت رویدادها
    useEffect(() => {
    const eventSource = new EventSource(
    /api/rooms/${roomId}/user-events?userId=${userId}
    );

    eventSource.addEventListener('PROMOTE', (event) => {
    const data = JSON.parse(event.data);
    console.log('ارتقا دریافت شد!', data);

    setViewerMode('transitioning');
    setLivekitToken(data.token);

    // تأخیر کوتاه برای اطمینان از انتقال تمیز
    setTimeout(() => {
    setViewerMode('interactive');
    setIsHandRaised(false);
    }, 500);
    });

    eventSource.addEventListener('DEMOTE', (event) => {
    console.log('تنزیل دریافت شد');
    setViewerMode('transitioning');

    setTimeout(() => {
    setLivekitToken(null);
    setViewerMode('passive');
    }, 500);
    });

    eventSource.onerror = (error) => {
    console.error('خطای اتصال SSE:', error);
    // منطق اتصال مجدد را پیاده‌سازی کنید
    };

    return () => eventSource.close();
    }, [roomId, userId]);

    const handleRaiseHand = useCallback(async () => {
    try {
    await fetch(/api/rooms/${roomId}/raise-hand, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    });
    setIsHandRaised(true);
    } catch (error) {
    console.error('درخواست صحبت ناموفق بود:', error);
    }
    }, [roomId]);

    // رندر بر اساس حالت فعلی
    if (viewerMode === 'transitioning') {
    return (




    در حال اتصال به جلسه زنده...




    );
    }

    if (viewerMode === 'interactive' && livekitToken) {
    return (
    token={livekitToken}
    serverUrl={process.env.NEXT_PUBLIC_LIVEKIT_URL}
    connect={true}
    audio={true}
    video={true}
    >


    );
    }

    // حالت غیرفعال - بدون اتصال WebRTC
    return (



    {/ دکمه درخواست صحبت /}

    onClick={handleRaiseHand}
    disabled={isHandRaised}
    className={px-6 py-3 rounded-full font-medium transition-all ${
    isHandRaised
    ? 'bg-yellow-500 text-black'
    : 'bg-blue-600 hover:bg-blue-700 text-white'
    }
    }
    >
    {isHandRaised ? '✋ درخواست ارسال شد' : '🙋 درخواست صحبت'}



    {/ نشانگر حالت غیرفعال /}

    👁️ حالت مشاهده


    );
    }

    هندلر ارتقا سمت سرور

    app.post('/api/rooms/:roomId/promote/:userId', authenticate, requireModerator, async (req, res) => {
    const { roomId, userId } = req.params;

    // بررسی ظرفیت اتاق برای شرکت‌کننده تعاملی دیگر
    const roomInfo = await getRoomInfo(roomId);
    if (roomInfo.interactiveCount >= MAX_INTERACTIVE_PARTICIPANTS) {
    return res.status(400).json({
    error: 'اتاق به ظرفیت شرکت‌کنندگان تعاملی رسیده است'
    });
    }

    // تولید توکن LiveKit با مجوزهای انتشار
    const token = new AccessToken(
    process.env.LIVEKIT_API_KEY!,
    process.env.LIVEKIT_API_SECRET!,
    {
    identity: userId,
    ttl: '24h',
    }
    );

    token.addGrant({
    room: roomId,
    roomJoin: true,
    canPublish: true,
    canSubscribe: true,
    canPublishData: true,
    });

    const jwt = token.toJwt();

    // انتشار رویداد ارتقا از طریق NATS
    await js.publish(room.${roomId}.user.${userId}.event, JSON.stringify({
    type: 'PROMOTE',
    token: jwt,
    roomId,
    timestamp: Date.now(),
    }));

    // به‌روزرسانی نقش کاربر در دیتابیس
    await updateUserRole(userId, roomId, 'interactive');

    res.json({ success: true, message: 'کاربر با موفقیت ارتقا یافت' });
    });

    نتایج عملکرد و درس‌های آموخته شده

    پس از تست بار گسترده و استقرار در محیط تولید، نتایج ما به شرح زیر است:

    معیارها

    | معیار | هدف | دستیابی |
    |--------|------|----------|
    | حداکثر کاربران همزمان | ۱,۰۰۰ | ۱,۲۴۷ |
    | تأخیر ارتقا | <۵ ثانیه | میانگین ۲.۳ ثانیه |
    | تأخیر تحویل سیگنال | <۱ ثانیه | میانگین ۰.۴ ثانیه |
    | هزینه زیرساخت در مقایسه با WebRTC خالص | -۵۰٪ | -۷۸٪ |
    | مصرف CPU کلاینت (غیرفعال) | <۱۰٪ | ۴.۲٪ |
    | توان عملیاتی پیام NATS | ۱۰k/ثانیه | ۵۲k/ثانیه |

    درس‌های کلیدی

  • NATS فوق‌العاده سبک است - یک سرور NATS تنها بیش از ۵۰,۰۰۰ پیام در ثانیه را با حداقل مصرف منابع مدیریت می‌کند. پایداری JetStream سربار ناچیزی اضافه می‌کند. این ویژگی آن را برای لایه سیگنالینگ ما عالی کرد.
  • SSE > WebSocket برای push ساده - برای ارتباط یک‌طرفه سرور به کلاینت، SSE ساده‌تر برای پیاده‌سازی و قابل اعتمادتر از WebSocket است. همچنین با load balancer‌ها بهتر کار می‌کند و به منطق upgrade اتصال نیاز ندارد.
  • UX انتقال حیاتی است - کاربران در ابتدا تأخیر ۲-۳ ثانیه‌ای ارتقا را آزاردهنده می‌یافتند. افزودن یک حالت "در حال انتقال" با انیمیشن بارگذاری، عملکرد ادراک‌شده را به طرز چشمگیری بهبود بخشید. بازخورد بصری باعث شد انتظار عمدی به نظر برسد نه خراب.
  • معماری بر اساس نقش مقیاس می‌دهد - با محدود کردن شرکت‌کنندگان تعاملی به ~۲۰۰ و نگه داشتن بقیه به عنوان ناظران غیرفعال، ما بهبود ظرفیت ۶ برابری با کاهش هزینه ۷۸٪ به دست آوردیم. کلید این بود که تشخیص دهیم همه کاربران به یک سطح تعامل نیاز ندارند.
  • نام‌گذاری subject در NATS اهمیت دارد - ما از subject‌های سلسله‌مراتبی مانند room.{id}.signal.{event} استفاده می‌کنیم که امکان الگوهای اشتراک انعطاف‌پذیر را فراهم می‌کند. مدیران می‌توانند به همه سیگنال‌های یک اتاق مشترک شوند یا بر اساس نوع رویداد فیلتر کنند.
  • تولید توکن سریع است - تولید توکن‌های LiveKit به‌صورت on-demand برای ارتقاها تنها ~۵۰ms تأخیر اضافه می‌کند. ما متادیتای اتاق را cache می‌کنیم اما توکن‌ها را تازه تولید می‌کنیم تا مجوزهای مناسب را تضمین کنیم.
  • نتیجه‌گیری

    ساخت اپلیکیشن‌های بلادرنگ مقیاس‌پذیر نیازمند تفکر فراتر از راه‌حل‌های تک‌فناوری است. معماری WebRTC بر اساس نقش ما نشان می‌دهد که با مدیریت هوشمند نقش‌های کاربر و استفاده از سیگنالینگ کانال جانبی، می‌توانیم هم تعاملی که کاربران انتظار دارند و هم مقیاس‌پذیری که کسب‌وکارها نیاز دارند را به دست آوریم.

    تصمیمات معماری کلیدی که این را ممکن ساخت:

  • جداسازی لایه‌ای بر اساس نقش - همه کاربران به یک سطح تعامل نیاز ندارند. ناظران غیرفعال به اتصالات WebRTC نیاز ندارند.

  • سیگنالینگ کانال جانبی با NATS - جداسازی بینندگان غیرفعال از زیرساخت WebRTC امکان مقیاس نامحدود برای ناظران را فراهم می‌کند.

  • انتقال‌های بدون وقفه سمت کلاینت - نامرئی کردن فناوری برای کاربر نهایی از طریق تعویض داغ کامپوننت‌ها بدون بارگذاری مجدد صفحه.

  • مدیریت هوشمند ظرفیت - مدیریت فعال تعداد شرکت‌کنندگان تعاملی برای ماندن در نقطه بهینه WebRTC.
  • این رویکرد به ما اجازه داده است پلتفرم کلاس مجازی خود را از صدها به هزاران کاربر مقیاس‌بندی کنیم در حالی که در واقع هزینه‌های زیرساخت را کاهش داده‌ایم. همین الگوها می‌توانند برای وبینارها، رویدادهای زنده، حالت‌های تماشاگر بازی، یا هر سناریویی که نیاز به ترکیب تعامل بلادرنگ با مشاهده در مقیاس بزرگ دارید، اعمال شوند.

    ---

    سؤالی در مورد پیاده‌سازی معماری‌های مشابه دارید؟ خوشحال می‌شویم نظرات شما را در بخش کامنت‌ها بشنویم.