پست

طراحی جستجوی پست در Facebook

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

طراحی جستجوی پست در Facebook

درک مسئله

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

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

فرض می‌کنیم مصاحبه‌کننده صریحاً به ما گفته که اجازه نداریم از موتور جست‌وجو مثل Elasticsearch یا ایندکس متن کامل آماده (مثل Postgres Full-Text) برای این مسئله استفاده کنیم. ممنوع‌کردن فناوری‌های مشخص آن‌قدر رایج است که باید برایش آماده باشید. هدف اینجا این است که دانش شما از اصول سازمان‌دهی داده و ایندکس‌گذاری سنجیده شود، نه ویژگی‌های یک فناوری خاص. بله، واقعاً این اتفاق می‌افتد!

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

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

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

  • کاربران بتوانند پست ایجاد کنند و آن‌ها را لایک کنند.
  • کاربران بتوانند پست‌ها را بر اساس کلمه کلیدی جست‌وجو کنند.
  • کاربران بتوانند نتایج جست‌وجو را مرتب‌شده بر اساس جدیدترین یا تعداد لایک دریافت کنند.

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

  • پشتیبانی از تطبیق فازی (Fuzzy Matching) عبارت‌ها (مثلاً جست‌وجوی «پرنده» با «شترمرغ» تطبیق پیدا کند).
  • شخصی‌سازی نتایج جست‌وجو (یعنی نتایج به جزئیات کاربر جست‌وجوکننده بستگی داشته باشد).
  • قوانین و فیلترهای حریم خصوصی
  • الگوریتم‌های پیچیده رتبه‌بندی مرتبط‌بودن (Relevance)
  • تصاویر و رسانه
  • به‌روزرسانی بلادرنگ صفحه جست‌وجو هنگام ورود پست‌های جدید

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

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

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

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

  • سیستم باید سریع باشد: کوئری‌های میانه (Median) باید کمتر از 500 میلی‌ثانیه برگردند.
  • سیستم باید حجم بالای درخواست را پشتیبانی کند (بعداً برآورد می‌کنیم).
  • پست‌های جدید باید در زمان کوتاهی، کمتر از 1 دقیقه، قابل جست‌وجو باشند.
  • همه پست‌ها باید قابل کشف باشند، از جمله پست‌های قدیمی یا غیرمحبوب (می‌توانیم برای این‌ها زمان بیشتری بگذاریم).
  • سیستم باید بسیار در دسترس باشد.

اگر داده زیادی در سیستم دارید، احتمال زیادی هست که داده «داغ» (Hot) و «سرد» (Cold) داشته باشید. داده داغ به‌راحتی در دسترس است و باید سریع سرو شود، اغلب از حافظه. داده سرد به‌ندرت خوانده می‌شود و می‌تواند از دیسک، پایگاه داده راه دور یا حتی نوار (Tape) سرو شود. در این مسئله، نیازمندی غیرعملکردی‌ای که می‌گوید می‌توانیم برای پست‌های قدیمی یا نامحبوب زمان بیشتری بگذاریم، یک تصمیم طراحی بالقوه را بعداً در مصاحبه پیش‌بینی می‌کند.

توجه کنید که نیازمندی‌های خارج از محدوده معمولاً از سؤال‌هایی مثل «آیا باید حریم خصوصی را مدیریت کنیم؟» می‌آیند. مصاحبه‌کننده‌ها معمولاً با جمله شما که «برای شروع حریم خصوصی را خارج از محدوده می‌گذارم» راحت‌اند و اگر لازم باشد شما را اصلاح می‌کنند.

برآورد مقیاس

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

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

فرض می‌کنیم Facebook یک میلیارد کاربر دارد. به‌طور میانگین هر کاربر 1 پست در روز می‌سازد (شاید 20 درصد کاربران 5 پست و 80 درصد 0 پست) و 10 پست را لایک می‌کند. برای سهولت محاسبه، یک روز را 100 هزار ثانیه می‌گیریم (گرد‌شده برای راحتی).

اول حجم نوشتن:

1
2
Posts created: 1B * 1 post/day / (100k seconds/day) = 10k posts/second
Likes created: 1B * 10 likes/day / (100k seconds/day) = 100k likes/second

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

