پست

مبانی شبکه برای مصاحبه‌های طراحی سیستم

مرور مفاهیم شبکه که در مصاحبه‌های طراحی سیستم لازم است شامل لایه‌های شبکه، TCP و UDP، HTTP، REST، GraphQL، gRPC، SSE، WebSocket، WebRTC، Load Balancing و مدیریت خرابی

مبانی شبکه برای مصاحبه‌های طراحی سیستم

مبانی شبکه

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

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

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

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

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

شبکه ۱۰۱

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

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

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

پشته کامل شبکه جذاب است، اما سه لایه کلیدی هستند که بیشتر در مصاحبه‌های طراحی سیستم ظاهر می‌شوند. هر کدام را کمی بررسی می‌کنیم. اول ببینیم این لایه‌ها چه می‌کنند و چطور با هم کار می‌کنند.

لایه شبکه (لایه ۳): در این لایه IP قرار دارد، پروتکلی که مسیریابی و آدرس‌دهی را انجام می‌دهد. IP داده را به بسته (packet) تقسیم می‌کند، انتقال بسته‌ها بین شبکه‌ها را مدیریت می‌کند و تحویل بسته به هر آدرس IP مقصد را با بهترین تلاش (best effort) انجام می‌دهد. پروتکل‌های دیگری هم در این لایه وجود دارند، مثل InfiniBand که در بارهای آموزشی بزرگ یادگیری ماشین زیاد استفاده می‌شود، اما IP با اختلاف رایج‌ترین پروتکل در مصاحبه‌های طراحی سیستم است.

لایه انتقال (لایه ۴): TCP، QUIC و UDP در این لایه‌اند و ارتباط سرتاسری (end-to-end) را فراهم می‌کنند. می‌توانید آن را لایه‌ای ببینید که روی لایه شبکه ویژگی‌هایی مثل قابلیت اطمینان، ترتیب و کنترل جریان اضافه می‌کند.

لایه کاربرد (لایه ۷): پروتکل‌های برنامه‌ای مثل DNS، HTTP، WebSocket و WebRTC در بالاترین لایه‌اند. این‌ها پروتکل‌های رایجی هستند که روی TCP ساخته می‌شوند (و در مورد WebRTC روی UDP) تا برای انواع مختلف داده‌ها در برنامه‌های وب یک لایه انتزاع فراهم کنند. همه را به‌طور عمیق بررسی می‌کنیم.

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

مثال، یک درخواست وب ساده

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

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

مراحل با جزئیات:

  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 تأیید می‌کند.

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

شاید جزئیات handshake و teardown در TCP آن‌قدر ریز به نظر برسند که در مصاحبه به کار نیایند، ولی چند نکته مهم وجود دارد.

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

دوم، هرچند در اینجا یک «درخواست» و «پاسخ» مفهومی داریم، برای انجام آن بسته‌ها و درخواست‌های زیادی بین سرورها رد و بدل شد. همه این‌ها تأخیر ایجاد می‌کنند که می‌توانیم نادیده‌اش بگیریم… تا وقتی که نتوانیم. هرچه در پشته بالاتر برویم، تأخیر و پردازش بیشتری لازم است. این موضوع به‌زودی در بحث Load Balancing مهم می‌شود.

سوم، ارتباط بین کلاینت و سرور یک state است که هر دو طرف باید آن را نگه دارند. مگر این‌که از قابلیت‌هایی مثل HTTP keep-alive یا HTTP/2 Multiplexing استفاده کنیم، که در این صورت باید فرایند برقراری اتصال را برای هر درخواست تکرار کنیم. این سربار می‌تواند قابل‌توجه باشد. در طراحی سیستم‌هایی که اتصال پایدار لازم دارند، مثل سیستم‌های به‌روزرسانی بلادرنگ، این موضوع اهمیت پیدا می‌کند.

پروتکل‌های لایه شبکه

اولین لایه در مسیر ما لایه شبکه است. پروتکل IP بر این لایه غالب است و مسئول مسیریابی و آدرس‌دهی است. در یک سیستم، معمولاً به گره‌ها هنگام راه‌اندازی از طریق سرور DHCP یک IP اختصاص داده می‌شود. این آدرس‌ها دلخواه‌اند و فقط تا جایی معنا دارند که به بقیه بگوییم. اگر بخواهم می‌توانم با سرورهایم یک شبکه خصوصی بسازم و هر IP دلخواهی به آن‌ها بدهم. اما اگر بخواهید ترافیک اینترنت بتواند آن‌ها را پیدا کند، باید از IPهای قابل‌مسیریابی استفاده کنید که توسط RIR (ثبت‌کننده منطقه‌ای اینترنت) اختصاص داده می‌شوند.

این IPهای تخصیص‌یافته IP عمومی (Public IP) نامیده می‌شوند و برای شناسایی دستگاه‌های روی اینترنت به کار می‌روند. مهم‌ترین نکته درباره آن‌ها این است که زیرساخت مسیریابی اینترنت برای مسیریابی ترافیک بین IPهای عمومی بهینه شده و می‌داند آن‌ها کجا هستند. مثلاً هر آدرسی که با ۱۷ شروع شود (مثل 17.0.0.0) متعلق به اپل است. backbone اینترنت می‌داند وقتی می‌خواهید بسته‌ای به این آدرس‌ها بفرستید، باید آن را به روترهای اپل برساند.

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

پروتکل‌های لایه انتقال

لایه انتقال جایی است که ارتباط سرتاسری بین برنامه‌ها برقرار می‌شود. به‌جای این‌که بسته‌های درهم‌ریخته به ما بدهد، تضمین‌هایی می‌دهد. سه پروتکل اصلی این لایه TCP، UDP و QUIC هستند و هرکدام ویژگی‌های خودشان را دارند که برای کاربردهای مختلف مناسب‌اند.

در بیشتر مصاحبه‌های طراحی سیستم، انتخاب واقعی شما بین TCP و UDP است. QUIC پروتکل جدیدی است که می‌خواهد بعضی مزایای TCP را همراه با مدرن‌سازی و کارایی بهتر ارائه کند. با این‌که QUIC محبوب‌تر می‌شود، هنوز نسبتاً جدید است و همه‌جا نیست. برای هدف ما آن را نسخه بهتری از TCP در نظر می‌گیریم، فقط بدون پذیرش گسترده TCP.

