پست

طراحی سایت نقد و بررسی کسب‌وکارهای محلی مانند Yelp

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

طراحی سایت نقد و بررسی کسب‌وکارهای محلی مانند Yelp

درک مسئله

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

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

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

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

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

  • کاربر بتواند کسب‌وکارها را بر اساس نام، مکان (عرض و طول جغرافیایی) و دسته‌بندی جست‌وجو کند.
  • کاربر بتواند کسب‌وکارها را (و نظرات آن‌ها را) مشاهده کند.
  • کاربر بتواند درباره کسب‌وکارها نظر بنویسد (امتیاز 1 تا 5 ستاره اجباری و متن اختیاری).

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

  • مدیران بتوانند کسب‌وکارها را اضافه، به‌روزرسانی و حذف کنند (ما فقط روی کاربر تمرکز می‌کنیم).
  • کاربران بتوانند کسب‌وکارها را روی نقشه ببینند.
  • کسب‌وکارهای مرتبط به کاربران پیشنهاد شود.

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

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

  • سیستم باید برای عملیات جست‌وجو Latency (تأخیر) کم داشته باشد (کمتر از 500 میلی‌ثانیه).
  • سیستم باید بسیار در دسترس باشد و Eventual Consistency (سازگاری نهایی) قابل قبول است.
  • سیستم باید مقیاس‌پذیر باشد و بتواند 100 میلیون کاربر روزانه و 10 میلیون کسب‌وکار را مدیریت کند.

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

  • محافظت از داده‌های کاربر و پایبندی به GDPR
  • تحمل خطا (Fault Tolerance)
  • محافظت در برابر هرزنامه (Spam) و سوءاستفاده

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

محدودیت‌ها

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

وقتی درباره Yelp می‌پرسم، این محدودیت را معرفی می‌کنم که هر کاربر برای هر کسب‌وکار فقط می‌تواند یک نظر بگذارد.

آماده‌سازی

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

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

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

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

  • Business (کسب‌وکار): یک کسب‌وکار یا خدمتی که در Yelp فهرست شده است. شامل جزئیاتی مثل نام، مکان، دسته‌بندی و میانگین امتیاز.
  • User (کاربر): کاربر Yelp که می‌تواند کسب‌وکارها را جست‌وجو کند و نظر ثبت کند.
  • Review (نظر): نظری که کاربر برای یک کسب‌وکار گذاشته است، شامل امتیاز و متن اختیاری.

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

API

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

هدف شما این است که نیازمندی‌های اصلی را یکی‌یکی مرور کنید و API های لازم برای برآورده کردن آن‌ها را تعریف کنید. معمولاً این‌ها یک‌به‌یک با نیازمندی‌های عملکردی نگاشت می‌شوند، اما گاهی چند endpoint برای برآورده کردن یک نیازمندی عملکردی لازم است.

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

1
2
// Search for businesses
GET /businesses?query&location&category&page -> Business[]

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

برای مشاهده یک کسب‌وکار و نظراتش به endpoint ای از نوع GET نیاز داریم که شناسه کسب‌وکار را بگیرد و جزئیات کسب‌وکار و نظراتش را برگرداند:

1
2
// View business details and reviews
GET /businesses/:businessId -> Business & Review[]

اگرچه این endpoint کافی است، می‌توانید کسب‌وکار و نظرات را به دو endpoint جدا هم تقسیم کنید. به این ترتیب می‌توانیم نظرات را صفحه‌بندی کنیم:

1
2
3
4
5
// View business details
GET /businesses/:businessId -> Business

// View reviews for a business
GET /businesses/:businessId/reviews?page= -> Review[]

برای ثبت نظر، به endpoint ای از نوع POST نیاز داریم که شناسه کسب‌وکار، امتیاز و متن اختیاری را بگیرد و یک نظر بسازد. شناسه کاربر هم از نشست یا توکن احراز هویت گرفته می‌شود، نه از بدنه درخواست:

1
2
3
4
5
6
// Leave a review
POST /businesses/:businessId/reviews
{
  rating: number,
  text?: string
}

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

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

1) جستجوی کسب‌وکارها

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

