پست

طراحی پلتفرم کدنویسی مانند LeetCode

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

طراحی پلتفرم کدنویسی مانند LeetCode

درک مسئله

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

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

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

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

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

  • احراز هویت کاربر
  • پروفایل کاربر
  • پردازش پرداخت
  • تحلیل داده کاربران (Analytics)
  • ویژگی‌های اجتماعی

برای این مسئله (و بیشتر مسئله‌های طراحی سیستم) می‌توانیم فرض کنیم کاربران قبلاً احراز هویت شده‌اند و شناسه کاربری آن‌ها در نشست (Session) یا JWT نگهداری می‌شود.

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

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

  • سیستم باید Availability (دسترس‌پذیری) را بر Consistency (سازگاری) مقدم بدارد.
  • هنگام اجرای کد کاربر، سیستم باید از ایزوله‌سازی (Isolation) و امنیت پشتیبانی کند.
  • سیستم باید نتایج ارسال را ظرف 5 ثانیه برگرداند.
  • سیستم باید مقیاسی داشته باشد که مسابقه‌هایی با 100 هزار کاربر را تحمل کند.

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

  • تحمل خطا (Fault Tolerance)
  • تراکنش‌های امن برای خرید
  • تست کامل و استقرار آسان (خطوط CI/CD)
  • پشتیبان‌گیری منظم

مهم است که بدانیم LeetCode فقط چند صد هزار کاربر و حدود 4000 مسئله دارد. نسبت به بیشتر مصاحبه‌های طراحی سیستم، این یک سیستم کوچک‌مقیاس است. این را در ذهن داشته باشید، چون تأثیر قابل توجهی روی طراحی ما می‌گذارد.

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

آماده‌سازی

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

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

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

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

برای پوشش نیازمندی‌های عملکردی به این موجودیت‌ها نیاز داریم:

  • Problem (مسئله): صورت مسئله، تست‌کیس‌ها و خروجی مورد انتظار را نگه می‌دارد.
  • Submission (ارسال): کد ارسالی کاربر و نتیجه اجرای آن در برابر تست‌کیس‌ها را نگه می‌دارد.
  • Leaderboard (جدول امتیازات): جدول امتیازات مسابقه‌ها را نگه می‌دارد.

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

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

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

برای مشاهده فهرست مسئله‌ها، یک endpoint ساده GET داریم که فهرست را برمی‌گرداند. کمی صفحه‌بندی (Pagination) هم اضافه کرده‌ام، چون تعداد مسئله‌ها بیشتر از آن است که در یک درخواست برگردانده یا در یک صفحه نمایش داده شود:

1
GET /problems?page=1&limit=100 -> Partial<Problem>[]

Partial از TypeScript آمده و یعنی فقط زیرمجموعه‌ای از موجودیت Problem را برمی‌گردانیم. در واقع فقط عنوان، شناسه، سطح و شاید یک تگ یا فیلد دسته‌بندی لازم است، و نیازی نیست کل صورت مسئله یا Code Stub را اینجا برگردانیم. تا وقتی با مصاحبه‌کننده شفاف باشید، چگونگی کوتاه کردن مهم نیست.

سپس برای مشاهده یک مسئله خاص به endpoint ای نیاز داریم. این هم یک GET است که شناسه مسئله را می‌گیرد (همان که با کلیک کاربر روی مسئله‌ای از فهرست به دست آمده) و صورت کامل مسئله و Code Stub را برمی‌گرداند:

1
GET /problems/:id?language={language} -> Problem

یک پارامتر کوئری برای زبان اضافه کرده‌ایم که می‌تواند پیش‌فرض داشته باشد (مثلاً python اگر داده نشد). این به ما اجازه می‌دهد Code Stub را به زبان دلخواه کاربر برگردانیم.

بعد به endpoint ای برای ارسال راه‌حل نیاز داریم. این یک POST است که شناسه مسئله و کد کاربر را می‌گیرد و نتیجه اجرای کد در برابر تست‌کیس‌ها را برمی‌گرداند:

1
2
3
4
5
POST /problems/:id/submit -> Submission
{
  code: string,
  language: string
}

