پست

طراحی سیستم نظرات زنده Facebook

بررسی گام‌به‌گام طراحی نظرات زنده در ویدیوهای Live شامل ثبت نظر، پخش بلادرنگ با SSE، صفحه‌بندی Cursor، Pub/Sub و مقیاس‌دهی برای میلیون‌ها بیننده هم‌زمان

طراحی سیستم نظرات زنده Facebook

درک مسئله

نظرات زنده Facebook (Facebook Live Comments) قابلیتی است که بینندگان را قادر می‌کند در فید ویدیوی زنده نظر ارسال کنند. بینندگان می‌توانند جریان پیوسته نظرات را به‌صورت بلادرنگ ببینند.

نیازمندی‌های عملکردی

نیازمندی‌های اصلی:

  • بینندگان بتوانند در فید ویدیوی زنده نظر ارسال کنند.
  • بینندگان هنگام تماشای ویدیوی زنده بتوانند نظرهای جدیدی را که ارسال می‌شود ببینند.
  • بینندگان بتوانند نظرهایی را که پیش از پیوستن آن‌ها به فید زنده ارسال شده است ببینند.

موارد خارج از محدوده:

  • پاسخ دادن بینندگان به نظرها
  • واکنش نشان دادن بینندگان به نظرها

نیازمندی‌های غیرعملکردی

نیازمندی‌های اصلی:

  • مقیاس سیستم باید طوری باشد که از میلیون‌ها ویدیوی زنده هم‌زمان و هزاران نظر در ثانیه برای هر ویدیوی زنده پشتیبانی کند.
  • سیستم باید Availability (دسترس‌پذیری) را بر Consistency (سازگاری) مقدم بدارد و Eventual Consistency (سازگاری نهایی) قابل قبول است.
  • سیستم باید Latency (تأخیر) کم داشته باشد و نظرها را نزدیک به بلادرنگ برای بینندگان پخش کند (تأخیر انتها به انتها کمتر از 200 میلی‌ثانیه در شرایط عادی شبکه).

موارد خارج از محدوده:

  • سیستم امن باشد و فقط کاربران مجاز بتوانند نظر ارسال کنند.
  • سیستم قیدهای یکپارچگی محتوا را اعمال کند و مناسب بودن نظرها را تضمین کند (مثلاً بدون هرزنامه و سخنان نفرت‌پراکن).

نکته جالب: اگر تعامل‌ها ظرف 200 میلی‌ثانیه رخ دهند، انسان‌ها معمولاً آن را بلادرنگ می‌دانند. هر Latency زیر 200 میلی‌ثانیه در رابط کاربری معمولاً آنی تلقی می‌شود، پس هنگام ساخت سیستم‌های بلادرنگ، این عدد هدف است.

نکته جالب: محبوب‌ترین ویدیوی زنده Facebook «Chewbacca Mom» نام داشت که مادری را نشان می‌داد که از یک ماسک پلاستیکی و غرغر Chewbacca کاملاً لذت می‌برد. بیش از 180 میلیون بار دیده شده است.

آماده‌سازی

برنامه‌ریزی رویکرد

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

تعریف موجودیت‌های اصلی

بهتر است با یک نمای کلی از موجودیت‌ها (Entities) شروع کنیم. این موجودیت‌های کلیدی فکر ما را هدایت می‌کنند و با پیشرفت به سمت تعریف API، پایه محکمی می‌سازند. آن‌ها را «اسم‌های» سیستم در نظر بگیرید.

چرا در این مرحله فقط موجودیت‌ها و نه کل مدل داده؟ چون در طراحی خیلی زود است و احتمالاً هنوز نمی‌توانیم همه ستون‌ها و فیلدها را دقیق بشماریم. در عوض با درک موجودیت‌های اصلی شروع می‌کنیم و با پیشرفت طراحی، مدل داده را می‌سازیم.

برای این مسئله فقط سه موجودیت اصلی داریم:

  • User (کاربر): می‌تواند بیننده یا پخش‌کننده (Broadcaster) باشد.
  • Live Video (ویدیوی زنده): ویدیویی که توسط یک کاربر پخش می‌شود (این متعلق به تیم دیگری است و مدیریت می‌شود، اما مرتبط است، چون باید با آن یکپارچه شویم).
  • Comment (نظر): پیامی که کاربر در یک ویدیوی زنده ارسال کرده است.

در مصاحبه، این می‌تواند فقط یک فهرست ساده مثل همین باشد.

API یا رابط سیستم

حالا API را مرور می‌کنیم و هر نیازمندی عملکردی را به ترتیب پوشش می‌دهیم. این رویکرد گام‌به‌گام به ما کمک می‌کند تمرکزمان را حفظ کنیم و دامنه را مؤثر مدیریت کنیم.

برای ساخت نظر به یک endpoint ساده از نوع POST نیاز داریم:

1
2
3
4
5
POST /comments/:liveVideoId
Header: JWT | SessionToken
{
    "message": "Cool video!"
}

