پست

طراحی سرویس سواری اشتراکی مانند Uber

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

طراحی سرویس سواری اشتراکی مانند Uber

درک مسئله

Uber یک پلتفرم سواری اشتراکی (Ride-sharing) است که مسافران را به رانندگانی که با خودروی شخصی خدمات حمل‌ونقل می‌دهند وصل می‌کند. به کاربران اجازه می‌دهد بر اساس تقاضا از گوشی هوشمندشان سفر رزرو کنند و با راننده‌ای نزدیک خود تطبیق داده شوند که آن‌ها را از مبدأ به مقصد می‌رساند.

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

مصاحبه را با تعریف نیازمندی‌های عملکردی و غیرعملکردی شروع کنید. در اپلیکیشن‌هایی مثل این، نیازمندی‌های عملکردی با عبارت «کاربر باید بتواند…» بیان می‌شوند و نیازمندی‌های غیرعملکردی کیفیت‌های سیستم را با عبارت «سیستم باید…» تعریف می‌کنند.

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

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

  • مسافر (Rider) بتواند مبدأ و مقصد را وارد کند و تخمین کرایه بگیرد.
  • مسافر بتواند بر اساس کرایه تخمینی درخواست سفر بدهد.
  • در صورت درخواست، مسافر با راننده‌ای نزدیک و در دسترس تطبیق داده شود.
  • راننده بتواند درخواست را بپذیرد یا رد کند و به سمت مبدأ (Pickup) و مقصد (Dropoff) حرکت کند.

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

  • مسافر بتواند بعد از سفر به سفر و راننده امتیاز بدهد.
  • راننده بتواند مسافر را امتیاز بدهد.
  • مسافر بتواند سفرها را از قبل زمان‌بندی کند.
  • مسافر بتواند دسته‌های مختلف سفر (مثل X، XL، Comfort) را درخواست بدهد.

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

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

  • سیستم باید تطبیق با Latency (تأخیر) کم را در اولویت بگذارد (کمتر از 1 دقیقه برای تطبیق یا شکست).
  • سیستم باید در تطبیق سفر Strong Consistency (سازگاری قوی) را تضمین کند تا از اختصاص هم‌زمان چند سفر به یک راننده جلوگیری شود.
  • سیستم باید Throughput (نرخ گذردهی داده) بالا را، به‌ویژه در ساعات اوج یا رویدادهای خاص (مثلاً 100 هزار درخواست از یک مکان)، مدیریت کند.

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

  • سیستم امنیت و حریم خصوصی داده کاربر و راننده را با رعایت مقرراتی مثل GDPR تضمین کند.
  • سیستم در برابر خرابی‌ها تاب‌آور باشد و مکانیزم‌های افزونگی و Failover داشته باشد.
  • سیستم مانیتورینگ، لاگ‌گیری و هشداردهی قوی برای شناسایی و رفع سریع مشکلات داشته باشد.
  • سیستم به‌روزرسانی و نگهداری آسان بدون توقف قابل توجه (خطوط CI/CD) را تسهیل کند.

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

آماده‌سازی

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

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

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

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

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

  • Rider (مسافر): هر کاربری که از پلتفرم برای درخواست سفر استفاده می‌کند. شامل اطلاعات شخصی مثل نام و اطلاعات تماس، روش‌های پرداخت ترجیحی برای تراکنش‌های سفر و غیره.
  • Driver (راننده): هر کاربری که به‌عنوان راننده در پلتفرم ثبت‌نام کرده و خدمات حمل‌ونقل ارائه می‌کند. شامل مشخصات شخصی، اطلاعات خودرو (سازنده، مدل، سال و غیره) و ترجیحات و وضعیت در دسترس بودن.
  • Fare (کرایه): کرایه تخمینی یک سفر. شامل مکان‌های مبدأ و مقصد، کرایه تخمینی و زمان تخمینی رسیدن (ETA). می‌توان این را روی شیء Ride هم گذاشت، اما فعلاً آن را جدا نگه می‌داریم. اینجا پاسخ درست یا غلط وجود ندارد.
  • Ride (سفر): یک سفر مجزا از لحظه‌ای که مسافر کرایه تخمینی را درخواست می‌کند تا تکمیل آن. همه جزئیات سفر را ثبت می‌کند، از جمله هویت مسافر و راننده، جزئیات خودرو، وضعیت، مسیر برنامه‌ریزی‌شده، کرایه واقعی پرداخت‌شده در پایان سفر و مهرهای زمانی مبدأ و مقصد.
  • Location (مکان): مکان بلادرنگ رانندگان را ذخیره می‌کند. شامل مختصات عرض و طول جغرافیایی و مهر زمانی آخرین به‌روزرسانی. این موجودیت برای تطبیق مسافران با رانندگان نزدیک و ردیابی پیشرفت سفر بسیار مهم است.

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

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

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