userId در API نمی‌آید. فرض می‌کنیم کاربر احراز هویت شده و userId در نشست نگهداری می‌شود.

در نهایت به endpoint ای برای مشاهده جدول امتیازات زنده مسابقه نیاز داریم. این یک GET است که فهرست رتبه‌بندی‌شده کاربران را بر اساس عملکردشان در مسابقه برمی‌گرداند:

1
GET /leaderboard/:competitionId?page=1&limit=100 -> Leaderboard

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

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

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

بیشتر سیستم‌هایی که در مصاحبه طراحی می‌شوند، با معماری میکروسرویس بهتر پوشش داده می‌شوند، همان‌طور که در مسئله‌های دیگر این راهنما بود. اما همیشه این‌طور نیست. برای سیستم‌های کوچک‌تر مثل این، معماری یکپارچه (Monolith) ممکن است مناسب‌تر باشد. دلیلش این است که سیستم آن‌قدر کوچک است که می‌توان آن را به‌راحتی در یک کدبیس نگه داشت و سربار مدیریت چند سرویس نمی‌ارزد. با این حال، برای این سیستم از یک معماری ساده کلاینت-سرور شروع می‌کنیم.

1) مشاهده فهرست مسئله‌ها

برای دیدن فهرست مسئله‌ها به یک سرور ساده نیاز داریم که فهرست را از پایگاه داده بگیرد و به کاربر برگرداند. این سرور مسئول صفحه‌بندی هم هست.

  • API Server: درخواست‌های ورودی کلاینت را پاسخ می‌دهد و داده مناسب را برمی‌گرداند. فعلاً فقط یک endpoint دارد، اما هرچه جلو می‌رویم بقیه را اضافه می‌کنیم.
  • پایگاه داده: همه مسئله‌ها اینجا ذخیره می‌شوند. باید مطمئن شویم پایگاه داده برای پشتیبانی از صفحه‌بندی درست ایندکس شده است. هم SQL و هم NoSQL اینجا جواب می‌دهد، اما من یک پایگاه داده NoSQL مثل DynamoDB را انتخاب می‌کنم، چون به کوئری‌های پیچیده نیاز نداریم و می‌خواهم تست‌کیس‌ها را به‌صورت یک زیرسند تودرتو (Nested) داخل موجودیت Problem نگه دارم.

طرح (Schema) مسئله چیزی شبیه این است:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
  id: string,
  title: string,
  question: string,
  level: string,
  tags: string[],
  codeStubs: {
    python: string,
    javascript: string,
    typescript: string,
    ...
  },
  testCases: {
    type: string,
    input: string,
    output: string
  }[]
}

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

2) مشاهده مسئله و نوشتن راه‌حل

برای دیدن مسئله، کلاینت با GET /problems/:id درخواستی به API Server می‌فرستد و سرور بعد از واکشی از پایگاه داده، صورت کامل مسئله و Code Stub را برمی‌گرداند. برای اینکه کاربران بتوانند راه‌حلشان را در مرورگر بنویسند، از ویرایشگر Monaco استفاده می‌کنیم.

3) ارسال راه‌حل و دریافت بازخورد فوری

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

رویکرد بد: اجرای کد روی خود API Server

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

چالش‌ها:

  • امنیت: اجرای کد کاربر روی سرور شما ریسک امنیتی بزرگی است. اگر کاربر کد مخربی بفرستد، ممکن است سرور را نابود کند یا سیستم را به خطر بیندازد. کاربری را تصور کنید که کدی می‌فرستد که همه داده‌هایتان را پاک می‌کند یا همه را برای شخص ثالث ارسال می‌کند. او حتی می‌تواند از سرور شما برای استخراج رمزارز، حمله DDoS یا هر فعالیت مخرب دیگر استفاده کند.
  • عملکرد: اجرای کد CPU مصرف می‌کند و اگر درست مدیریت نشود، به‌راحتی سرور را از کار می‌اندازد، به‌خصوص اگر کد حلقه بی‌نهایت یا نشت حافظه (Memory Leak) داشته باشد.
  • ایزوله‌سازی: اجرای کد در همان فرایند API Server یعنی اگر کد کرش کند، سرور شما را هم با خودش می‌برد و دیگر هیچ درخواستی پردازش نمی‌شود.

