پست

الگوی به‌روزرسانی بلادرنگ در مصاحبه‌های طراحی سیستم

روش‌های رساندن به‌روزرسانی بلادرنگ از سرور به کلاینت شامل Polling، Long Polling، SSE، WebSocket و WebRTC، و روش‌های انتشار در سمت سرور با Consistent Hashing و Pub/Sub

الگوی به‌روزرسانی بلادرنگ در مصاحبه‌های طراحی سیستم

به‌روزرسانی بلادرنگ

در این فصل روش‌های راه‌اندازی به‌روزرسانی بلادرنگ در طراحی سیستم را یاد می‌گیریم.

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

برای این‌که این اتفاق بیفتد باید به دو چیز توجه کنیم، ارتباط بین کلاینت و سرور، و ارتباط بین سرور و داده‌ها یا رویدادها. به عبارت دیگر، دو hop داریم.

نحوه انجام این کار بخش مهمی از طراحی سیستم است. فراهم کردن این به‌روزرسانی‌های بلادرنگ می‌تواند پیامدهای قابل‌توجهی برای کل معماری داشته باشد و اغلب منشأ سوال‌های عمیق و جالب مصاحبه است (مثل Uber، نظرهای زنده، Google Docs و مانند آن). برای مهندسان میدلول این اغلب یک امتیاز مثبت است و کمک می‌کند از کاندیداهای دیگر متمایز شوید. برای مهندسان سطح Staff انتظار می‌رود مقداری آشنایی داشته باشید.

در این بررسی عمیق، روش‌های مختلف فراهم کردن به‌روزرسانی بلادرنگ را در طراحی سیستم پوشش می‌دهیم. اول کمی پیش‌زمینه درباره مفاهیم شبکه‌ای می‌گوییم که به‌روزرسانی بلادرنگ را ممکن می‌کنند (اگر قبلاً آشنا هستید می‌توانید از آن رد شوید). بعد سراغ hop اول می‌رویم و پروتکل‌های مختلف برقراری ارتباط از سرور به کلاینت را با مزایا و معایبشان در مصاحبه بررسی می‌کنیم. در نهایت hop دوم را می‌بینیم، یعنی مسائل push و pull در سمت سرور که باید با آن‌ها کنار بیایید.

شروع کنیم.

مبانی شبکه

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

لایه‌های شبکه

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

  • لایه شبکه (لایه ۳): در این لایه IP قرار دارد، پروتکلی که مسیریابی و آدرس‌دهی را انجام می‌دهد. IP داده را به بسته تقسیم می‌کند، انتقال بسته بین شبکه‌ها را مدیریت می‌کند و تحویل بسته به هر آدرس IP مقصد را با بهترین تلاش (best effort) انجام می‌دهد. اما هیچ تضمینی نیست. بسته‌ها ممکن است در مسیر گم شوند، کپی شوند یا ترتیبشان عوض شود.
  • لایه انتقال (لایه ۴): در این لایه TCP و UDP داریم که ارتباط سرتاسری را فراهم می‌کنند. TCP پروتکل اتصال‌محور است. قبل از ارسال داده باید با طرف مقابل ارتباط برقرار کنید. وقتی اتصال برقرار شد، تضمین می‌کند داده درست و مرتب تحویل داده شود. این تضمین عالی است، اما یعنی برقراری اتصال TCP زمان، منابع و پهنای باند می‌خواهد. UDP پروتکل بدون اتصال است. می‌توانید بدون هیچ راه‌اندازی قبلی داده را به هر IP دیگری در شبکه بفرستید. این پروتکل تضمین نمی‌کند داده درست یا مرتب برسد. «شلیک کن و دعا کن!»
  • لایه کاربرد (لایه ۷): در بالاترین لایه پروتکل‌های برنامه‌ای مثل DNS، HTTP، WebSocket و WebRTC قرار دارند. این‌ها پروتکل‌های رایجی هستند که روی TCP ساخته می‌شوند تا برای انواع مختلف داده در برنامه‌های وب یک لایه انتزاع فراهم کنند. کمی بعد به آن‌ها می‌رسیم.

این لایه‌ها با هم کار می‌کنند تا همه ارتباطات شبکه ممکن شود. برای دیدن عملی این همکاری، یک درخواست وب ساده را قدم‌به‌قدم دنبال می‌کنیم.

چرخه حیات یک درخواست

وقتی URL را در مرورگر تایپ می‌کنید، چند لایه از پروتکل‌های شبکه وارد کار می‌شوند. ببینیم این لایه‌ها برای گرفتن یک صفحه وب ساده با HTTP چطور کار می‌کنند. اول با DNS یک نام دامنه قابل‌خواندن برای انسان مثل hellointerview.com را به آدرس IP مثل 32.42.52.62 تبدیل می‌کنیم. (DNS از نظر فنی می‌تواند روی TCP یا UDP اجرا شود، اما برای سادگی این تصویر آن را کنار می‌گذاریم.) بعد مجموعه‌ای از مراحل هماهنگ شروع می‌شود.

  1. تفکیک DNS: کلاینت با DNS نام دامنه وب‌سایت را به آدرس IP تبدیل می‌کند.
  2. TCP Handshake: کلاینت با handshake سه‌مرحله‌ای اتصال TCP با سرور را برقرار می‌کند. اول بسته SYN، بعد پاسخ SYN-ACK از سرور و در پایان ACK از کلاینت.
  3. درخواست HTTP: بعد از برقراری اتصال TCP، کلاینت درخواست HTTP GET را برای گرفتن صفحه می‌فرستد.
  4. پردازش سرور: سرور درخواست را پردازش می‌کند، صفحه را بازیابی می‌کند و پاسخ HTTP را آماده می‌کند.
  5. پاسخ HTTP: سرور پاسخ HTTP را که محتوای صفحه را دارد برای کلاینت می‌فرستد.
  6. TCP Teardown: بعد از اتمام انتقال داده، کلاینت و سرور اتصال TCP را با handshake چهارمرحله‌ای می‌بندند. کلاینت FIN می‌فرستد، سرور با ACK تأیید می‌کند، سرور FIN خودش را می‌فرستد و کلاینت با ACK تأیید می‌کند.

جزئیات handshake در TCP ممکن است خیلی فنی به نظر برسد، اما دو نکته کلیدی برای مصاحبه‌های طراحی سیستم مهم است.

  • اول: هر رفت‌وبرگشت بین کلاینت و سرور به درخواست ما تأخیر اضافه می‌کند، از جمله رفت‌وبرگشت‌هایی که برای برقراری ارتباط قبل از ارسال داده برنامه لازم است.
  • دوم: خود اتصال TCP یک state است که هم کلاینت و هم سرور باید آن را نگه دارند. مگر این‌که از قابلیت‌هایی مثل HTTP keep-alive استفاده کنیم، باید این فرایند برقراری اتصال را برای هر درخواست تکرار کنیم، که سربار قابل‌توجهی دارد.

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

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