API دریافت تخمین کرایه ساده است. یک endpoint ساده از نوع POST تعریف می‌کنیم که مکان فعلی کاربر و مقصد مورد نظر را می‌گیرد و شیء Fare را با کرایه و ETA تخمینی برمی‌گرداند. اینجا از POST استفاده می‌کنیم، چون یک موجودیت جدید (Fare) در پایگاه داده می‌سازیم:

1
2
3
4
5
POST /fare -> Fare
Body: {
  pickupLocation,
  destination
}

endpoint درخواست سفر: مسافران از این endpoint برای تأیید درخواست سفر پس از بررسی کرایه تخمینی استفاده می‌کنند. این فرایند تطبیق سفر را با اعلام به باطن برای پیدا کردن راننده مناسب شروع می‌کند و در نتیجه یک شیء Ride جدید می‌سازد:

1
2
3
4
POST /rides -> Ride
Body: {
  fareId
}

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

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

1
2
3
4
POST /drivers/location -> Success/Error
Body: {
  lat, long
}

توجه: driverId در Session Cookie یا JWT است، نه در بدنه یا پارامترهای مسیر.

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

endpoint پذیرش درخواست سفر: این endpoint به رانندگان اجازه می‌دهد درخواست سفر را بپذیرند. بعد از پذیرش، سیستم وضعیت سفر را به‌روز می‌کند و مختصات مکان Pickup را به راننده می‌دهد:

1
2
3
4
PATCH /rides/:rideId -> Ride
Body: {
  accept/deny
}

شیء Ride اطلاعاتی درباره مبدأ و مقصد دارد تا کلاینت بتواند آن را به راننده نشان بدهد.

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

1) وارد کردن مبدأ و مقصد و دریافت کرایه تخمینی

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

اجزای لازم برای ارتباط بین کلاینت و میکروسرویس‌ها را می‌چینیم و اولین سرویس خود یعنی «Ride Service» را اضافه می‌کنیم که تخمین کرایه را انجام می‌دهد.

  • Rider Client: نقطه تماس اصلی کاربران مسافر، روی iOS و Android. این کلاینت با سرویس‌های باطن ارتباط می‌گیرد تا تجربه کاربری یکپارچه ارائه دهد.
  • API Gateway (دروازه API): نقطه ورود درخواست‌های کلاینت. درخواست‌ها را به میکروسرویس‌های مناسب می‌رساند. دغدغه‌های مشترک مثل احراز هویت و Rate Limiting (محدودسازی نرخ درخواست) را هم مدیریت می‌کند.
  • Ride Service: این میکروسرویس مسئول مدیریت وضعیت سفر است که با محاسبه تخمین کرایه شروع می‌شود. برای تعیین مسافت و زمان سفر بین مکان‌ها با API نقشه شخص ثالث کار می‌کند و از مدل قیمت‌گذاری شرکت برای ساخت تخمین کرایه استفاده می‌کند. برای این مصاحبه، این پیچیدگی را انتزاعی می‌کنیم.
  • Third-party Mapping API (API نقشه شخص ثالث): از سرویس شخص ثالث (مثل Google Maps) برای قابلیت نقشه و مسیریابی استفاده می‌کنیم. Ride Service از آن برای محاسبه مسافت و زمان سفر بین مکان‌ها استفاده می‌کند.
  • پایگاه داده: پایگاه داده فعلاً مسئول ذخیره موجودیت‌های Fare است. کرایه‌ای با اطلاعات قیمت، ETA و غیره می‌سازد.