رویکرد خوب: اجرا در ماشین مجازی (VM)

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

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

رویکرد عالی: اجرا در کانتینر

راه‌حل عالی (و مسلماً بهینه) اجرای کد کاربر در یک کانتینر (Container) است. کانتینرها شبیه VM ها هستند، چون محیط ایزوله‌ای برای اجرای کد می‌دهند، اما بسیار سبک‌تر هستند و سریع‌تر بالا می‌آیند. تفاوت‌های کلیدی VM و کانتینر:

  • VM ها: روی سخت‌افزار فیزیکی و از طریق Hypervisor (مثل VMware یا Hyper-V) اجرا می‌شوند. هر VM یک کپی کامل از سیستم‌عامل، برنامه، باینری‌های ضروری و کتابخانه‌ها را دارد، پس حجمشان بیشتر است و دیرتر شروع می‌شوند. VM ها کاملاً ایزوله‌اند و پشته کامل سیستم‌عامل را اجرا می‌کنند که سربار اضافه می‌کند، اما ایزوله‌سازی و امنیت قوی می‌دهد.
  • کانتینرها: هسته (Kernel) سیستم میزبان را به اشتراک می‌گذارند و فرایندهای برنامه را از هم جدا می‌کنند. برنامه و وابستگی‌هایش (کتابخانه‌ها و باینری‌ها) را دارند، اما سیستم‌عامل کامل را ندارند. همین آن‌ها را سبک می‌کند و شروع سریع‌تری ممکن می‌شود. کانتینرها ایزوله‌سازی کمتری از VM دارند، ولی از نظر مصرف منابع و مقیاس‌پذیری کارآمدترند.

برای هر زمان اجرا (Runtime) که پشتیبانی می‌کنیم (مثلاً Python، JavaScript و Java) یک کانتینر می‌سازیم که وابستگی‌های لازم را نصب می‌کند و کد را در محیط Sandbox اجرا می‌کند. به‌جای ساختن VM جدید برای هر ارسال، می‌توانیم از همان کانتینرها برای چند ارسال استفاده کنیم، مصرف منابع را کم کنیم و عملکرد را بهتر کنیم.

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

رویکرد عالی: تابع Serverless

گزینه عالی دیگر، اجرای کد ارسالی کاربر در یک تابع Serverless است. توابع Serverless توابع کوچک، بدون حالت و رویدادمحوری هستند که در پاسخ به محرک‌هایی مثل درخواست HTTP اجرا می‌شوند. ارائه‌دهنده ابری آن‌ها را مدیریت می‌کند و خودکار بر اساس تقاضا مقیاس می‌گیرند، پس برای اجرای کدی که CPU زیادی می‌خواهد یا بارش پیش‌بینی‌ناپذیر است، گزینه بسیار خوبی هستند.

در این رویکرد، برای هر Runtime پشتیبانی‌شده (مثل Python، JavaScript و Java) یک تابع Serverless می‌سازیم که وابستگی‌های لازم را نصب می‌کند و کد را در Sandbox اجرا می‌کند. وقتی کاربر کدش را ارسال می‌کند، تابع Serverless مناسب را فعال می‌کنیم تا کد را اجرا کند و نتیجه را برگرداند.

چالش‌ها: چالش اصلی این است که توابع Serverless Cold Start دارند که می‌تواند اولین درخواست یک تابع را با تأخیر مواجه کند. علاوه بر این، محدودیت منابع دارند که می‌تواند روی کدهای طولانی‌مدت یا پرمصرف اثر بگذارد. باید این محدودیت‌ها را با دقت مدیریت کنیم تا منابعمان تمام نشود یا به گلوگاه عملکردی نخوریم.

اگرچه توابع Serverless (مثل Lambda) گزینه عالی‌اند، من چون انتظار تغییر چشمگیری در حجم ارسال‌ها ندارم و می‌خواهم از Latency مربوط به Cold Start جلوگیری کنم، با رویکرد کانتینر ادامه می‌دهم. با این تصمیم، طراحی سطح بالا را طوری به‌روز می‌کنیم که یک سرویس کانتینری برای اجرای کد کاربر داشته باشد، و جریان داده در سیستم را مرور می‌کنیم.