با Load Balancer

در سیستم‌های واقعی معمولاً چند سرور پشت Load Balancer کار می‌کنند. Load Balancer درخواست‌های ورودی را بین این سرورها پخش می‌کند تا بار یکنواخت شود و دسترس‌پذیری بالا حفظ شود. دو نوع اصلی Load Balancer در مصاحبه‌ها می‌بینید، لایه ۴ و لایه ۷. این‌ها در تصمیم‌های بعدی ما اثر دارند، پس فرقشان را مختصر مرور می‌کنیم.

Load Balancer لایه ۴: در لایه انتقال (TCP/UDP) کار می‌کند. تصمیم مسیریابی را بر اساس اطلاعات شبکه مثل IP و پورت می‌گیرد و به محتوای واقعی بسته‌ها نگاه نمی‌کند. اثر Load Balancer لایه ۴ این است که انگار یک سرور بک‌اند را تصادفی انتخاب کرده‌اید و فرض می‌کنید اتصال TCP مستقیم بین کلاینت و آن سرور است. این مدل ذهنی چندان دور از واقعیت نیست. ویژگی‌های کلیدی آن:

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

مثلاً اگر کلاینتی از طریق Load Balancer لایه ۴ اتصال TCP برقرار کند، همان سرور همه درخواست‌های بعدی آن نشست TCP را مدیریت می‌کند. این Load Balancer را برای پروتکل‌هایی که اتصال پایدار لازم دارند، مثل WebSocket، مناسب می‌کند. در سطح مفهومی، انگار اتصال TCP مستقیمی بین کلاینت و سرور داریم که می‌توانیم برای ارتباط در لایه‌های بالاتر از آن استفاده کنیم.

Load Balancer لایه ۷: در لایه کاربرد کار می‌کند و پروتکل‌هایی مثل HTTP را می‌فهمد. می‌تواند محتوای واقعی هر درخواست را بررسی کند و تصمیم مسیریابی هوشمندانه‌تری بگیرد. ویژگی‌های کلیدی آن:

  • اتصال‌های ورودی را خاتمه می‌دهد و برای سرورهای بک‌اند اتصال جدید می‌سازد.
  • می‌تواند بر اساس محتوای درخواست (URL، هدرها، کوکی‌ها و غیره) مسیریابی کند.
  • به‌خاطر بازرسی بسته‌ها CPU بیشتری مصرف می‌کند.
  • انعطاف و ویژگی‌های بیشتری ارائه می‌دهد.
  • برای ترافیک مبتنی بر HTTP مناسب‌تر است.

مثلاً Load Balancer لایه ۷ می‌تواند همه درخواست‌های API را به یک مجموعه سرور و درخواست‌های صفحه وب را به مجموعه دیگری بفرستد (با کارکردی شبیه API Gateway). یا می‌تواند مطمئن شود همه درخواست‌های یک کاربر مشخص، بر اساس کوکی، به همان سرور می‌روند. اتصال TCP زیرینی که از طریق Load Balancer لایه ۷ به سرور شما برقرار می‌شود چندان اهمیتی ندارد. این فقط راهی است که Load Balancer درخواست‌های L7 مثل HTTP را به سرور برساند.

Load Balancer لایه ۷ کمک می‌کند نگران جزئیات سطح پایین مثل اتصال TCP نباشیم، اما اگر بخواهیم اتصال پایدار به سرورهای ثابت داشته باشیم، نمی‌توانیم واقعیت سطح اتصال را نادیده بگیریم.

انتخاب بین Load Balancer لایه ۴ و ۷ اغلب هنگام بحث درباره قابلیت‌های بلادرنگ در مصاحبه مطرح می‌شود. بعضی Load Balancerهای L7 صریحاً از پروتکل‌های اتصال‌محور مثل WebSocket پشتیبانی می‌کنند. اما به‌طور کلی Load Balancer لایه ۴ برای اتصال WebSocket بهتر است، در حالی که Load Balancer لایه ۷ انعطاف بیشتری برای راه‌حل‌های مبتنی بر HTTP مثل Long Polling می‌دهد. در بخش بعدی بیشتر درباره‌اش می‌گوییم.

رویکردهای به‌روزرسانی کلاینت

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

Simple Polling، خط پایه

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

بسیاری از سوال‌های مصاحبه در واقع به به‌روزرسانی بلادرنگ نیاز ندارند. با دید انتقادی به محصول فکر کنید و از خودتان بپرسید که آیا به‌روزرسانی با فرکانس کمتر (مثلاً هر ۲ تا ۵ ثانیه) کافی است. اگر بله، شاید بخواهید رویکرد ساده مبتنی بر polling را پیشنهاد کنید. بهتر است راه‌حلی کمتر از ایده‌آل پیشنهاد کنید تا این‌که راه‌حل پیچیده را بد پیاده‌سازی کنید. با این حال، قبل از قطعی کردن، این پیشنهاد را با مصاحبه‌گر مطرح کنید. اگر درباره WebSocket، SSE یا WebRTC صحبت می‌کنید، بهتر است او زودتر بداند.

طرز کار ساده است. کلاینت در بازه زمانی مشخص از سرور درخواست می‌دهد و سرور با وضعیت فعلی دنیا پاسخ می‌دهد. در اپلیکیشن چت ما مدام می‌پرسیم «تا الان چه پیام‌هایی را دریافت نکرده‌ام؟»

1
2
3
4
5
6
7
8
async function poll() {
  const response = await fetch('/api/updates');
  const data = await response.json();
  processData(data);
}

// Poll every 2 seconds
setInterval(poll, 2000);

مزایا:

  • پیاده‌سازی ساده.
  • Stateless.
  • بدون نیاز به زیرساخت خاص.
  • با هر زیرساخت استاندارد شبکه کار می‌کند.
  • توضیح دادنش زمان زیادی نمی‌برد.

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

معایب:

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

چه زمانی Simple Polling را انتخاب کنیم

Simple Polling پایه عالی است و اگر مسئله شما مشخصاً به تأخیر بسیار کم و به‌روزرسانی بلادرنگ واقعی نیاز ندارد، راه‌حل عالی‌ای است. همچنین وقتی بازه‌ای که در آن به به‌روزرسانی نیاز دارید کوتاه است مناسب است، مثل طراحی سیستم Leetcode.

چیزهایی که باید در مصاحبه بگویید