توجه کنید که userId در بدنه درخواست نیست. به‌جای آن، بخشی از هدر درخواست است، یا از طریق Session Token یا JWT. این الگوی رایجی در اپلیکیشن‌های وب مدرن است. کلاینت مسئول نگه داشتن Session Token یا JWT و ارسال آن در هدر درخواست است. سپس سرور توکن را تأیید می‌کند و userId را از آن بیرون می‌کشد. این روش امن‌تر از فرستادن userId در بدنه است، چون جلوی دستکاری درخواست و جعل هویت کاربران دیگر را می‌گیرد.

همچنین باید بتوانیم نظرهای گذشته یک ویدیوی زنده را واکشی کنیم:

1
GET /comments/:liveVideoId?cursor={last_comment_id}&pageSize=10&sort=desc

صفحه‌بندی (Pagination) برای این endpoint مهم خواهد بود. بعداً که عمیق‌تر شدیم، بیشتر درباره‌اش صحبت می‌کنیم.

طراحی سطح بالا

برای شروع طراحی سطح بالا، اول نیازمندی عملکردی اول را پوشش می‌دهیم.

1) ارسال نظر در فید ویدیوی زنده

اول از همه، باید مطمئن شویم کاربران می‌توانند نظر ارسال کنند. این باید نسبتاً ساده باشد. کاربران یک POST همراه پیام نظر به endpoint ‏POST /comments/:liveVideoId می‌فرستند. سپس سرور درخواست را اعتبارسنجی می‌کند و نظر را در پایگاه داده ذخیره می‌کند.

  • Commenter Client (کلاینت نظردهنده): یک اپلیکیشن وب یا موبایل که به کاربران اجازه می‌دهد در فید ویدیوی زنده نظر ارسال کنند. مسئول احراز هویت کاربر و ارسال نظر به Comment Management Service است.
  • Comment Management Service (سرویس مدیریت نظرها): مسئول ساخت و کوئری نظرهاست. نظرها را از کلاینت نظردهنده می‌گیرد و در پایگاه داده نظرها ذخیره می‌کند. همچنین مسئول بازیابی نظرها از پایگاه داده و فرستادن آن‌ها به کلاینت بیننده است (بعداً بیشتر می‌گوییم).
  • پایگاه داده نظرها: برای پایگاه داده نظرها، DynamoDB را انتخاب می‌کنیم، چون پایگاه داده سریع، مقیاس‌پذیر و بسیار در دسترس است. برای کاربرد ما مناسب است، چون نظرهای ساده‌ای ذخیره می‌کنیم که به رابطه یا تراکنش پیچیده نیاز ندارند، هرچند پایگاه‌های داده دیگر مثل Postgres یا MySQL هم اینجا جواب می‌دهند.

ببینیم وقتی کاربر نظر جدید می‌فرستد دقیقاً چه می‌شود:

  1. کاربر پیش‌نویس نظر را روی دستگاهش می‌نویسد (کلاینت نظردهنده).
  2. کلاینت نظردهنده نظر را با endpoint ‏POST /comments/:liveVideoId به Comment Management Service می‌فرستد.
  3. Comment Management Service درخواست را می‌گیرد و نظر را در پایگاه داده نظرها ذخیره می‌کند.

عالی. آسان بود. اما وقتی بررسی می‌کنیم کاربران چطور نظرها را ببینند، همه‌چیز کمی پیچیده‌تر می‌شود.

2) مشاهده نظرهای جدید هنگام تماشای ویدیوی زنده

حالا که ساخت نظر را مدیریت کرده‌ایم، باید چالش توزیع نظر را حل کنیم: مطمئن شویم وقتی کاربری نظری می‌فرستد، همه بینندگان دیگر آن ویدیوی زنده می‌توانند آن را ببینند.

می‌توانیم با ساده‌ترین رویکرد شروع کنیم: Polling.

یک رویکرد ساده‌لوحانه ولی کارا این است که کلاینت‌ها را هر چند ثانیه یک‌بار برای نظرهای جدید Poll کنیم. از endpoint ‏GET /comments/:liveVideoId?since={last_comment_id} استفاده می‌کنیم و پارامتر since را به درخواست اضافه می‌کنیم که به آخرین شناسه نظری که کلاینت دیده اشاره می‌کند. سرور همه نظرهایی را که بعد از since ارسال شده‌اند برمی‌گرداند و کلاینت آن‌ها را به فهرست نظرهای نمایش‌داده‌شده روی صفحه اضافه می‌کند.

این شروع خوبی است، اما مقیاس نمی‌گیرد. با زیاد شدن تعداد نظرها و بینندگان، تعداد Poll ها هم باید زیاد شود تا با تقاضا هماهنگ بماند. این فشار زیادی روی پایگاه داده می‌آورد و درخواست‌های غیرضروری زیادی می‌سازد (چون بیشتر وقت‌ها نظر جدیدی برای واکشی نیست). برای برآورده کردن نیاز «نزدیک به بلادرنگ»، باید هر چند میلی‌ثانیه پایگاه داده را Poll کنیم که شدنی نیست.

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

3) مشاهده نظرهای قبل از پیوستن به فید زنده