API را قبلاً روی GET /businesses?query&location&category&page تعریف کرده‌ایم. حالا فقط باید یک معماری پایه بکشیم که بتواند این درخواست ورودی را پاسخ بدهد. برای اینکه کاربران بتوانند کسب‌وکارها را جست‌وجو کنند، با یک معماری اولیه شروع می‌کنیم:

  • Client (مشتری): کاربران از طریق وب یا اپلیکیشن موبایل با سیستم کار می‌کنند.
  • API Gateway (دروازه API): درخواست‌های ورودی را به سرویس‌های مناسب می‌رساند. در این مورد، Business Service.
  • Business Service: درخواست‌های جست‌وجوی ورودی را با پردازش پارامترهای کوئری و ساختن عبارت‌های جست‌وجوی پایگاه داده برای بازیابی اطلاعات کسب‌وکار مدیریت می‌کند.
  • پایگاه داده: اطلاعات کسب‌وکارها، مثل نام، توضیحات، مکان و دسته را ذخیره می‌کند.

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

  1. کلاینت یک GET به /businesses می‌فرستد و پارامترهای جست‌وجو را به‌عنوان پارامتر اختیاری کوئری می‌دهد.
  2. API Gateway درخواست را به Business Service می‌رساند.
  3. Business Service پایگاه داده را بر اساس معیارهای جست‌وجو کوئری می‌کند.
  4. نتایج از طریق API Gateway به کلاینت برمی‌گردند.

2) مشاهده کسب‌وکارها

وقتی کاربران جست‌وجو را ارسال کردند، فهرستی از کسب‌وکارها را در صفحه نتایج جست‌وجو می‌بینند. اقدام بعدی کاربر کلیک روی یک کسب‌وکار برای دیدن جزئیات آن است. بعد از این کار، کلاینت درخواست GET /businesses/:businessId را به API Gateway می‌فرستد.

برای این کار لازم نیست سرویس اضافه معرفی کنیم. فقط API Gateway درخواست را به Business Service می‌رساند. Business Service جزئیات کسب‌وکار و بعد نظرات را از پایگاه داده کوئری می‌کند. فعلاً نظرات را در همان پایگاه داده کسب‌وکارها نگه می‌داریم، اما باید این دو جدول را Join کنیم.

الگو: مقیاس‌دهی خواندن (Scaling Reads). با توجه به نسبت عظیم خواندن به نوشتن، خواندن کسب‌وکارها نمونه عالی الگوی مقیاس‌دهی خواندن است.

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

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

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

  1. کلاینت یک GET به /businesses/:businessId می‌فرستد.
  2. API Gateway آن را به Business Service می‌رساند.
  3. Business Service جزئیات و نظرات کسب‌وکار را از پایگاه داده می‌گیرد.
  4. اطلاعات ترکیبی به کلاینت برمی‌گردد.

3) ثبت نظر درباره کسب‌وکارها

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

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

وقتی کاربر نظر می‌گذارد:

  1. کلاینت یک POST همراه داده نظر به /businesses/:businessId/reviews می‌فرستد.
  2. API Gateway آن را به Review Service می‌رساند.
  3. Review Service نظر را در پایگاه داده ذخیره می‌کند.
  4. یک تأیید به کلاینت فرستاده می‌شود.

آیا باید داده نظرات را در پایگاه داده جداگانه ذخیره کنیم؟ مگر قرار نیست هر میکروسرویس پایگاه داده خودش را داشته باشد؟

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

در این مورد، حجم داده بسیار کم است: 10 میلیون کسب‌وکار ضرب‌در 100 نظر، حدود 1 ترابایت. پایگاه‌های داده مدرن به‌راحتی می‌توانند این را روی یک نمونه (Instance) تحمل کنند، پس حتی لازم نیست نگران Sharding باشیم. علاوه بر این، نظرات و کسب‌وکارها کاملاً به هم مرتبط‌اند و نمی‌خواهیم برای گرفتن جزئیات و نظرات یک کسب‌وکار مجبور باشیم بین سرویس‌ها Join کنیم.

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

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

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

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

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

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

محاسبه میانگین امتیاز در لحظه برای هر کوئری جست‌وجو به‌شدت ناکارآمد است. چند رویکرد را بررسی می‌کنیم که این را بهینه می‌کنند.

رویکرد بد: محاسبه میانگین در لحظه

همان‌طور که گفتیم، ساده‌ترین روش محاسبه میانگین امتیاز در لحظه است. این روش ساده است و به داده یا زیرساخت اضافه نیاز ندارد. فقط یک کوئری می‌سازیم که کسب‌وکارها و نظرات را Join می‌کند و بعد میانگین امتیاز هر کسب‌وکار را حساب می‌کند:

1
2
3
4
5
6
7
8
9
SELECT
  b.business_id,
  b.name,
  b.location,
  b.category,
  AVG(r.rating) AS average_rating
FROM businesses b
JOIN reviews r ON b.business_id = r.business_id
GROUP BY b.business_id;

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

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

با توجه به مقیاس 100 میلیون کاربر روزانه و 10 میلیون کسب‌وکار، این رویکرد ساده‌لوحانه احتمالاً به عملکرد غیرقابل قبول و تجربه کاربری بد می‌رسد.

رویکرد خوب: پیش‌محاسبه با Cron Job

از طرف دیگر، می‌توانیم میانگین امتیاز هر کسب‌وکار را از پیش محاسبه کنیم و در پایگاه داده ذخیره کنیم. این پیش‌محاسبه را می‌توان با یک Cron Job انجام داد که دوره‌ای اجرا می‌شود (مثلاً یک‌بار در روز، یک‌بار در ساعت و غیره). Cron Job جدول نظرات را کوئری می‌کند، میانگین امتیاز هر کسب‌وکار را حساب می‌کند و ستون جدید average_rating را در جدول Business به‌روز می‌کند. حالا هر وقت کسب‌وکاری را جست‌وجو یا کوئری کنیم، فقط average_rating را از پایگاه داده می‌خوانیم.

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

رویکرد عالی: به‌روزرسانی هم‌زمان با Optimistic Locking

هدف راه‌حل عالی این است که میانگین امتیاز را با رسیدن نظرات جدید به‌روز کنیم، نه دوره‌ای با Cron Job. انجام این کار نسبتاً ساده است، چون فقط لازم است هم نظر جدید را به جدول نظرات اضافه کنیم و هم میانگین امتیاز کسب‌وکار را در جدول Business به‌روز کنیم.

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

1
new_avg = (old_avg * num_reviews + new_rating) / (num_reviews + 1)

این محاسبه ارزان است و فقط چند چرخه CPU می‌خواهد، پس می‌توان آن را هم‌زمان برای هر نظر جدید انجام داد.

چالش‌ها: با این کار در واقع مشکل تازه‌ای ساخته‌ایم. اگر چند نظر هم‌زمان برای یک کسب‌وکار برسد چه می‌شود؟ یکی می‌تواند دیگری را بازنویسی کند و به وضعیت ناسازگار برسیم! جدول زمانی رویدادها را تصور کنید:

  1. کسب‌وکار A دارای 100 نظر با میانگین 4.0 است.
  2. کاربر 1 وضعیت فعلی را می‌خواند: num_reviews = 100 و avg_rating = 4.0.
  3. کاربر 2 هم وضعیت فعلی را می‌خواند: num_reviews = 100 و avg_rating = 4.0.
  4. کاربر 1 نظر 5 ستاره‌ای می‌فرستد و محاسبه می‌کند: new_avg = (4.0 * 100 + 5) / 101 ≈ 4.01.
  5. کاربر 2 نظر 3 ستاره‌ای می‌فرستد و محاسبه می‌کند: new_avg = (4.0 * 100 + 3) / 101 ≈ 3.99.
  6. به‌روزرسانی کاربر 1 تکمیل می‌شود: num_reviews = 101 و avg_rating = 4.01.
  7. به‌روزرسانی کاربر 2 آن را بازنویسی می‌کند: num_reviews = 101 و avg_rating = 3.99.

وضعیت نهایی (num_reviews = 101 و avg_rating = 3.99) نادرست است، چون نظر 5 ستاره‌ای کاربر 1 را در نظر نمی‌گیرد. میانگین درست باید این باشد: (4.0 * 100 + 5 + 3) / 102 ≈ 4.00.

برای حل این مشکل می‌توان از Optimistic Locking (قفل خوش‌بینانه) استفاده کرد. این تکنیکی است که در آن ابتدا وضعیت فعلی کسب‌وکار را می‌خوانیم و بعد سعی می‌کنیم آن را به‌روز کنیم. اگر از زمانی که خوانده‌ایم وضعیت تغییر کرده باشد، به‌روزرسانی ما شکست می‌خورد. معمولاً برای قفل کردن روی جدول، یک شماره نسخه (Version) اضافه می‌کنیم، اما در مورد ما می‌توانیم فقط بررسی کنیم تعداد نظرات تغییر کرده یا نه.

