پست

طراحی اپلیکیشن پیام‌رسان مانند WhatsApp

بررسی گام‌به‌گام طراحی سیستم WhatsApp شامل ساخت چت گروهی، ارسال و دریافت پیام، تحویل پیام به کاربران آفلاین، ارسال رسانه، مقیاس‌دهی به میلیاردها کاربر و پشتیبانی از چند دستگاه

طراحی اپلیکیشن پیام‌رسان مانند WhatsApp

درک مسئله

WhatsApp یک سرویس پیام‌رسانی است که به کاربران اجازه می‌دهد از تلفن و رایانه خود پیام و تماس رمزگذاری‌شده ارسال و دریافت کنند. WhatsApp در ابتدا با Erlang ساخته شد (دیگر نه!) و به‌خاطر مدیریت مقیاس بسیار بالا با هزینه‌های مهندسی و زیرساخت محدود، شهرت زیادی دارد.

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

اپلیکیشن‌هایی مثل WhatsApp و Messenger ویژگی‌های زیادی دارند، اما مصاحبه‌کننده انتظار ندارد همه آن‌ها را پوشش بدهید. واضح‌ترین قابلیت‌ها تقریباً قطعاً داخل محدوده‌اند، ولی خوب است از مصاحبه‌کننده بپرسید می‌خواهد از آن‌ها فراتر بروید یا نه. صرف زمان زیاد روی نیازمندی‌ها، پرداختن به جزئیات در ادامه مصاحبه را برایتان سخت می‌کند، پس اینجا زیاد معطل نمی‌شویم!

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

  • کاربر بتواند یک چت گروهی با چند شرکت‌کننده شروع کند (حداکثر 100 نفر).
  • کاربر بتواند پیام ارسال و دریافت کند.
  • کاربر بتواند پیام‌هایی را که در زمان آفلاین بودن برایش ارسال شده، دریافت کند (تا 30 روز).
  • کاربر بتواند در پیام‌هایش رسانه ارسال و دریافت کند.

مورد سوم برای همه بدیهی نیست (ولی طراحی جالبی دارد) و اگر من مصاحبه‌کننده شما بودم، احتمالاً شما را به سمت آن هدایت می‌کردم.

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

  • تماس صوتی و تصویری
  • تعامل با کسب‌وکارها
  • ثبت‌نام و مدیریت پروفایل

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

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

  • پیام‌ها باید با Latency (تأخیر) کم، کمتر از 500 میلی‌ثانیه، به کاربران در دسترس تحویل داده شوند.
  • باید تحویل پیام را تضمین کنیم. پیام‌ها باید حتماً به کاربران برسند.
  • سیستم باید بتواند میلیاردها کاربر را با Throughput (نرخ گذردهی داده) بالا مدیریت کند (بعداً برآورد می‌کنیم).
  • پیام‌ها نباید بیش از مدت لازم روی سرورهای متمرکز بمانند.
  • سیستم باید در برابر خرابی اجزای مجزا تاب‌آور (Resilient) باشد.

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

  • پوشش جامع نگرانی‌های امنیتی
  • سیستم‌های جلوگیری از هرزنامه (Spam) و Scraping

افزودن ویژگی‌های خارج از محدوده «اضافه‌ای مفید» است. تفکر محصولی را نشان می‌دهد و به مصاحبه‌کننده فرصت می‌دهد بر اساس آنچه می‌خواهد ببیند، شما را در اولویت‌بندی کمک کند. اما اگر این ویژگی‌ها زود به ذهنتان نمی‌رسد (یا قبلاً زمان زیادی را مصرف کرده‌اید)، وقت را هدر ندهید و ادامه بدهید. صرف زمان گران‌بها برای مشخص کردن ویژگی‌های خارج از محدوده آسان است، ولی در تصمیم استخدام ارزش ناچیزی دارد.

آماده‌سازی

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

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

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

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