وقتی کاربر به یک ویدیوی زنده می‌پیوندد، به دو چیز نیاز دارد:

  1. باید فوراً شروع به دیدن نظرهای جدید کند، همزمان با ارسال بلادرنگ آن‌ها.
  2. باید تاریخچه نظرهایی را که قبل از پیوستن او ارسال شده ببیند.

برای تاریخچه نظرها، کاربران باید بتوانند برای دیدن نظرهای قدیمی‌تر به بالا اسکرول کنند. این الگوی رابط کاربری «اسکرول بی‌نهایت» (Infinite Scroll) نام دارد و معمولاً در اپلیکیشن‌های چت استفاده می‌شود.

می‌توانیم مجموعه اولیه نظرهای اخیر را با endpoint ‏GET /comments/:liveVideoId واکشی کنیم. اگرچه می‌توانیم از پارامتر since که قبلاً اضافه کردیم استفاده کنیم، این پارامتر نظرهای جدیدتر از یک مهر زمانی را می‌دهد، برخلاف آنچه برای بارگذاری نظرهای تاریخی می‌خواهیم. چیزی که واقعاً می‌خواهیم این است: «N نظر آخر قبل از یک مهر زمانی مشخص را بده».

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

برای پیاده‌سازی صفحه‌بندی دو رویکرد اصلی داریم: صفحه‌بندی Offset و صفحه‌بندی Cursor.

رویکرد بد: صفحه‌بندی Offset

ساده‌ترین روش صفحه‌بندی Offset است. این تکنیک از یک Offset برای تعیین نقطه شروع واکشی مجموعه‌ای از نتایج استفاده می‌کند. در ابتدا Offset روی 0 تنظیم می‌شود تا جدیدترین نظرها بارگذاری شوند و هر بار که کاربر برای بارگذاری بیشتر اسکرول می‌کند، به اندازه تعداد نظرهای واکشی‌شده (اندازه صفحه) زیاد می‌شود. اگرچه پیاده‌سازی ساده است، در فید نظر پرحجم و سریع، چالش‌های مهمی دارد.

نمونه درخواست: GET /comments/:liveVideoId?offset=0&pagesize=10

چالش‌ها: اول اینکه صفحه‌بندی Offset با زیاد شدن حجم نظرها ناکارآمد است. پایگاه داده برای هر کوئری باید همه ردیف‌های قبل از Offset را بشمارد و با حجم بیشتر نظرها زمان پاسخ کندتر می‌شود. مهم‌تر اینکه صفحه‌بندی Offset پایدار نیست. اگر هنگام اسکرول کاربر نظری اضافه یا حذف شود، Offset نادرست می‌شود و کاربر نظرهای تکراری یا گم‌شده می‌بیند.

رویکرد خوب: صفحه‌بندی Cursor

روش بهتر صفحه‌بندی Cursor (نشانگر) است. این تکنیک از یک Cursor برای تعیین نقطه شروع واکشی استفاده می‌کند. Cursor یک شناسه یکتاست که به یک مورد مشخص در فهرست نتایج اشاره می‌کند. در ابتدا Cursor روی جدیدترین نظر تنظیم می‌شود و هر بار که کاربر برای بارگذاری بیشتر اسکرول می‌کند، به‌روز می‌شود. این رویکرد از صفحه‌بندی Offset کارآمدتر است، چون پایگاه داده لازم نیست برای هر کوئری همه ردیف‌های قبل از Cursor را بشمارد (با فرض اینکه روی فیلد Cursor ایندکس ساخته‌ایم). علاوه بر این، صفحه‌بندی Cursor پایدار است، یعنی اگر هنگام اسکرول کاربر نظری اضافه یا حذف شود، Cursor همچنان به مورد درست در فهرست نتایج اشاره می‌کند.

نمونه درخواست: GET /comments/:liveVideoId?cursor={last_comment_id}&pageSize=10

چون DynamoDB را برای پایگاه داده نظرها انتخاب کردیم، می‌توانیم last_comment_id را از طریق ویژگی LastEvaluatedKey در DynamoDB به‌عنوان Cursor استفاده کنیم. کوئری چیزی شبیه این خواهد بود:

1
2
3
4
5
6
7
8
9
10
{
    "TableName": "comments",
    "KeyConditionExpression": "liveVideoId = :liveVideoId AND commentId < :cursor",
    "ExpressionAttributeValues": {
        ":liveVideoId": "liveVideoId",
        ":cursor": "last_comment_id"
    },
    "ScanIndexForward": false,
    "Limit": "pageSize"
}

چالش‌ها: اگرچه صفحه‌بندی Cursor بار پایگاه داده را نسبت به Offset کم می‌کند، هنوز برای هر صفحه جدید از نتایج به یک کوئری پایگاه داده نیاز دارد که در محیط پرترافیک مثل ما می‌تواند قابل توجه باشد.

صفحه‌بندی مبتنی بر Cursor برای کاربرد ما مناسب‌تر است. برخلاف Offset کارآمدتر است، چون لازم نیست همه ردیف‌های قبلی را اسکن کنیم. پایدار است و نظرهای جدید موقعیت Cursor را هنگام اسکرول به هم نمی‌زنند. با ویژگی LastEvaluatedKey در DynamoDB هم خوب کار می‌کند و چون با زیاد شدن حجم نظرها عملکرد ثابت می‌ماند، بهتر مقیاس می‌گیرد.