حالا وقتی کاربر 2 بخواهد نتیجه کاربر 1 را بازنویسی کند، به‌روزرسانی‌اش شکست می‌خورد، چون تعداد نظرات تغییر کرده است. کاربر 2 باید وضعیت را دوباره بخواند و میانگین را دوباره حساب کند. این مشکل به‌روزرسانی‌های هم‌زمان را حل می‌کند و تضمین می‌کند میانگین امتیاز همیشه به‌روز و سازگار است.

صف پیام (Message Queue) چطور؟ هر وقت این سؤال را می‌پرسم، به‌ویژه از داوطلبان Senior، بیشتر آن‌ها پیشنهاد می‌کنند نظرات ورودی را در یک صف پیام بنویسیم و بعد یک Consumer میانگین امتیاز را در سرویس جدا به‌روز کند. اگرچه این پاسخ معتبری است، مهم است توجه کنیم که پیچیدگی اضافه می‌آورد که با توجه به حجم کار، می‌توان استدلال کرد ضروری نیست.

همان‌طور که قبلاً گفتیم، افراد زیادی کسب‌وکارها را جست‌وجو یا مشاهده می‌کنند، اما تعداد بسیار کمی نظر می‌گذارند. می‌توانیم نسبت خواندن به نوشتن را حدود 1000 به 1 برآورد کنیم. با 100 میلیون کاربر، یعنی فقط 100 هزار نوشتن در روز، یا حدود 1 نوشتن در ثانیه. این عدد کوچک است. پایگاه‌های داده مدرن می‌توانند هزاران نوشتن در ثانیه را تحمل کنند، پس حتی با محاسبه جهش‌ها این تقریباً هرگز مشکل نمی‌سازد.

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

2) محدودیت یک نظر برای هر کاربر در هر کسب‌وکار

باید محدودیتی اعمال کنیم که به کاربران اجازه بدهد برای هر کسب‌وکار فقط یک نظر بگذارند. این محدودیت به‌عنوان اقدامی اساسی برای جلوگیری از هرزنامه و سوءاستفاده عمل می‌کند. مثلاً رقبا را از گذاشتن مکرر نظرات منفی (مثل امتیاز 1 ستاره) برای کسب‌وکار رقیب بازمی‌دارد. چند گزینه داریم.

رویکرد بد: بررسی در لایه اپلیکیشن

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

1
2
3
4
5
6
def leave_review(user_id, business_id):
    reviews_for_business = db.get_reviews_for_business(business_id)
    if user_id in reviews_for_business:
        return "User has already reviewed this business"
    else:
        db.leave_review(user_id, business_id)

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

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

رویکرد عالی: Unique Constraint در پایگاه داده

راه درست برای مدیریت این مسئله، یک قید (Constraint) در پایگاه داده است که محدودیت را در سطح موتور پایگاه داده اعمال می‌کند. می‌توان این کار را با یک قید یکتا (UNIQUE) روی فیلدهای user_id و business_id انجام داد:

1
2
ALTER TABLE reviews
ADD CONSTRAINT unique_user_business UNIQUE (user_id, business_id);

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

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

3) بهبود جست‌وجو برای کوئری‌های پیچیده

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

چالش این است که جست‌وجو بر اساس عرض و طول جغرافیایی در پایگاه داده سنتی بدون ایندکس‌گذاری مناسب برای مجموعه داده‌های بزرگ بسیار ناکارآمد است. وقتی برای پیدا کردن کسب‌وکارها در یک جعبه مرزی (Bounding Box) از مقایسه‌های نابرابری ساده استفاده می‌کنیم (lat > ... و lat < ... و long > ... و long < ...)، پایگاه داده باید اسکن کامل جدول (Full Table Scan) انجام بدهد و هر رکورد را با این شرط‌ها بسنجد. همین مورد درباره جست‌وجوی عبارت در نام یا توضیحات کسب‌وکار هم صدق می‌کند. این کار یک جست‌وجوی Wildcard روی کل پایگاه داده با عبارت LIKE می‌خواهد.

1
2
3
4
5
6
-- This query sucks. Very very slow.
SELECT *
FROM businesses
WHERE latitude > 10 AND latitude < 20
AND longitude > 10 AND longitude < 20
AND name LIKE '%coffee%';