بعضی مصاحبه‌گرهای متمرکز بر کارایی از آشنایی شما با پروتکل‌های مدرن مثل QUIC و HTTP/3 تحت‌تأثیر قرار می‌گیرند. ولی بیشتر مصاحبه‌گرهای طراحی سیستم ترجیح می‌دهند وقتتان را جای دیگری از طراحی صرف کنید.

UDP، سریع اما نامطمئن

پروتکل User Datagram (UDP) مثل مسلسل عمل می‌کند. روی IP ویژگی‌های کمی اضافه می‌کند، اما بسیار سریع است. «شلیک کن و دعا کن» روش درستی برای فکر کردن به آن است. سرویس ساده‌تر و بدون اتصال (connectionless) ارائه می‌دهد و تحویل، ترتیب و محافظت در برابر تکرار را تضمین نمی‌کند.

اگر برنامه‌ای بنویسید که دیتاگرام‌های UDP را دریافت می‌کند، می‌توانید ببینید از کجا آمده‌اند (IP مبدأ و پورت) و به کجا می‌روند (IP مقصد و پورت). همین. بقیه فقط یک Blob باینری است.

ویژگی‌های کلیدی UDP:

  • بدون اتصال: نه handshake دارد و نه راه‌اندازی اتصال.
  • بدون تضمین تحویل: بسته‌ها ممکن است بی‌خبر گم شوند.
  • بدون ترتیب: بسته‌ها ممکن است با ترتیبی غیر از ترتیب ارسال برسند.
  • تأخیر کمتر: سربار کمتر یعنی انتقال سریع‌تر.

بدون اتصال بودن خوب به نظر می‌رسد، اما موارد ۲ و ۳ تا حدی بد هستند. پس چرا باید از UDP استفاده کنیم؟

UDP برای برنامه‌هایی عالی است که سرعت برایشان مهم‌تر از قابلیت اطمینان است، مثل پخش زنده ویدیو، بازی آنلاین، VoIP و جستجوی DNS. در این موارد، برنامه یا کلاینت آماده است با گم شدن گاه‌به‌گاه بسته یا رسیدن نامرتب بسته کنار بیاید. مثلاً در VoIP ممکن است کلاینت گاهی بسته‌ای را رها کند که باعث لحظه‌ای قطع صدا می‌شود، اما مکالمه در کل قابل‌فهم می‌ماند. این خیلی بهتر از ارسال دوباره بسته‌های گم‌شده و شلوغ کردن شبکه با ACK است.

مرورگرها خارج از WebRTC پشتیبانی گسترده‌ای از UDP ندارند (به WebRTC هم می‌رسیم). اگر طراحی‌ای در ذهن دارید که می‌تواند از UDP استفاده کند (مثل ارسال سیل قلب‌ها و واکنش‌ها در کامنت‌های زنده فیس‌بوک)، فکر کنید برای کاربران مرورگری چه می‌کنید. ممکن است کاربران اپلیکیشن موبایل جریان UDP بلادرنگ واکنش‌ها را بگیرند، اما کاربران مرورگر جریان HTTP کندتر و دسته‌ای دریافت کنند که شما آن را در طول زمان در UI پخش می‌کنید.

TCP، قابل‌اعتماد اما با سربار

پروتکل Transmission Control (TCP) موتور اصلی اینترنت است. تحویل قابل‌اعتماد، مرتب و با بررسی خطا را فراهم می‌کند. اتصال را با handshake سه‌مرحله‌ای برقرار می‌کند (در مثال HTTP بالا دیدیم) و تا پایان جلسه ارتباط آن را نگه می‌دارد.

این اتصال «stream» نامیده می‌شود و اتصالی پایدار بین کلاینت و سرور است. همین اتصال مبنایی هم برای صحبت درباره ترتیب می‌دهد، یعنی دو پیام ارسال‌شده روی یک stream یا اتصال به همان ترتیب دریافت می‌شوند. TCP مطمئن می‌شود گیرنده‌ها دریافت پیام را تأیید می‌کنند و اگر تأیید نکنند، پیام را دوباره می‌فرستد تا تأیید شود.

ویژگی‌های کلیدی TCP:

  • اتصال‌محور: قبل از انتقال داده یک اتصال اختصاصی برقرار می‌کند.
  • تحویل قابل‌اعتماد: تضمین می‌کند داده به ترتیب و بدون خطا برسد.
  • کنترل جریان (Flow Control): جلوی غرق شدن گیرنده در داده بیش از حد را می‌گیرد.
  • کنترل ازدحام (Congestion Control): با ازدحام شبکه سازگار می‌شود تا شبکه از کار نیفتد.

TCP برای برنامه‌هایی ایده‌آل است که یکپارچگی داده برایشان حیاتی است. یعنی عملاً هر چیزی که UDP برایش مناسب نیست.

چه زمانی کدام پروتکل را انتخاب کنیم

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

اما اگر بتوانید کاربرد UDP را مطرح کنید و در جزئیات گم نشوید، امتیاز بیشتری می‌گیرید. پس باید از خودتان بپرسید که آیا UDP برای موردتان مناسب‌تر است یا نه. UDP را در این شرایط انتخاب کنید:

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

اپلیکیشن‌های مدرن اغلب از هر دو پروتکل استفاده می‌کنند. مثلاً یک اپلیکیشن کنفرانس ویدیویی تحت وب ممکن است برای signaling و احراز هویت از TCP/HTTP استفاده کند، اما برای جریان‌های واقعی صدا و تصویر از UDP/WebRTC.

مقایسه TCP و UDP

ویژگیUDPTCP
اتصالبدون اتصالاتصال‌محور
قابلیت اطمینانتحویل با بهترین تلاشتحویل تضمین‌شده
ترتیببدون تضمین ترتیبحفظ ترتیب
کنترل جریانندارددارد
اندازه هدر۸ بایت۲۰ تا ۶۰ بایت
سرعتسریع‌تر، سربار کمترکندتر، سربار بیشتر
کاربرداستریم، بازی، VoIPبقیه موارد

پروتکل‌های لایه کاربرد

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