در بخش موجودیت‌های اصلی (Core Entities) به «اسم‌های» اصلی سیستم فکر می‌کنیم. هدف این است که زبان مناسبی برای استدلال درباره مسئله داشته باشیم و زمینه را برای API و مدل داده فراهم کنیم.

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

می‌توانیم نیازمندی‌های عملکردی را مرور کنیم تا بفهمیم موجودیت‌های اصلی چیست. به این‌ها نیاز داریم:

  • User (کاربر)
  • Chat (چت): بین 2 تا 100 کاربر
  • Message (پیام)
  • Client (کلاینت): یک کاربر ممکن است چند دستگاه داشته باشد

از این زبان برای استدلال درباره مسئله استفاده می‌کنیم.

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

در مرحله بعد درباره API سیستم فکر می‌کنیم. برخلاف بسیاری از محصولات دیگر که احتمالاً REST API برایشان مناسب است، در یک اپلیکیشن چت، به‌روزرسانی‌های پرتناوب هم ارسال و هم دریافت می‌شوند. این مورد استفاده عالی برای اتصال سوکت دوطرفه (Bidirectional Socket Connection) است!

الگو: به‌روزرسانی بلادرنگ (Real-time Updates). اتصال‌های WebSocket و پیام‌رسانی هم‌زمان نمونه‌ای از الگوی گسترده‌تر به‌روزرسانی‌های بلادرنگ هستند که در بسیاری از سیستم‌های توزیع‌شده به کار می‌رود. چه پیام چت باشد، چه داشبورد زنده، ویرایش مشترک یا بازی، اصول یکسان است: اتصال‌های پایدار برای Latency کم، Pub/Sub (الگوی انتشار/اشتراک) برای مقیاس‌دهی بین سرورها و مدیریت دقیق وضعیت برای قابلیت اطمینان.

برای این مصاحبه فقط از WebSocket ها استفاده می‌کنیم، هرچند یک اتصال ساده TLS هم کافی است. ایده این است که کاربران اپلیکیشن را باز می‌کنند و به سرور وصل می‌شوند و این سوکت باز می‌شود. از این سوکت برای ارسال و دریافت فرمان‌هایی استفاده می‌شود که API ما را می‌سازند.

فقط دانستن اینکه اتصال WebSocket داریم کافی نیست. باید بدانیم چه فرمان‌هایی را می‌خواهیم روی این سوکت رد و بدل کنیم.

اول اجازه بدهید بتوانیم یک چت بسازیم:

1
2
3
4
5
6
7
// -> createChat
{
    "participants": [],
    "name": ""
} -> {
    "chatId": ""
}

حالا باید بتوانیم در چت پیام بفرستیم:

1
2
3
4
5
6
// -> sendMessage
{
    "chatId": "",
    "message": "",
    "attachments": []
} -> "SUCCESS" | "FAILURE"

به راهی برای ساخت پیوست (Attachment) هم نیاز داریم (توجه: بعداً این را در مصاحبه اصلاح می‌کنم):

1
2
3
4
5
6
7
// -> createAttachment
{
    "body": ...,
    "hash":
} -> {
    "attachmentId": ""
}

و به راهی برای اضافه یا حذف کاربران از چت نیاز داریم:

1
2
3
4
5
6
// -> modifyChatParticipants
{
    "chatId": "",
    "userId": "",
    "operation": "ADD" | "REMOVE"
} -> "SUCCESS" | "FAILURE"

هر کدام از این فرمان‌ها یک فرمان موازی دارند که برای کلاینت‌های دیگر فرستاده می‌شود. وقتی کلاینت‌ها فرمان را دریافت کردند، یک فرمان ack برای سرور می‌فرستند و به او اطلاع می‌دهند که فرمان دریافت شده است (و لازم نیست دوباره ارسال شود)!

