کلاسهای مجازی مقیاسپذیر: ساخت اتاقهای ۱۰۰۰+ کاربری با LiveKit و NATS
مقدمه
ساخت اپلیکیشنهای همکاری بلادرنگ در مقیاس بالا یکی از چالشبرانگیزترین مسائل در مهندسی وب مدرن است. وقتی شروع به ایجاد یک پلتفرم کلاس مجازی با قابلیت میزبانی هزاران کاربر همزمان کردیم، به سرعت متوجه شدیم که WebRTC به تنهایی نمیتواند این مشکل را به خوبی حل کند. در حالی که WebRTC تجربه تعاملی با تأخیر کم را ارائه میدهد، فراتر از چند صد شرکتکننده در یک اتاق مقیاسپذیر نیست.
این داستان چگونگی ساخت یک معماری اتاق مقیاسپذیر است که از طریق مدیریت هوشمند بر اساس نقش، سیگنالینگ کانال جانبی و ارتقای بدون وقفه کاربر، از بیش از ۱۰۰۰ کاربر همزمان پشتیبانی میکند—و تصمیمات مهندسی که آن را ممکن ساخت.
صورت مسئله: سقف مقیاسپذیری WebRTC
WebRTC در ارتباطات دوطرفه با تأخیر کم عالی عمل میکند. این فناوری پشت Google Meet، Zoom و بیشمار اپلیکیشن ویدیو کنفرانس دیگر قرار دارد. با این حال، WebRTC برای ارتباطات گروههای کوچک طراحی شده است، نه سناریوهای مقیاس بزرگ.
یک SFU معمولی LiveKit میتواند ۲۰۰-۳۰۰ شرکتکننده را قبل از افت کیفیت مدیریت کند. فراتر از این نقطه، چندین مشکل ظاهر میشود:
در همین حال، پلتفرم کلاس مجازی ما نیاز به پشتیبانی از بیش از ۱۰۰۰ بیننده همزمان داشت در حالی که تعامل بلادرنگ برای معلمان و دانشجویان فعال حفظ شود. یک سخنرانی معمولی ممکن است یک معلم، ۱۰-۲۰ شرکتکننده فعال که سؤال میپرسند، و صدها ناظر غیرفعال که به ارتباط دوطرفه نیاز ندارند داشته باشد.
چالش واضح شد: چگونه اتاقهای WebRTC را فراتر از محدودیتهای طبیعیشان مقیاسبندی کنیم در حالی که تجربه تعاملی برای کسانی که به آن نیاز دارند حفظ شود؟
راهکار: معماری بر اساس نقش
ما یک معماری بر اساس نقش طراحی کردیم که کاربران را بر اساس نیازهای تعاملیشان جدا میکند:
| نقش | نوع اتصال | مجوزها | ظرفیت | کاربرد |
|------|-----------|--------|--------|--------|
| تعاملی | LiveKit WebRTC | انتشار/اشتراک کامل | ~۲۰۰ کاربر | معلمان، دانشجویان فعال |
| غیرفعال | بدون اتصال مستقیم | فقط مشاهده، بدون انتشار | نامحدود | ناظران، تازهواردها |
بینش کلیدی این است که در اکثر سناریوهای آموزشی، تنها درصد کمی از کاربران در هر لحظه به ارتباط دوطرفه نیاز دارند. اکثریت ناظران غیرفعالی هستند که میتوانند از طریق یک کانال سیگنالینگ جداگانه مدیریت شوند بدون مصرف منابع WebRTC.
دیاگرام معماری
┌─────────────────────────────────────────────────────────────┐
│ خوشه SFU لایوکیت │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ مدرس │ │ دانشجو A │ │ دانشجو B │ │
│ │ (انتشار) │ │(انتشار/دریافت)│ │(انتشار/دریافت)│ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ └────────────────┼────────────────┘ │
│ │ │
│ شرکتکنندگان تعاملی (WebRTC) │
│ │ │
└──────────────────────────┼──────────────────────────────────┘
│
│
┌─────────┴─────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ ha-api │ │ NATS JetStream│
│ (هماهنگکننده)│ │ (سیگنالینگ) │
└──────┬───────┘ └──────┬───────┘
│ │
│ │
▼ ▼
┌───────────────────────────────────────┐
│ بینندگان غیرفعال (۱۰۰۰+) │
│ ┌──────────┐ ┌──────────┐ ┌──────┐│
│ │ بیننده ۱ │ │ بیننده ۲ │ │ ... ││
│ │ (HTTP) │ │ (HTTP) │ │ ││
│ └──────────┘ └──────────┘ └──────┘│
└───────────────────────────────────────┘
سرویس ha-api: لایه هماهنگسازی
سرویس ha-api مغز معماری مقیاسپذیر ماست. ساخته شده با Node.js و Express، این سرویس مسئول موارد زیر است:
نحوه مدیریت ایجاد اتاق و صدور توکن:
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 بدون هیچ بارگذاری مجدد صفحه یا از دست دادن زمینه.
سفر کاربر
canPublish: true و canSubscribe: trueپیادهسازی سمت کلاینت (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/ثانیه |
درسهای کلیدی
room.{id}.signal.{event} استفاده میکنیم که امکان الگوهای اشتراک انعطافپذیر را فراهم میکند. مدیران میتوانند به همه سیگنالهای یک اتاق مشترک شوند یا بر اساس نوع رویداد فیلتر کنند.نتیجهگیری
ساخت اپلیکیشنهای بلادرنگ مقیاسپذیر نیازمند تفکر فراتر از راهحلهای تکفناوری است. معماری WebRTC بر اساس نقش ما نشان میدهد که با مدیریت هوشمند نقشهای کاربر و استفاده از سیگنالینگ کانال جانبی، میتوانیم هم تعاملی که کاربران انتظار دارند و هم مقیاسپذیری که کسبوکارها نیاز دارند را به دست آوریم.
تصمیمات معماری کلیدی که این را ممکن ساخت:
این رویکرد به ما اجازه داده است پلتفرم کلاس مجازی خود را از صدها به هزاران کاربر مقیاسبندی کنیم در حالی که در واقع هزینههای زیرساخت را کاهش دادهایم. همین الگوها میتوانند برای وبینارها، رویدادهای زنده، حالتهای تماشاگر بازی، یا هر سناریویی که نیاز به ترکیب تعامل بلادرنگ با مشاهده در مقیاس بزرگ دارید، اعمال شوند.
---
سؤالی در مورد پیادهسازی معماریهای مشابه دارید؟ خوشحال میشویم نظرات شما را در بخش کامنتها بشنویم.