بررسی‌های عمیق

1) پخش بلادرنگ نظرها برای بینندگان

راه‌حل ساده Polling شروع خوبی بود، اما قرار نیست مصاحبه را موفق کند. به‌جای اینکه کلاینت «حدس بزند» نظرهای جدید چه زمانی آماده‌اند و آن‌ها را درخواست کند، می‌توانیم مدل مبتنی بر Push استفاده کنیم. به این ترتیب سرور می‌تواند نظرهای جدید را به محض ساخته شدن به کلاینت بفرستد.

دو راه اصلی برای پیاده‌سازی داریم: WebSocket ها و Server-Sent Events (SSE). مزایا و معایب هر کدام را می‌سنجیم.

الگو: به‌روزرسانی بلادرنگ (Real-time Updates). نظرات زنده Facebook الگوی به‌روزرسانی‌های بلادرنگ را در مقیاس وسیع نشان می‌دهد. چه پخش نظرها از طریق Server-Sent Events باشد، چه توزیع به‌روزرسانی‌ها از طریق سیستم‌های Pub/Sub یا هماهنگی بین چند سرور، این الگو برای هر سیستمی که به تحویل فوری داده نیاز دارد صدق می‌کند، از ویرایش مشترک تا داشبوردهای زنده و پلتفرم‌های بازی.

جریان به‌روزشده ما با SSE این است:

  1. کاربر نظری ارسال می‌کند و در پایگاه داده ذخیره می‌شود (همان‌طور که بالا گفتیم).
  2. برای اینکه همه بینندگان نظر را ببینند، Comment Management Service نظر را از طریق SSE به همه کلاینت‌های متصلی که در آن ویدیوی زنده مشترک شده‌اند می‌فرستد.
  3. کلاینت بیننده نظر را دریافت می‌کند و آن را به فید نظر اضافه می‌کند تا بیننده ببیند.

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

2) مقیاس‌دهی برای میلیون‌ها بیننده هم‌زمان

به SSE که فناوری مناسبی بود رسیدیم. حالا باید بفهمیم چطور مقیاس بدهیم. با SSE باید برای هر بیننده یک اتصال باز نگه داریم. سرورها و سیستم‌عامل‌های مدرن می‌توانند تعداد زیادی اتصال هم‌زمان را مدیریت کنند، معمولاً در حدود 100 هزار. واقع‌بینانه، منابع سیستم مثل CPU، حافظه و File Descriptor قبل از رسیدن به هر محدودیت نظری گلوگاه می‌شوند. اگر بخواهیم میلیون‌ها بیننده هم‌زمان را پشتیبانی کنیم، نمی‌توانیم این کار را روی یک ماشین انجام بدهیم. باید با اضافه کردن سرور بیشتر افقی مقیاس بدهیم.

سؤال بعدی این است که بار را چطور بین چند سرور توزیع کنیم و مطمئن شویم هر سرور می‌داند کدام نظرها را برای کدام بیننده بفرستد.

برخلاف تصور غلط رایج، ظرفیت به 65535 اتصال محدود نیست. این عدد به محدوده شماره پورت‌ها اشاره دارد، نه تعداد اتصال‌هایی که یک پورت سرور می‌تواند تحمل کند. هر اتصال TCP با ترکیب یکتای IP مبدأ، پورت مبدأ، IP مقصد و پورت مقصد شناسایی می‌شود. با تنظیم مناسب سیستم‌عامل و تخصیص منابع، یک پورت در حال گوش دادن می‌تواند صدها هزار یا حتی میلیون‌ها اتصال SSE هم‌زمان را تحمل کند. با این حال در عمل، محدودیت‌های سخت‌افزار و سیستم‌عامل سرور، نه محدودیت نظری پورت، حداکثر تعداد اتصال هم‌زمان را تعیین می‌کنند.

قبل از رفتن سراغ راه‌حل‌ها، چالش اصلی مقیاس افقی را بفهمیم. وقتی سرور بیشتری برای مدیریت بار اضافه می‌کنیم، بینندگانی که یک ویدیوی زنده را تماشا می‌کنند ممکن است به سرورهای مختلف وصل باشند. مثلاً:

  • UserA در حال تماشای Live Video 1 است و به Server 1 وصل است.
  • UserB هم Live Video 1 را تماشا می‌کند ولی به Server 2 وصل است.

حالا تصور کنید نظر جدیدی در Live Video 1 ارسال می‌شود. اگر این درخواست نظر به Server 1 برسد:

  • Server 1 به‌راحتی می‌تواند آن را برای UserA بفرستد، چون مستقیم وصل‌اند.
  • اما Server 1 راهی ندارد آن را برای UserB که به Server 2 وصل است بفرستد.

چالش اصلی ما این است: چطور مطمئن شویم همه بینندگان نظرهای جدید را می‌بینند، فارغ از اینکه به کدام سرور وصل‌اند؟

