پست

طراحی فید خبری Facebook

بررسی گام‌به‌گام طراحی فید خبری شبکه اجتماعی شامل ایجاد پست، دنبال‌کردن کاربران، ساخت فید، مدیریت Fan-out و حل مشکل کلید داغ

طراحی فید خبری Facebook

درک مسئله

فید خبری Facebook محصولی است که پست‌های اخیر کاربران را در گراف اجتماعی شما نشان می‌دهد و Facebook پیشگام آن بوده است. این یک مسئله کلاسیک طراحی سیستم است که با Fan-out و مدیریت داده سروکار دارد.

در این مسئله فرض می‌کنیم رابطه «دنبال‌کردن» (Follow) یک‌طرفه است، نه رابطه «دوستی» دوطرفه که در نسخه‌های اولیه Facebook اهمیت بیشتری داشت. کار را با تعریف نیازمندی‌ها شروع می‌کنیم.

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

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

  • کاربر بتواند پست ایجاد کند.
  • کاربر بتواند افراد را دوست یا دنبال کند.
  • کاربر بتواند فید پست‌های افرادی را که دنبال می‌کند، به ترتیب زمانی ببیند.
  • کاربر بتواند در فید خود صفحه‌به‌صفحه (Paginate) جلو برود.

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

  • لایک و نظر دادن روی پست‌ها
  • خصوصی بودن پست‌ها یا محدود کردن دید آن‌ها

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

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

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

  • سیستم باید بسیار در دسترس باشد و Availability (دسترس‌پذیری) بر Consistency (سازگاری) مقدم است. تا یک دقیقه کهنگی پست را تحمل می‌کنیم (Eventual Consistency یا سازگاری نهایی).
  • ثبت پست و مشاهده فید باید سریع باشد و در کمتر از 500 میلی‌ثانیه برگردد.
  • سیستم باید تعداد بسیار زیادی کاربر (2 میلیارد) را مدیریت کند.
  • کاربران باید بتوانند تعداد نامحدودی کاربر را دنبال کنند و هر کاربر باید بتواند تعداد نامحدودی دنبال‌کننده داشته باشد.

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

آماده‌سازی

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

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

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

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

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

  • User (کاربر): کاربر در سیستم ما.
  • Follow (دنبال‌کردن): یک پیوند یک‌طرفه بین کاربران.
  • Post (پست): پستی که کاربری در سیستم ایجاد کرده است. هر کاربری می‌تواند پست بسازد و این پست در فید کاربرانی که پست‌دهنده را دنبال می‌کنند نمایش داده می‌شود.

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

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

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

برای نیازمندی اول، باید پست ایجاد کنیم:

1
2
3
4
5
6
7
8
// POST /posts
{
    "content": { }
}
// -> 200 OK
{
    "postId": // ...
}

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

در ادامه باید بتوانیم افراد را دنبال کنیم. برای این کار از یک endpoint ساده RESTful با متد PUT استفاده می‌کنیم:

1
2
3
// PUT /users/[id]/followers
{ }
// -> 200 OK

این عمل دوحالته است. با PUT می‌توانیم Idempotent بودن را تضمین کنیم، یعنی اگر کاربر دو بار روی دنبال‌کردن کلیک کند، اتفاق بدی نمی‌افتد. لغو دنبال‌کردن (خارج از محدوده) با DELETE انجام می‌شود. به بدنه نیازی نداریم، اما برای کامل‌بودن یک بدنه خالی گذاشته‌ایم و جلو می‌رویم.

آخرین نیازمندی این است که فید را ببینیم و در آن صفحه‌به‌صفحه جلو برویم:

1
2
3
4
5
// GET /feed?pageSize={size}&cursor={timestamp?}
{
    items: Post[],
    nextCursor: string
}

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

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

حالا چند API داریم. سراغ ساخت طراحی سطح بالای پشت آن‌ها می‌رویم.

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

1) ایجاد پست

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

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

کاربر با یک درخواست پست جدید به API Gateway می‌رسد. درخواست به یکی از endpoint های Post Service می‌رود که یک رویداد درج (Insert) برای پایگاه داده می‌سازد. ساده است.