تأیید دریافت پیام (Ack) کمی ناملموس است، ولی برای اینکه مطمئن شویم پیام‌ها گم نمی‌شوند، بسیار مهم است. با اجبار کلاینت‌ها به Ack کردن، می‌توانیم قطعاً بدانیم پیام تمام مسیر را تا کلاینت طی کرده است.

وقتی چتی ساخته یا به‌روز می‌شود…

1
2
3
4
5
// <- chatUpdate
{
    "chatId": "",
    "participants": [],
} -> "RECEIVED"

وقتی پیامی دریافت می‌شود…

1
2
3
4
5
6
7
// <- newMessage
{
    "chatId": "",
    "userId": ""
    "message": "",
    "attachments": []
} -> "RECEIVED"

و به همین ترتیب…

توجه کنید که شمردن همه این API ها می‌تواند زمان‌بر باشد! در مصاحبه واقعی، ممکن است فقط نام فرمان‌ها را بنویسم و API کامل را ننویسم. همچنین معمولاً ایده خوبی است که پیش از رفتن به طراحی سطح بالا، API را در ابتدا خلاصه کنید تا در صورت نیاز به تغییر چیزی، فرصت داشته باشید. جمله «با یادگیری بیشتر برمی‌گردم» کاملاً قابل قبول است!

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

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

1) شروع چت گروهی با چند شرکت‌کننده (حداکثر 100 نفر)

برای اولین نیازمندی به راهی نیاز داریم که کاربر چت بسازد. با یک سرویس ساده پشت یک Load Balancer لایه 4 (L4) شروع می‌کنیم (برای پشتیبانی از WebSocket ها!) که می‌تواند Metadata چت را در پایگاه داده بنویسد. به‌خاطر عملکرد سریع کلید-مقدار و مقیاس‌پذیری، از DynamoDB استفاده می‌کنیم، هرچند گزینه‌های بسیار دیگری هم داریم.

مراحل کار:

  1. کاربر به این سرویس وصل می‌شود و پیام createChat را می‌فرستد.
  2. سرویس، داخل یک تراکنش، یک رکورد Chat در پایگاه داده می‌سازد و برای هر کاربر چت یک رکورد ChatParticipant می‌سازد.
  3. سرویس chatId را به کاربر برمی‌گرداند.

در جدول Chat معمولاً فقط می‌خواهیم جزئیات را با شناسه چت بخوانیم. یک کلید اصلی ساده روی chatId برای این کار کافی است.

برای جدول ChatParticipant می‌خواهیم بتوانیم (1) همه شرکت‌کنندگان یک چت مشخص و (2) همه چت‌های یک کاربر مشخص را جست‌وجو کنیم. می‌توانیم این کار را با کلید اصلی مرکب روی فیلدهای chatId و participantId انجام دهیم. جست‌وجوی بازه‌ای (Range Query) روی chatId همه شرکت‌کنندگان یک چت مشخص را می‌دهد.

به یک Global Secondary Index (GSI) با participantId به‌عنوان Partition Key و chatId به‌عنوان Sort Key نیاز داریم. این به ما اجازه می‌دهد همه چت‌های یک کاربر مشخص را کارآمد جست‌وجو کنیم. GSI را DynamoDB خودکار با جدول پایه همگام می‌کند.

عالی است. چند چت ساختیم. در مورد پیام‌ها چه؟

2) ارسال و دریافت پیام

برای اینکه کاربران بتوانند پیام ارسال و دریافت کنند، باید از اتصال WebSocket ای که ساخته‌ایم استفاده کنیم. برای ساده نگه داشتن همه‌چیز در شروع کار، فرض می‌کنیم برای سرور چت فقط یک میزبان (Host) داریم.

واضح است که این برای مقیاس راه‌حل وحشتناکی است (و ممکن است برای جلوگیری از اعتراض مصاحبه‌کننده، همین را به او بگویید)، اما نقطه شروع خوبی است که اجازه می‌دهد آن مشکلات را تدریجی حل کنیم.

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