رویکرد خوب: Pub/Sub ساده

اولین کار این است که ترافیک نوشتن و خواندن را با ساختن سرورهای پیام‌رسانی بلادرنگ (Realtime Messaging Servers) که مسئول فرستادن نظرها به بینندگان‌اند، جدا کنیم. این را جدا می‌کنیم، چون ترافیک نوشتن بسیار کمتر از ترافیک خواندن است و باید بتوانیم ترافیک خواندن را مستقل مقیاس بدهیم.

برای توزیع یکنواخت ترافیک ورودی بین چند سرور، می‌توانیم از یک الگوریتم Load Balancing ساده مثل Round Robin استفاده کنیم. بعد از وصل شدن به سرور پیام‌رسانی بلادرنگ از طریق Load Balancer، کلاینت باید پیامی بفرستد که به سرور بگوید کدام ویدیوی زنده را تماشا می‌کند. سپس سرور پیام‌رسانی نگاشتی را در حافظه محلی با این اطلاعات به‌روز می‌کند. این نگاشت چیزی شبیه این است:

1
2
3
4
5
{
    "liveVideoId1": ["sseConnection1", "sseConnection2", "sseConnection3"],
    "liveVideoId2": ["sseConnection4", "sseConnection5", "sseConnection6"],
    "liveVideoId3": ["sseConnection7", "sseConnection8", "sseConnection9"]
}

که sseConnection{N} نماینده اتصال SSE آن بیننده است. حالا هر بار که نظر جدیدی ساخته می‌شود، سرور می‌تواند فهرست بینندگان آن ویدیوی زنده را پیمایش کند و نظر را برای هر کدام بفرستد.

سپس سؤال این است که هر سرور پیام‌رسانی بلادرنگ چطور می‌فهمد نظر جدیدی ساخته شده؟ رایج‌ترین راه‌حل معرفی Pub/Sub (الگوی انتشار/اشتراک) است.

سیستم Pub/Sub یک سیستم پیام‌رسانی است که از مدل انتشار/اشتراک استفاده می‌کند. ناشران (Publisher) به یک کانال یا Topic پیام می‌فرستند و مشترک‌ها (Subscriber) پیام‌های یک کانال یا Topic را دریافت می‌کنند. در مورد ما، Comment Management Service هر بار که نظر جدیدی ساخته می‌شود، پیامی در یک کانال منتشر می‌کند. همه سرورهای پیام‌رسانی بلادرنگ در این کانال مشترک می‌شوند و پیام را می‌گیرند. سپس نظر را برای همه بینندگانی که آن ویدیوی زنده را تماشا می‌کنند می‌فرستند.

چالش‌ها: این روش کار می‌کند، اما خیلی کارآمد نیست. هر سرور پیام‌رسانی بلادرنگ باید هر نظر را پردازش کند، حتی اگر ویدیوی زنده‌ای را که آن نظر به آن مربوط است سرو نکند. این به ناکارآمدی، عملکرد کند و شدت محاسباتی بالا می‌رسد که در مقیاس Facebook غیرعملی است.

رویکرد خوب: Pub/Sub پارتیشن‌بندی‌شده

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

چون داشتن یک کانال برای هر ویدیوی زنده منابع زیادی مصرف می‌کند و در بعضی موارد مثل Kafka ممکن است حتی ممکن نباشد، می‌توانیم از یک تابع هش برای توزیع بار بین چند کانال استفاده کنیم. می‌توانیم N کانال بسازیم و با hash(liveVideoId) % N تعیین کنیم نظر در کدام کانال پخش شود. این تضمین می‌کند حد بالای معقولی برای تعداد کانال‌ها وجود دارد.

چالش‌ها: اگرچه این رویکرد کارآمدتر است، کامل نیست. با استفاده از Load Balancer ساده این خطر هست که یک سرور در نهایت بینندگان جریان‌های مختلف را سرو کند و در نتیجه به کانال‌های مختلف مشترک شود و مشکل رویکرد قبلی تکرار شود.

رویکرد عالی: Pub/Sub با Layer 7 Load Balancer یا Dispatcher

رویکرد عالی (الف): هم‌مکان کردن بینندگان یک ویدیو

برای حل مشکل سرورهایی که بینندگان ویدیوهای زنده مختلف زیادی را مدیریت می‌کنند، به استراتژی تخصیص هوشمندانه‌تری نیاز داریم. هدف این است که هر سرور عمدتاً بینندگان یک ویدیوی زنده را مدیریت کند، که محدود کردن تعداد Topic ها یا کانال‌هایی را که هر سرور باید مشترک آن‌ها باشد آسان‌تر می‌کند. دو راه برای رسیدن به این تخصیص داریم که هر دو به اسکریپت یا پیکربندی نیاز دارند.