برای پایگاه داده می‌توانیم از هر ذخیره‌ساز کلید-مقدار استفاده کنیم. در این مسئله به‌خاطر سادگی و مقیاس‌پذیری DynamoDB را انتخاب می‌کنیم. DynamoDB اجازه می‌دهد تقریباً ظرفیت نامحدود تأمین کنیم، به شرطی که بار را به‌طور مساوی بین پارتیشن‌ها پخش کنیم.

عالی است، می‌توانیم پست ایجاد کنیم. جلو می‌رویم.

2) دنبال‌کردن افراد

از نظر عملکردی، دنبال‌کردن یا دوستی یعنی ساختن رابطه بین دو کاربر. این یک رابطه چند-به-چند است و می‌توان آن را به‌صورت گراف مدل کرد. معمولاً برای گراف از پایگاه داده گراف تخصصی (مثل Neo4j) یا Triplestore استفاده می‌کنند. اما برای هدف ما می‌توانیم از یک ذخیره‌ساز کلید-مقدار ساده استفاده کنیم و خود گراف را مدل کنیم.

پایگاه داده‌های گراف زمانی مفیدترند که باید در گراف پیمایش کنید یا آن را درک کنید. این معمولاً یعنی حرکت بین گره‌های مختلف، مثل گرفتن دوستانِ دوستانتان یا ساختن Embedding برای سیستم توصیه‌گر. نیازهای ما در این مسئله ساده است، پس سیستم را ساده نگه می‌داریم و از سؤال‌های ترسناک‌تری مثل «Neo4j را چطور مقیاس می‌دهید؟» دور می‌مانیم. برای این مسئله به پایگاه داده گراف کامل نیاز نداریم.

برای این کار یک جدول ساده Follow داریم که کل رابطه را با userFollowing به‌عنوان Partition Key و userFollowed به‌عنوان Sort Key نگه می‌دارد. همچنین می‌توانیم یک Global Secondary Index (GSI) با رابطه معکوس بسازیم (Partition Key برابر userFollowed و Sort Key برابر userFollowing) تا بتوانیم همه دنبال‌کنندگان یک کاربر را جست‌وجو کنیم.

این ساختار اجازه می‌دهد کوئری‌های مهم را انجام دهیم:

  • برای بررسی اینکه کاربری کاربر دیگری را دنبال می‌کند یا نه، با هر دو کلید (Partition Key و Sort Key) کوئری می‌کنیم، یعنی userFollowing:userFollowed. این یک جست‌وجوی ساده است.
  • برای گرفتن همه کاربرانی که یک کاربر دنبال می‌کند، با Partition Key کوئری می‌کنیم (userFollowing). این یک جست‌وجوی بازه‌ای (Range Query) است.
  • برای گرفتن همه کاربرانی که کاربر مشخصی را دنبال می‌کنند، GSI را با Partition Key آن کوئری می‌کنیم (userFollowed). این هم جست‌وجوی بازه‌ای است.

برای سادگی می‌توانیم این داده را در یک جدول DynamoDB جدا نگه داریم، همراه با یک Follow Service.

شروع خوبی داریم. ادامه می‌دهیم.

3) مشاهده فید پست‌های افراد دنبال‌شده

چالش مشاهده فید پست‌ها را می‌توان به مراحلی تقسیم کرد که با طراحی فعلی و فقط چند تغییر قابل انجام است:

  1. اول همه کاربرانی را که کاربر مشخص دنبال می‌کند می‌گیریم.
  2. بعد همه پست‌های آن کاربران را می‌گیریم.
  3. در نهایت همه این پست‌ها را بر اساس زمان مرتب می‌کنیم و به کاربر برمی‌گردانیم.

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

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

پیدا کردن همه پست‌های یک کاربر مشخص با جدول Post فعلی دشوار است. ما روی جدول Follow یک ایندکس داریم تا بتوانیم سریع همه کاربران دنبال‌شده را پیدا کنیم، اما در جدول Post ایندکسی برای پیدا کردن سریع همه پست‌های مجموعه‌ای از کاربران نداریم. می‌توانیم با اضافه‌کردن یک GSI به جدول Post این مشکل را حل کنیم که creatorId را Partition Key و مهر زمانی ساخت پست یعنی createdAt را Sort Key داشته باشد. این به ما اجازه می‌دهد سریع همه پست‌های مجموعه‌ای از کاربران را به ترتیب زمانی بگیریم.