دقیقاً ببینیم وقتی مسافر تخمین کرایه می‌خواهد این اجزا چطور تعامل می‌کنند:

  1. مسافر مبدأ و مقصد مورد نظر را در اپلیکیشن کلاینت وارد می‌کند و کلاینت یک POST به /fare در باطن می‌فرستد.
  2. API Gateway درخواست را می‌گیرد، احراز هویت و Rate Limiting لازم را انجام می‌دهد و بعد به Ride Service می‌فرستد.
  3. Ride Service از Third-party Mapping API مسافت و زمان سفر بین مبدأ و مقصد را می‌گیرد، بعد مدل قیمت‌گذاری شرکت را روی مسافت و زمان اعمال می‌کند تا تخمین کرایه بسازد.
  4. Ride Service یک موجودیت Fare جدید با جزئیات کرایه تخمینی در پایگاه داده می‌سازد.
  5. سپس موجودیت Fare را به API Gateway برمی‌گرداند و آن به Rider Client می‌رسد تا کاربر درباره پذیرش کرایه و درخواست سفر تصمیم بگیرد.

2) درخواست سفر بر اساس کرایه تخمینی

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

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

  1. کاربر درخواست سفرش را در اپلیکیشن کلاینت تأیید می‌کند و کلاینت یک POST همراه شناسه کرایه‌ای که می‌پذیرد به باطن می‌فرستد.
  2. API Gateway احراز هویت و Rate Limiting لازم را انجام می‌دهد و بعد درخواست را به Ride Service می‌فرستد.
  3. Ride Service درخواست را می‌گیرد و یک ورودی جدید در جدول Ride می‌سازد، آن را به Fare پذیرفته‌شده وصل می‌کند و وضعیت سفر را روی «درخواست‌شده» (Requested) می‌گذارد.
  4. در مرحله بعد جریان تطبیق را شروع می‌کند تا راننده‌ای به سفر اختصاص بدهیم (پایین را ببینید).

3) تطبیق مسافر با راننده نزدیک و در دسترس

حالا باید چند جزء جدید معرفی کنیم تا تطبیق راننده را ممکن کنیم.

  • Driver Client: علاوه بر Rider Client، Driver Client را معرفی می‌کنیم که رابط رانندگان برای دریافت درخواست‌های سفر و ارائه به‌روزرسانی مکان است. Driver Client با Location Service ارتباط می‌گیرد تا به‌روزرسانی‌های مکان را بلادرنگ بفرستد.
  • Location Service (سرویس مکان): داده مکان بلادرنگ رانندگان را مدیریت می‌کند. مسئول دریافت به‌روزرسانی مکان از رانندگان، ذخیره این اطلاعات در پایگاه داده و ارائه آخرین داده مکان به Ride Matching Service برای تسهیل تطبیق دقیق و کارآمد رانندگان است.
  • Ride Matching Service (سرویس تطبیق سفر): درخواست‌های ورودی سفر را مدیریت می‌کند و از یک الگوریتم پیچیده (که برای هدف این مصاحبه انتزاعی شده) استفاده می‌کند تا این درخواست‌ها را بر اساس نزدیکی، دسترس‌پذیری، امتیاز راننده و سایر عوامل مرتبط با بهترین رانندگان موجود تطبیق بدهد.

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

  1. کاربر درخواست سفرش را در اپلیکیشن کلاینت تأیید می‌کند و کلاینت یک POST همراه شناسه کرایه‌ای که می‌پذیرد به باطن می‌فرستد.
  2. API Gateway احراز هویت و Rate Limiting لازم را انجام می‌دهد و بعد درخواست را به Ride Matching Service می‌فرستد.
  3. همان‌طور که بالا گفتیم، شیء Ride می‌سازیم و بعد گردش کار تطبیق را شروع می‌کنیم (راه‌های مقاوم‌تر کردن آن را بعداً بحث می‌کنیم، فعلاً ساده نگه می‌داریم).
  4. در همین حال، رانندگان همیشه مکان فعلی‌شان را به Location Service می‌فرستند و ما پایگاه داده را با آخرین موقعیتشان به‌روز می‌کنیم تا بدانیم کجا هستند.
  5. سپس گردش کار تطبیق از این مکان‌های به‌روز برای جست‌وجوی نزدیک‌ترین رانندگان موجود استفاده می‌کند تا تطبیق بهینه پیدا کند.

4) پذیرش یا رد درخواست توسط راننده و حرکت به مبدأ و مقصد

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