گزینه 1: مسیریابی هوشمند از طریق اسکریپت یا پیکربندی Load Balancer. یک Load Balancer لایه 7 (Layer 7) یا API Gateway می‌تواند از Consistent Hashing (هشینگ پایدار) بر اساس liveVideoId استفاده کند. با بررسی درخواست، مثلاً هدر یا پارامتر مسیری که liveVideoId را دارد، Load Balancer می‌تواند تابع هش اعمال کند که همیشه بینندگان یک ویدیو را به یک سرور مشخص می‌فرستد. ابزارهایی مثل NGINX یا Envoy را می‌توان با Lua یا دستورالعمل‌های پیکربندی برای رسیدن به این مسیریابی مبتنی بر محتوا اسکریپت کرد، که سربار را کم می‌کند و بینندگان مرتبط را کنار هم نگه می‌دارد.

گزینه 2: جست‌وجوی پویا از طریق سرویس هماهنگی (مثلاً ZooKeeper). به‌جای تکیه بر هشینگ، می‌توانیم نگاشت پویای liveVideoId به یک سرور مشخص را در سرویس هماهنگی مثل ZooKeeper ذخیره کنیم. Load Balancer یا یک لایه Proxy سفارشی از ZooKeeper می‌پرسد تا برای هر liveVideoId سرور درست را تعیین کند. با تغییر الگوی ترافیک، سرورهای جدید می‌توانند خودشان را ثبت کنند یا نگاشت‌های موجود در ZooKeeper به‌روز شوند و Load Balancer درخواست‌های آینده را بر آن اساس مسیریابی کند. این رویکرد انعطاف بیشتری دارد، اما پیچیدگی عملیاتی می‌آورد و ذخیره‌سازی و مدیریت خرابی دقیق می‌خواهد.

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

داوطلبان پیشرفته ممکن است به مبادلات سیستم‌های Pub/Sub مختلف اشاره کنند. مثلاً Kafka یک سیستم Pub/Sub محبوب است که بسیار مقیاس‌پذیر و تحمل‌پذیر در برابر خطاست، اما با سناریوهایی که اشتراک و لغو اشتراک پویا بر اساس تعامل کاربر لازم است (مثل اسکرول در فید زنده یا جابه‌جایی بین ویدیوهای زنده) مشکل دارد. Redis Pub/Sub تأخیر کمی دارد، اما ماندگاری پیام ندارد (Fire-and-forget)، که می‌تواند در صورت قطع شدن به پیام‌های گم‌شده برسد. Kafka با اینکه تأخیر بیشتری دارد، ماندگاری پیام و معناشناسی تحویل «دقیقاً یک‌بار» (Exactly-once) را فراهم می‌کند و برای سناریوهایی که تضمین تحویل پیام حیاتی است، مناسب‌تر است.

با این حال، نگرانی اصلی Redis امکان از دست رفتن داده به‌خاطر نوشتن متناوب روی دیسک و چالش‌های محدودیت حافظه است که می‌تواند گلوگاه مقیاس‌پذیری باشد. علاوه بر این، اگرچه Redis پیکربندی‌های دسترس‌پذیری بالا مثل Redis Sentinel یا Redis Active-Active ارائه می‌کند، این پیکربندی‌ها پیچیدگی عملیاتی مدیریت سیستم مبتنی بر Redis را بیشتر می‌کنند. راه‌حل Pub/Sub «پاسخ درست آکادمیک» است و باید به‌راحتی مصاحبه را موفق کند، اما واقعیت این است که انتخاب سیستم Pub/Sub مناسب تصمیم پیچیده‌ای است که درک عمیق نیازمندی‌ها و مبادلات سیستم را می‌خواهد.

رویکرد عالی (ب): سرویس Dispatcher

همه رویکردهای قبلی ما به یک سیستم Pub/Sub تکیه داشتند، که در آن هر سرور پیام‌رسانی بلادرنگ در Topic ها مشترک می‌شود و نظرها را هنگام انتشار دریافت می‌کند. یعنی سرویس ساخت نظر لازم نیست سرور مقصد را بشناسد. فقط پیام‌ها را منتشر می‌کند و سرورهای مناسب آن‌ها را می‌گیرند. این رویکرد با اینکه استاندارد و مؤثر است، در مدیریت اشتراک‌ها و مقیاس‌دهی بارکاری سنگین پیچیدگی اضافه می‌کند.

رویکرد جایگزین، مدل را معکوس می‌کند: سرویس ساخت نظر هر نظر جدید را مستقیم به سرور پیام‌رسانی درست مسیریابی می‌کند. برای این کار یک سرویس Dispatcher معرفی می‌کنیم. به‌جای پخش در Topic ها، سرویس Dispatcher سرور دقیقی را که مسئول بینندگان یک ویدیوی زنده است تعیین می‌کند و نظر را به همان‌جا می‌فرستد. بنابراین وقتی نظر جدیدی می‌رسد، سرور دریافت‌کننده از سرویس Dispatcher می‌پرسد: «من باید این نظر را برای کدام سرورها بفرستم؟»