حالا خواندن:

1
Searches: 1B * 1 search/day / (100k seconds/day) = 10k searches/second

اگرچه این عدد بزرگ است (و ممکن است 10 برابر یا بیشتر شود)، سیستم ما نوشتن‌محور (Write-heavy) است، نه خواندن‌محور. این هم نکته دیگری است که باید به آن توجه کرد.

در نهایت نیازهای ذخیره‌سازی سیستم را ارزیابی می‌کنیم. اول فرض می‌کنیم Facebook ده‌ساله است و Metadata کامل هر پست 1 کیلوبایت است (احتمالاً بیش از حد).

1
2
Posts searchable: 1B posts/day * 365 days/year * 10 years = 3.6T posts
Raw size: 3.6T posts * 1kb/post = 3.6 PB

وای، این فضای ذخیره‌سازی زیادی است! باید راهی پیدا کنیم که این را در سیستم جست‌وجویمان محدود کنیم.

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

آماده‌سازی

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

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

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

با شناسایی موجودیت‌های اصلی که با آن‌ها کار می‌کنیم شروع می‌کنیم. خوشبختانه برای این مسئله موجودیت‌های اصلی بسیار ساده‌اند:

  • User (کاربر): پست می‌سازد.
  • Post (پست): چیزی که دنبالش می‌گردیم! محتوا دارد، توسط کاربر ساخته شده و ضمناً تعداد لایک دارد.
  • Like (لایک): وقتی کاربر پستی را دوست دارد ساخته می‌شود، اما برای این مسئله بیشتر به تعداد لایک‌ها اهمیت می‌دهیم.

لازم نیست زمان اضافه صرف کنیم، چون موجودیت‌های این مسئله عمداً ساده‌اند. ادامه می‌دهیم.

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

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

API های ما ساده‌اند. دو مسیر داریم: مسیر جست‌وجو برای جست‌وجو و مسیر نوشتن برای ساخت پست و لایک.

در سیستم واقعی ممکن است برای هر دوی این رویدادها از Stream مثل Kafka یا Event Bus دیگر استفاده کنیم. اما برای وضوح، برای هر کدام یک endpoint API جدا می‌سازیم و می‌توانیم به مصاحبه‌کننده بگوییم که یکپارچه‌کردن این‌ها با بقیه سیستم را انتخاب می‌کنیم.

با تعریف این API ها شکل سیستم دیده می‌شود. نوشتن‌ها از دو endpoint نوشتن وارد می‌شوند و در پایگاه داده نوشته می‌شوند. کوئری‌ها از endpoint جست‌وجو وارد می‌شوند و از پایگاه داده خوانده می‌شوند. شروع خوبی است!

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

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

1) ایجاد پست و لایک

اولین نیازمندی ما در مسیر نوشتن است که به کاربران اجازه می‌دهد پست بسازند و لایک کنند. باید بتوانیم این فراخوانی‌ها را بپذیریم و در پایگاه داده بنویسیم.

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

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

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

2) جست‌وجوی پست‌ها بر اساس کلمه کلیدی

در مرحله بعد باید به کاربران اجازه بدهیم واقعاً جست‌وجو کنند. برای این کار باید بخش خواندن سیستم را راه بیندازیم. با اضافه کردن ماشین‌آلات پایه لازم برای سرو درخواست‌های کاربر شروع می‌کنیم: API Gateway برای احراز هویت و Rate Limiting (محدودسازی نرخ درخواست) و یک Search Service که افقی مقیاس می‌گیرد و درخواست را می‌پذیرد و ایندکس را جست‌وجو می‌کند.

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

رویکرد بد: LIKE روی پایگاه داده رابطه‌ای

راه‌حل ساده‌لوحانه این است که همه پست‌ها را در پایگاه داده رابطه‌ای نگه داریم و از عبارتی مثل این استفاده کنیم:

1
SELECT * FROM Posts WHERE content LIKE '%keyword%';

از نظر فنی این برای کوئری داده‌شده نتایج درست برمی‌گرداند!

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