به نظر می‌رسد کار می‌کند. برای یک کاربر مشخص، اول همه کاربرانی را که دنبال می‌کند از جدول Follow می‌گیریم. بعد همه پست‌های آن N کاربر را از جدول Post از طریق GSI می‌گیریم. بعد همه این پست‌ها را بر اساس مهر زمانی مرتب می‌کنیم و نتیجه را برمی‌گردانیم!

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

  1. کاربر ما ممکن است کاربران زیادی را دنبال کند.
  2. هرکدام از این کاربران ممکن است پست‌های زیادی داشته باشند.
  3. مجموعه کل پست‌ها ممکن است به‌خاطر مورد (1) یا (2) بسیار بزرگ باشد.

باید آن‌ها را حل کنیم، ولی قبل از آن بیایید نیازمندی‌های عملکردی را تمام کنیم.

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

4) صفحه‌بندی فید

می‌خواهیم برای کاربران تجربه‌ای شبیه «اسکرول بی‌نهایت» ارائه کنیم. برای این کار باید بدانیم تا اینجا چه چیزی از فیدشان را دیده‌اند و بتوانیم سریع مجموعه بعدی پست‌ها را بکشیم. خوشبختانه «آنچه تا اینجا در فید دیده‌اند» را می‌توان کوتاه توصیف کرد: مهر زمانی قدیمی‌ترین پستی که دیده‌اند. چون کاربران پست‌ها را از جدیدترین به قدیمی‌ترین می‌بینند، همان یک مهر زمانی نشان می‌دهد کجا متوقف شده‌اند و Cursor عالی برای مجموعه بعدی پست‌هاست.

چون قبلاً روی جدول Post یک GSI داریم که پست‌ها را بر اساس زمان ساخت مرتب می‌کند، می‌توانیم مستقیم از آن برای فیلتر کردن پست‌های جدیدتر از Cursor ورودی استفاده کنیم. این دقیقاً مثل نیازمندی قبلی است، با یک اضافه:

  1. اول همه کاربرانی را که کاربر مشخص دنبال می‌کند می‌گیریم.
  2. بعد همه پست‌های آن کاربران را که قدیمی‌تر از مهر زمانی ورودی‌اند می‌گیریم.
  3. در نهایت همه این پست‌ها را بر اساس زمان مرتب می‌کنیم و به کاربر برمی‌گردانیم.

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

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

1) کاربرانی که تعداد زیادی کاربر را دنبال می‌کنند

اول، اگر کاربر تعداد زیادی کاربر را دنبال کند، برگرداندن کوئری جدول Follow مدتی طول می‌کشد. به محض اینکه همه افراد دنبال‌شده را بگیریم، باید تعداد زیادی کوئری روی جدول Post بزنیم تا فیدشان ساخته شود. این مشکل به نام Fan-out شناخته می‌شود، یعنی یک درخواست باعث ساخته شدن درخواست‌های بیشتری می‌شود. این مسئله به‌ویژه وقتی Latency (تأخیر) دغدغه باشد مشکل‌ساز است، و به همین دلیل نقطه بحث خوبی برای مصاحبه‌های طراحی سیستم است.

ساخته‌شدن 10 تا 100 درخواست برای پاسخ دادن به یک درخواست ورودی غیرعادی نیست، اما به‌ندرت پیش می‌آید که مجبور شوید 1000 درخواست بسازید، به‌خصوص برای سرویسی که باید با Latency کم به کاربران خدمت کند.

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

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

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

جدول PrecomputedFeed فقط فهرستی از شناسه پست‌هاست که به ترتیب زمانی ذخیره می‌شود و تعداد آن محدود است (مثلاً 200 یا کمی بیشتر). می‌خواهیم این جدول فشرده باشد تا فضای لازم را حداقل کنیم. از userId صاحب فید به‌عنوان Partition Key استفاده می‌کنیم و مقدار آن فهرست مرتب شناسه پست‌هاست. چون فقط با شناسه کاربر به این جدول دسترسی داریم، نیازی به ایندکس ثانویه نداریم.

مردم اغلب می‌پرسند: «اگر کاربر 100 صفحه در فیدش جلو برود چه؟» این سؤال منصفانه است، اما بیشتر سیستم‌ها اصلاً از این پشتیبانی نمی‌کنند (سعی کنید در Google چند ده صفحه جلو بروید)، چون کاربران واقعی این کار را نمی‌کنند.