معمولاً لایه کاربرد در «فضای کاربر» (user space) پردازش می‌شود، در حالی که لایه‌های پایین‌تر در هسته سیستم‌عامل و در «فضای هسته» (kernel space) پردازش می‌شوند. یعنی لایه کاربرد انعطاف‌پذیرتر است و راحت‌تر از لایه‌های پایین‌تر عوض می‌شود. در مقابل، تغییر لایه‌های پایین‌تر سخت‌تر است، اما می‌تواند بسیار کارآمد باشد.

HTTP و HTTPS، بنیاد وب

پروتکل Hypertext Transfer Protocol (HTTP) استاندارد عملی ارتباط داده در وب است. این پروتکل از نوع درخواست و پاسخ است. کلاینت‌ها درخواست می‌فرستند و سرورها با داده درخواستی پاسخ می‌دهند.

HTTP پروتکلی بدون حالت (stateless) است، یعنی هر درخواست مستقل است و سرور لازم نیست اطلاعات درخواست‌های قبلی را نگه دارد. این معمولاً چیز خوبی است. در طراحی سیستم می‌خواهید تا جای ممکن بخش‌های stateful را به حداقل برسانید. بیشتر سرورهای ساده HTTP را می‌توان تابعی از پارامترهای درخواست دانست، یعنی stateless هستند.

یک درخواست و پاسخ ساده HTTP را می‌توانید خودتان با باز کردن اتصال TCP و ابزاری مثل nc دستی بفرستید. چند مفهوم کلیدی در آن می‌بینید.

  • متدهای درخواست: GET، POST، PUT، DELETE و غیره.
  • کدهای وضعیت: 200 OK، 404 Not Found، 500 Server Error و غیره.
  • هدرها: metadata درباره درخواست یا پاسخ.
  • بدنه (Body): محتوای واقعی که منتقل می‌شود.

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

متدهای رایج:

  • GET: درخواست داده از سرور. درخواست‌های GET باید idempotent باشند و بدنه نداشته باشند.
  • POST: ارسال داده به سرور.
  • PUT: به‌روزرسانی داده روی سرور.
  • PATCH: به‌روزرسانی بخشی از یک منبع.
  • DELETE: حذف داده از سرور. درخواست‌های DELETE باید idempotent باشند.

کدهای وضعیت رایج:

  • موفقیت (2xx): 200 OK یعنی درخواست موفق بود. 201 Created یعنی درخواست موفق بود و منبع جدیدی ساخته شد.
  • انتقال (3xx): 302 Found یعنی منبع موقتاً جابه‌جا شده. 301 Moved Permanently یعنی منبع برای همیشه جابه‌جا شده.
  • خطای کلاینت (4xx): 404 Not Found یعنی منبع پیدا نشد. 401 Unauthorized یعنی درخواست احراز هویت لازم دارد. 403 Forbidden یعنی سرور درخواست را فهمید اما اجازه انجامش را نداد. 429 Too Many Requests یعنی کلاینت در یک بازه زمانی درخواست‌های زیادی فرستاده.
  • خطای سرور (5xx): 500 Internal Server Error یعنی سرور به خطا خورد. 502 Bad Gateway یعنی سرور از سرور بالادستی پاسخ نامعتبر گرفت.

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

هدرهای HTTP نمونه خوبی از طراحی رابطی‌اند که برای کاربردهای ناشناخته آینده انعطاف‌پذیر است و درس خوبی برای طراحی API دارند. مذاکره محتوا (content negotiation) یک نمونه کامل است. مثلاً هدر Accept-Encoding راهی به کلاینت می‌دهد که نشان دهد چه انواعی از رمزگذاری (encoding) محتوا را می‌تواند مدیریت کند. به این ترتیب سرورها می‌توانند در صورت امکان پاسخ‌های فشرده با gzip یا br (Brotli) بدهند. سرور بعد با کارآمدترین encoding برای آن کلاینت و با هدر Content-Encoding: X جواب می‌دهد. نتیجه هم سازگاری با نسخه‌های قدیمی است و هم کاهش تدریجی کیفیت به شکل مناسب (graceful degradation).

HTTPS با اضافه کردن لایه امنیتی TLS/SSL ارتباط را رمزگذاری می‌کند و در برابر شنود و حمله مرد میانی (man-in-the-middle) محافظت می‌کند. اگر وب‌سایت عمومی می‌سازید، بدون استثنا از HTTPS استفاده می‌کنید. معمولاً یعنی محتوای درخواست‌ها و پاسخ‌های HTTP هنگام انتقال رمزگذاری و امن است.

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

این به آن معنا نیست که نباید شناسه کاربر در درخواست‌ها باشد. فقط یعنی باید بتوانید آن را روی سرور تأیید کنید و API نباید چیزی را که نمی‌توانید به آن اعتماد کنید از کلاینت بخواهد.

خلاصه HTTP همین بود. حالا ببینیم چطور با آن API می‌سازیم.

REST، ساده و انعطاف‌پذیر

از HTTP می‌توان مستقیم برای ساخت وب‌سایت استفاده کرد، اما اغلب طراحی‌های سیستم به ارتباط بین سرویس‌ها از طریق API مربوط‌اند. برای ساخت این APIها سه الگوی اصلی داریم، REST، GraphQL و gRPC.

REST رایج‌ترین الگوی API در مصاحبه‌های طراحی سیستم است. راهی ساده و منعطف برای ساخت APIهایی است که فهم و استفاده از آن‌ها آسان است. اصل اصلی REST این است که کلاینت‌ها اغلب عملیات ساده‌ای روی منابع (resource) انجام می‌دهند. منابع را مثل جدول‌های پایگاه داده یا فایل‌های روی سرور ببینید.

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

اگر چارچوب تحویل (Delivery Framework) ما را دنبال کرده باشید، موجودیت‌های اصلی شما اغلب مستقیم به منابع API نگاشت می‌شوند. این یک امتیاز اضافه است.

یک API ساده RESTful (که در آن User یک شیء JSON است و کاربر را نشان می‌دهد) شبیه این است:

1
GET /users/{id} -> User