درباره مصالحه‌های polling در برابر روش‌های دیگر با مصاحبه‌گر شفاف باشید. توضیح خوب سادگی رویکرد را برجسته می‌کند و اگر بعداً فهمیدید به چیز پیچیده‌تری نیاز دارید، یک راه خروج هم برای خودتان می‌گذارد. مثلاً می‌توانید بگویید: «من با polling ساده شروع می‌کنم تا بتوانم روی X تمرکز کنم، اما اگر بعداً لازم شد می‌توانیم به چیز پیچیده‌تری برویم.»

رایج‌ترین اعتراض مصاحبه‌گرها به polling این است که کند یا ناکارآمد است. آماده باشید توضیح دهید چرا فرکانس polling انتخابی شما مناسب و برای مسئله کافی است. درباره کارایی، خوب است بتوانید بگویید چطور سربار را کم می‌کنید. یک راه، استفاده از اتصال‌های HTTP keep-alive است. اگر keep-alive را بیشتر از بازه polling تنظیم کنید، در بیشتر موارد فقط یک بار باید اتصال TCP برقرار کنید و سربار راه‌اندازی و جمع‌کردن آن حداقل می‌شود.

Long Polling، راه‌حل ساده

بعد از Simple Polling، Long Polling ساده‌ترین روش برای رسیدن به به‌روزرسانی تقریباً بلادرنگ است. روی HTTP استاندارد ساخته شده و پیاده‌سازی و مقیاس‌دهی آن آسان است.

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

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

  1. کلاینت درخواست HTTP را به سرور می‌فرستد.
  2. سرور درخواست را باز نگه می‌دارد تا داده جدید در دسترس باشد.
  3. سرور با داده پاسخ می‌دهد.
  4. کلاینت بلافاصله درخواست جدید می‌فرستد.
  5. فرایند تکرار می‌شود.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Client-side of long polling
async function longPoll() {
  while (true) {
    try {
      const response = await fetch('/api/updates');
      const data = await response.json();

      // Handle data
      processData(data);
    } catch (error) {
      // Handle error
      console.error(error);

      // Add small delay before retrying on error
      await new Promise(resolve => setTimeout(resolve, 1000));
    }
  }
}

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

فرض کنید تأخیر بین کلاینت و سرور ۱۰۰ میلی‌ثانیه است. اگر دو به‌روزرسانی با فاصله ۱۰ میلی‌ثانیه رخ دهند، در Long Polling اولین به‌روزرسانی را ۱۰۰ میلی‌ثانیه بعد از رخ دادن می‌گیریم (فقط تأخیر شبکه). اما به‌روزرسانی دوم ممکن است تا ۲۹۰ میلی‌ثانیه بعد از رخ دادن برسد. این عدد از جمع این سه بخش می‌آید: ۹۰ میلی‌ثانیه برای تمام شدن رسیدن پاسخ اول به کلاینت، ۱۰۰ میلی‌ثانیه برای رسیدن درخواست جدید کلاینت به سرور، و ۱۰۰ میلی‌ثانیه برای برگشتن پاسخ دوم.

مزایا:

  • روی HTTP استاندارد ساخته شده و هر جا HTTP کار کند، کار می‌کند.
  • پیاده‌سازی آسان.
  • بدون نیاز به زیرساخت خاص.
  • سمت سرور stateless است.

معایب:

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

چه زمانی Long Polling را انتخاب کنیم

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

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

چیزهایی که باید در مصاحبه بگویید

چون Long Polling از زیرساخت HTTP موجود استفاده می‌کند، زیرساخت اضافه‌ای نیست که لازم باشد درباره‌اش حرف بزنید. حتی با این‌که polling «طولانی» است، باز هم باید درباره فرکانس polling دقیق باشید. یادتان باشد هر hop در زیرساخت باید از این درخواست‌های طولانی خبر داشته باشد. نمی‌خواهید Load Balancer شما بعد از ۳۰ ثانیه اتصالی را قطع کند، در حالی که سرور شما راضی است اتصال را ۶۰ ثانیه باز نگه دارد. (فاصله polling ۱۵ تا ۳۰ ثانیه بسیار رایج است و این مشکل را کم می‌کند.)

رویداد ارسالی از سرور (SSE)، خیابان یک‌طرفه کارآمد

SSE توسعه‌ای روی Long Polling است که به سرور اجازه می‌دهد جریانی از داده را برای کلاینت بفرستد.

معمولاً پاسخ‌های HTTP هدری مثل Content-Length دارند که به کلاینت می‌گوید چقدر داده باید انتظار داشته باشد. SSE در عوض از هدر مخصوص Transfer-Encoding: chunked استفاده می‌کند که به کلاینت می‌گوید پاسخ مجموعه‌ای از chunkهاست. تا وقتی آن‌ها را نفرستیم، نمی‌دانیم چند تا هستند یا چقدر بزرگ‌اند. این به ما اجازه می‌دهد از یک درخواست و پاسخ اتمیک به یک «stream» ریزدانه‌تر از داده حرکت کنیم.

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

در اپلیکیشن چت ما، درخواستی برای stream کردن پیام‌ها باز می‌کنیم و هر پیام جدید به‌صورت یک chunk برای کلاینت فرستاده می‌شود.

SSE چطور کار می‌کند:

  1. کلاینت اتصال SSE را برقرار می‌کند.
  2. سرور اتصال را باز نگه می‌دارد.
  3. وقتی داده تغییر می‌کند یا به‌روز می‌شود، سرور پیام می‌فرستد.
  4. کلاینت به‌روزرسانی‌ها را بلادرنگ دریافت می‌کند.

مرورگرهای مدرن از طریق شیء EventSource پشتیبانی داخلی از SSE دارند که پیاده‌سازی سمت کلاینت را ساده می‌کند.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Client-side
const eventSource = new EventSource('/api/updates');

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  updateUI(data);
};

// Server-side (Node.js/Express example)
app.get('/api/updates', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  const sendUpdate = (data) => {
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  };

  // Send updates when data changes
  dataSource.on('update', sendUpdate);

  // Clean up on client disconnect
  req.on('close', () => {
    dataSource.off('update', sendUpdate);
  });
});

مزایا:

  • در مرورگرها داخلی است.
  • اتصال مجدد خودکار.
  • روی HTTP کار می‌کند.
  • به‌خاطر برقراری و جمع‌کردن کمتر اتصال، کارآمدتر از Long Polling است.
  • پیاده‌سازی ساده.

معایب:

  • ارتباط فقط یک‌طرفه.
  • پشتیبانی محدود مرورگر (برای مرورگرهای مدرن مشکلی نیست).
  • بعضی پراکسی‌ها و تجهیزات شبکه از streaming پشتیبانی نمی‌کنند. دیباگ کردنش بد است!
  • مرورگرها تعداد اتصال‌های هم‌زمان به هر دامنه را محدود می‌کنند، یعنی شاید فقط بتوانید چند اتصال SSE برای هر دامنه داشته باشید.
  • مانیتورینگ را سخت‌تر می‌کند، چون درخواست‌ها می‌توانند مدت طولانی باز بمانند.