الگو: مدیریت وظایف طولانی‌مدت (Long-running Tasks). اجرای کد در LeetCode می‌تواند چند ثانیه طول بکشد، چون کانتینرها کد کاربر را در برابر صدها تست‌کیس اجرا می‌کنند. این الگوی وظایف طولانی‌مدت را نشان می‌دهد، که در آن API ها فوراً شناسه کار (Job ID) برمی‌گردانند و Worker های پس‌زمینه عملیات زمان‌بر مثل انکود ویدیو، تولید گزارش یا پردازش داده را انجام می‌دهند. این الگو از Timeout جلوگیری می‌کند و سیستم را مقیاس‌پذیر می‌کند.

وقتی کاربر ارسال می‌کند، سیستم ما این مراحل را اجرا می‌کند:

  1. API Server کد کاربر و شناسه مسئله را می‌گیرد و آن را به کانتینر مناسب زبان انتخاب‌شده می‌فرستد.
  2. کانتینر ایزوله، کد کاربر را در محیط Sandbox اجرا می‌کند و نتیجه را به API Server برمی‌گرداند.
  3. سپس API Server نتایج ارسال را در پایگاه داده ذخیره می‌کند و نتیجه را به کلاینت برمی‌گرداند.

4) مشاهده جدول امتیازات زنده مسابقه

اول باید مسابقه را تعریف کنیم:

  • مدت 90 دقیقه
  • 10 مسئله
  • تا 100 هزار کاربر

امتیازدهی بر اساس تعداد مسئله‌های حل‌شده در 90 دقیقه است. در صورت تساوی، بر اساس زمانی که طول کشیده تا مسئله‌ها تکمیل شوند (از شروع مسابقه) رتبه‌بندی می‌کنیم.

وقتی کاربران جدول امتیازات را از طریق /leaderboard/:competitionId می‌خواهند، ساده‌ترین کار این است که جدول Submissions را برای همه ردیف‌های آن competitionId کوئری کنیم، سپس بر اساس userId گروه‌بندی کنیم و بر اساس تعداد ارسال‌های موفق مرتب کنیم. در SQL این کوئری چنین شکلی دارد:

1
2
3
4
5
SELECT userId, COUNT(*) as numSuccessfulSubmissions
FROM submissions
WHERE competitionId = :competitionId AND passed = true
GROUP BY userId
ORDER BY numSuccessfulSubmissions DESC

در پایگاه داده NoSQL مثل DynamoDB، competitionId باید Partition Key باشد. سپس همه آیتم‌ها را در حافظه می‌گیرید، گروه‌بندی و مرتب می‌کنید.

وقتی جدول امتیازات را داشتیم، آن را برای نمایش به کلاینت می‌فرستیم. برای تازه ماندن، کلاینت باید هر 5 ثانیه یک‌بار دوباره جدول امتیازات را بخواهد.

همه را کنار هم می‌گذاریم:

  1. کاربر جدول امتیازات را از طریق /leaderboard/:competitionId می‌خواهد.
  2. API Server یک کوئری روی جدول Submissions در پایگاه داده اجرا می‌کند تا همه ارسال‌های موفق آن مسابقه را بگیرد.
  3. چه با خود کوئری و چه در حافظه، جدول امتیازات را با رتبه‌بندی کاربران بر اساس تعداد ارسال‌های موفق می‌سازیم.
  4. جدول امتیازات را به کلاینت برمی‌گردانیم.
  5. کلاینت بعد از 5 ثانیه دوباره جدول امتیازات را می‌خواهد تا به‌روز بماند.

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

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

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

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