بعضی داوطلبان ممکن است مشکل را اینجا ببینند و فکر کنند می‌توانند با Sharding یا Replication آن را حل کنند. این عملکرد را کمی بهتر می‌کند: اگر نیاز داشتیم به N پست نگاه کنیم و M گره داشته باشیم، حالا برای هر گره فقط باید به N/M پست نگاه کنیم. اما هنوز به‌طور چشمگیری کند است. این بن‌بست است.

رویکرد خوب: ایندکس معکوس در Redis

این یک کاربرد کلاسیک برای ایندکس معکوس (Inverted Index) است. مهم‌ترین ایده پشت ایندکس معکوس این است که می‌توانیم دیکشنری‌ای بسازیم که کلمات کلیدی را به اسنادی که آن‌ها را دارند نگاشت می‌کند. در این صورت یک نگاشت از کلمات کلیدی به پست‌ها می‌سازیم!

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

برای اینکه همه‌چیز ساده و سریع باشد، برای این کار از Redis استفاده می‌کنیم. Redis این ایندکس‌های معکوس را در حافظه نگه می‌دارد که کوئری‌ها را فوق‌العاده سریع می‌کند. بدیهی است که اینجا نگرانی‌هایی درباره دوام (Durability) هست، اما قابل غلبه‌اند و می‌توانیم با جایگزین بادوام مثل MemoryDB یا در یک بررسی عمیق به آن‌ها بپردازیم.

فهرستی از همه شناسه‌های پست‌هایی که با کلمه کلیدی داده‌شده می‌خوانند را به‌صورت یک List در Redis نگه می‌داریم. وقتی پست‌ها ساخته می‌شوند، آن‌ها را داخل Ingestion Service به هر کلمه کلیدی که ممکن است با آن بخواند تقسیم می‌کنیم (فرایندی که Tokenization نام دارد) و بعد شناسه آن پست را به فهرست هر کلمه کلیدی اضافه می‌کنیم.

چالش‌ها:

  • توجه کنید که این فهرست‌های شناسه پست خیلی بزرگ می‌شوند، به‌ویژه برای کلمه‌های کلیدی رایج. بعداً باید به این موضوع بپردازیم.
  • همچنین باید برای هر پست روی کلیدهای زیادی بنویسیم، چون یک پست ممکن است 10 تا 1000 کلمه کلیدی داشته باشد. باید بعضی از چالش‌های مقیاس‌دهی مربوط به این را مدیریت کنیم.

3) مرتب‌سازی نتایج بر اساس جدیدترین یا تعداد لایک

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

رویکرد بد: مرتب‌سازی در زمان کوئری

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

فرض کنید بر اساس تازگی مرتب می‌کنیم. مثال را مرور می‌کنیم. اول برای یک کلمه کلیدی از ایندکس کوئری می‌کنیم. فهرستی از شناسه‌های پست برمی‌گرداند. برای هر کدام از این شناسه‌ها، از Post Service مهر زمانی ساخت را می‌پرسیم. وقتی این مهرهای زمانی را گرفتیم، می‌توانیم پست‌ها را در Search Service مرتب کنیم و به کاربر برگردانیم.

این … عالی نیست.

چالش‌ها: بزرگ‌ترین مشکل این رویکرد این است که تعداد شناسه‌های پست برای کلمه‌های کلیدی رایج ممکن است بسیار زیاد باشد. اگر کلمه‌ای مثل «Taylor» 10 میلیون نتیجه داشته باشد، به‌راحتی ممکن است Payload هایی در حد 100 مگابایت از ایندکس برگردانیم. علاوه بر این، برای هر کدام از این نتایج باید جست‌وجو کنیم، یعنی ده‌ها میلیون مورد. در نهایت، مرتب‌سازی میلیون‌ها مورد در زمان کوئری، Latency غیرضروری به سیستم اضافه می‌کند.

رویکرد خوب: چند ایندکس (Creation Index و Likes Index)

رویکرد متفاوت این است که دو ایندکس جدا داشته باشیم: یکی بر اساس زمان ساخت و دیگری مرتب‌شده بر اساس تعداد لایک (از این به بعد این‌ها را «ایندکس ساخت» و «ایندکس لایک» می‌نامم). با رویکرد مبتنی بر Redis که قبلاً داشتیم، می‌توانیم بسته به مرتب‌سازی بر اساس لایک یا تاریخ ساخت، کلیدهای جدا داشته باشیم.

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