Notification Service (سرویس اعلان): مسئول ارسال اعلان‌های بلادرنگ به رانندگان است، وقتی درخواست سفر جدیدی با آن‌ها تطبیق داده شد. این تضمین می‌کند رانندگان سریع مطلع شوند تا بتوانند درخواست‌های سفر را به‌موقع بپذیرند و تجربه کاربری روانی حفظ شود. اعلان‌ها برای دستگاه‌های iOS و Android به ترتیب از طریق APN (Apple Push Notification) و FCM (Firebase Cloud Messaging) فرستاده می‌شوند.

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

  1. بعد از اینکه Ride Matching Service فهرست رتبه‌بندی‌شده رانندگان واجد شرایط را تعیین کرد، از طریق APN یا FCM یک اعلان برای راننده برتر فهرست می‌فرستد.
  2. راننده اعلانی دریافت می‌کند که درخواست سفر جدیدی در دسترس است. او Driver Client را باز می‌کند و درخواست را می‌پذیرد، که یک PATCH همراه rideId به باطن می‌فرستد. (اگر راننده به‌جای پذیرش، سفر را رد کند، سیستم اعلان را برای راننده بعدی فهرست می‌فرستد.)
  3. API Gateway درخواست را می‌گیرد و به Ride Service می‌رساند.
  4. Ride Service درخواست را می‌گیرد، وضعیت سفر را به «پذیرفته‌شده» (Accepted) به‌روز می‌کند و راننده اختصاص‌یافته را مطابق آن به‌روز می‌کند. سپس مختصات مکان Pickup را به Driver Client برمی‌گرداند.
  5. راننده با مختصات در دست، از GPS کلاینت برای حرکت به محل Pickup استفاده می‌کند.

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

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

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

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

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

  • تناوب بالای نوشتن: با حدود 10 میلیون راننده که تقریباً هر 5 ثانیه مکان می‌فرستند، حدود 2 میلیون به‌روزرسانی در ثانیه داریم! هر پایگاه داده‌ای که انتخاب کنیم، مثل DynamoDB یا PostgreSQL (که هر دو برای بقیه سیستم گزینه‌های عالی‌اند)، یا زیر بار نوشتن از پا درمی‌آید یا باید آن‌قدر بزرگ شود که برای بیشتر شرکت‌ها به‌شدت گران شود.
  • کارایی کوئری: بدون بهینه‌سازی، برای کوئری گرفتن از جدول بر اساس عرض و طول جغرافیایی باید اسکن کامل جدول انجام بدهیم و فاصله بین مکان هر راننده و مکان مسافر را حساب کنیم. این با میلیون‌ها راننده بسیار ناکارآمد است. حتی با ایندکس روی ستون‌های lat/long، ایندکس‌های B-tree سنتی برای داده چندبعدی مثل مختصات جغرافیایی مناسب نیستند و عملکرد کوئری مجاورت را نابهینه می‌کنند. این در اصل یک راه‌حل غیرقابل شروع است.

مخصوصاً برای DynamoDB، نوشتن 2 میلیون رکورد 100 بایتی در ثانیه تقریباً روزانه حدود 100 هزار دلار هزینه دارد. درباره DynamoDB و محدودیت‌هایش بیشتر یاد بگیرید.

پس برای رفع این مشکلات چه کاری می‌توانیم بکنیم؟

رویکرد بد: نوشتن مستقیم مکان‌ها در پایگاه داده

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

رویکرد خوب: نوشتن دسته‌ای و پایگاه داده مکانی تخصصی

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

پردازش دسته‌ای (Batch Processing) تکنیک رایجی برای کم کردن تعداد عملیات نوشتن در پایگاه داده است. این شامل جمع‌آوری داده در یک بازه زمانی از پیش تعریف‌شده و سپس نوشتن آن در پایگاه داده در یک عملیات دسته‌ای است. این می‌تواند تعداد تراکنش‌های نوشتن را به‌طور چشمگیری کم کند و به بهتر شدن توان نوشتن و کم شدن رقابت قفل کمک کند.

پایگاه‌های داده مکانی از ساختارهای داده تخصصی استفاده می‌کنند، مثل Quadtree (چهاردرخت) برای ایندکس کردن مکان رانندگان، که اجرای سریع‌تر جست‌وجوهای مجاورت را ممکن می‌کند. Quadtree ها به‌ویژه برای داده فضایی دوبعدی مثل مختصات جغرافیایی مناسب‌اند، چون فضا را بازگشتی به چهار ربع تقسیم می‌کنند، که می‌تواند فرایند پیدا کردن همه رانندگان در یک منطقه مشخص اطراف درخواست سفر را به‌طور قابل توجهی سریع کند. اگر بخواهیم از PostgreSQL استفاده کنیم، افزونه‌ای به نام PostGIS دارد که اجازه می‌دهد از انواع داده و توابع مکانی بدون نیاز به پایگاه داده اضافه استفاده کنیم.

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