1) ایزوله‌سازی و امنیت هنگام اجرای کد کاربر

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

  • سیستم فایل فقط‌خواندنی (Read-only): برای جلوگیری از نوشتن کاربر روی سیستم فایل، می‌توانیم دایرکتوری کد را فقط‌خواندنی Mount کنیم و هر خروجی را در یک دایرکتوری موقت بنویسیم که مدت کوتاهی بعد از پایان اجرا حذف می‌شود.
  • محدودیت CPU و حافظه: برای اینکه کاربران بیش از حد منابع مصرف نکنند، روی کانتینر محدودیت CPU و حافظه می‌گذاریم. اگر از این محدودیت‌ها بیشتر شود، کانتینر کشته می‌شود و از تمام شدن منابع جلوگیری می‌شود.
  • Timeout صریح: برای جلوگیری از اجرای حلقه‌های بی‌نهایت، کد کاربر را در یک Timeout می‌پیچیم که اگر فرایند از حد زمانی از پیش تعریف‌شده (مثلاً 5 ثانیه) بیشتر طول بکشد، کشته شود. این به برآورده شدن نیازمندی بازگرداندن نتیجه ظرف 5 ثانیه هم کمک می‌کند.
  • محدودکردن دسترسی شبکه: برای جلوگیری از ارسال درخواست شبکه توسط کاربران، می‌توانیم دسترسی شبکه را در کانتینر غیرفعال کنیم تا هیچ تماس خارجی ممکن نباشد. اگر در اکوسیستم AWS کار می‌کنید، می‌توانیم از Virtual Private Cloud (VPC)، Security Group ها و NACL برای محدود کردن همه ترافیک ورودی و خروجی، جز اتصال‌های از پیش تعریف‌شده و ضروری، استفاده کنیم.
  • محدودکردن System Call ها (Seccomp): نمی‌خواهیم کاربران بتوانند System Call هایی بزنند که ممکن است سیستم میزبان را به خطر بیندازد. می‌توانیم با seccomp، System Call هایی را که کانتینر می‌تواند اجرا کند محدود کنیم.

توجه کنید در مصاحبه احتمالاً انتظار نمی‌رود درباره اجرای دقیق هر کدام از این اقدام‌های امنیتی وارد جزئیات شوید. به‌جای آن روی مفاهیم سطح بالا و چگونگی کمک آن‌ها به امن‌سازی سیستم تمرکز کنید. اگر مصاحبه‌کننده به بخش مشخصی علاقه داشته باشد، می‌خواهد عمیق‌تر شوید. به‌طور مشخص، همین جمله احتمالاً کافی است: «از کانتینرهای Docker استفاده می‌کنیم، دسترسی شبکه را محدود می‌کنیم، محدودیت CPU و حافظه می‌گذاریم و Timeout برای اجرای کد اعمال می‌کنیم.»

2) کارآمدتر کردن واکشی جدول امتیازات

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

رویکرد بد: Polling و کوئری مستقیم پایگاه داده

این همان چیزی است که الان در طراحی سطح بالا داریم. همه نتایج ارسال را در پایگاه داده اصلی نگه می‌داریم. کلاینت‌ها هر چند ثانیه از سرور Poll می‌کنند و در هر Poll، سرور از پایگاه داده N کاربر برتر را کوئری می‌کند، مرتب می‌کند و نتیجه را برمی‌گرداند. با Poll کلاینت هر 5 ثانیه، داده را تازه نگه می‌داریم. اگر به پایگاه داده رابطه‌ای بروید، این رویکرد بهتر کار می‌کند، اما هنوز ایرادهای مهمی دارد.

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

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

رویکرد خوب: کش با Redis و به‌روزرسانی دوره‌ای

برای کم کردن بار پایگاه داده می‌توانیم یک کش (مثلاً Redis) بگذاریم که جدول امتیازات فعلی را نگه می‌دارد. کش به‌صورت دوره‌ای، مثلاً هر 30 ثانیه، با کوئری از پایگاه داده به‌روز می‌شود. وقتی کلاینت‌ها از سرور Poll می‌کنند، به‌جای کوئری از پایگاه داده در هر بار، جدول امتیازات کش‌شده را برمی‌گردانیم.

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

رویکرد عالی: Sorted Set در Redis با Polling

این راه‌حل از Sorted Set در Redis برای نگه داشتن یک جدول امتیازات بلادرنگ استفاده می‌کند، در حالی که نتایج ارسال را در پایگاه داده اصلی هم ذخیره می‌کند. وقتی یک ارسال پردازش می‌شود، هم پایگاه داده و هم Sorted Set در Redis به‌روز می‌شوند. کلاینت‌ها هر 5 ثانیه برای به‌روزرسانی جدول امتیازات از سرور Poll می‌کنند و سرور N کاربر برتر را از Sorted Set در Redis می‌گیرد که سریع است و نیازی به کوئری گران پایگاه داده ندارد.