برای ایندکس لایک، هر کلید می‌تواند از Sorted Set در Redis استفاده کند. Sorted Set اجازه می‌دهد فهرستی از اقلام مرتب‌شده بر اساس امتیاز نگه داریم، به همان روشی که صف اولویت یا فهرست مرتب‌شده کار می‌کند، با همان پیچیدگی زمانی برای درج و کوئری.

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

چالش‌ها:

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

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

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

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

1) مدیریت حجم بالای درخواست‌های کاربر

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

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

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

رویکرد خوب: کش توزیع‌شده کنار Search Service

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

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

رویکرد عالی: کش لبه با CDN

علاوه بر کش جست‌وجوی Redis، می‌توانیم از کش لبه (Edge Cache) از طریق CDN مثل Cloudflare یا AWS CloudFront هم استفاده کنیم. بیشتر CDN ها مثل مجموعه بزرگی از کش‌های HTTP پراکنده جغرافیایی عمل می‌کنند. شما یک «مبدأ» (Origin) یا هدف برای کش پیکربندی می‌کنید و DNS را طوری تنظیم می‌کنید که از طریق CDN مسیریابی شود. اگر کش چیزی را که کاربر دنبالش است داشته باشد، می‌تواند سریع‌تر از هر گزینه دیگری برگرداند: بیشتر CDN ها نقطه‌هایی بسیار نزدیک به بیشتر کاربران دارند. اگر کش را از دست بدهد، CDN درخواست را به سرورهای شما Proxy می‌کند.

استفاده از CDN اینجا ساده است: در پاسخ endpoint ‏/search می‌توانیم هدر Cache-Control اضافه کنیم که به CDN می‌گوید نتیجه را کی و تا چه مدت کش کند. وقتی کاربر سعی می‌کند به Search Service ما برسد، ابتدا از میزبان CDN محلی جغرافیایی می‌گذرد. در صورت Cache Hit، این کاربران نتایج را در حدود 10 میلی‌ثانیه می‌گیرند، به‌جای 100 میلی‌ثانیه‌ای که شاید لازم می‌شد اگر مجبور بودند از API Gateway، Search Service و فراخوانی کش جست‌وجو و برگشت بگذرند. اگر در کش نباشد، مثل همیشه درخواست پردازش می‌شود.

2) پشتیبانی از کوئری‌های چندکلمه‌ای و عبارتی

گاهی کاربران ما ممکن است یک کلمه مثل «dog» جست‌وجو کنند، اما ممکن است رشته‌هایی مثل «Taylor Swift» یا حتی عبارت‌های کامل مثل «All your base are belong to us» را هم دنبال کنند. بسیاری از موتورهای جست‌وجو از یک زبان بولی کامل برای کوئری‌هایی مثل +Taylor +Swift -Red پشتیبانی می‌کنند. بیایید فرض کنیم کوئری اینجا ساده است: چطور می‌توانیم کوئری‌های عبارتی چندکلمه‌ای را پاسخ بدهیم؟

رویکرد خوب: اشتراک مجموعه‌ها (Set Intersection)

کمترین تغییری که می‌توانیم بدهیم این است که به اشتراک مجموعه‌ها بین هر کلمه کلیدی جست‌وجوی خود نگاه کنیم. برای جست‌وجوی «Taylor Swift» مرتب‌شده بر اساس لایک، می‌خواهیم:

  1. شناسه پست‌ها را از «Taylor» و «Swift» کوئری کنیم.
  2. آن‌ها را قطع (Intersect) کنیم، یعنی همه postId هایی را که هم در «Taylor» و هم در «Swift» هستند پیدا کنیم.
  3. محتوای پست را برای هر کدام از این شناسه‌ها بگیریم و مطمئن شویم واقعاً «Taylor Swift» را دارند، نه رشته‌ای جداشده مثل «my friend Taylor did a swift exit».
  4. پست‌هایی را که فیلتر مرحله 3 را رد می‌کنند، به ترتیب برگردانیم.