رویکرد عالی: Redis با دستورات مکانی (Geo)

می‌توانیم با استفاده از یک ذخیره‌ساز داده درون‌حافظه‌ای مثل Redis که از انواع داده و دستورات مکانی پشتیبانی می‌کند، همه محدودیت‌های راه‌حل‌های قبلی را برطرف کنیم. این به ما اجازه می‌دهد به‌روزرسانی مکان راننده و جست‌وجوی مجاورت را با Throughput بالا و Latency کم انجام بدهیم و در عین حال با انقضای خودکار داده، هزینه ذخیره‌سازی را حداقل کنیم.

Redis از Geohash (هش جغرافیایی) برای رمزگذاری مختصات عرض و طول جغرافیایی در یک کلید رشته‌ای استفاده می‌کند که بعد با Sorted Set ایندکس می‌شود. این ذخیره‌سازی و جست‌وجوی کارآمد داده مکانی را ممکن می‌کند.

Redis دستورات مکانی مثل GEOADD برای افزودن داده مکان و GEOSEARCH برای جست‌وجوی مکان‌های نزدیک در یک شعاع یا جعبه مرزی مشخص ارائه می‌کند. این دستورات برای داده مکانی بسیار بهینه‌اند و می‌توان از آن‌ها برای مدیریت کارآمد به‌روزرسانی بلادرنگ مکان رانندگان و جست‌وجوهای مجاورت استفاده کرد. دستور GEOSEARCH که در Redis 6.2 معرفی شد، دستورات قدیمی‌تر GEORADIUS و GEORADIUSBYMEMBER را جایگزین می‌کند و انعطاف و عملکرد بهتری می‌دهد.

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

چالش‌ها: چالش اصلی این رویکرد دوام (Durability) است. چون Redis ذخیره‌ساز درون‌حافظه‌ای است، در صورت خرابی سیستم یا قطع برق خطر از دست رفتن داده هست. با این حال این خطر را به چند روش می‌توان کم کرد:

  • ماندگاری Redis: می‌توانیم مکانیزم‌های ماندگاری Redis مثل RDB یا AOF را فعال کنیم تا دوره‌ای داده درون‌حافظه‌ای روی دیسک ذخیره شود.
  • Redis Sentinel: می‌توانیم برای دسترس‌پذیری بالا از Redis Sentinel استفاده کنیم. Sentinel در صورت از کار افتادن گره اصلی، Failover خودکار فراهم می‌کند و مطمئن می‌شود یک Replica به Master ارتقا می‌یابد.

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

2) مدیریت اضافه‌بار ناشی از به‌روزرسانی‌های مکرر مکان راننده با حفظ دقت مکان

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

رویکرد خوب: فواصل به‌روزرسانی تطبیقی

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

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

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

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

3) جلوگیری از ارسال هم‌زمان چند درخواست سفر به یک راننده

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

رویکرد بد: قفل در سطح اپلیکیشن

شهود اصلی این است که باید رانندگان را قفل کنیم تا از ارسال هم‌زمان چند درخواست سفر به یک راننده جلوگیری کنیم. سؤال فقط «چطور» می‌شود. یک رویکرد، قفل در سطح اپلیکیشن است، که در آن هر نمونه Ride Matching Service، وقتی درخواست سفر برای راننده فرستاده می‌شود، آن را «قفل‌شده» علامت می‌زند. سپس برای مدت قفل یک تایمر شروع می‌کند. اگر راننده در این مدت سفر را نپذیرد، نمونه قفل را دستی آزاد می‌کند و درخواست را در اختیار رانندگان دیگر می‌گذارد.