چه زمانی SSE را انتخاب کنیم

SSE ارتقای عالی برای Long Polling است، چون مشکلات به‌روزرسانی‌های پرتکرار را برطرف می‌کند و هم‌زمان روی HTTP استاندارد ساخته می‌شود. با این حال پشتیبانی کلی کمتری دارد، چون برای پشتیبانی از پاسخ‌های streaming هم مرورگر و هم همه زیرساخت بین کلاینت و سرور باید آماده باشد.

یکی از کاربردهای بسیار محبوب SSE امروز، اپلیکیشن‌های چت هوش مصنوعی است که اغلب باید توکن‌ها (کلمه‌های) تازه تولیدشده را برای کاربر stream کنند تا رابط کاربری پاسخگو بماند.

نمونه‌ای از مشکل زیرساخت این است که بسیاری از پراکسی‌ها و Load Balancerها از پاسخ streaming پشتیبانی نمی‌کنند. در این موارد پراکسی سعی می‌کند پاسخ را تا کامل شدن بافر کند، که عملاً stream ما را به شکلی آزاردهنده و مبهم مسدود می‌کند و دیباگش سخت است. (ما در Hello Interview خودمان هم از SSE زیاد استفاده می‌کنیم و زمانی که صرف مشکلات لبه‌ای شبکه کرده‌ایم بسیار گیج‌کننده بوده است.)

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

چیزهایی که باید در مصاحبه بگویید

SSE از زیرساخت HTTP موجود استفاده می‌کند، پس زیرساخت اضافه زیادی نیست که لازم باشد درباره‌اش صحبت کنید. فاصله polling هم برای مذاکره یا تنظیم ندارید.

بیشتر اتصال‌های SSE طولانی‌مدت نخواهند بود (مثلاً ۳۰ تا ۶۰ ثانیه بسیار معمول است). پس اگر باید پیام را مدت طولانی بفرستید، باید درباره نحوه اتصال مجدد کلاینت‌ها و برخورد آن‌ها با شکاف‌ها صحبت کنید. استاندارد SSE شامل Last-Event-ID است که برای پوشش همین شکاف طراحی شده و شیء EventSource در مرورگرها این منطق اتصال مجدد را به‌طور صریح کنترل می‌کند. اگر کلاینتی قطع شود، می‌تواند دوباره وصل شود و آخرین شناسه رویدادی را که دریافت کرده بفرستد. سرور هم می‌تواند با آن شناسه همه رویدادهایی را که هنگام قطع بودن کلاینت رخ داده‌اند بفرستد.

WebSocket، قهرمان Full-Duplex

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

WebSocket چطور کار می‌کند

WebSocket روی یک پروتکل «ارتقا» (upgrade) بر HTTP ساخته می‌شود که به اتصال TCP موجود اجازه می‌دهد پروتکل لایه ۷ را عوض کند. این کاربردی است، چون می‌توانید از بعضی اطلاعات نشست HTTP موجود (مثل کوکی‌ها و هدرها) استفاده کنید.

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

وقتی اتصال برقرار شد، هم کلاینت و هم سرور می‌توانند «پیام» برای هم بفرستند که عملاً Blobهای باینری مات هستند. می‌توانید رشته، JSON، Protobuf یا هر چیز دیگری را رد و بدل کنید. WebSocket را مثل اتصال TCP ببینید با چند امکان خوب که ارتباط را راحت‌تر می‌کند، به‌خصوص برای مرورگرها.

  1. کلاینت handshake مربوط به WebSocket را روی HTTP شروع می‌کند.
  2. اتصال به پروتکل WebSocket ارتقا پیدا می‌کند.
  3. هم کلاینت و هم سرور می‌توانند پیام بفرستند.
  4. اتصال تا وقتی صریحاً بسته نشود باز می‌ماند.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Client-side
const ws = new WebSocket('ws://api.example.com/socket');

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  handleUpdate(data);
};

ws.onclose = () => {
  // Implement reconnection logic
  setTimeout(connectWebSocket, 1000);
};

// Server-side (Node.js/ws example)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    // Handle incoming messages
    const data = JSON.parse(message);
    processMessage(data);
  });

  // Send updates to client
  dataSource.on('update', (data) => {
    ws.send(JSON.stringify(data));
  });
});

چالش‌های اضافه

چون WebSocket اتصال پایدار است، زیرساخت ما باید از آن پشتیبانی کند. بعضی Load Balancerهای L7 از WebSocket پشتیبانی می‌کنند، اما پشتیبانی عموماً ناقص است. (یادتان باشد Load Balancer L7 تضمین نمی‌کند برای هر درخواست ورودی از یک اتصال TCP استفاده کنیم.) Load Balancerهای L4 به‌طور بومی از WebSocket پشتیبانی می‌کنند، چون برای هر درخواست همان اتصال TCP استفاده می‌شود.

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

در نهایت، Load Balancing سرورهای WebSocket می‌تواند پیچیده‌تر باشد. اگر اتصال‌ها واقعاً طولانی‌اند، باید به هر تصمیم تخصیصی که گرفته‌ایم «چسبیده» بمانیم. اگر Load Balancer بخواهد درخواست جدید را به سرور دیگری بفرستد، بدون قطع کردن اتصال WebSocket موجود نمی‌تواند.

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

مزایا:

  • ارتباط full-duplex (هم خواندن و هم نوشتن).
  • به‌خاطر سربار کمتر (مثلاً بدون هدر)، تأخیر کمتری از HTTP دارد.
  • برای پیام‌های پرتکرار کارآمد است.
  • پشتیبانی گسترده مرورگرها.

معایب:

  • پیاده‌سازی پیچیده‌تر.
  • زیرساخت ویژه لازم دارد.
  • اتصال‌های stateful، Load Balancing و مقیاس‌دهی را پیچیده‌تر می‌کند.
  • باید اتصال مجدد را مدیریت کنید.

چه زمانی WebSocket را انتخاب کنیم

به‌طور کلی، اگر ارتباط دوطرفه با فرکانس بالا لازم دارید، WebSocket می‌خواهید. من روی «فرکانس بالا» تأکید می‌کنم، چون همیشه می‌توانید درخواست‌ها یا اتصال‌های اضافه برای نوشتن داشته باشید. الگوی بسیار رایج این است که اشتراک SSE برای دریافت به‌روزرسانی‌ها داشته باشید و هر وقت لازم شد، نوشتن را با HTTP POST یا PUT ساده انجام دهید.

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

چیزهایی که باید در مصاحبه بگویید