اینجا از متد GET استفاده می‌کنیم تا نشان دهیم منبع را درخواست می‌کنیم. {id} جایگاه شناسه منبع است، یعنی شناسه کاربری که می‌خواهیم بگیریم. برای به‌روزرسانی همان کاربر، از متد PUT استفاده می‌کنیم تا نشان دهیم منبع موجود را به‌روز می‌کنیم.

1
2
3
4
5
PUT /users/{id} -> User
{
  "username": "john.doe",
  "email": "[email protected]"
}

با متد POST هم می‌توانیم منبع جدید بسازیم. بدنه شامل محتوای منبعی است که می‌خواهیم ایجاد کنیم. توجه کنید که اینجا شناسه نمی‌دهیم، چون سرور شناسه را تعیین می‌کند.

1
2
3
4
5
POST /users -> User
{
  "username": "stefan.mai",
  "email": "[email protected]"
}

در نهایت، برای نشان دادن رابطه بین منابع می‌توان آن‌ها را تو در تو (nested) کرد. مثلاً یک کاربر ممکن است پست‌های زیادی داشته باشد. پس این رابطه را با قرار دادن پست‌ها زیر منبع کاربر نشان می‌دهیم.

1
GET /users/{id}/posts -> [Post]

بسیاری از مهندسان اغلب به متدهایی مثل UpdateUser یا startGame فکر می‌کنند. این‌ها عملیات هستند، نه منبع، پس RESTful نیستند. در REST باید برحسب منابع و عملیاتی که می‌شود روی آن‌ها انجام داد فکر کنیم. بنابراین UpdateUser می‌تواند PUT /users/{id} شود و startGame می‌تواند یک PATCH /games/{id} باشد که وضعیت بازی را به «started» تغییر می‌دهد.

کجا از آن استفاده کنیم

REST به‌طور کلی برای طیف گسترده‌ای از موارد کاربرد بسیار منعطف است. مثلاً ElasticSearch از آن برای مدیریت اسناد، پیکربندی ایندکس‌ها و کارهای دیگر استفاده می‌کند. اگر نمونه خوبی از یک API به سبک RESTful می‌خواهید، آن را ببینید.

REST کارآمدترین راه‌حل برای سرویس‌هایی با throughput بسیار بالا نیست، و JSON به‌طور کلی قالب نسبتاً ناکارآمدی برای serialization و deserialization است. با این حال، بیشتر برنامه‌ها به‌خاطر serialization درخواست‌ها به گلوگاه نمی‌خورند.

مثل TCP، REST هم همان جایی است که پیشنهاد می‌کنیم در مصاحبه‌ها پیش‌فرض شما باشد. REST خوب فهمیده شده و پایه مناسبی برای ساخت سیستم‌های مقیاس‌پذیر است. اگر نیازهای خاصی دارید که REST برآورده نمی‌کند، سراغ GraphQL، gRPC، SSE یا WebSocket بروید. برای الگوهای عملی طراحی REST API، راهنمای «طراحی API» ما را ببینید.

GraphQL، واکشی انعطاف‌پذیر داده

GraphQL الگوی جدیدتری برای API است (حدود سال ۲۰۱۵ توسط فیس‌بوک متن‌باز شد) که به کلاینت‌ها اجازه می‌دهد دقیقاً همان داده‌ای را که لازم دارند درخواست کنند.

مشکلی که GraphQL حل می‌کند این است که تیم‌ها و سیستم‌ها معمولاً به فرانت‌اند و بک‌اند تقسیم می‌شوند. مثلاً فرانت‌اند ممکن است یک اپلیکیشن موبایل و بک‌اند یک API مبتنی بر پایگاه داده باشد.

وقتی تیم فرانت‌اند می‌خواهد صفحه جدیدی نشان دهد، سه راه دارد. (الف) چند درخواست مختلف به endpointهای بک‌اند بفرستد (مثلاً یک API برای گرفتن لیست کاربران و ۱۰ تماس API دیگر برای دریافت جزئیات آن‌ها). (ب) APIهای حجیمی بسازد که نگهداری‌شان سخت و تغییرشان کند است. (ج) برای هر صفحه جدید یک API جدید بنویسد. هیچ‌کدام راه‌حل خوبی نیستند، اما با یک REST API استاندارد باید با همین‌ها کنار بیایید.

مشکل under-fetching این است که ممکن است به چند درخواست و رفت‌وبرگشت نیاز داشته باشید. این کار سربار و تأخیر به بارگذاری صفحه اضافه می‌کند. Over-fetching برعکس آن است. یعنی برای محافظت در برابر نیازهای احتمالی آینده که امروز نداریم، بیش از نیاز داده در پاسخ API می‌گذاریم. نتیجه این است که APIها برای بارگذاری و برگرداندن داده اضافی زمان زیادی می‌برند. نوشتن API جدید برای هر صفحه جدید هم کابوس است.

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

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

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
30
31
32
33
34
35
36
37
query GetUsersWithProfilesAndGroups($limit: Int = 10, $offset: Int = 0) {
  users(limit: $limit, offset: $offset) {
    id
    username
    # ...

    profile {
      id
      fullName
      avatar
      # ...
    }

    groups {
      id
      name
      description
      # ...

      category {
        id
        name
        icon
      }
    }

    status {
      isActive
      lastActiveAt
    }
  }

  _metadata {
    totalCount
    hasNextPage
  }
}

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

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

کجا از آن استفاده کنیم

GraphQL برای وقتی مناسب است که تیم فرانت‌اند باید سریع تکرار کند (iterate) و تنظیمات را تغییر دهد. آن‌ها می‌توانند دقیقاً داده موردنیازشان را از بک‌اند بگیرند. از طرف دیگر، اجرای این کوئری‌های GraphQL می‌تواند منبع تأخیر و پیچیدگی برای بک‌اند باشد و گاهی همان کد سفارشی بک‌اند را که می‌خواستیم از آن دوری کنیم لازم می‌کند. در عمل، GraphQL بیشتر با کلاینت‌های پیچیده و وقتی چند تیم کوئری‌های گسترده روی داده‌های همپوشان می‌نویسند جواب می‌دهد.

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