وقتی کاربران اتصال WebSocket با سرور چت برقرار می‌کنند، ارتباط آن‌ها را با یک Hash Map ساده نگه می‌داریم که شناسه کاربر را به اتصال WebSocket نگاشت می‌کند. به این ترتیب می‌دانیم کدام کاربران متصل‌اند و می‌توانیم برایشان پیام بفرستیم.

برای ارسال پیام:

  1. کاربر پیام sendMessage را به سرور چت می‌فرستد.
  2. سرور چت همه شرکت‌کنندگان چت را از جدول ChatParticipant جست‌وجو می‌کند.
  3. سرور چت اتصال WebSocket هر شرکت‌کننده را در Hash Map داخلی خودش پیدا می‌کند و پیام را از طریق هر اتصال می‌فرستد.

اینجا چند فرض خیلی قوی داریم! فرض کرده‌ایم همه کاربران آنلاین‌اند، به همان سرور چت وصل‌اند و برای هر کدام یک اتصال WebSocket داریم. اما در این مرحله دنبال حرکت سریع هستیم، پس ادامه می‌دهیم.

3) دریافت پیام در زمان آفلاین بودن (تا 30 روز)

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

برای هر کاربر یک «صندوق ورودی» (Inbox) نگه می‌داریم که همه پیام‌های تحویل‌نشده را دارد. وقتی پیام‌ها ارسال می‌شوند، آن‌ها را در صندوق ورودی هر گیرنده می‌نویسیم. اگر گیرنده قبلاً آنلاین باشد، می‌توانیم ادامه بدهیم و سعی کنیم پیام را فوراً تحویل بدهیم. اگر آنلاین نباشد، پیام را ذخیره می‌کنیم و منتظر می‌مانیم تا بعداً برگردد.

بنابراین برای ارسال پیام:

  1. کاربر پیام sendMessage را به سرور چت می‌فرستد.
  2. سرور چت همه شرکت‌کنندگان چت را از جدول ChatParticipant جست‌وجو می‌کند.
  3. سرور چت یک تراکنش می‌سازد که (الف) پیام را در جدول Message می‌نویسد و (ب) برای هر گیرنده یک ورودی در جدول Inbox می‌سازد.
  4. سرور چت موفقیت یا شکست را همراه شناسه نهایی پیام به کاربر برمی‌گرداند.
  5. سرور چت اتصال WebSocket هر شرکت‌کننده را پیدا می‌کند و سعی می‌کند پیام را با newMessage به هر کدام تحویل بدهد.
  6. (برای کلاینت‌های متصل) بعد از دریافت، کلاینت یک پیام تأیید (Ack) به سرور چت می‌فرستد تا نشان بدهد پیام را گرفته است. سپس سرور چت پیام را از جدول Inbox حذف می‌کند.

برای کلاینت‌هایی که متصل نیستند، پیام‌ها را در جدول Inbox نگه می‌داریم. وقتی کلاینت بعداً به سرویس وصل شد، این کارها را انجام می‌دهیم:

  1. صندوق ورودی کاربر را جست‌وجو می‌کنیم و شناسه پیام‌های تحویل‌نشده را پیدا می‌کنیم.
  2. برای شناسه هر پیام، پیام را در جدول Message جست‌وجو می‌کنیم.
  3. آن پیام‌ها را با پیام newMessage روی اتصال کلاینت می‌نویسیم.
  4. بعد از دریافت، کلاینت یک Ack به سرور چت می‌فرستد.
  5. سپس سرور چت پیام را از جدول Inbox حذف می‌کند.

در نهایت باید پیام‌های قدیمی جدول‌های Inbox و Message را دوره‌ای پاکسازی کنیم. می‌توانیم این کار را با یک Cron Job ساده انجام دهیم که پیام‌های قدیمی‌تر از 30 روز را حذف می‌کند.

عالی است. بعضی مشکلات دوام (Durability) راه‌حل اولیه را برطرف کردیم و تحویل آفلاین را ممکن کردیم. راه‌حل ما هنوز مقیاس نمی‌گیرد و کارهای زیادی داریم، پس ادامه می‌دهیم.