WebSocket ابزار قدرتمندی است، اما پیچیدگی زیادی هم دارد. باید درباره نحوه مدیریت اتصال‌ها و برخورد با اتصال مجدد صحبت کنید. همچنین باید در نظر بگیرید که استراتژی deploy شما راه‌اندازی مجدد سرور را چطور مدیریت می‌کند.

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

درباره نحوه مقیاس‌دهی سرورهای WebSocket هم بحث زیاد است. بار می‌تواند نامتوازن باشد و به hot spot و خرابی منجر شود. استفاده از استراتژی «کمترین اتصال» در Load Balancer و همچنین حداقل کردن کاری که سرورهای WebSocket هنگام پردازش پیام‌ها باید انجام دهند کمک می‌کند. استفاده از معماری مرجع WebSocket و واگذاری پردازش‌های سنگین‌تر به سرویس‌های دیگر (که مستقل مقیاس می‌گیرند) هم کمک می‌کند.

WebRTC، راه‌حل Peer-to-Peer

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

کلاینت‌ها با یک «سرور signaling» مرکزی صحبت می‌کنند که peerهای موجود را همراه اطلاعات اتصالشان ردیابی می‌کند. وقتی کلاینت اطلاعات اتصال peer دیگر را داشت، می‌تواند سعی کند بدون عبور از سرور واسط، اتصال مستقیم برقرار کند.

در عمل، بیشتر کلاینت‌ها به دلایل امنیتی اتصال ورودی را قبول نمی‌کنند (به‌جز سرورهایی که دسترس‌پذیری‌شان را روی پورت‌های مشخص و آدرس‌های مشخص اعلام می‌کنند) و از دستگاه‌هایی مثل NAT (ترجمه آدرس شبکه) استفاده می‌کنند. پس اگر همین‌جا متوقف شویم، بیشتر peerها نمی‌توانند با هم «حرف بزنند». استاندارد WebRTC دو روش برای دور زدن این محدودیت‌ها دارد.

  • STUN: مخفف Session Traversal Utilities for NAT است. این پروتکل و مجموعه‌ای از تکنیک‌ها (مثل hole punching) به peerها اجازه می‌دهد آدرس و پورت عمومی قابل‌مسیریابی خودشان را پیدا کنند. وارد جزئیات نمی‌شوم، اما با این‌که شبیه hack به نظر می‌رسد، روش استاندارد برای مقابله با NAT traversal است. در آن مرتب پورت‌های باز ایجاد می‌شود و از طریق سرور signaling با peerها به اشتراک گذاشته می‌شود.
  • TURN: مخفف Traversal Using Relays around NAT است. در واقع یک سرویس رله است و راهی برای برگرداندن درخواست‌ها از طریق یک سرور مرکزی است که بعد می‌تواند آن‌ها را به peer درست برساند.

راه‌اندازی WebRTC

در عمل، سرور signaling نسبتاً سبک است و پهنای باند زیادی مصرف نمی‌کند، چون بخش عمده ترافیک را اتصال‌های peer-to-peer مدیریت می‌کنند. اما جالب است که سرور signaling عملاً مثل یک سیستم به‌روزرسانی بلادرنگ برای کلاینت‌هایش عمل می‌کند (تا بتوانند peerها را پیدا کنند و اطلاعات اتصال‌شان را به‌روز کنند). پس خودش باید از WebSocket، SSE یا یکی از روش‌های بالا استفاده کند.

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

WebRTC چطور کار می‌کند:

  1. peerها از طریق سرور signaling همدیگر را پیدا می‌کنند.
  2. اطلاعات اتصال (ICE candidate) را رد و بدل می‌کنند.
  3. با STUN/TURN در صورت نیاز، اتصال مستقیم peer برقرار می‌شود.
  4. صدا و تصویر stream می‌شود یا داده مستقیم فرستاده می‌شود.

بسیار ساده است، به‌جز مخفف‌ها و NAT traversal.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Simplified WebRTC setup
async function startCall() {
  const pc = new RTCPeerConnection();

  // Get local stream
  const stream = await navigator.mediaDevices.getUserMedia({
    video: true,
    audio: true
  });

  // Add tracks to peer connection
  stream.getTracks().forEach(track => {
    pc.addTrack(track, stream);
  });

  // Create and send offer
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);

  // Send offer to signaling server
  signalingServer.send(offer);
}

چه زمانی WebRTC را انتخاب کنیم

WebRTC پیچیده‌ترین و سنگین‌ترین گزینه‌ای است که بحث کردیم. برای بیشتر کاربردهای به‌روزرسانی بلادرنگ زیادی است، اما ابزار عالی‌ای برای سناریوهایی مثل تماس تصویری و صوتی، اشتراک صفحه و بازی است.

استثنای قابل‌توجه این است که می‌توان از آن برای کم کردن بار سرور استفاده کرد. اگر سیستمی دارید که کلاینت‌ها باید مرتب با هم حرف بزنند، می‌توانید با WebRTC بار سرورهایتان را کم کنید، چون کلاینت‌ها خودشان اتصال‌هایشان را می‌سازند. Canva این رویکرد را برای اشتراک‌گذاری حضور و اشاره‌گر ماوس کاربران در ویرایشگر خودش به کار برد. این رویکرد همچنین برای ویرایش مشترک سند مثل Google Docs هم محبوب است، وقتی همراه CRDTها استفاده شود که برای معماری peer-to-peer مناسب‌ترند.

مزایا:

  • ارتباط مستقیم با peerها.
  • تأخیر کمتر.
  • کاهش هزینه سرور.
  • پشتیبانی بومی از صدا و تصویر.

معایب:

  • راه‌اندازی پیچیده (بیشتر از WebSocket).
  • سرور signaling لازم دارد.
  • مشکلات NAT و فایروال.
  • تأخیر در راه‌اندازی اتصال.

چیزهایی که باید در مصاحبه بگویید

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

بعضی مصاحبه‌ها جالب می‌شوند و محدودیت‌های غیرواقعی می‌گذارند تا شما را وادار کنند خارج از چارچوب فکر کنید، مثل «سیستم باید روی Raspberry Pi اجرا شود». شاید این همان موردی باشد که معماری peer-to-peer در آن منطقی است، اما با احتیاط جلو بروید.

داشتن اطلاعات درباره نیازهای زیرساختی (STUN/TURN، سرورهای signaling و غیره) به شما انعطاف می‌دهد بهترین تصمیم طراحی را برای سیستم خودتان بگیرید. همچنین باید به‌طور گسترده درباره الگوهای ارتباطی بین peerهای کلاینت و هر همگام‌سازی نهایی با یک سرور مرکزی صحبت کنید. تقریباً همه سوال‌های طراحی برای ذخیره داده یا گزارش نتیجه به یک «مادرکشتی» مرکزی نیاز دارند.