توصیه ما این است که GraphQL را وقتی مطرح کنید که مسئله به‌وضوح حول انعطاف‌پذیری می‌چرخد (مثلاً مصاحبه‌گر بگوید باید بتوانیم اپلیکیشن را سریع با نیازهای در حال تغییر تطبیق دهیم) یا وقتی الزامات عمداً مبهم گذاشته شده‌اند. برای توصیه‌های بیشتر مربوط به GraphQL در مصاحبه، مقاله «طراحی API» ما را ببینید.

gRPC، ارتباط کارآمد بین سرویس‌ها

gRPC یک چارچوب RPC (فراخوانی از راه دور) با کارایی بالا از گوگل است («g» اول آن) که از HTTP/2 و Protocol Buffers استفاده می‌کند.

Protocol Buffers را مثل JSON ببینید، اما با schema سفت‌وسخت‌تر که کارایی بهتر و serialization کارآمدتر ممکن می‌کند. نمونه تعریف Protocol Buffer برای منبع کاربر:

1
2
3
4
message User {
  string id = 1;
  string name = 2;
}

به‌جای یک شیء JSON حجیم که schema در آن جاسازی شده است (۴۰ بایت):

1
2
3
4
{
  "id": "123",
  "name": "John Doe"
}

یک رمزگذاری باینری (۱۵ بایت) از همان داده داریم، با تگ‌های بسیار نازک و رمزگذاری طول متغیر برای رشته‌ها. فضای کمتر و CPU کمتر برای parse کردن:

1
0A 03 31 32 33 12 08 6A 6F 68 6E 20 64 6F 65

gRPC بر همین اساس، تعریف سرویس‌ها را هم فراهم می‌کند. نمونه تعریف سرویس gRPC برای UserService:

1
2
3
4
5
6
7
8
9
10
11
message GetUserRequest {
  string id = 1;
}

message GetUserResponse {
  User user = 1;
}

service UserService {
  rpc GetUser (GetUserRequest) returns (GetUserResponse);
}

احتمالاً لازم نیست جزئیات این تعریف را توضیح بدهیم.

این تعریف‌ها برای کلاینت و سرور کامپایل می‌شوند و طیف گسترده‌ای از زبان‌ها و فریم‌ورک‌ها می‌توانند از آن‌ها برای ساخت سرویس و کلاینت استفاده کنند. gRPC مجموعه‌ای از ویژگی‌ها دارد که به معماری‌های میکروسرویس در مقیاس بزرگ مربوط‌اند (در اصل توسط گوگل اختراع شده)، مثل streaming، deadline و load balancing سمت کلاینت. اما مهم‌ترین نکته این است که پروتکلی باینری است و از JSON روی HTTP سریع‌تر و کارآمدتر است.

کجا از آن استفاده کنیم

gRPC در معماری میکروسرویس می‌درخشد، جایی که سرویس‌ها باید کارآمد با هم ارتباط بگیرند. تایپ قوی آن کمک می‌کند خطاها در زمان کامپایل پیدا شوند، نه در زمان اجرا. پروتکل باینری آن هم از JSON روی HTTP کارآمدتر است (بعضی benchmarkها حتی throughput تا ۱۰ برابر را نشان می‌دهند). gRPC را برای ارتباط داخلی سرویس‌به‌سرویس در نظر بگیرید، به‌خصوص وقتی کارایی حیاتی است یا تأخیر را شبکه تعیین می‌کند، نه کاری که سرور انجام می‌دهد.

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

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

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

SSE، ارتباط push بلادرنگ

تا اینجا بیشتر درباره APIهای درخواست و پاسخ صحبت کردیم، اما خیلی از برنامه‌ها باید داده را به‌صورت جریانی به کلاینت‌ها بفرستند. gRPC از streaming پشتیبانی می‌کند، اما به‌خاطر پشتیبانی محدود برای APIهای خارجی ایده‌آل نیست (مثلاً امروز هیچ مرورگری از gRPC پشتیبانی نمی‌کند). Server-Sent Events (SSE) مشخصه‌ای است که روی HTTP تعریف شده و به سرور اجازه می‌دهد پیام‌های زیادی را از طریق یک اتصال HTTP برای کلاینت بفرستد.

این‌طور به آن فکر کنید: SSE یک hack خوب روی HTTP است که به سرور اجازه می‌دهد در طول زمان پیام‌های زیادی را در یک پاسخ برای کلاینت stream کند.

در بیشتر APIهای HTTP یک Blob یکپارچه JSON به‌عنوان پاسخ سرور می‌گیرید که بعد از دریافت کامل پردازش می‌شود:

1
2
3
4
5
6
7
8
{
  "events": [
    { "id": 1, "timestamp": "2025-01-01T00:00:00Z", "description": "Event 1" },
    { "id": 2, "timestamp": "2025-01-01T00:00:01Z", "description": "Event 2" },
    ...
    { "id": 100, "timestamp": "2025-01-01T00:00:10Z", "description": "Event 100" }
  ]
}

چون باید منتظر رسیدن پاسخ کامل بمانیم تا آن را پردازش کنیم، این روش برای push notification مناسب نیست. در مقابل، با SSE سرور می‌تواند پیام‌های زیادی را به‌صورت «chunk» در یک پاسخ بفرستد:

1
2
3
4
data: {"id": 1, "timestamp": "2025-01-01T00:00:00Z", "description": "Event 1"}
data: {"id": 2, "timestamp": "2025-01-01T00:00:01Z", "description": "Event 2"}
...
data: {"id": 100, "timestamp": "2025-01-01T00:00:10Z", "description": "Event 100"}

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

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

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

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

کجا از آن استفاده کنیم

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

WebSocket، ارتباط دوطرفه بلادرنگ

SSE راه خوبی برای ارسال داده از سرور به کلاینت است، اما بسیاری از برنامه‌ها به ارتباط دوطرفه بلادرنگ نیاز دارند. gRPC هم با این‌که streaming دارد، به‌خاطر پشتیبانی محدود برای APIهای خارجی ایده‌آل نیست (مثلاً امروز هیچ مرورگری از gRPC پشتیبانی نمی‌کند). پس یک کاندیدای مصاحبه چه باید بکند؟

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

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

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

طرز کار

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