مشکلات: این رویکرد چند مشکل دارد که مهم‌ترین‌هایش:

  • نبود هماهنگی: با اجرای چند نمونه Ride Matching Service، هماهنگی متمرکزی وجود ندارد که به شرایط رقابتی بالقوه می‌رسد: دو نمونه ممکن است هم‌زمان سعی کنند یک درخواست سفر را قفل کنند.
  • وضعیت قفل ناسازگار: اگر نمونه‌ای قفلی بگذارد اما قبل از آزاد کردن آن شکست بخورد (به‌خاطر خرابی یا مشکل شبکه)، نمونه‌های دیگر از این قفل خبر ندارند، که می‌تواند درخواست سفر را برای مدت نامحدود قفل نگه دارد.
  • نگرانی‌های مقیاس‌پذیری: با مقیاس‌پذیر شدن سیستم و زیاد شدن تعداد نمونه‌ها، مشکل هماهنگ کردن قفل‌ها بین نمونه‌ها برجسته‌تر می‌شود که به احتمال بیشتر خطا و ناسازگاری می‌رسد.

رویکرد خوب: قفل در پایگاه داده با Timeout در حافظه

برای حل مشکل هماهنگی می‌توانیم قفل را به پایگاه داده منتقل کنیم. این اجازه می‌دهد از قابلیت‌های تراکنش داخلی پایگاه داده استفاده کنیم تا مطمئن شویم فقط یک نمونه می‌تواند در یک لحظه یک درخواست سفر را قفل کند. وقتی درخواستی را به راننده می‌فرستیم، وضعیت آن راننده را به outstanding_request یا چیزی مشابه به‌روز می‌کنیم. اگر راننده درخواست را بپذیرد، وضعیت را به accepted به‌روز می‌کنیم و اگر رد کند، وضعیت را به available برمی‌گردانیم. سپس می‌توانیم از یک مکانیزم Timeout ساده در Ride Service استفاده کنیم تا اگر راننده در پنجره 10 ثانیه‌ای پاسخ نداد، قفل آزاد شود.

چالش‌ها: اگرچه مشکل هماهنگی را حل کرده‌ایم، همچنان با تکیه بر Timeout درون‌حافظه‌ای برای کنترل آزاد شدن قفل در صورت پاسخ ندادن راننده مشکل داریم. اگر Ride Service از کار بیفتد یا ری‌استارت شود، Timeout از بین می‌رود و قفل برای مدت نامحدود می‌ماند. این مشکل رایج Timeout های درون‌حافظه‌ای است و دلیلی است که در صورت امکان باید از آن‌ها دوری کرد. یک راه‌حل این است که یک Cron Job دوره‌ای قفل‌های منقضی را بررسی و آزاد کند. این کار می‌کند، اما پیچیدگی غیرضروری اضافه می‌کند و در آزاد شدن درخواست سفر تأخیر می‌سازد.

رویکرد عالی: قفل توزیع‌شده با TTL در Redis

برای حل مشکل Timeout می‌توانیم از یک قفل توزیع‌شده (Distributed Lock) استفاده کنیم که با یک ذخیره‌ساز درون‌حافظه‌ای مثل Redis پیاده شده است. وقتی درخواست سفر برای راننده فرستاده می‌شود، قفلی با شناسه یکتا (مثلاً driverId) و یک TTL برابر مدت پنجره پذیرش 10 ثانیه‌ای گذاشته می‌شود. Ride Matching Service سعی می‌کند قفل driverId را در Redis بگیرد. اگر قفل با موفقیت گرفته شد، یعنی هیچ نمونه سرویس دیگری نمی‌تواند درخواست سفر را برای همان راننده بفرستد، تا وقتی که قفل منقضی یا آزاد شود. اگر راننده سفر را در پنجره TTL بپذیرد، Ride Matching Service وضعیت سفر را در پایگاه داده به accepted به‌روز می‌کند و قفل در Redis آزاد می‌شود. اگر راننده سفر را در پنجره TTL نپذیرد، قفل در Redis خودکار منقضی می‌شود. این انقضا به Ride Matching Service اجازه می‌دهد دوباره راننده را برای درخواست‌های سفر جدید در نظر بگیرد.

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

4) تضمین حذف نشدن هیچ درخواست سفر در دوره‌های اوج تقاضا

در دوره‌های اوج تقاضا، سیستم ممکن است حجم بالایی از درخواست‌های سفر بگیرد که می‌تواند باعث حذف شدن درخواست‌ها شود. این به‌ویژه در رویدادهای خاص یا تعطیلات که تقاضا بالاست و سیستم تحت فشار است مشکل‌ساز است. همچنین باید در برابر مواردی محافظت کنیم که نمونه‌ای از Ride Matching Service از کار می‌افتد یا ری‌استارت می‌شود و باعث حذف درخواست‌ها می‌شود.