چالش‌ها: بزرگ‌ترین چالش این رویکرد این است که مجموعه شناسه پست‌ها برای «Taylor» و «Swift» ممکن است بسیار بزرگ باشد و عبور از شبکه و اشتراک‌گیری را غیرممکن کند. اگر میلیون‌ها پست برای هر کلمه کلیدی باشد، ممکن است مگابایت‌ها داده را منتقل کنیم تا در یک Hash Table بگذاریم و قطع کنیم. انجام این کار در حالی که SLA ای معادل 500 میلی‌ثانیه را رعایت می‌کنیم سخت است.

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

رویکرد عالی: ایندکس Bigram (Shingle)

می‌توانیم کارایی زمان خواندن را با ایندکس‌گذاری «Bigram» ها یا «Shingle» ها (در اصطلاح Lucene) بهتر کنیم. ایده ساده است. در این جمله:

«I saw Taylor Swift at the concert»

می‌توانیم برای هر جفت کلمه Token بسازیم:

  • «I saw»
  • «saw Taylor»
  • «Taylor Swift»
  • «Swift at»
  • «at the»
  • «the concert»

این‌ها را می‌توان در ایندکس‌های «لایک» و «ساخت» درج کرد. وقتی می‌خواهیم «Taylor Swift» را جست‌وجو کنیم، به‌جای اینکه «Taylor» و «Swift» را بگیریم و نتایج را قطع کنیم، می‌توانیم مستقیم سراغ ورودی «Taylor Swift» برویم و شناسه پست‌های مربوط را مستقیم بگیریم.

چالش‌ها: بزرگ‌ترین مشکل این رویکرد این است که اندازه ایندکس‌های ما را به‌طور چشمگیری زیاد می‌کند. اگرچه تعداد Bigram های یک جمله با تعداد کلمات جمله خطی است، Bigram ها بسیار یکتاترند. «Swift at» در پست‌های دیگر خیلی کمتر رخ می‌دهد، پس پراکنده (Sparse) است. بنابراین به‌جای 10 میلیون کلمه کلیدی تک‌کلمه‌ای، ممکن است به 100 میلیون کلید ایندکس‌شده برسیم.

یک راه‌حل بالقوه این است که فقط Bigram هایی را استخراج کنیم که احتمالاً جست‌وجو می‌شوند و وقتی هیچ ورودی در ایندکس نیست، به رویکرد اشتراک مجموعه‌ها برگردیم. ممکن است از Count-Min Sketch یا ساختارهای احتمالاتی دیگر برای تعیین تناوب رخ‌دادن Bigram ها در پست‌های ایندکس‌شده استفاده کنیم. اما همه این‌ها پیچیدگی و چالش بیشتری می‌آورند!

3) مدیریت حجم بالای نوشتن

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

ساخت پست

وقتی پستی ساخته می‌شود، Ingestion Service را صدا می‌زنیم که پست را Tokenize می‌کند (به کلمات کلیدی تقسیم می‌کند) و بعد postId را در ایندکس‌های ساخت و لایک می‌نویسد. اگر پستی 100 کلمه داشته باشد، ممکن است بیش از 100 نوشتن فعال کنیم، یکی برای هر کلمه.

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

می‌توانیم این مشکل را با اضافه کردن ظرفیت بیشتر به Ingestion Service و پارتیشن‌بندی درخواست‌های ورودی برطرف کنیم. با یک Log یا Stream مثل Kafka، می‌توانیم درخواست‌های ساخت را بین چند نمونه Ingestion پخش کنیم و بار را تقسیم کنیم. همچنین می‌توانیم درخواست‌ها را بافر کنیم تا بتوانیم جهش ساخت پست را تحمل کنیم. در نهایت می‌توانیم ایندکس را با Shard کردن فهرست‌ها بر اساس کلمه کلیدی مقیاس بدهیم. به این ترتیب نوشتن‌ها در ایندکس‌ها روی نمونه‌های متعدد Redis پخش می‌شود.

لایک

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

این فرصت خوبی برای بهینه‌سازی سیستم ماست. چطور می‌توانیم تعداد نوشتن‌هایی را که باید روی ایندکس‌هایمان انجام بدهیم کم کنیم؟