WebSocketها پروتکل برنامه را تعیین نمی‌کنند. عملاً کانالی دارید که می‌توانید بسته‌های باینری را از کلاینت به سرور و برعکس بفرستید. یعنی خودتان باید روشی تعیین کنید که مشخص شود کلاینت و سرور چه چیزی رد و بدل می‌کنند. برای بسیاری از برنامه‌های WebSocket پیام‌های JSON ساده گزینه خوبی هستند. این همچنین فرصتی می‌دهد API سرویس را برای طراحی خودتان تعریف کنید.

کجا از آن استفاده کنیم

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

برای برنامه‌هایی که فقط باید درخواست بفرستید و پاسخ بگیرید، یا موقعیت‌هایی که با push notification در SSE راهتان باز می‌شود، WebSocket زیادی است.

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

WebRTC، ارتباط peer-to-peer

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

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

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

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

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

اتصال WebRTC عملاً چهار مرحله دارد.

  1. کلاینت‌ها به سرور signaling مرکزی وصل می‌شوند تا درباره peerهای خود بفهمند.
  2. کلاینت‌ها به سرور STUN مراجعه می‌کنند تا IP عمومی و پورت خودشان را پیدا کنند.
  3. کلاینت‌ها این اطلاعات را از طریق سرور signaling با هم به اشتراک می‌گذارند.
  4. کلاینت‌ها اتصال مستقیم peer-to-peer برقرار می‌کنند و شروع به ارسال داده می‌کنند.

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

کجا از آن استفاده کنیم

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

در عمل، بیشتر ویرایشگرهای مشترک نیازی ندارند برای هزاران کلاینت مقیاس بگیرند. علاوه بر این، اغلب به یک سرور مرکزی برای ذخیره سند و هماهنگی بین کلاینت‌ها نیاز دارید. به همین دلیل در تحلیل مسئله Google Docs از WebSocket استفاده می‌کنیم. اما جایگزینی هم هست که از WebRTC و CRDT (انواع داده تکرارشونده بدون تعارض) برای رسیدن به تجربه‌ای واقعاً peer-to-peer استفاده می‌کند.

برای مصاحبه پیشنهاد می‌کنیم WebRTC را برای تماس تصویری و صوتی و کنفرانس به کار ببرید. WebRTC برای درست کار کردن دردسر مطلق است و حتی بهترین پیاده‌سازی‌ها هم هنوز مشکل دارند. واقعاً راه‌حلی خاص و محدود (niche) است.

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

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

Load Balancing

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

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

ترجیح شخصی من این است که تا جای ممکن از مقیاس عمودی استفاده کنم. سخت‌افزار مدرن فوق‌العاده قدرتمند است و دوران نیاز به هزاران سرور کوچک، وقتی چند سرور بزرگ‌تر می‌توانند بار را تحمل کنند، گذشته است. (درباره توانایی سخت‌افزار مدرن در بررسی عمیق «Numbers to Know» بیشتر بخوانید.)

با این حال، واقعیت مصاحبه این است که رایج‌ترین الگوی مقیاس‌دهی که می‌بینید مقیاس افقی است، یعنی برای تحمل بار سرورهای بیشتری اضافه می‌کنیم. اما اگر به کلاینت‌ها نگوییم با کدام سرور صحبت کنند، فقط اضافه کردن جعبه به تخته کمکی نمی‌کند. اینجاست که Load Balancing وارد می‌شود.

انواع Load Balancing

باید درخواست‌های ورودی (بار) را پخش کنیم و تصمیم بگیریم هر درخواست را کدام سرور مدیریت کند. دو راه برای کنترل Load Balancing وجود دارد، سمت کلاینت یا سمت سرور. هر کدام مزایا و معایبی دارند.

Load Balancing سمت کلاینت

در Load Balancing سمت کلاینت، خود کلاینت تصمیم می‌گیرد با کدام سرور حرف بزند. معمولاً کلاینت از یک رجیستری یا دایرکتوری سرویس که فهرست سرورهای موجود را دارد درخواست می‌کند. بعد مستقیم به یکی از آن سرورها درخواست می‌فرستد. کلاینت باید به‌صورت دوره‌ای poll کند یا وقتی چیزی تغییر کرد، به‌روزرسانی را دریافت کند (push).

Load Balancing سمت کلاینت می‌تواند بسیار سریع و کارآمد باشد. چون کلاینت خودش تصمیم می‌گیرد، می‌تواند بدون تأخیر اضافی سریع‌ترین سرور را انتخاب کند. به‌جای این‌که برای هدایت هر درخواست به سرور مناسب یک hop کامل شبکه صرف شود، فقط باید (به‌صورت دوره‌ای) فهرست سرورها را با رجیستری سرورها همگام کنیم.

مثال، Redis Cluster: یک مثال عالی از این حالت Redis Cluster است. گره‌های Redis Cluster با هم یک پروتکل gossip نگه می‌دارند تا اطلاعات خوشه را به اشتراک بگذارند، مثل این‌که چه گره‌هایی هستند و وضعیتشان چیست. هر گره از همه گره‌های دیگر خبر دارد.

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

مثال، DNS: نمونه دیگر Load Balancing سمت کلاینت DNS است. وقتی برای نام دامنه‌ای مثل example.com درخواست می‌کنید، DNS resolver شما فهرستی چرخشی از آدرس‌های IP آن دامنه را برمی‌گرداند. هر درخواست جدید ترتیب متفاوتی از IPها (یا حتی مجموعه کاملاً متفاوتی) می‌گیرد. چون هر کلاینت ترتیب متفاوتی از IPها را می‌گیرد، به سرورهای مختلفی هم وصل می‌شود. پس DNS resolver در عمل Load Balancing سمت کلاینت را برای ما انجام می‌دهد.

رفتار DNS همچنین نشان می‌دهد چطور می‌توان از single point of failure در Load Balancer جلوگیری کرد. دو Load Balancer راه می‌اندازید (برای اطمینان در مراکز داده یا ریجن‌های مختلف) و با DNS بین آن‌ها می‌چرخید. اگر یکی از کار بیفتد، کلاینت‌ها خودکار شروع می‌کنند آن یکی را امتحان کنند.

کجا از آن استفاده کنیم

Load Balancing سمت کلاینت در دو حالت خوب کار می‌کند. یا (۱) تعداد کمی کلاینت داریم که کنترلشان با ماست (مثل کلاینت Redis Cluster یا Load Balancing سمت کلاینت gRPC برای سرویس‌های داخلی). یا (۲) کلاینت‌های زیادی داریم اما می‌توانیم به‌روزرسانی‌های کند را تحمل کنیم (مثل DNS).

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

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