4) ارسال و دریافت رسانه در پیام‌ها

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

رویکرد بد: ذخیره پیوست‌ها در پایگاه داده

بدترین رویکرد این است که سرور چت رسانه پیوست را از طریق اتصال WebSocket بپذیرد و در پایگاه داده ذخیره کند. بعد باید یک نوع پیام اضافه کنیم تا کاربران بتوانند پیوست‌ها را بازیابی کنند.

چالش‌ها: این راه‌حل خوبی نیست. اول اینکه بیشتر پایگاه‌های داده (از جمله DynamoDB) برای مدیریت Blob های باینری بزرگ بهینه نشده‌اند. دوم اینکه پهنای باند سرورهای چت را با مشغول کردنشان به ذخیره و بازیابی ساده از کار می‌اندازیم. راه‌حل‌های بهتری وجود دارد!

رویکرد خوب: ذخیره در Blob Storage از طریق سرور چت

رویکرد ساده این است که سرور چت رسانه پیوست را بپذیرد و بعد آن را به Blob Storage (ذخیره‌سازی باینری حجیم) با TTL معادل 30 روز بفرستد (یادتان باشد لازم نیست پیام‌ها را برای همیشه نگه داریم!).

کاربرانی که می‌خواهند یک پیوست مشخص را بگیرند، می‌توانند مستقیم (با یک Presigned URL برای مجوز) از Blob Storage بخوانند. در حالت ایده‌آل راهی پیدا می‌کنیم که رسانه بعد از دریافت توسط همه گیرندگان منقضی شود. اگرچه می‌توانیم جلوی Blob Storage یک CDN بگذاریم، چون حداکثر 100 شرکت‌کننده داریم، مزایای کش نسبتاً کم است.

چالش‌ها: متأسفانه سرورهای چت ما هنوز باید رسانه ورودی را مدیریت کنند و به Blob Storage بفرستند (یک مرحله بیهوده). پیوست‌هایی که پس از دانلود توسط همه گیرندگان باید منقضی شوند، کنترل نمی‌شوند. مدیریت رمزگذاری و امنیت هم مراحل اضافه می‌خواهد.

رویکرد عالی: آپلود مستقیم با Presigned URL

رویکرد ایده‌آل این است که به کاربران اجازه بدهیم (مثلاً از طریق Presigned URL) مستقیم در Blob Storage آپلود کنند. مثلاً می‌توانند پیام getAttachmentTarget را به سرور چت بفرستند که یک Presigned URL برمی‌گرداند. بعد از آپلود، کاربر یک URL برای پیوست دارد که می‌تواند آن را به‌عنوان یک URL مبهم (Opaque) به سرور چت بفرستد.

بعد از آن، راه‌حل ما بسیار شبیه راه‌حل «خوب» عمل می‌کند. کاربرانی که می‌خواهند پیوست مشخصی را بگیرند، می‌توانند مستقیم (از طریق Presigned URL برای مجوز) از Blob Storage بخوانند. در حالت ایده‌آل راهی پیدا می‌کنیم که رسانه بعد از دریافت توسط همه گیرندگان منقضی شود. اگرچه می‌توانیم جلوی Blob Storage یک CDN بگذاریم، چون حداکثر 100 شرکت‌کننده داریم، مزایای کش نسبتاً کم است.

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

بسیار عالی. حالا سیستمی داریم که پیام‌ها را بلادرنگ تحویل می‌دهد و موارد آفلاین و پیوست‌ها را مدیریت می‌کند.

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

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