رویکرد خوب: دسته‌ای کردن لایک‌ها (Batching)

یکی از روش‌هایی که می‌توانیم استفاده کنیم، دسته‌ای کردن نوشتن لایک‌هاست. به‌جای نوشتن هر به‌روزرسانی لایک در ایندکس‌ها، می‌توانیم لایک‌های یک postId مشخص را در یک بازه (مثلاً 30 ثانیه) جمع کنیم. بعد به‌جای 500 بار نوشتن برای پست وایرال، می‌توانیم یک به‌روزرسانی با افزایش 500 انجام دهیم.

برای این کار به یک سرویس ثانویه «Batcher» نیاز داریم که رویدادهای لایک را بپذیرد و در پنجره ثابتی (شاید 30 ثانیه) جمع کند، قبل از اینکه آن‌ها را به Kafka برگرداند تا Ingestion Service مصرفشان کند.

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

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

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

اما این بدون مبادله نیست! یعنی ایندکس ما ذاتاً کهنه است و نمی‌توان به داده‌اش کاملاً اعتماد کرد. اما ترتیب تقریباً درست است. پستی که 10 هزار لایک دارد قطعاً جلوی پستی با 0 یا 1 لایک خواهد بود.

اگر بخواهیم N پست را برای یک کوئری بازیابی کنیم، N×2 پست برتر را از ایندکس لایک می‌گیریم. برای هر پست در آن فهرست، از Like Service تعداد لایک به‌روز را می‌پرسیم. سپس قبل از برگرداندن به کاربر، بر اساس تعداد لایک تازه مرتب می‌کنیم.

در این مورد، ایندکس ما تقریبی است اما نتیجه نهایی ما دقیق است و جدیدترین داده را دارد. این سبک «معماری دومرحله‌ای» (Two-stage Architecture) که در آن یک سرویس تقریباً درست داریم که با رتبه‌بندی مجدد (Re-ranking) گران‌تر پشتیبانی می‌شود، در سیستم‌های بازیابی اطلاعات و توصیه‌گر بسیار رایج است.

چالش‌ها: این رویکرد از بقیه پیچیده‌تر است و به تلاش مهندسی بیشتری برای پیاده‌سازی نیاز دارد. این لزوماً معنای شمارش‌های مشابه در ایندکس را تغییر می‌دهد. ممکن است اسم آن‌ها را logLikes یا approxLikes بگذاریم تا برای توسعه‌دهندگان روشن باشد.

4) بهینه‌سازی ذخیره‌سازی سیستم

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

اول، می‌توانیم برای هر ایندکس معکوس سقف و محدودیت بگذاریم. احتمالاً به هر 10 میلیون پستی که «Mark» جایی در محتوایشان دارند نیاز نخواهیم داشت. با نگه داشتن فهرست‌ها روی 10 تا 1000 مورد، می‌توانیم ذخیره‌سازی لازم را یک مرتبه بزرگی کم کنیم.

در مرحله بعد، بیشتر کلمه‌های کلیدی به‌ندرت یا حتی هرگز جست‌وجو نمی‌شوند. بر اساس تحلیل جست‌وجوهایمان، می‌توانیم یک Batch Job اجرا کنیم که کلمه‌های کلیدی کم‌استفاده را به ذخیره‌سازی کم‌دسترس‌تر ولی ارزان‌تر ببرد. یک راه برای این کار انتقال این فهرست‌های کلمه کلیدی به ذخیره‌سازی سرد Blob مثل S3 یا R2 است.

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

طراحی کامل ما ممکن است این شکل را داشته باشد: کلاینت و API Gateway، Search Service با کش و CDN، Ingestion Service با Kafka و Batcher، ایندکس‌های معکوس Redis (ساخت و لایک) و ذخیره‌سازی سرد Blob برای ایندکس‌های کم‌استفاده. اگرچه بیشتر داوطلبان (به‌ویژه Mid-level) از همه این بررسی‌های عمیق عبور نخواهند کرد.

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

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

سطح Mid-level

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

سطح Senior

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

سطح Staff به بالا

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