در نهایت، همین عدد ثابت 200 را می‌توان بر اساس اینکه چقدر روی کاربران پرمصرف (Tail) اثر می‌گذارد و هزینه آن چقدر است، بالا یا پایین برد. اگر بخواهیم برای کاربرانی که به انتهای فید رسیده‌اند محتوای بیشتری بدهیم، همیشه می‌توانیم به راه‌حل ساده قبلی برگردیم و جدول‌های پایه Follow و Post را کوئری کنیم.

بیایید قبل از ادامه، حجم ذخیره‌سازی لازم برای این راه‌حل را بررسی کنیم. فرض کنید شناسه هر پست 10 بایت است. برای ذخیره 200 پست به‌ازای هر کاربر به 2 کیلوبایت به‌ازای هر کاربر نیاز داریم. اگر 2 میلیارد کاربر داشته باشیم، برای همه این فیدها حدود 4 ترابایت فضا لازم است. این برای یک سیستم مدرن کاملاً منطقی است.

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

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

2) کاربرانی که تعداد زیادی دنبال‌کننده دارند

وقتی کاربری دنبال‌کننده زیاد دارد، هنگام ساخت پست با مشکل Fan-out مشابهی روبه‌رو می‌شویم: باید در میلیون‌ها فید بنویسیم!

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

رویکرد بد: انفجار نوشتن از Post Service

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

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

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

گزینه بهتر استفاده از Worker های ناهمگام (Async) پشت یک صف است. چون سیستم ما بین زمان نوشته‌شدن پست و زمان آماده‌شدن آن در فیدها تأخیر را تحمل می‌کند، می‌توانیم درخواست‌های نوشتن را در صف بگذاریم و ناوگانی از Worker ها این درخواست‌ها را مصرف کنند و فیدها را به‌روز کنند.

هر صفی که حداقل «یک‌بار تحویل پیام» (At-least-once) را پشتیبانی کند و بسیار مقیاس‌پذیر باشد، اینجا جواب می‌دهد. Amazon Simple Queue Service (SQS) انتخاب خوبی است.

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

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

همچنین باید به مقدار متغیر کار برای هر ورودی صف توجه کنیم. پست‌گذاشتن برای کاربری با 1 میلیون دنبال‌کننده، کار بسیار بیشتری از پست‌گذاشتن برای کاربری با 1000 دنبال‌کننده است. شاید لازم باشد این کارها را به قطعات کوچک‌تر تقسیم (Shard) کنیم.

رویکرد عالی: Fan-out ترکیبی

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

اما اینجا می‌توانیم هوشمندتر باشیم: می‌توانیم انتخاب کنیم کدام حساب‌ها را از پیش در فیدها محاسبه کنیم و کدام را نه. برای Justin Bieber (و دیگر حساب‌های با دنبال‌کننده زیاد)، به‌جای نوشتن برای بیش از 90 میلیون دنبال‌کننده، می‌توانیم یک پرچم (Flag) به جدول Follow اضافه کنیم که نشان بدهد این دنبال‌شده خاص «از پیش محاسبه نمی‌شود». در صف Worker ناهمگام، درخواست‌های این کاربران را نادیده می‌گیریم.

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

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

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

3) خواندن ناهموار پست‌ها

تا اینجا Feed Service هر بار که کاربر فیدش را می‌خواهد، مستقیم از جدول Post می‌خواند. اکثریت قریب‌به‌اتفاق پست‌ها چند روز خوانده می‌شوند و بعد دیگر هرگز. اما برای بعضی پست‌ها (به‌ویژه پست‌های حساب‌هایی با دنبال‌کننده زیاد)، تعداد خواندن در چند ساعت اول عظیم است.

DynamoDB، مثل بسیاری از ذخیره‌سازهای کلید-مقدار، مقیاس تقریباً بی‌نهایت را به شرط برخی شرایط ارائه می‌دهد. یکی از مهم‌ترین شرایط، بار یکنواخت در فضای کلیدهاست. در پس‌زمینه DynamoDB ماشین‌های فیزیکی با محدودیت‌های واقعی وجود دارند، مثل هر پایگاه داده دیگر. اگر Post1 در هر ثانیه 500 هزار درخواست بگیرد و Post2 تا Post1000 در هر ثانیه صفر درخواست، این بار یکنواخت نیست!