نمای کلی به‌روزرسانی کلاینت

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

  • اگر به تأخیر حساس نیستید، Simple Polling پایه عالی است. احتمالاً باید از همین شروع کنید، مگر نیاز خاصی در سیستم‌تان باشد.
  • اگر به ارتباط دوطرفه نیاز ندارید، SSE انتخاب عالی است. سبک است و برای بسیاری از موارد خوب کار می‌کند. چند ملاحظه زیرساختی هست که باید در نظر داشته باشید، اما نسبت به WebSocket کمتر تهاجمی‌اند و مصاحبه‌گرها معمولاً کمتر با آن‌ها آشنا هستند، یا اگر به آن‌ها توجه نکنید کمتر مهم می‌شوند.
  • اگر به ارتباط دوطرفه پرتکرار نیاز دارید، WebSocket راه درست است. پیچیده‌تر است، اما مزایای کارایی آن بسیار زیاد است.
  • در نهایت، اگر به تماس صوتی و تصویری نیاز دارید، WebRTC تنها راه ممکن است. در بعضی موارد همکاری peer-to-peer را می‌توان با WebRTC تقویت کرد، اما بعید است در مصاحبه طراحی سیستم آن را ببینید.

حالا که hop اول را داریم، ببینیم چطور به‌روزرسانی‌ها را از منبع به سرور موردنظر برسانیم.

Push و Pull سمت سرور

حالا که گزینه‌هایمان برای hop سرور به کلاینت را بررسی کردیم (Simple Polling، Long Polling، SSE، WebSocket، WebRTC)، درباره این صحبت می‌کنیم که چطور به‌روزرسانی‌ها را از منبع به سرور برسانیم.

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

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

  • Pull با Polling.
  • Push با Consistent Hashing.
  • Push با Pub/Sub.

Pull با Simple Polling

در Simple Polling از مدل مبتنی بر pull استفاده می‌کنیم. کلاینت مرتب از سرور به‌روزرسانی می‌خواهد و سرور باید state لازم برای پاسخ به این درخواست‌ها را نگه دارد. رایج‌ترین راه برای این کار داشتن پایگاه داده‌ای است که بتوانیم به‌روزرسانی‌ها را در آن ذخیره کنیم (مثلاً همه پیام‌های اتاق چت)، و کلاینت‌ها هر وقت لازم شد، به‌روزرسانی‌ها را از آن بگیرند. در اپلیکیشن چت ما عملاً می‌پرسیم «چه پیام‌هایی با timestamp بزرگ‌تر از آخرین پیامی که گرفته‌ام به اتاق فرستاده شده؟»

یادتان باشد در polling تأخیر را تحمل می‌کنیم. خود polling را به‌عنوان trigger استفاده می‌کنیم، حتی اگر به‌روزرسانی واقعی مدتی قبل رخ داده باشد.

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

مزایا:

  • پیاده‌سازی بسیار ساده.
  • state فقط در پایگاه داده ما است.
  • زیرساخت خاصی لازم نیست.
  • توضیح دادنش زمان زیادی نمی‌برد.

معایب:

  • تأخیر بالا.
  • وقتی به‌روزرسانی‌ها کم رخ می‌دهند و polling پرتکرار است، بار اضافه روی پایگاه داده وجود دارد.

چه زمانی Pull با Polling را انتخاب کنیم

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

چیزهایی که باید در مصاحبه بگویید

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

در بسیاری از مواردی که از این رویکرد استفاده می‌شود، تعداد کلاینت‌ها می‌تواند خیلی زیاد باشد. اگر یک میلیون کلاینت هر ۱۰ ثانیه poll کنند، ۱۰۰ هزار TPS بار خواندن دارید! فراموش کردن این نکته آسان است.

Push با Consistent Hashing

روش‌های باقی‌مانده شامل push کردن به‌روزرسانی‌ها به کلاینت‌ها هستند. در بسیاری از مکانیزم‌های به‌روزرسانی کلاینت که بالا بحث کردیم (Long Polling، SSE، WebSocket)، کلاینت یک اتصال پایدار به یک سرور دارد و آن سرور مسئول فرستادن به‌روزرسانی‌ها به کلاینت است.

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

در حالت ایده‌آل، وقتی باید پیامی فرستاده شود، این کار را می‌کنیم:

  1. پیدا می‌کنیم کاربر C به کدام سرور وصل است.
  2. پیام را به آن سرور می‌فرستیم.
  3. آن سرور پیدا می‌کند کدام درخواست (WebSocket، SSE یا Long Polling) مربوط به کاربر C است.
  4. سرور پیام را از طریق اتصال مناسب می‌نویسد.

دو راه رایج برای مدیریت این وضعیت هست و اولی استفاده از hashing است. شهود را در دو مرحله می‌سازیم.

Hashing ساده

اولین رویکرد ما ممکن است استفاده از عملیات مدول ساده برای فهمیدن این باشد که کدام سرور مسئول کاربر مشخصی است. آن‌وقت همیشه یک سرور داریم که «مالک» اتصال‌های آن کاربر است.

برای این کار، یک سرویس مرکزی داریم که می‌داند N سرور وجود دارد و می‌تواند به هرکدام عددی از ۰ تا N-1 اختصاص دهد. این سرویس اغلب Apache ZooKeeper یا etcd است که اجازه می‌دهد این metadata را مدیریت کنیم و سرورها هنگام تغییر همگام شوند، هرچند در عمل گزینه‌های جایگزین زیادی هست.

سرور شماره n را مسئول کاربر u می‌کنیم، به‌طوری که u % N = n. وقتی کلاینت‌ها در ابتدا به سرویس ما وصل می‌شوند، می‌توانیم یکی از این دو کار را بکنیم. (الف) مستقیم آن‌ها را به سرور مناسب وصل کنیم (مثلاً با دانستن N و آدرس سرور مربوط به هر شاخص). (ب) آن‌ها را تصادفی به یکی از سرورها وصل کنیم و از آن سرور بخواهیم با داده داخلی خودش آن‌ها را به سرور مناسب هدایت کند.

وقتی کلاینت وصل می‌شود این اتفاق می‌افتد:

  1. کلاینت به یک سرور تصادفی وصل می‌شود.
  2. سرور hash کلاینت را در ZooKeeper جستجو می‌کند تا بفهمد کدام سرور مسئول اوست.
  3. سرور کلاینت را به سرور مناسب هدایت می‌کند.
  4. کلاینت به سرور درست وصل می‌شود.
  5. سرور آن کلاینت را به نقشه اتصال‌هایش اضافه می‌کند.