رویکرد بد: ایندکس B-tree روی مختصات

اولین کاری که برای بهبود عملکرد این کوئری می‌توانیم بکنیم، اضافه کردن یک ایندکس B-tree ساده روی ستون‌های طول و عرض جغرافیایی است. در حالت ایده‌آل، این باعث می‌شود کسب‌وکارهای داخل جعبه مرزی سریع‌تر پیدا شوند:

1
CREATE INDEX idx_latitude_longitude ON businesses (latitude, longitude);

چالش‌ها: واقعیت این است که این رویکرد آن‌قدر که امیدواریم کارآمد نیست. ایندکس B-tree ساده‌ای که معمولاً برای ایندکس تک‌ستونی یا ترکیبی استفاده می‌کنیم، برای جست‌وجوی داده دوبعدی مثل عرض و طول جغرافیایی به‌خوبی بهینه نیست. دلیلش این است:

  • کوئری‌های بازه‌ای: وقتی کسب‌وکارها را در یک منطقه جغرافیایی جست‌وجو می‌کنیم، در واقع روی عرض و طول جغرافیایی کوئری بازه‌ای می‌زنیم. ایندکس‌های B-tree برای کوئری بازه‌ای تک‌بعدی کارآمدند، اما با بازه‌های چندبعدی مشکل دارند.
  • نبود آگاهی فضایی: ایندکس‌های B-tree رابطه فضایی بین نقاط را نمی‌فهمند. عرض و طول جغرافیایی را مقدارهای مستقل می‌بینند و ماهیت دوبعدی مختصات جغرافیایی را نشان نمی‌دهند.

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

رویکرد خوب: ایندکس تخصصی برای هر نوع فیلتر

سه نوع استراتژی ایندکس‌گذاری مختلف لازم داریم، برای سه نوع فیلتر بسیار متفاوت:

  • مکان: برای جست‌وجوی مؤثر بر اساس مکان باید از ایندکس مکانی مثل Geohash، Quadtree یا R-tree استفاده کنیم.
  • نام: برای جست‌وجوی مؤثر بر اساس نام باید از ایندکس جست‌وجوی متن کامل (Full-text Search) استفاده کنیم که از تکنیکی به نام ایندکس معکوس (Inverted Index) برای جست‌وجوی سریع عبارت‌ها در یک سند بهره می‌برد.
  • دسته‌بندی: برای جست‌وجوی مؤثر بر اساس دسته‌بندی می‌توانیم از یک ایندکس B-tree ساده استفاده کنیم.

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

Elasticsearch از استراتژی‌های ایندکس مکانی مختلف از جمله Geohashing، Quadtree و R-tree پشتیبانی می‌کند و جست‌وجوی مکانی کارآمد را ممکن می‌کند. همچنین در جست‌وجوی متن کامل و فیلتر دسته‌بندی عالی است و برای نیازهای جست‌وجوی چندوجهی ما ایده‌آل است.

می‌توانیم یک درخواست جست‌وجوی واحد به Elasticsearch بفرستیم که همه فیلترهایمان را ترکیب می‌کند و فهرست رتبه‌بندی‌شده کسب‌وکارها را برمی‌گرداند:

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
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "name": "coffee"
          }
        },
        {
          "geo_distance": {
            "distance": "10km",
            "location": {
              "lat": 40.7128,
              "lon": -74.0060
            }
          }
        },
        {
          "term": {
            "category": "coffee shop"
          }
        }
      ]
    }
  }
}

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

در نتیجه به راهی نیاز داریم که داده Elasticsearch با پایگاه داده اصلی ما همگام (سازگار) بماند. بهترین راه برای این کار استفاده از سیستم Change Data Capture (CDC) برای ثبت تغییرات پایگاه داده اصلی و اعمال آن‌ها در Elasticsearch است. این سیستم به این صورت کار می‌کند که همه تغییرات پایگاه داده به‌عنوان رویداد در یک صف یا Stream نوشته می‌شوند. بعد یک فرایند Consumer داریم که از صف یا Stream می‌خواند و همان تغییرات را در Elasticsearch اعمال می‌کند.

رویکرد عالی: Postgres با افزونه‌ها

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