Load Balancer اختصاصی

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

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

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

Load Balancer لایه ۴

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

ویژگی‌های کلیدی Load Balancer لایه ۴:

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

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

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

Load Balancer لایه ۷

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

بر خلاف Load Balancer لایه ۴، جزئیات سطح اتصال اینجا چندان مهم نیست. Load Balancer لایه ۷ یک درخواست سطح برنامه (مثل HTTP GET) می‌گیرد و آن را به سرور بک‌اند مناسب می‌فرستد.

ویژگی‌های کلیدی Load Balancer لایه ۷:

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

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

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

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

بررسی سلامت و تحمل خطا

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

این قابلیت Failover خودکار، Load Balancerها را برای دسترس‌پذیری بالا ضروری می‌کند. آن‌ها می‌توانند خرابی‌ها را شناسایی کنند و بدون دخالت کاربر دور بزنند.

برای این کار از health check استفاده می‌کنند. health check راهی است که Load Balancer می‌فهمد سرور سالم است یا نه. می‌توان آن را طوری تنظیم کرد که سرور را در فواصل زمانی مختلف و با پروتکل‌های مختلف بررسی کند. یک رویکرد رایج health check از نوع TCP است، که راه ساده و کارآمدی برای بررسی پذیرش اتصال جدید توسط سرور است. health check لایه ۷ ممکن است یک درخواست HTTP به سرور بفرستد و مطمئن شود پاسخ موفق است (مثلاً کد 200 در مقابل 500 که نشان‌دهنده خطای داخلی یا نبود پاسخ‌گویی است).

الگوریتم‌های Load Balancer

مزیت دیگر Load Balancer اختصاصی این است که برای الگوریتم توزیع ترافیک گزینه‌های بیشتری داریم. بیشتر Load Balancerها چند گزینه دارند.

  • Round Robin: درخواست‌ها به‌ترتیب بین سرورها توزیع می‌شوند.
  • Random: درخواست‌ها تصادفی بین سرورها توزیع می‌شوند.
  • Least Connections: درخواست‌ها به سروری می‌روند که کمترین اتصال فعال را دارد.
  • Least Response Time: درخواست‌ها به سروری می‌روند که سریع‌ترین زمان پاسخ را دارد.
  • IP Hash: IP کلاینت تعیین می‌کند کدام سرور درخواست را بگیرد (برای ماندگاری نشست مفید است).

معمولاً الگوریتم Round Robin یا Random کافی است، به‌خصوص برای برنامه‌های stateless که انتظار نداریم سرور خاصی از بقیه محبوب‌تر باشد. برای سرویس‌هایی که اتصال پایدار لازم دارند (مثل سرویس‌هایی که اتصال SSE یا WebSocket می‌دهند)، استفاده از Least Connections ایده خوبی است. چون جلوی حالتی را می‌گیرد که یک سرور به‌تدریج همه اتصال‌های فعال را روی خودش جمع کند.

پیاده‌سازی در دنیای واقعی

در عمل Load Balancerهای اختصاصی را به شکل‌های مختلف می‌بینید.

  • Load Balancer سخت‌افزاری: دستگاه‌های فیزیکی مثل F5 BIG-IP.
  • Load Balancer نرم‌افزاری: HAProxy، NGINX، Envoy.
  • Load Balancer ابری: AWS ELB/ALB/NLB، Google Cloud Load Balancer، Azure Load Balancer.

Load Balancerهای سخت‌افزاری سازمانی می‌توانند تا ۱۰۰ میلیون درخواست در ثانیه مقیاس بگیرند، در حالی که Load Balancerهای نرم‌افزاری محدودترند. مقیاس‌دهی خود Load Balancerها تقریباً هیچ‌وقت بخشی از مصاحبه طراحی سیستم برای مهندس نرم‌افزار نیست (به‌جز در بعضی تخصص‌های شبکه). اما اگر دیدید throughput مورد نیاز Load Balancer بسیار بالاست، اشاره به Load Balancer سخت‌افزاری کار خوبی است.

بررسی عمیق و چالش‌های رایج

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

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

منطقه‌بندی و تأخیر

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

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

نور در کابل فیبر نوری با سرعتی حدود دو سوم سرعت نور در خلأ حرکت می‌کند، یعنی تقریباً ۲۰۰ هزار کیلومتر در ثانیه. بنابراین رفت‌وبرگشت بین نیویورک و لندن (حدود ۵۶۰۰ کیلومتر) حداقل تأخیر نظری حدود ۵۶ میلی‌ثانیه دارد. این رقم فقط از فیزیک انتشار سیگنال می‌آید، قبل از اضافه شدن هر زمان پردازش. این محدودیت فیزیکی دلیل ضروری بودن توزیع جغرافیایی برای کاربردهای کم‌تأخیر است.

برای این مسئله چه کنیم؟

برای حل این مسئله باید به موضوع محل قرارگیری داده (data locality) برگردیم. در همه محاسبات، وقتی داده تا حد ممکن نزدیک محاسباتی باشد که باید انجام دهیم، بیشترین کارایی را داریم.

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

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

شبکه‌های توزیع محتوا (CDN)

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

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

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

پارتیشن‌بندی منطقه‌ای

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

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

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

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

مدیریت خرابی‌ها و حالت‌های خطا

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

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

Timeout و Retry با Backoff

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

تلاش مجدد برای مقابله با خرابی‌های گذرا استراتژی عالی است. اگر سروری موقتاً کند باشد، می‌توانیم درخواست را دوباره بفرستیم و احتمالاً موفق می‌شود. داشتن APIهای Idempotent اینجا کلیدی است، چون می‌توانیم یک درخواست را چند بار بدون ایجاد مشکل دوباره بفرستیم.

Backoff

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

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

در مصاحبه‌های طراحی سیستم، مصاحبه‌گرها اغلب دنبال عبارت جادویی «retry با exponential backoff» هستند. در مصاحبه‌های سطح ارشد ممکن است از شما بخواهند درباره اضافه کردن jitter توضیح بدهید. اگر بخواهید بیشتر بدانید، AWS یک پست وبلاگ عالی درباره timeout، retry و backoff با jitter دارد.