Sorted Set در Redis یک ساختار داده درون‌حافظه‌ای است که اجازه می‌دهد داده را بر اساس امتیاز کارآمد ذخیره و بازیابی کنیم. این برای کاربرد ما عالی است، چون می‌توانیم با اضافه یا حذف کاربران و امتیازشان، جدول امتیازات را به‌روز کنیم و سپس N کاربر برتر با بیشترین امتیاز را بگیریم.

Sorted Set در Redis از قالب کلید competition:leaderboard:{competitionId} استفاده می‌کند. امتیاز (Score) همان امتیاز کل کاربر یا زمان حل است و مقدار (Member) همان userId است. یک endpoint مثل GET /competitions/:competitionId/leaderboard?top=100 پیاده می‌کنیم. هنگام پردازش یک ارسال، Redis را با دستوری مثل این به‌روز می‌کنیم:

1
ZADD competition:leaderboard:{competitionId} {score} {userId}

برای گرفتن N کاربر برتر از این دستور استفاده می‌کنیم:

1
ZREVRANGE competition:leaderboard:{competitionId} 0 N-1 WITHSCORES

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

مزایا: این رویکرد چند مزیت دارد. پیاده‌سازی آن در مقایسه با WebSocket ها ساده‌تر است و در عین حال به‌روزرسانی‌های تقریباً هم‌زمان می‌دهد. تأخیر 5 ثانیه‌ای معمولاً برای بیشتر کاربران قابل قبول است. بار پایگاه داده اصلی را به‌طور محسوسی کم می‌کند و برای پایگاه کاربری مورد انتظار ما خوب مقیاس می‌گیرد. البته در صورت نیاز می‌توانیم تناوب Poll را هم کمتر کنیم، اما با توجه به تناوب نسبی به‌روزرسانی جدول امتیازات، احتمالاً 5 ثانیه به اندازه کافی خوب است.

بسیاری از داوطلبانی که این سؤال را از آن‌ها می‌پرسم، اتصال WebSocket را برای به‌روزرسانی بلادرنگ پیشنهاد می‌کنند. اگرچه لزوماً اشتباه نیست، با توجه به تعداد متوسط کاربران و تأخیر قابل قبول 5 ثانیه‌ای، برای سیستم ما اضافی است. Sorted Set در Redis همراه با Polling دوره‌ای تعادل خوبی بین به‌روزرسانی بلادرنگ و سادگی سیستم می‌سازد. برای نیازهای ما بیش از حد کافی است و در آینده، اگر لازم شد، به‌راحتی قابل ارتقاست.

اگر دیدیم بازه 5 ثانیه خیلی زیاد است، به‌راحتی می‌توانیم آن را تنظیم کنیم. حتی می‌توانیم یک استراتژی Polling تدریجی پیاده کنیم که در چند دقیقه آخر مسابقه بیشتر Poll کند (مثلاً هر 2 ثانیه) و در زمان‌های دیگر کمتر (مثلاً هر 10 ثانیه).

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

3) مقیاس‌دهی برای مسابقه‌های 100 هزار کاربره

نگرانی اصلی اینجا این است که با جهش ناگهانی ترافیک مواجه شویم، مثلاً از یک مسابقه یا یک مسئله محبوب، که می‌تواند کانتینرهایی را که کد کاربر را اجرا می‌کنند تحت تأثیر بگذارد. واقعیت این است که 100 هزار هنوز کاربر زیادی نیست و API Server ما با مقیاس افقی باید بتواند این بار را بدون مشکل تحمل کند. اما چون اجرای کد CPU زیادی می‌خواهد، باید مراقب نحوه مدیریت کانتینرها باشیم.

رویکرد بد: مقیاس عمودی

ساده‌ترین گزینه این است که کانتینرها را روی یک نمونه (Instance) بزرگ‌تر با CPU و حافظه بیشتر اجرا کنیم. این را مقیاس عمودی (Vertical Scaling) می‌گویند و ساده‌ترین راه برای بالا بردن ظرفیت سیستم است.