خوشبختانه، پست‌ها خیلی بیشتر ساخته می‌شوند تا ویرایش شوند. اما مشکل کلیدهای داغ (Hot Keys) را در جدول Post چطور حل کنیم؟

رویکرد خوب: کش توزیع‌شده پست

یک راه‌حل خوب این است که بین خواننده‌های جدول Post و خود جدول، یک کش توزیع‌شده بگذاریم. چون پست‌ها به‌ندرت ویرایش می‌شوند، می‌توانیم TTL طولانی روی پست‌ها بگذاریم و پست‌هایی را که اخیراً کمتر استفاده شده‌اند (LRU) از کش خارج کنیم. تا زمانی که کش ما به‌اندازه کافی بزرگ باشد که جدیدترین پست‌ها در آن جا شوند، اکثریت قریب‌به‌اتفاق درخواست‌ها به جدول Post به کش می‌خورند. اگر N میزبان با حافظه M داشته باشیم، کش ما می‌تواند به اندازه پست‌هایی که در N×M جا می‌شود نگه دارد.

وقتی پست‌ها ویرایش می‌شوند (نه ساخته!)، کش آن شناسه پست را باطل (Invalidate) می‌کنیم.

برای ثبات، دوباره از Redis استفاده می‌کنیم و شناسه پست را هش می‌کنیم تا پست‌ها را یکنواخت بین خوشه توزیع کنیم.

چالش‌ها: بزرگ‌ترین چالش Post Cache این است که همان مشکل کلید داغ جدول Post را دارد! برای Shard یا پارتیشنِ بدشانسی که چند پست وایرال دارد، میزبان‌های پشتیبان آن دوباره بار نامتوازن می‌گیرند و مقیاس‌دهی این کش بسیار سخت می‌شود. بسیاری از میزبان‌ها کم‌استفاده خواهند بود.

رویکرد عالی: کش‌های تکراری پست (Redundant Post Cache)

مثل Distributed Post Cache بالا، راه‌حل عالی برای این مشکل هم گذاشتن یک کش بین خواننده‌های جدول Post و خود جدول است. چون پست‌ها به‌ندرت ویرایش می‌شوند، می‌توانیم TTL طولانی بگذاریم و پست‌های کم‌استفاده اخیر را (LRU) خارج کنیم. تا زمانی که کش به‌اندازه کافی بزرگ باشد که محبوب‌ترین پست‌ها در آن جا شوند، اکثریت قریب‌به‌اتفاق درخواست‌ها به جدول Post به کش می‌خورند.

برخلاف راه‌حل Distributed Post Cache، می‌توانیم چند کش جدا از هم داشته باشیم که خواننده‌های ما به آن‌ها دسترسی دارند. این نمونه‌های کش نیازی به هماهنگی با هم ندارند. به‌جای توزیع پست‌ها بین کل ناوگان کش، می‌توانیم چند نمونه داشته باشیم که همگی بتوانند یک شناسه پست را سرو کنند. این یعنی ممکن است درخواست‌های بیشتری در نهایت به پایگاه داده برسد (برای یک پست بسیار وایرال، با N نمونه کش ممکن است N درخواست به پایگاه داده بزنیم، به‌جای 1 درخواست اگر کش را با postId شارد کرده بودیم). اما N درخواست بسیار کمتر از میلیون‌ها درخواستی است که اگر کش نداشتیم باید مدیریت می‌کردیم.

هر دو مشکل را حل می‌کند، اما این راه‌حل یعنی N برابر Throughput برای یک کلید داغ داریم، بدون نیاز به هماهنگی اضافه.

چالش‌ها: در این حالت پست‌های کمتری در کش خواهیم داشت، نسبت به وقتی که پست‌ها را توزیع یا پارتیشن‌بندی می‌کردیم. این احتمالاً مشکلی نیست، چون ذخیره‌ساز DynamoDB پشت آن می‌تواند مقداری تغییر در توان خواندن را تحمل کند و همچنان سریع باشد.

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

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

سطح Mid-level

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

سطح Senior

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

سطح Staff به بالا

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