حالا آماده فرستادن به‌روزرسانی و پیام هستیم. وقتی می‌خواهیم برای کاربر C پیام بفرستیم، می‌توانیم شناسه کاربر را hash کنیم تا بفهمیم کدام سرور مسئول اوست و پیام را به آنجا بفرستیم.

  1. سرور به‌روزرسانی ما به ZooKeeper وصل می‌ماند و آدرس همه سرورها و N را می‌داند.
  2. وقتی سرور به‌روزرسانی باید برای کاربر C پیام بفرستد، شناسه کاربر را hash می‌کند تا بفهمد کدام سرور مسئول اوست (مثلاً سرور ۲) و پیام را به آنجا می‌فرستد.
  3. سرور ۲ پیام را می‌گیرد، پیدا می‌کند کدام اتصال مربوط به کاربر C است و پیام را روی آن اتصال می‌فرستد.

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

Consistent Hashing

وقتی N ثابت باشد، رویکرد hash عالی کار می‌کند. اما وقتی بخواهیم سرویس را بزرگ یا کوچک کنیم مشکل‌ساز می‌شود. در Modulo Hashing ساده، تغییر تعداد سرورها تقریباً همه کاربران را مجبور می‌کند اتصال را قطع کنند و به سرورهای دیگر وصل شوند. این عملیات گران است و سرویس را مختل می‌کند.

Consistent Hashing این مشکل را با حداقل کردن تعداد اتصال‌هایی که هنگام مقیاس‌دهی باید جابه‌جا شوند حل می‌کند. هم سرورها و هم کاربران را روی یک حلقه هش می‌گذارد و هر کاربر به اولین سروری وصل می‌شود که هنگام حرکت در جهت عقربه‌های ساعت دور حلقه می‌بیند. وقتی سرور اضافه یا حذف می‌کنیم، فقط کاربران بخش‌های متأثر از حلقه باید جابه‌جا شوند. این کار آشفتگی اتصال‌ها (connection churn) را هنگام مقیاس‌دهی بسیار کم می‌کند.

مزایا:

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

معایب:

  • پیاده‌سازی درست آن پیچیده است.
  • به سرویس هماهنگی (مثل ZooKeeper) نیاز دارد.
  • همه سرورها باید اطلاعات مسیریابی را نگه دارند.
  • با خرابی سرور، state اتصال از بین می‌رود.

چه زمانی Consistent Hashing را انتخاب کنیم

Consistent Hashing وقتی ایده‌آل است که باید اتصال‌های پایدار (WebSocket یا SSE) را نگه دارید و سیستم شما به مقیاس‌دهی پویا نیاز دارد. این روش به‌خصوص وقتی ارزشمند است که هر اتصال به state قابل‌توجهی در سمت سرور نیاز دارد و انتقال آن بین سرورها گران تمام می‌شود.

مثلاً در طراحی Google Docs، اتصال‌ها با سندهای مشخصی مرتبط می‌شوند که برای حفظ عملکرد ویرایش مشترک به state قابل‌توجهی نیاز دارند. Consistent Hashing کمک می‌کند آن state روی یک سرور واحد بماند و هم‌زمان مقیاس‌دهی ممکن باشد.

با این حال، اگر فقط پیام‌های کوچک بدون state مرتبط را به کلاینت‌ها push می‌کنید، احتمالاً بهتر است از رویکرد بعدی استفاده کنید، یعنی Pub/Sub.

چیزهایی که باید در مصاحبه بگویید

اگر در مصاحبه رویکرد Consistent Hashing را مطرح می‌کنید، فقط درباره نحوه مسیریابی به‌روزرسانی‌ها (مثلاً با یک سرویس هماهنگی مثل ZooKeeper یا etcd) صحبت نکنید. مصاحبه‌گرها معمولاً علاقه دارند بفهمند سیستم چطور مقیاس می‌گیرد: وقتی باید تعداد گره‌ها را زیاد یا کم کنیم چه می‌شود. برای این موارد باید بتوانید دانش خود درباره Consistent Hashing را به اشتراک بگذارید و همچنین درباره منطق orchestration لازم برای کار کردنش صحبت کنید. در عمل این معمولاً یعنی:

  1. شروع یک رویداد مقیاس‌دهی را اعلام کنید و تخصیص سرور قدیمی و جدید را ثبت کنید.
  2. به‌تدریج کلاینت‌ها را از سرور قدیمی قطع کنید و دوباره به سرور تخصیص‌یافته جدیدشان وصل کنید.
  3. پایان رویداد مقیاس‌دهی را اعلام کنید و سرویس هماهنگی را با تخصیص جدید سرور به‌روز کنید.
  4. در این بین، پیام‌ها را هم به سرور قدیمی و هم به سرور جدید بفرستید تا انتقال کامل شود.

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

Push با Pub/Sub

روش دیگر برای راه‌اندازی به‌روزرسانی‌ها استفاده از مدل Pub/Sub (انتشار و اشتراک) است. در این مدل یک سرویس واحد داریم که مسئول جمع کردن به‌روزرسانی‌ها از منبع و پخش آن‌ها برای همه کلاینت‌های علاقه‌مند است. گزینه‌های محبوب اینجا Kafka و Redis هستند.

سرویس Pub/Sub بزرگ‌ترین منبع state برای به‌روزرسانی‌های بلادرنگ ما می‌شود. اتصال‌های پایدار ما حالا با سرورهای سبک ساخته می‌شوند که فقط در موضوع‌های (topic) مرتبط مشترک می‌شوند و به‌روزرسانی‌ها را به کلاینت‌های مناسب push می‌کنند. من به این سرورها «سرور endpoint» می‌گویم.

وقتی کلاینت‌ها وصل می‌شوند، لازم نیست آن‌ها را به سرور endpoint مشخصی وصل کنیم (مثل Consistent Hashing). در عوض می‌توانیم به هر کدام وصل کنیم. بعد از اتصال، سرور endpoint کلاینت را در سرور Pub/Sub ثبت می‌کند تا هر به‌روزرسانی مربوط به او فرستاده شود.

در سمت اتصال این اتفاق می‌افتد:

  1. کلاینت با یک سرور endpoint ارتباط برقرار می‌کند.
  2. سرور endpoint کلاینت را در سرویس Pub/Sub ثبت می‌کند، اغلب با ساختن یک topic، مشترک شدن در آن و نگه داشتن نقشه‌ای از topicها به اتصال‌های مربوط به آن‌ها.

در سمت پخش به‌روزرسانی این اتفاق می‌افتد:

  1. به‌روزرسانی‌ها به سرویس Pub/Sub و topic مربوط فرستاده می‌شوند.
  2. سرویس Pub/Sub به‌روزرسانی را برای همه مشترکان آن topic پخش می‌کند.
  3. سرور endpoint به‌روزرسانی را می‌گیرد، پیدا می‌کند کدام کلاینت در آن topic مشترک شده و به‌روزرسانی را از طریق اتصال موجود برای آن کلاینت می‌فرستد.