اینجا زمان خوبی برای کمی محاسبه است. فرض کنید حداکثر 10 هزار ارسال هم‌زمان داریم (مثلاً در یک مسابقه) و هر ارسال با 100 تست‌کیس اجرا می‌شود. احتمالاً خیلی سریع به سقف CPU می‌رسیم. فرض کنیم اجرای هر تست 100 میلی‌ثانیه طول بکشد. یعنی اجرای هر ارسال 10 ثانیه طول می‌کشد. با 10 هزار ارسال، 1 میلیون تست‌کیس داریم که 100 هزار ثانیه (بیش از 27 ساعت) برای اجرا طول می‌کشد. برای مدیریت این بار ظرف یک دقیقه، تقریباً به 1667 هسته CPU نیاز داریم. این به‌روشنی نشان می‌دهد مقیاس عمودی به‌تنهایی راه‌حل مناسبی نیست، به‌ویژه با توجه به محدودیت انواع نمونه‌های فعلی AWS، مثل نمونه‌های X1 که حداکثر 128 هسته دارند.

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

رویکرد خوب: مقیاس افقی کانتینرها

می‌توانیم کانتینرهای مخصوص هر زبان را افقی مقیاس بدهیم تا ارسال‌های بیشتری را تحمل کنند. یعنی می‌توانیم چند کانتینر برای هر زبان بالا بیاوریم و ارسال‌ها را بین آن‌ها توزیع کنیم. این کار را می‌توان به‌صورت پویا با Auto Scaling Group ها انجام داد که تعداد نمونه‌های فعال را خودکار بر اساس تقاضای ترافیک، مصرف CPU یا معیارهای حافظه تنظیم می‌کنند. در AWS می‌توانیم از ECS برای مدیریت کانتینرها و ECS Service Auto Scaling برای تنظیم خودکار تعداد کانتینرها بر اساس تقاضا استفاده کنیم.

چالش‌ها: تنها نقطه ضعف قابل توجه خطر تأمین بیش از حد (Over-provisioning) است. اگر کانتینرهای زیادی بالا بیاوریم، ممکن است منابع هدر برود و هزینه غیرضروری بدهیم. با این حال ارائه‌دهندگان ابری مدرن مقیاس بالا و پایین را بر اساس تقاضا آسان کرده‌اند، پس این ریسک قابل مدیریت است.

رویکرد عالی: مقیاس افقی همراه با صف

می‌توانیم دقیقاً همان رویکرد بالا را بگیریم، اما بین API Server و کانتینرها یک صف (Queue) اضافه کنیم. این به ما اجازه می‌دهد در زمان اوج بار، ارسال‌ها را بافر کنیم و مطمئن شویم کانتینرها زیر بار نمی‌روند. می‌توانیم از یک سرویس صف مدیریت‌شده مثل Amazon SQS برای این کار استفاده کنیم.

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

نکته مهم این است که معرفی صف، سیستم را ناهمگام (Asynchronous) کرده است. یعنی API Server دیگر نمی‌تواند نتیجه ارسال را فوراً برگرداند. به‌جای آن، کاربر باید برای نتیجه از سرور Poll کند. یک endpoint جدید معرفی می‌کنیم، مثلاً GET /check/:id، که کلاینت هر ثانیه آن را Poll می‌کند تا ببیند ارسال پردازش شده است یا نه. این endpoint ساده ارسال را در پایگاه داده جست‌وجو می‌کند و اگر نتیجه آماده بود برمی‌گرداند، در غیر این صورت پیام «در حال پردازش» می‌دهد.

بعضی داوطلبان سعی می‌کنند برای جلوگیری از Polling، اتصال پایدار مثل WebSocket ها معرفی کنند. این قطعاً می‌تواند کار کند، اما پیچیدگی به سیستم اضافه می‌کند و چون بازه Poll فقط یک ثانیه است، احتمالاً لازم نیست.

نکته جالب: LeetCode هنگام ارسال راه‌حل برای یک مسئله دقیقاً همین کار را می‌کند. اگر Network Tab مرورگرتان را باز کنید و روی ارسال کلیک کنید، Polling را در عمل می‌بینید!

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

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