Postgres افزونه‌ای مکانی به نام PostGIS دارد که برای ایندکس‌گذاری و جست‌وجوی داده جغرافیایی استفاده می‌شود:

1
CREATE EXTENSION IF NOT EXISTS postgis;

Postgres همچنین افزونه‌ای به نام pg_trgm دارد که برای ایندکس‌گذاری و جست‌وجوی داده متنی به کار می‌رود:

1
CREATE EXTENSION IF NOT EXISTS pg_trgm;

با این افزونه‌ها می‌توانیم بدون معرفی سرویس جدید، یک ایندکس جغرافیایی روی ستون‌های عرض و طول جغرافیایی و یک ایندکس جست‌وجوی متنی روی ستون‌های نام و توضیحات کسب‌وکار بسازیم.

با توجه به اینکه حجم داده کم است، 10 میلیون کسب‌وکار ضرب‌در 1 کیلوبایت برای هر کدام، یعنی 10 گیگابایت، به‌علاوه 10 میلیون کسب‌وکار ضرب‌در 100 نظر برای هر کدام ضرب‌در 1 کیلوبایت، یعنی 1 ترابایت، لازم نیست زیاد نگران باشیم.

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

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

  • استراتژی ایندکس مکانی درست: از شما می‌خواهند استراتژی درست ایندکس مکانی را مشخص کنید و بتوانید درباره‌اش صحبت کنید. این معمولاً به سنجیدن مبادله Geohashing و Quadtree برمی‌گردد، هرچند در صورت آشنایی می‌توان به ایندکس‌های پیچیده‌تر مثل R-tree هم اشاره کرد. به نظر من، بین Geohashing و Quadtree، Quadtree را انتخاب می‌کنم، چون به‌روزرسانی‌های ما بسیار نادر است و کسب‌وکارها در مناطق پرجمعیت (مثل نیویورک) متمرکز می‌شوند.
  • فیلتر مرحله دوم (Second-pass Filter): سپس می‌خواهید درباره فیلتر مرحله دوم صحبت کنید. این فرایندی است که نتایج جست‌وجوی مکانی را می‌گیرد و بر اساس فاصله دقیق فیلتر می‌کند. این کار با محاسبه فاصله بین عرض و طول جغرافیایی کاربر و کسب‌وکار و حذف هر چیزی که بیرون شعاع مطلوب است انجام می‌شود. از نظر فنی با چیزی به نام فرمول هاورساین (Haversine) انجام می‌شود، که مثل قضیه فیثاغورث است ولی برای محاسبه فاصله روی کره بهینه شده است.
  • ترتیب مراحل: در نهایت، مصاحبه‌کننده اغلب دنبال این است که ترتیب مراحل را بیان کنید. هدف اینجا کم کردن اندازه فضای جست‌وجو در سریع‌ترین زمان ممکن است. فاصله معمولاً محدودکننده‌ترین فیلتر است، پس می‌خواهیم اول آن را اعمال کنیم. وقتی مجموعه کوچک‌تری از کسب‌وکارها داشتیم، فیلترهای دیگر (نام، دسته‌بندی و غیره) را روی همان مجموعه کوچک‌تر اعمال می‌کنیم تا نتایج نهایی به دست بیاید.

4) جستجو با نام مکان‌های از پیش تعریف‌شده (شهر و محله)

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

طراحی ما فعلاً از جست‌وجو بر اساس عرض و طول جغرافیایی کسب‌وکار پشتیبانی می‌کند. اما کاربران اغلب با عبارت‌های زبان طبیعی مثل نام شهر یا نام محله جست‌وجو می‌کنند. مثلاً «پیتزا در نیویورک».

شایان ذکر است که این مکان‌ها فقط کد پستی، ایالت یا شهر نیستند. می‌توانند پیچیده‌تر هم باشند، مثل یک محله. مثلاً «Mission» در سان‌فرانسیسکو.

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

این چندضلعی‌ها فقط فهرستی از نقطه‌ها هستند و از منابع مختلفی می‌آیند. مثلاً GeoJSON قالب محبوبی برای ذخیره داده جغرافیایی است و برای کار با چندضلعی‌ها قابلیت دارد. آن‌ها می‌توانند فقط فهرستی از مختصات هم باشند که به‌صورت سری نقاط عرض و طول جغرافیایی نشان داده می‌شوند.