در سطح بالا، روش کار این است:

  1. نگهداری پویا: سرویس Dispatcher نگاشت پویایی نگه می‌دارد که مشخص می‌کند کدام سرور پیام‌رسانی بلادرنگ مسئول کدام liveVideoId است. این می‌تواند در حافظه نگه داشته شود و دوره‌ای از یک ذخیره‌ساز پایدار (مثل ZooKeeper یا etcd) به‌روز شود، یا از طریق Heartbeat و پروتکل‌های ثبت‌نام هنگام پیوستن یا ترک سرورها به‌روز شود.
  2. ثبت‌نام و کشف (Registration and Discovery): وقتی سرور پیام‌رسانی بلادرنگ آنلاین می‌شود، خود را در سرویس Dispatcher (یا ذخیره‌ساز هماهنگی‌ای که با آن مشورت می‌کند) ثبت می‌کند. Dispatcher نگاشت داخلی خود را به‌روز می‌کند تا نشان بدهد کدام سرور اکنون ویدیوهای زنده مشخصی را سرو می‌کند. به همین ترتیب، اگر سرور کم شود یا از کار بیفتد، Dispatcher نگاشت را مطابق آن به‌روز می‌کند.
  3. مسیریابی مستقیم: هنگام دریافت نظر جدید، Comment Management Service با سرویس Dispatcher تماس می‌گیرد تا سرور درست را تعیین کند. Dispatcher از نگاشت فعلی خود استفاده می‌کند و نظر را مستقیم به سرور پیام‌رسانی بلادرنگ می‌فرستد و آن سرور آن را برای بینندگان متصل پخش می‌کند.
  4. مقیاس‌پذیری و افزونگی: در سناریوهای پرتقاضا، چند نمونه Dispatcher می‌توانند موازی پشت یک Load Balancer اجرا شوند. همه آن‌ها از داده هماهنگی یکسانی استفاده می‌کنند و سازگاری را تضمین می‌کنند. این همانندسازی افزونگی اضافه می‌کند و سیستم را در برابر خرابی مقاوم‌تر می‌کند.

چالش‌ها: چالش اصلی تضمین دقیق و به‌روز بودن نگاشت سرویس Dispatcher است. تغییرات سریع در توزیع بینندگان (مثلاً یک پخش وایرال که هجوم ناگهانی بینندگان می‌آورد) ایجاب می‌کند دید Dispatcher از سیستم مکرر تازه شود. این ممکن است سرویس‌های هماهنگی مثل ZooKeeper یا etcd را درگیر کند که باید تغییرات را مؤثر بین همه نمونه‌های Dispatcher منتشر کنند.

مشکل دیگر حفظ سازگاری بین چند نمونه Dispatcher است. اگر اطلاعات مسیریابی را کش کنند، به‌روزرسانی‌ها باید هماهنگ شوند تا نگاشت‌های کهنه نداشته باشیم. Strong Consistency را می‌توان از طریق سرویس هماهنگی توزیع‌شده، سیاست‌های دقیق ابطال کش و احتمالاً مکانیزم انتخاب رهبر (Leader Election) برای مدیریت امن به‌روزرسانی‌ها به دست آورد.

هر دو رویکرد Pub/Sub با هم‌مکان کردن بینندگان و رویکرد سرویس Dispatcher راه‌حل‌های عالی‌اند. Pub/Sub معمولاً موارد گوشه‌ای کمتری دارد و ساده‌تر است، پس همان چیزی است که خودم در مصاحبه استفاده می‌کنم، ولی هر دو پاسخ «درست»‌اند.

انتظارات در هر سطح

سطح Mid-level

  • عرض در برابر عمق: تمرکز بیشتر روی عرض است (حدود 80 درصد عرض و 20 درصد عمق). باید بتوانید طراحی سطح بالایی بسازید که نیازمندی‌های عملکردی تعریف‌شده را پوشش بدهد، هرچند با بسیاری از اجزا فقط آشنایی سطحی داشته باشید.
  • مرور مبانی: مصاحبه‌کننده مدتی را صرف بررسی مبانی می‌کند تا مطمئن شود می‌دانید هر جزء چه کاری می‌کند. مثلاً اگر API Gateway اضافه کنید، انتظار داشته باشید بپرسد چه کاری می‌کند و در سطح بالا چطور کار می‌کند. او هیچ چیز را بدیهی فرض نمی‌کند.
  • ترکیب هدایت و همراهی: در مراحل اولیه مصاحبه باید خودتان جلو بروید، اما مصاحبه‌کننده انتظار ندارد مشکلات طراحی را با دقت بالا پیش‌دستانه تشخیص بدهید. طبیعی است که در مراحل بعد، با بررسی طراحی شما، خودش هدایت را به دست بگیرد.
  • معیار برای نظرات زنده FB: انتظار دارم داوطلب فعالانه محدودیت‌های رویکرد Polling را بفهمد و شروع به استدلال درباره مدل مبتنی بر Push کند. با کمی راهنمایی، باید بتواند راه‌حل Pub/Sub را ارائه بدهد و با کمک مصاحبه‌کننده آن را مقیاس بدهد.