میزان پیش‌قدمی داوطلب در هدایت بررسی‌های عمیق به سطح ارشدیت او بستگی دارد. در این مسئله همه سطوح باید سریع به این نکته اشاره کنند که راه‌حل تک‌میزبانی من مقیاس نمی‌گیرد. اما فراتر از این گلوگاه‌ها، در مصاحبه Mid-level منطقی است که مصاحبه‌کننده بیشتر بخش‌های عمیق را هدایت کند. اما در مصاحبه Senior و Staff به بالا، انتظار پیش‌قدمی و مالکیت از داوطلب بیشتر می‌شود. آن‌ها باید فعالانه گوشه‌ها را بررسی کنند، مشکلات احتمالی طراحی را شناسایی کنند و راه‌حل پیشنهاد بدهند.

1) مدیریت میلیاردها کاربر هم‌زمان

سیستم تک‌میزبانی ما راحت است، اما غیرواقعی. خدمت‌رسانی به میلیاردها کاربر از یک دستگاه امکان‌پذیر نیست و استقرار و مدیریت خرابی را به کابوس تبدیل می‌کند. پس چه کنیم؟ پاسخ واضح این است که تعداد سرورهای چت را زیاد کنیم.

اگر 1 میلیارد کاربر داشته باشیم، ممکن است انتظار داشته باشیم در هر لحظه 200 میلیون نفر از آن‌ها متصل باشند. WhatsApp مشهور است که به 1 تا 2 میلیون کاربر روی هر میزبان خدمت می‌دهد، اما این یعنی صدها سرور چت. یعنی تعداد بسیار زیادی اتصال هم‌زمان (!).

توجه کنید که من اینجا چند محاسبه سرانگشتی (Back-of-the-envelope) را در متن آورده‌ام. مصاحبه‌کننده احتمالاً انتظار آن‌ها را دارد، اما اگر آن‌ها را به‌موقع انجام بدهید، امتیاز بیشتری می‌گیرید: وقتی باید یک گلوگاه مقیاس را مشخص کنید.

اضافه کردن سرورهای چت بیشتر مشکلات تازه‌ای هم می‌سازد: حالا کاربران ارسال‌کننده و دریافت‌کننده ممکن است به میزبان‌های مختلف وصل باشند. اگر کاربر A بخواهد پیامی برای کاربر B و C بفرستد، ولی B و C به سرورهای چت متفاوت وصل باشند، مشکل پیدا می‌کنیم.

مشکل اصلی مسیریابی است: باید پیام‌ها را به سرورهای چت درست برسانیم تا تحویل داده شوند. اینجا چند گزینه داریم که در بررسی عمیق «به‌روزرسانی‌های بلادرنگ» به‌تفصیل بحث شده‌اند.

رویکرد بد: Load Balancer ساده جلوی سرورهای چت

ساده‌لوحانه‌ترین راه‌حل (که کار نمی‌کند) این است که یک Load Balancer جلوی سرورهای چت بگذاریم و افقی مقیاس بدهیم.

چالش‌ها: این کار نمی‌کند. یک سرور ممکن است پیامی را برای ارسال به یک چت بپذیرد، اما دیگر تضمین نداریم که با کلاینت‌هایی که باید پیام را بگیرند ارتباط دارد. نمی‌توانیم رویدادها و پیام‌ها را تحویل بدهیم!

رویکرد خوب: یک Topic در Kafka برای هر کاربر

بسیاری از داوطلبان غریزی دنبال صف یا Stream می‌گردند تا مشکل مقیاس را حل کنند. یک مثال، ساختن یک Topic در Kafka برای هر کاربر سیستم است. ایده این است که جدول Inbox را به‌صورت Topic در Kafka نگه داریم. سپس سرورهای چت مشترک (Subscriber) Topic ها می‌شوند و پیام‌ها را به کاربر تحویل می‌دهند.

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

چالش‌ها: متأسفانه این کار نمی‌کند. Kafka برای میلیاردها Topic ساخته نشده و برای هر کدام سربار قابل توجهی دارد (در حدود 50 کیلوبایت برای هر Topic).