رویکرد بد: پردازش مستقیم بدون صف

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

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

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

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

برای مدیریت این مسئله، می‌توانیم یک سیستم صف (Queue) با مقیاس‌دهی پویا معرفی کنیم. وقتی درخواست سفر می‌رسد، به صف اضافه می‌شود. سپس Ride Matching Service درخواست‌های صف را به روش «اول بیاید، اول سرویس بگیرد» (FIFO) پردازش می‌کند. اگر صف بیش از حد بزرگ شود، سیستم با اضافه کردن نمونه‌های بیشتر Ride Matching Service برای مدیریت بار بیشتر، افقی مقیاس می‌گیرد. این اجازه می‌دهد سیستم را بر اساس تقاضا پویا مقیاس بدهیم و مطمئن شویم هیچ درخواستی حذف نمی‌شود. همچنین می‌توانیم صف‌ها را بر اساس منطقه جغرافیایی پارتیشن‌بندی کنیم تا کارایی را بیشتر کنیم.

می‌توانیم از سیستم Message Queue (صف پیام) توزیع‌شده مثل Kafka استفاده کنیم که اجازه می‌دهد Offset پیام را در صف فقط بعد از پیدا کردن موفق یک تطبیق Commit کنیم. به این ترتیب، اگر Ride Matching Service از کار بیفتد، درخواست تطبیق همچنان در صف خواهد ماند و نمونه جدید سرویس آن را برمی‌دارد. این رویکرد تضمین می‌کند هیچ درخواست سفری به‌خاطر خرابی سرویس از بین نمی‌رود و Fault Tolerance (تحمل خطا) برای سیستم فراهم می‌کند.

چالش‌ها: چالش اصلی این رویکرد پیچیدگی مدیریت سیستم صف است. باید مطمئن شویم صف مقیاس‌پذیر، تحمل‌پذیر در برابر خطا و بسیار در دسترس است. می‌توانیم با استفاده از سرویس صف مدیریت‌شده مثل Amazon SQS یا Kafka که این قابلیت‌ها را آماده می‌دهند، این مشکل را حل کنیم. این اجازه می‌دهد بدون نگرانی از زیرساخت زیرین، روی منطق کسب‌وکار سیستم تمرکز کنیم.

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

5) اگر راننده به‌موقع پاسخ ندهد چه می‌شود؟

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

الگو: فرایندهای چندمرحله‌ای (Multi-step Processes). فرایندهای انسان‌در‌حلقه (Human-in-the-loop) نشانه بزرگی برای استفاده از الگوی فرایندهای چندمرحله‌ای هستند. در واقع Uber نویسنده اصلی پروژه متن‌باز Cadence است که چارچوب اجرای بادوام Temporal را به وجود آورد و دقیقاً برای کاربردهایی مثل این ساخته شده است.

رویکرد خوب: صف تأخیری (Delay Queue)

می‌توانیم مکانیزم صف تأخیری پیاده کنیم تا اگر راننده فعلی در بازه زمانی مقرر پاسخ نداد، درخواست سفر خودکار با راننده موجود بعدی دوباره امتحان شود. وقتی درخواست سفر برای راننده فرستاده می‌شود، هم‌زمان یک پیام تأخیری در صفی زمان‌بندی می‌کنیم (مثل صف تأخیری Amazon SQS) که بعد از مدت 10 ثانیه پردازش می‌شود.

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

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

چالش‌ها: چالش اصلی این رویکرد مدیریت پیچیدگی پیام‌های تأخیری متعدد و تضمین لغو درست آن‌ها وقتی راننده سفر را می‌پذیرد است. اگر راننده بعد از زمان‌بندی پیام تأخیری اما قبل از پردازش آن سفر را بپذیرد، باید مطمئن شویم پیام تأخیری درست مدیریت می‌شود و باعث تخصیص اشتباه سفر نمی‌شود. علاوه بر این، این رویکرد هماهنگی دقیق بین صف تأخیری و Ride Matching Service برای تضمین سازگاری و دوری از شرایط رقابتی می‌خواهد.

رویکرد عالی: چارچوب اجرای بادوام (Durable Execution)