Idempotency

Retry جالب است، مگر وقتی که عوارض جانبی (side effect) داشته باشد. سیستم پرداختی را تصور کنید که می‌خواهیم برای چیزی ۱۰ دلار از کاربر بگیریم. اگر یک درخواست را چند بار دوباره بفرستیم، به‌جای ۱۰ دلار ممکن است ۲۰ دلار (یا ۲۰۰۰ دلار) از کاربر بگیریم.

به همین دلیل باید مطمئن شویم APIهایمان idempotent هستند. APIهای idempotent آن‌هایی هستند که می‌توان چندین بار صدایشان زد و هر بار همان نتیجه را داد. درخواست‌های HTTP GET نمونه‌های رایج APIهای idempotent هستند. محتوایی که یک GET برمی‌گرداند ممکن است تغییر کند، اما عمل خواندن محتوا وضعیت سیستم را تغییر نمی‌دهد.

اما خواندن داده آسان است، نوشتن داده چطور؟ برای نوشتن معمولاً یک کلید idempotency به API اضافه می‌کنیم. کلید idempotency شناسه یکتای یک درخواست است که با آن می‌توانیم idempotent بودن همان درخواست را تضمین کنیم.

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

Circuit Breaker

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

اگر پایگاه داده شما سرد (cold) شده باشد و مجبور شوید آن را از نو بالا بیاورید، داشتن انبوهی retry و کاربران عصبانی ممکن است همان نمونه را از ابتدا زمین بزند (که گاهی «thundering herd» می‌نامندش). نمی‌توانید نمونه اول را بالا بیاورید، پس امیدی به برگرداندن کل پایگاه داده ندارید. گیر کرده‌اید.

مهندسان باتجربه‌ای که شیفت on-call داشته‌اند داستان‌های جنگی زیادی درباره خرابی‌های آبشاری دارند. مشکلی رایج است که معمولاً تا ساعت ۳ صبح که گازتان می‌گیرد کسی به آن توجه نمی‌کند. به همین دلیل سوال مصاحبه عالی‌ای است. هم به پیدا کردن کاندیداهایی کمک می‌کند که می‌دانند چطور جلوی بعضی از مخرب‌ترین مشکلات را بگیرند، هم غربالگری خوبی برای تجربه است که بسیاری از مصاحبه‌گرها دنبالش هستند.

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

اینجا Circuit Breaker وارد می‌شود. الگویی حیاتی برای طراحی سیستم مقاوم که مستقیم روی ارتباط شبکه اثر می‌گذارد. وقتی تماس‌های شبکه به وابستگی‌ها مدام شکست می‌خورند، Circuit Breaker از سیستم شما محافظت می‌کند. طرز کارش این است:

  1. Circuit Breaker هنگام تماس با سرویس‌های خارجی خرابی‌ها را زیر نظر دارد.
  2. وقتی خرابی‌ها از یک آستانه بیشتر شود، مدار به حالت «باز» (open) می‌رود.
  3. در حالت باز، درخواست‌ها بدون تلاش برای تماس واقعی، فوراً شکست می‌خورند.
  4. بعد از مدتی مدار به حالت «نیمه‌باز» (half-open) می‌رود.
  5. یک درخواست آزمایشی تعیین می‌کند مدار بسته شود یا باز بماند.

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

  • Fail Fast: به‌جای انتظار برای timeout، درخواست‌های سرویس‌های ناموفق را سریع رد می‌کند.
  • کاهش بار: مانع فرستادن درخواست‌های بیشتر به سرویس‌هایی می‌شود که از قبل مشکل دارند.
  • خودترمیمی: بدون بار کامل ترافیک، بازیابی را خودکار آزمایش می‌کند.
  • تجربه کاربری بهتر: به‌جای هنگ کردن رابط کاربری، fallback سریع ارائه می‌دهد.
  • پایداری سیستم: جلوی سرایت خرابی یک سرویس به کل سیستم را می‌گیرد.

کجا از آن استفاده کنیم

وقتی مصاحبه‌گر درباره قابلیت اطمینان، حالت‌های خرابی یا بازیابی از فاجعه (disaster recovery) عمیق می‌شود، Circuit Breaker می‌تواند پاسخ عالی باشد. توانایی نام بردن از آن و استفاده از آن در جاهای مناسب راه خوبی برای نشان دادن دانشی است که معمولاً از تجربه واقعی می‌آید، مثلاً ساعت ۳ صبح که سیستم در حال تقلاست و بالا نمی‌آید.

چند جای نمونه برای استفاده از Circuit Breaker:

  • تماس‌های API خارجی به سرویس‌های شخص ثالث.
  • اتصال‌ها و کوئری‌های پایگاه داده.
  • ارتباط سرویس‌به‌سرویس در میکروسرویس‌ها.
  • عملیات سنگین که ممکن است timeout شوند.
  • هر تماس شبکه‌ای که ممکن است شکست بخورد یا کند شود.

جمع‌بندی

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

  • اصول اولیه را بفهمید: آدرس‌دهی IP، DNS و مدل TCP/IP.
  • پروتکل‌ها را بشناسید: TCP در برابر UDP، HTTP و HTTPS، WebSocket و gRPC.
  • Load Balancing اصلی را بدانید: Load Balancer سمت کلاینت و Load Balancer اختصاصی.
  • برای واقعیت‌های عملی برنامه داشته باشید: منطقه‌بندی و الگوهای مدیریت خرابی.

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

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

ادامه یادگیری

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

بعد از آن، چند خرابی رایج شبکه را شبیه‌سازی کنید. نرم‌افزار Network Link Conditioner در مک (که از طریق Xcode در دسترس است) ابزار عالی‌ای است که اجازه می‌دهد تأخیر شبکه یا از دست رفتن بسته را شبیه‌سازی کنید. یک اتصال موبایل واقعاً بد را شبیه‌سازی کنید و ببینید وب‌سایت‌ها و اپلیکیشن‌ها چه واکنشی نشان می‌دهند. اغلب با تعجب (و باگ‌های زیاد) روبه‌رو می‌شوید. لذت ببرید.