راه‌حل‌های احتمالی‌ای هم هست که ممکن است به ذهنتان برسد، مثل ساختن «Super Topic» هایی که همه کاربران یک سرور چت مشخص را در خود جمع می‌کنند. اما خیلی زود می‌فهمید که دارید جنبه‌های خوب راه‌حل‌های جایگزین زیر را با مزایای کم دوباره اختراع می‌کنید.

رویکرد خوب: Consistent Hashing برای اختصاص کاربر به سرور

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

برای این کار باید یک رجیستری مرکزی داشته باشیم که تعداد سرورهای چت، آدرس‌هایشان و بخش‌هایی از فضای هش ثابتی که به هر کدام تعلق دارد را نگه می‌دارد. ممکن است برای این کار از سرویسی مثل ZooKeeper یا etcd استفاده کنیم.

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

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

همه این‌ها مشکلات قابل حل هستند، اما مصاحبه‌کننده انتظار دارد درباره آن‌ها و چگونگی حلشان صحبت کنید.

رویکرد عالی: Pub/Sub

آخرین رویکرد، استفاده از یک سیستم تخصصی برای ارسال پیام بین سرورهاست. Redis Pub/Sub نمونه خوبی است که از یک نگاشت بسیار سبک از اتصال‌های سوکت استفاده می‌کند تا پیام‌ها را به مقصدهای دلخواه برساند. با Pub/Sub می‌توانید برای یک شناسه کاربری مشخص اشتراک بسازید و سپس پیام‌هایی را برای آن اشتراک منتشر کنید که مشترک‌ها «حداکثر یک‌بار» (At-most-once) دریافت می‌کنند.

برای اتصال:

  1. وقتی کاربران به سرور چت وصل می‌شوند، آن سرور به Pub/Sub وصل می‌شود تا در Topic مربوط به آن شناسه کاربری مشترک شود.
  2. هر پیامی که در آن اشتراک برسد، به اتصال WebSocket آن کاربر فرستاده می‌شود.

وقتی باید پیامی بفرستیم:

  1. پیام را در Pub/Sub برای شناسه کاربری مربوط منتشر می‌کنیم.
  2. پیام را همه سرورهای چت مشترک دریافت می‌کنند.
  3. آن سرورهای چت پیام را به اتصال WebSocket کاربر می‌فرستند.

Pub/Sub از نوع «حداکثر یک‌بار» است، یعنی تحویل را تضمین نمی‌کند. اگر هیچ مشترکی گوش نکند، آن پیام Pub/Sub گم می‌شود. اما این در طراحی ما مشکلی نیست، چون دوام پیام‌ها را قبلاً با جدول Inbox مدیریت کرده‌ایم. اگر کلاینت‌ها متصل نباشند، وقتی بعداً وصل شوند، همه پیام‌های تحویل‌نشده را می‌گیرند (همچنین می‌توانیم دوره‌ای برای پیام‌های تحویل‌نشده Poll کنیم تا خرابی‌های گذرا در این فاصله را پوشش بدهیم).

چالش‌ها: اجرای Pub/Sub Latency بیشتری ایجاد می‌کند، چون باید پیام‌ها را از Redis عبور بدهیم. این مقدار کوچک است (چند میلی‌ثانیه)، اما هنوز هزینه دارد. همچنین رابطه همه‌به‌همه (All-to-All) بین سرورهای چت و سرورهای کلاستر Redis داریم، یعنی باید بین هر سرور چت و هر سرور Redis ارتباط برقرار باشد.

2) پشتیبانی از چند کلاینت برای یک کاربر

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

داشتن چند کلاینت یا دستگاه مشکلات تازه‌ای می‌سازد:

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