اینجا پاسخ درست یا غلط مطلقی وجود ندارد. سنجیدن مزایا و معایب هر رویکرد در واقع کلید مصاحبه است.

4) اجرای تست‌کیس‌ها

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

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

برای این کار به یک روش استاندارد برای Serialize کردن ورودی و خروجی هر تست نیاز دارید، و برای هر زبان یک مهار تست (Test Harness) که بتواند این ورودی‌ها را Deserialize کند، به کد کاربر بدهد و خروجی را با خروجی مورد انتظار مقایسه کند.

برای مثال، یک مسئله ساده را در نظر بگیرید که «حداکثر عمق یک درخت دودویی» را می‌پرسد. با Python به‌عنوان مثال، می‌بینیم که خود تابع یک شیء TreeNode می‌گیرد:

1
2
3
4
5
6
7
8
9
10
11
12
# Definition for a binary tree node.
# class TreeNode(object):
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution(object):
    def maxDepth(self, root):
        """
        :type root: TreeNode
        :rtype: int
        """

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
  "id": 1,
  "title": "Max Depth of Binary Tree",
  ...
  "testCases": [
    {
      "type": "tree",
      "input": [3,9,20,null,null,15,7],
      "output": 3
    },
    {
      "type": "tree",
      "input": [1,null,2],
      "output": 2
    }
  ]
}

برای شیء درخت، تصمیم گرفته‌ایم آن را با پیمایش سطح‌به‌سطح (Level-order) در یک آرایه Serialize کنیم. هر زبان نسخه خودش از کلاس TreeNode را خواهد داشت که می‌تواند این آرایه را به شیء TreeNode تبدیل کند و به کد کاربر بدهد.

باید استراتژی Serialize را برای هر ساختار داده تعریف کنیم و مطمئن شویم مهار تست هر زبان می‌تواند ورودی‌ها را Deserialize کند و خروجی را با خروجی مورد انتظار مقایسه کند.

با کنار هم گذاشتن همه این‌ها، یک طراحی نهایی می‌تواند به این شکل باشد: کلاینت (با ویرایشگر Monaco)، API Server، پایگاه داده مسئله‌ها و ارسال‌ها، صف ارسال‌ها (SQS)، Worker های کانتینری به‌ازای هر زبان با مهار تست، و Sorted Set در Redis برای جدول امتیازات.

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

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

سطح Mid-level

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

سطح Senior

  • عمق تخصص: انتظار به سمت دانش عمیق‌تر می‌رود، حدود 60 درصد عرض و 40 درصد عمق. باید در حوزه‌هایی که تجربه عملی دارید وارد جزئیات فنی شوید و درک عمیقی از مفاهیم و فناوری‌های کلیدی مسئله نشان بدهید.
  • تشریح تصمیم‌های معماری: باید مزایا و معایب انتخاب‌های معماری را روشن بیان کنید، به‌ویژه اثر آن‌ها روی مقیاس‌پذیری، عملکرد و قابلیت نگهداری، و تصمیم‌هایتان را توجیه کنید.
  • حل مسئله و پیش‌قدمی: باید مهارت حل مسئله قوی و رویکرد فعالانه نشان بدهید. یعنی چالش‌های احتمالی طراحی را پیش‌بینی کنید، بهبود پیشنهاد بدهید، گلوگاه‌ها را پیدا و رفع کنید، عملکرد را بهینه کنید و از قابلیت اطمینان سیستم مطمئن شوید.
  • معیار برای LeetCode: از داوطلبان IC5 انتظار می‌رود طراحی سطح بالای اولیه را سریع جلو ببرند تا بتوانند وقتشان را صرف بحث درباره اجرای ایمن و ایزوله کد کاربر بکنند. باید بتوانند مزایا و معایب اجرای کد در کانتینر، VM و تابع Serverless را بحث کنند و انتخابشان را توجیه کنند. همچنین باید بتوانند از «حالت کشیدن جعبه» خارج شوند و درباره نحوه اجرای تست‌کیس‌ها روی کد کاربر بحث کنند.

سطح Staff به بالا

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