راه‌حل مقاوم‌تر، استفاده از چارچوب اجرای بادوام مثل Temporal یا AWS Step Functions است. این سیستم‌ها پشتیبانی داخلی برای Timeout، Retry و مدیریت وضعیت دارند، به شکلی که از خرابی‌ها و ری‌استارت سرویس جان سالم به در می‌برد.

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

مثلاً Workflow می‌تواند:

  1. درخواست سفر را برای راننده اول بفرستد.
  2. Timeout 10 ثانیه‌ای تنظیم کند.
  3. اگر راننده پذیرفت، Workflow را کامل کند.
  4. اگر راننده رد کرد یا Timeout شد، خودکار به راننده بعدی برود.
  5. این روند را ادامه دهد تا راننده‌ای پیدا شود یا همه رانندگان تمام شوند.

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

چالش‌ها: چالش اصلی این رویکرد پیچیدگی اضافه معرفی یک سیستم ارکستراسیون Workflow است. این رویکرد از تیم‌ها می‌خواهد مفاهیم و ابزارهای جدید یاد بگیرند و جزء دیگری به سیستم اضافه می‌کند که مانیتورینگ و نگهداری می‌خواهد. با این حال مزایای اجرای تضمین‌شده، Fault Tolerance داخلی و منطق کسب‌وکار ساده‌شده اغلب بر این چالش‌ها می‌چربد، به‌ویژه برای سیستم‌های حیاتی مثل پلتفرم‌های سواری اشتراکی که درخواست‌های حذف‌شده مستقیم روی درآمد و تجربه کاربری اثر می‌گذارند.

6) مقیاس‌دهی سیستم برای کم کردن Latency و بهتر کردن توان

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

ساده‌ترین رویکرد برای مقیاس‌دهی، مقیاس عمودی (Vertical Scaling) است، یعنی ظرفیت سرورهای موجود را با اضافه کردن CPU، حافظه یا ذخیره‌سازی بیشتر زیاد کنیم. این راه سریع و آسانی برای بالا بردن ظرفیت است، اما چند محدودیت دارد.

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

رویکرد عالی: مقیاس افقی با Sharding جغرافیایی

رویکرد بهتر، مقیاس افقی با اضافه کردن سرور بیشتر است. می‌توانیم این کار را با Shard کردن جغرافیایی داده‌ها و استفاده از Read Replica برای بهتر کردن توان خواندن انجام بدهیم. این اجازه می‌دهد سیستم را برای تحمل درخواست‌های بیشتر مقیاس بدهیم و در عین حال Latency را کم و Throughput را بهتر کنیم. نکته مهم این است که این نه‌تنها اجازه می‌دهد مقیاس بدهیم، بلکه با کم کردن فاصله بین کلاینت و سرور، Latency را کم می‌کند. این برای همه چیز صدق می‌کند، از سرویس‌های ما و Message Queue گرفته تا پایگاه داده، که همه را می‌توان جغرافیایی تقسیم کرد. تنها زمانی که به Scatter-gather نیاز داریم (یعنی کوئری چند Shard) وقتی است که روی مرز یک ناحیه جست‌وجوی مجاورت می‌کنیم.

چالش‌ها: چالش اصلی این رویکرد پیچیدگی Sharding و مدیریت چند سرور است. باید مطمئن شویم داده به‌طور یکنواخت بین Shard ها توزیع می‌شود و سیستم می‌تواند خرابی‌ها و بالانس مجدد را مدیریت کند. می‌توانیم با استفاده از Consistent Hashing (هشینگ پایدار) برای توزیع داده بین Shard ها و پیاده‌سازی استراتژی Replication برای تضمین اینکه داده روی چند سرور تکثیر می‌شود، این مشکل را حل کنیم. این اجازه می‌دهد سیستم را افقی مقیاس بدهیم و در عین حال Fault Tolerance و High Availability (دسترس‌پذیری بالا) را حفظ کنیم.

بعد از اعمال راه‌حل‌های «عالی»، طراحی نهایی شما باید چیزی شبیه این باشد: Rider Client و Driver Client، API Gateway، Ride Service، Ride Matching Service با صف (Kafka یا SQS) و Workflow بادوام (Temporal)، Location Service با Redis Geo، قفل توزیع‌شده Redis، Notification Service (APN و FCM) و پایگاه داده Shard شده جغرافیایی.

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

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

سطح Mid-level

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

سطح Senior

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

سطح Staff به بالا

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