ببینیم می‌توانیم این را با حداقل تغییر در طراحی پوشش بدهیم:

  • یک جدول جدید Clients می‌سازیم تا کلاینت‌ها را بر اساس شناسه کاربر دنبال کند.
  • وقتی شرکت‌کنندگان چت را جست‌وجو می‌کنیم، باید همه کلاینت‌های آن کاربر را هم جست‌وجو کنیم.
  • جدول Inbox را طوری به‌روز می‌کنیم که برای هر کلاینت باشد، نه برای هر کاربر.
  • وقتی پیامی می‌فرستیم، باید آن را برای همه کلاینت‌های آن کاربر بفرستیم.
  • در سمت Pub/Sub چیزی نیاز به تغییر ندارد. سرورهای چت همچنان در Topic ای با userId مشترک می‌مانند.
  • احتمالاً می‌خواهیم برای جلوگیری از انفجار ذخیره‌سازی و Throughput، محدودیتی بگذاریم (مثلاً 3 کلاینت به‌ازای هر حساب).

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

شاید فکر کنید «واقعاً چقدر از این‌ها در مصاحبه لازم است؟» بیایید تفکیک کنیم.

سطح Mid-level

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

سطح Senior

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

سطح Staff به بالا

  • تأکید بر عمق: انتظار می‌رود وارد ظرافت‌های طراحی سیستم شوید، با حدود 40 درصد عرض و 60 درصد عمق. حتی اگر این مسئله خاص را قبلاً حل نکرده باشید، باید در دنیای واقعی آن‌قدر مسئله حل کرده باشید که با پشتوانه تجربه‌تان با اطمینان راه‌حل طراحی کنید.
  • دانش عملی فناوری‌ها: باید بدانید از چه فناوری‌هایی استفاده کنید، نه فقط در تئوری بلکه در عمل، و با تجربه‌های قبلی توضیح بدهید چطور برای حل مسائل خاص به کار می‌روند. مصاحبه‌کننده می‌داند مباحث ساده‌تر را می‌دانید، پس سریع از آن‌ها عبور می‌کنید تا برای بخش‌های جذاب‌تر وقت بماند.
  • پیش‌قدمی بالا: انتظار می‌رود مسائل را مستقل شناسایی و حل کنید و چالش‌های اصلی طراحی را بشناسید. این فقط پاسخ به مشکلات پس از بروز نیست، بلکه پیش‌بینی آن‌ها و اجرای راه‌حل‌های پیشگیرانه هم هست. مصاحبه‌کننده فقط باید برای تمرکز دخالت کند، نه برای هدایت.
  • کاربرد عملی فناوری: باید در به‌کارگیری عملی فناوری‌های مختلف مسلط باشید. تجربه شما باید گفت‌وگو را هدایت کند و درک روشنی از پیکربندی ابزارها و سیستم‌ها در سناریوهای واقعی نشان بدهد.
  • حل مسئله پیچیده و تصمیم‌گیری: نه‌تنها باید چالش‌های فنی پیچیده را حل کنید، بلکه تصمیم‌های آگاهانه‌ای بگیرید که مقیاس‌پذیری، عملکرد، قابلیت اطمینان و نگهداری را در نظر می‌گیرد.
  • طراحی پیشرفته و مقیاس‌پذیری: رویکرد شما باید بر مقیاس‌پذیری و قابلیت اطمینان در بار بالا متمرکز باشد. این شامل درک کامل سیستم‌های توزیع‌شده، Load Balancing، استراتژی‌های ذخیره‌سازی و دیگر مفاهیم پیشرفته است.
  • معیار برای WhatsApp: برای داوطلب Staff به بالا، انتظار از عمق و کیفیت راه‌حل‌ها بالاست، به‌ویژه برای سناریوهای پیچیده‌ای که بحث شد. داوطلبان عالی در دو یا سه حوزه عمیق می‌شوند تا درباره حالت‌های شکست، گلوگاه‌ها و سایر مسائل طراحی‌شان بحث کنند. موضوع‌های زیادی برای بحث وجود دارد، مثل Fault Tolerance (تحمل خطا)، بهینه‌سازی پایگاه داده، منطقه‌بندی (Regionalization) و معماری مبتنی بر سلول (Cell-based Architecture) و موارد دیگر.