سطح Senior

  • عمق تخصص: انتظار به سمت دانش عمیق‌تر می‌رود، حدود 60 درصد عرض و 40 درصد عمق. باید در حوزه‌هایی که تجربه عملی دارید وارد جزئیات فنی شوید و درک عمیقی از مفاهیم و فناوری‌های کلیدی مسئله نشان بدهید.
  • طراحی سیستم پیشرفته: باید با اصول پیشرفته طراحی سیستم آشنا باشید. مثلاً دانستن اینکه چطور از Pub/Sub برای پخش پیام استفاده کنید. همچنین انتظار می‌رود بعضی چالش‌های ناشی از آن را بفهمید و درباره استراتژی‌های دقیق مقیاس‌دهی بحث کنید (حتی اگر نیاز به کمی کاوش و نکته از مصاحبه‌کننده داشته باشد). توانایی شما در حرکت با اطمینان و وضوح در این موضوع‌های پیشرفته کلیدی است.
  • تشریح تصمیم‌های معماری: باید مزایا و معایب انتخاب‌های معماری را روشن بیان کنید، به‌ویژه اثر آن‌ها روی مقیاس‌پذیری، عملکرد و قابلیت نگهداری، و تصمیم‌هایتان را توجیه کنید.
  • حل مسئله و پیش‌قدمی: باید مهارت حل مسئله قوی و رویکرد فعالانه نشان بدهید. یعنی چالش‌های احتمالی طراحی را پیش‌بینی کنید، بهبود پیشنهاد بدهید، گلوگاه‌ها را پیدا و رفع کنید، عملکرد را بهینه کنید و از قابلیت اطمینان سیستم مطمئن شوید.
  • معیار برای نظرات زنده FB: از داوطلبان E5 انتظار می‌رود طراحی سطح بالای اولیه را سریع جلو ببرند تا بتوانند وقتشان را صرف بحث درباره مقیاس‌دهی سیستم کنند. باید بتوانید درباره محدودیت‌های طراحی اولیه استدلال کنید و با حداقل راهنمایی، راه‌حل Pub/Sub ارائه بدهید. باید بحث مقیاس‌دهی را فعالانه هدایت کنید و بتوانید درباره مبادلات راه‌حل‌های مختلف استدلال کنید.

سطح Staff به بالا

  • تأکید بر عمق: انتظار می‌رود وارد ظرافت‌های طراحی سیستم شوید، با حدود 40 درصد عرض و 60 درصد عمق. حتی اگر این مسئله خاص را قبلاً حل نکرده باشید، باید در دنیای واقعی آن‌قدر مسئله حل کرده باشید که با پشتوانه تجربه‌تان با اطمینان راه‌حل طراحی کنید.
  • دانش عملی فناوری‌ها: باید بدانید از چه فناوری‌هایی استفاده کنید، نه فقط در تئوری بلکه در عمل، و با تجربه‌های قبلی توضیح بدهید چطور برای حل مسائل خاص به کار می‌روند. مصاحبه‌کننده می‌داند مباحث ساده‌تر (REST API، نرمال‌سازی داده و غیره) را می‌دانید، پس سریع از آن‌ها عبور می‌کنید تا برای بخش‌های جذاب‌تر وقت بماند.
  • پیش‌قدمی بالا: انتظار می‌رود مسائل را مستقل شناسایی و حل کنید و چالش‌های اصلی طراحی را بشناسید. این فقط پاسخ به مشکلات پس از بروز نیست، بلکه پیش‌بینی آن‌ها و اجرای راه‌حل‌های پیشگیرانه هم هست. مصاحبه‌کننده فقط باید برای تمرکز دخالت کند، نه برای هدایت.
  • کاربرد عملی فناوری: باید در به‌کارگیری عملی فناوری‌های مختلف مسلط باشید. تجربه شما باید گفت‌وگو را هدایت کند و درک روشنی از پیکربندی ابزارها و سیستم‌ها در سناریوهای واقعی نشان بدهد.
  • حل مسئله پیچیده و تصمیم‌گیری: نه‌تنها باید چالش‌های فنی پیچیده را حل کنید، بلکه تصمیم‌های آگاهانه‌ای بگیرید که مقیاس‌پذیری، عملکرد، قابلیت اطمینان و نگهداری را در نظر می‌گیرد.
  • طراحی پیشرفته و مقیاس‌پذیری: رویکرد شما باید بر مقیاس‌پذیری و قابلیت اطمینان در بار بالا متمرکز باشد. این شامل درک کامل سیستم‌های توزیع‌شده، Load Balancing، استراتژی‌های ذخیره‌سازی و دیگر مفاهیم پیشرفته است.
  • معیار برای نظرات زنده FB: برای داوطلب Staff به بالا، انتظار از عمق و کیفیت راه‌حل‌ها بالاست، به‌ویژه درباره مقیاس‌دهی پخش نظرها. از داوطلبان Staff به بالا انتظار دارم نه‌تنها راه‌حل Pub/Sub را شناسایی کنند، بلکه فعالانه محدودیت‌های قابلیت اطمینان یا مقیاس‌پذیری را مطرح کنند و راه‌حل پیشنهاد بدهند. آن‌ها احتمالاً درک خوبی از فناوری دقیقی که استفاده می‌کنند دارند و می‌توانند مبادلات راه‌حل‌های مختلف را به‌تفصیل بحث کنند.