برای اپلیکیشن چت ما، برای هر کاربر یک topic می‌سازیم. وقتی کلاینت به endpoint ما وصل می‌شود، در topic مربوط به کاربر متصل مشترک می‌شود. وقتی باید پیامی بفرستیم، آن را در topic آن کاربر منتشر می‌کنیم و سرویس Pub/Sub آن را برای همه سرورهای مشترک پخش می‌کند. بعد این سرورها آن را از طریق اتصال‌های موجود برای کلاینت‌های مناسب می‌فرستند.

مزایا:

  • مدیریت بار روی سرورهای endpoint آسان است. می‌توانیم از یک Load Balancer ساده با استراتژی «کمترین اتصال» استفاده کنیم.
  • می‌توانیم به‌روزرسانی‌ها را برای تعداد زیادی کلاینت کارآمد پخش کنیم.
  • پخش شدن state در سیستم را حداقل می‌کنیم.

معایب:

  • نمی‌دانیم مشترکان به سرور endpoint وصل هستند یا کی قطع می‌شوند.
  • سرویس Pub/Sub به نقطه شکست و گلوگاه تبدیل می‌شود.
  • یک لایه غیرمستقیم اضافه می‌کنیم که می‌تواند به تأخیر اضافه کند.
  • بین میزبان‌های سرویس Pub/Sub و سرورهای endpoint ارتباط چندبه‌چند (many-to-many) وجود دارد.

چه زمانی Pub/Sub را انتخاب کنیم

Pub/Sub انتخاب عالی برای وقتی است که باید به‌روزرسانی را برای تعداد زیادی کلاینت پخش کنید. راه‌اندازی آن آسان است و از سمت سرورهای endpoint سربار کمی می‌خواهد. اثر آن روی تأخیر حداقل است (کمتر از ۱۰ میلی‌ثانیه). اگر لازم نیست به رویدادهای اتصال و قطع پاسخ بدهید یا state زیادی مرتبط با کلاینت مشخص نگه دارید، این روش عالی است.

چیزهایی که باید در مصاحبه بگویید

اگر از مدل Pub/Sub استفاده می‌کنید، احتمالاً باید درباره single point of failure و گلوگاه سرویس Pub/Sub صحبت کنید. Redis Cluster روش محبوبی برای مقیاس دادن سرویس Pub/Sub است که در آن اشتراک‌ها بر اساس کلیدشان بین چند میزبان تقسیم می‌شوند. این کار تعداد اشتراک‌هایی را که می‌توانید پشتیبانی کنید و throughput را افزایش می‌دهد.

معرفی یک cluster برای مؤلفه Pub/Sub یعنی باید ارتباط چندبه‌چند بین سرویس Pub/Sub و سرورهای endpoint را مدیریت کنید (هر سرور endpoint به همه میزبان‌های cluster وصل می‌شود). در بعضی موارد با انتخاب دقیق topicها برای پارتیشن‌بندی سرویس می‌توان این را مدیریت کرد، اما در بسیاری از موارد تعداد گره‌های cluster کم است.

برای اتصال‌های ورودی به سرورهای endpoint، احتمالاً Load Balancer با استراتژی «کمترین اتصال» می‌خواهید. این کمک می‌کند بار بین سرورهای cluster توزیع شود. چون خود اتصال (و پیام‌هایی که از آن می‌گذرند) عملاً تنها منبع مصرفی است، Load Balancing بر اساس تعداد اتصال راه عالی برای مدیریت بار است.

نمای کلی Push و Pull سمت سرور

مثل اتصال‌های کلاینت، در سمت سرور هم چند گزینه برای نحوه انتشار به‌روزرسانی به سرورهای endpoint (و در نهایت کلاینت‌ها) داریم. اگر بتوانید از مدل polling استفاده کنید، باز هم ساده‌ترین روش است. اگر polling ممکن نیست، مدل Pub/Sub انتخاب اول عالی است. مقدار state را که باید در سیستم مدیریت کنیم حداقل می‌کند، از سوال‌های دقیق درباره مقیاس‌دهی و هماهنگی دوری می‌کند و راه ساده‌ای برای پخش به‌روزرسانی به تعداد زیادی کلاینت می‌دهد. Pub/Sub در همه برنامه‌ها کار می‌کند، اما Consistent Hashing رویکرد تخصصی‌تری است که باید برای موقعیت‌های مشخص نگهش دارید.

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

به‌طور خلاصه:

  • در صورت امکان polling را ترجیح دهید!
  • اگر به به‌روزرسانی بلادرنگ نیاز دارید، Pub/Sub را در نظر بگیرید. برای بیشتر موارد کاربرد دارد و پیاده‌سازی‌اش آسان است.
  • در نهایت، اگر برنامه شما برای هر اتصال به state و منابع زیادی نیاز دارد، Consistent Hashing استفاده بهینه‌تری از منابع فراهم می‌کند.

جمع‌بندی

وقتی نوبت به به‌روزرسانی بلادرنگ در مصاحبه‌های طراحی سیستم می‌رسد، گزینه‌های زیادی دارید. می‌توانید مسئله را به دو بخش تقسیم کنید.

  1. کلاینت‌ها چطور به سیستم من وصل می‌شوند و به‌روزرسانی می‌گیرند؟
  2. چطور به‌روزرسانی‌ها را از منبع راه‌اندازی کنم و به کلاینت‌ها برسانم؟

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

برای گرفتن به‌روزرسانی در کلاینت‌ها گزینه‌های شما Long Polling، SSE، WebSocket و WebRTC هستند. انتخاب درست به نیاز سیستم شما بستگی دارد. SSE و WebSocket عمومی‌ترین راه‌حل‌ها هستند و برای بیشتر موارد کاربرد دارند. اگر مجبور به انتخاب هستید، با همین‌ها بروید. دانستن مزایا و معایب هرکدام انعطاف می‌دهد بهترین تصمیم طراحی را برای سیستم‌تان بگیرید و از آن مهم‌تر، به شما کمک می‌کند مصالحه‌ها را به مصاحبه‌گر منتقل کنید.

برای راه‌اندازی به‌روزرسانی‌ها از منبع می‌توانید از Pub/Sub یا Consistent Hashing استفاده کنید. به‌طور کلی Pub/Sub را به‌خاطر سهولت اجرا، انعطاف و تعداد کم اجزای متحرک و نقطه‌های لبه‌ای که ممکن است مصاحبه‌گر بخواهد درباره‌شان صحبت کند ترجیح دهید. اگر به برنامه‌ای رسیدید که هر اتصال به منابع زیادی نیاز دارد، به این فکر کنید که آیا Consistent Hashing بهتر به شما خدمت می‌کند یا نه.

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