به راهی نیاز داریم که:

  1. از نام مکان به چندضلعی برسد.
  2. از آن چندضلعی برای فیلتر کردن مجموعه کسب‌وکارهای داخل آن استفاده کند.

حل مورد 1 نسبتاً ساده است. می‌توانیم یک جدول جدید در پایگاه داده بسازیم که نام مکان‌ها را به چندضلعی‌ها نگاشت می‌کند. این چندضلعی‌ها را می‌توان از مجموعه داده‌های عمومی مختلف گرفت (مثلاً Geoapify یک نمونه است). برای پیاده‌سازی:

  1. یک جدول Locations بسازید که ستون‌هایی برای نام (مثلاً «San Francisco»)، نوع (مثلاً «city» یا «neighborhood») و داده هندسی چندضلعی (Polygon Geometry) دارد.
  2. این جدول را با داده منبع انتخابی پر کنید.
  3. ستون نام را برای جست‌وجوی کارآمد ایندکس کنید.

این رویکرد اجازه می‌دهد نام مکان را سریع به چندضلعی متناظرش برای استفاده در کوئری‌های جغرافیایی ترجمه کنیم.

حالا مورد 2 چه؟ وقتی چندضلعی داریم، چطور از آن برای فیلتر کردن کسب‌وکارها استفاده کنیم؟ خوشبختانه هم Postgres (با افزونه PostGIS) و هم Elasticsearch قابلیت کار با چندضلعی‌ها را دارند، با نام‌های Geography/Geometry shapes و Geo-shapes.

در Elasticsearch می‌توانیم یک فیلد geo_shape جدید به اسناد کسب‌وکار اضافه کنیم و از جست‌وجوی geo_shape برای پیدا کردن کسب‌وکارهای داخل یک چندضلعی استفاده کنیم. نمونه کوئری جعبه مرزی:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
  "query": {
    "geo_bounding_box": {
      "location": {
        "top_left": {
          "lat": 42,
          "lon": -72
        },
        "bottom_right": {
          "lat": 40,
          "lon": -74
        }
      }
    }
  }
}

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

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

1
2
3
4
5
6
{
  "id": "123",
  "name": "Pizza Place",
  "location_names": ["bay_area","san_francisco", "mission_district"],
  "category": "restaurant"
}

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

پس از اعمال همه بررسی‌های عمیق، ممکن است به طراحی نهایی‌ای برسیم که شامل Client، API Gateway، Business Service و Review Service، پایگاه داده اصلی (Postgres)، CDC و Elasticsearch برای جست‌وجوی مکانی و متنی است.

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

بنابراین، من در هر سطح دنبال چه چیزی هستم؟

سطح Mid-level

در سطح Mid-level، بیشتر دنبال توانایی داوطلب در ساخت یک طراحی سطح بالای کارآمد هستم که در عین حال بتواند منطقی به سؤال‌های تکمیلی من درباره میانگین امتیاز و بهینه‌سازی جست‌وجو پاسخ بدهد. انتظار ندارم لزوماً درباره قیدهای پایگاه داده بدانند، اما می‌خواهم مسئله را حل کنند و درباره نزدیک کردن قید به لایه ماندگاری فکر کنند. همچنین انتظار دانش عمیق درباره انواع مختلف ایندکس‌گذاری را ندارم، اما باید بتوانند فناوری‌های «درست» را برای حل مسئله به کار ببرند.

سطح Senior

برای داوطلبان Senior انتظار دارم بیشتر بررسی‌های عمیق را پوشش بدهید، به‌جز «جستجو بر اساس نام مکان». به تمایل شما به مهندسی بیش از حد (Over-engineering) توجه می‌کنم و می‌خواهم توجیه‌های قوی برای انتخاب‌هایتان ببینم. باید انواع مختلف ایندکس‌های لازم را بفهمید و بتوانید برای انتخاب مؤثرترین فناوری، مبادلات را بسنجید.

سطح Staff به بالا

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

  • استفاده از افزونه‌های Postgres برای اجتناب از معرفی فناوری جدید (مثل Elasticsearch) و دوری از مشکلات سازگاری.
  • تشخیص اینکه توان نوشتن بسیار کوچک است و بنابراین به صف پیام نیازی نداریم.
  • تشخیص اینکه حجم داده واقعاً کم است و بنابراین یک Read Replica ساده و/یا کش کافی است و نیازی به نگرانی درباره Sharding نیست.

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