پست

طراحی سرویس تجمیع اخبار مانند Google News

بررسی گام‌به‌گام طراحی سیستم Google News شامل جمع‌آوری مقالات از ناشران، صفحه‌بندی با Cursor، کش با Redis، مدیریت اخبار فوری، دسته‌بندی و شخصی‌سازی فید

طراحی سرویس تجمیع اخبار مانند Google News

درک مسئله

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

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

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

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

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

  • شخصی‌سازی فید بر اساس علایق کاربر
  • ذخیره مقالات برای مطالعه بعدی
  • اشتراک‌گذاری مقالات در شبکه‌های اجتماعی

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

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

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

  • دسترس‌پذیری بر سازگاری مقدم است (قضیه CAP).
  • سیستم باید مقیاس‌پذیر باشد و 100 میلیون کاربر فعال روزانه (DAU) را تحمل کند، با امکان رشد تا 500 میلیون.
  • بارگذاری فید باید Latency (تأخیر) کم و زیر 200 میلی‌ثانیه داشته باشد.

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

  • محافظت از داده‌های کاربر و حریم خصوصی
  • مدیریت جهش ترافیک در رویدادهای خبری فوری (در ادامه به آن می‌پردازیم)
  • مانیتورینگ و Observability (قابلیت مشاهده) مناسب
  • تحمل خرابی API ناشران

آماده‌سازی

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

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

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

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

هنگام توضیح موجودیت‌ها به مصاحبه‌کننده، به‌جای فهرست‌کردن همه ویژگی‌ها، روی رابطه بین موجودیت‌ها و هدف هر کدام تمرکز کنید.

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

  • Article (مقاله): یک مقاله خبری با ویژگی‌هایی مثل شناسه، عنوان، خلاصه، نشانی تصویر کوچک (Thumbnail)، تاریخ انتشار، شناسه ناشر، منطقه و نشانی‌های رسانه. این موجودیت محتوای اصلی ماست.
  • Publisher (ناشر): منبع خبری با ویژگی‌هایی مثل شناسه، نام، نشانی وب، نشانی فید و منطقه. ناشران منشأ محتوای ما هستند.
  • User (کاربر): کاربر سیستم با ویژگی‌هایی مثل شناسه و منطقه (که ممکن است از IP استنباط شود یا صریحاً تنظیم شده باشد). حتی برای کاربران ناشناس هم اطلاعات پایه را نگه می‌داریم.

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

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

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

برای مشاهده فید مقالات:

1
2
// Get a page of articles for the user's feed
GET /feed?page={page}&limit={limit}&region={region} -> Article[]

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

برای مشاهده یک مقاله خاص به endpoint نیاز نداریم، چون مرورگر با کلیک روی مقاله، بر اساس فیلد url در شیء Article مستقیم به وب‌سایت ناشر می‌رود.

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

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

1) مشاهده فید انبوه مقالات از ناشران مختلف

کاربر هنگام ورود به Google News باید فیدی از مقالات اخیر ببیند. این کار دو چالش جدا دارد: جمع‌آوری محتوا از ناشران و تحویل کارآمد آن به کاربران.

اول جمع‌آوری داده را بررسی می‌کنیم. به یک سرویس جمع‌آوری (Data Collection Service) نیاز داریم که به‌عنوان فرایند پس‌زمینه، مدام از هزاران منبع خبری محتوا بگیرد:

  • سرویس جمع‌آوری داده: فیدهای RSS و API های ناشران را بر اساس تناوب به‌روزرسانی هر ناشر، هر 3 تا 6 ساعت Poll می‌کند.
  • ناشران: هزاران منبع خبری در سراسر جهان که محتوا را از طریق RSS یا API ارائه می‌کنند.
  • پایگاه داده: مقالات، ناشران و Metadata جمع‌آوری‌شده را ذخیره می‌کند.
  • Object Storage (ذخیره‌سازی شیء): تصاویر کوچک مقالات را نگه می‌دارد.

روند کار سرویس جمع‌آوری:

  1. فهرست ناشران و نشانی فید RSS آن‌ها را از پایگاه داده می‌گیرد و یکی‌یکی Poll می‌کند.
  2. محتوای مقاله و Metadata را استخراج می‌کند و فایل رسانه‌ای را برای ساخت تصویر کوچک دانلود می‌کند.
  3. تصویر کوچک را در Object Storage و داده مقاله را همراه نشانی رسانه در پایگاه داده ذخیره می‌کند.

RSS چیست؟ RSS یک قالب ساده مبتنی بر XML است که ناشران با آن محتوایشان را در اختیار سایت‌ها و خواننده‌های دیگر قرار می‌دهند. این قالب برای تجمیع‌کننده‌هایی مثل Google News رایج است، چون ساده و استاندارد است و بسیاری از ناشران از آن پشتیبانی می‌کنند. تجزیه (Parse) فیدهای RSS هم نسبتاً سبک است و همین آن را انتخاب خوبی برای سیستم ما می‌کند. RSS روی HTTP کار می‌کند. کافی است یک درخواست GET به نشانی فید بفرستیم. پاسخ یک سند XML است که عنوان مقاله، لینک و سایر Metadata را دارد.

شاید بپرسید چرا به‌جای دانلود و ذخیره تصویر در Object Storage خودمان، مستقیم به نشانی تصویری که روی سرور ناشر است اشاره نمی‌کنیم. سؤال خوبی است. می‌خواهیم تصاویر را سریع و کارآمد به کاربر برسانیم و به سرورهای ناشر که ممکن است کند، شلوغ یا کاملاً از دسترس خارج باشند، وابسته نباشیم. علاوه بر این، می‌خواهیم کیفیت و اندازه تصاویر را استاندارد کنیم تا تجربه کاربری یکنواخت بماند.

حالا که داده در جریان است، باید آن را به کاربر برسانیم. برای این کار یک Feed Service اضافه می‌کنیم:

  • Client (مشتری): کاربران از طریق مرورگر یا اپلیکیشن موبایل با Google News کار می‌کنند و فید خبری را درخواست می‌دهند.
  • API Gateway (دروازه API): درخواست‌ها را مسیریابی می‌کند و پیش از ارسال به سرویس‌ها، احراز هویت، Rate Limiting (محدودسازی نرخ درخواست) و اعتبارسنجی را انجام می‌دهد.
  • Feed Service: درخواست‌های فید را با جست‌وجوی مقالات مرتبط بر اساس منطقه کاربر پاسخ می‌دهد و پاسخ را قالب‌بندی می‌کند.

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

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

  1. کلاینت یک GET به /feed?region=US&limit=20 می‌فرستد.
  2. API Gateway درخواست را به Feed Service هدایت می‌کند.
  3. Feed Service از پایگاه داده، مقالات اخیر منطقه کاربر را که بر اساس تاریخ انتشار مرتب شده‌اند، کوئری می‌کند.
  4. پایگاه داده داده مقالات را شامل Metadata و نشانی‌های رسانه (که به Object Storage اشاره می‌کنند) برمی‌گرداند.
  5. Feed Service پاسخ را قالب‌بندی می‌کند و از طریق API Gateway به کلاینت می‌فرستد.

2) اسکرول بی‌نهایت در فید

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

بر پایه معماری فعلی، Feed Service را برای پشتیبانی از صفحه‌بندی ساده مبتنی بر Offset، با شماره صفحه و اندازه صفحه، بهبود می‌دهیم.

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

  1. کلاینت GET /feed?region=US&limit=20&page=1 را می‌فرستد.
  2. Feed Service 20 مقاله اول منطقه کاربر را به ترتیب تاریخ انتشار کوئری می‌کند.
  3. پاسخ شامل مقالات و Metadata صفحه‌بندی (total_pages و current_page) است.
  4. کلاینت شماره صفحه فعلی را برای درخواست بعدی نگه می‌دارد.

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

  1. کلاینت خودکار GET /feed?region=US&limit=20&page=2 را می‌فرستد.
  2. Feed Service مقدار Offset را (صفحه منهای یک، ضرب‌در limit) حساب می‌کند و 20 مقاله بعدی را می‌گیرد.
  3. کوئری پایگاه داده مقالات را با OFFSET و LIMIT واکشی می‌کند.
  4. با ادامه اسکرول، این فرایند تکرار می‌شود.

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

3) کلیک روی مقاله و رفتن به سایت ناشر

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

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

در Google News واقعی، کلیک‌های مقاله برای فهم رفتار کاربر و بهبود پیشنهادها ردیابی می‌شود. ما این را خارج از محدوده می‌گذاریم، اما روش کار این است: لینک مقاله به یک endpoint ردیابی مثل GET /article/{article_id} اشاره می‌کند. این endpoint رویداد کلیک را ثبت می‌کند و با Redirect از نوع 302 کاربر را به سایت ناشر می‌فرستد. این داده‌های کلیک به آموزش الگوریتم‌های پیشنهاددهی و سنجش میزان تعامل کمک می‌کند.

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

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

تا این مرحله سیستمی پایه‌ای و کارآمد داریم که نیازمندی‌های عملکردی اصلی را پوشش می‌دهد: کاربران می‌توانند مقالات انبوه را ببینند، در فید بی‌نهایت اسکرول کنند و به سایت ناشر بروند. اما طراحی فعلی محدودیت‌های مهمی دارد، به‌ویژه در ثبات صفحه‌بندی و عملکرد تحویل فید در مقیاس بالا. با نگاه به نیازمندی‌های غیرعملکردی، بررسی می‌کنیم چطور سیستم را برای 100 میلیون DAU با Latency کم و توزیع جهانی بهبود بدهیم.

1) بهبود ثبات و کارایی صفحه‌بندی

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

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

رویکرد خوب: Cursor مبتنی بر مهر زمانی

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

1
2
3
WHERE published_at < cursor_timestamp
ORDER BY published_at DESC
LIMIT 20

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

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

رویکرد خوب: Cursor ترکیبی از مهر زمانی و شناسه

راه‌حل پیچیده‌تر، مهر زمانی و شناسه مقاله را برای ساخت یک Cursor یکتا و با ترتیب کامل ترکیب می‌کند. مثلاً Cursor ترکیبی مثل 2024-01-15T10:30:00Z_article123 می‌سازیم که هم مهر زمانی و هم شناسه یکتای مقاله را دارد. حتی وقتی چند مقاله مهر زمانی یکسان دارند، شناسه مقاله ترتیب کلی را تضمین می‌کند، چون نقش «تعیین‌کننده تساوی» (Tie-breaker) را بازی می‌کند.

کوئری پایگاه داده این‌طور می‌شود:

1
2
3
WHERE (published_at, article_id) < (cursor_timestamp, cursor_id)
ORDER BY published_at DESC, article_id DESC
LIMIT 20

این کوئری از قابلیت مقایسه چندستونی SQL برای مدیریت کارآمد ترتیب ترکیبی استفاده می‌کند. برای سریع ماندن کوئری‌ها حتی با میلیون‌ها مقاله، یک ایندکس ترکیبی روی (published_at, article_id) می‌سازیم.

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

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

رویکرد عالی: شناسه یکنواخت افزایشی به‌عنوان Cursor

راه‌حل ساده‌تری که به همین نتیجه می‌رسد، طراحی شناسه مقاله به‌صورت یکنواخت افزایشی (Monotonically Increasing) از همان ابتداست. به‌جای UUID تصادفی، می‌توانیم از UUID های مرتب‌شده بر اساس زمان (مثل ULID) یا شناسه‌های Auto-increment پایگاه داده استفاده کنیم که به‌طور طبیعی با هر مقاله جدید بزرگ‌تر می‌شوند. چون مقالات به‌ترتیب زمانی جمع‌آوری می‌شوند، مقالات جدیدتر همیشه شناسه بزرگ‌تری از قدیمی‌ترها دارند.

حالا صفحه‌بندی خیلی ساده می‌شود: فقط شناسه مقاله را به‌عنوان Cursor به کار می‌بریم.

1
2
3
WHERE article_id < cursor_id
ORDER BY article_id DESC
LIMIT 20

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

این روش مشکل برخورد مهر زمانی را کاملاً حذف می‌کند، چون هر مقاله فارغ از زمان انتشار، یک شناسه مرتب و یکتا می‌گیرد. پایگاه داده فقط به یک ایندکس ساده روی article_id نیاز دارد و کوئری‌ها تا حد ممکن سریع و ساده‌اند. بسیاری از سیستم‌های مدرن از ULID (شناسه یکتای جهانی و قابل‌مرتب‌سازی واژگانی) استفاده می‌کنند که مزایای UUID را با ترتیب زمانی ترکیب می‌کند.

چالش‌ها: محدودیت اصلی این است که استراتژی شناسه را باید از ابتدا در طراحی سیستم برنامه‌ریزی کنید. اگر الان از UUID تصادفی یا مهر زمانی به‌عنوان کلید اصلی استفاده می‌کنید، مهاجرت به شناسه‌های یکنواخت به جابه‌جایی دقیق داده نیاز دارد. همچنین باید مطمئن شوید سیستم تولید شناسه بدون ایجاد گلوگاه، Throughput (نرخ گذردهی) بالایی دارد. در سیستم‌های توزیع‌شده، برای حفظ ترتیب باید تولید شناسه را بین چند نمونه هماهنگ کنید، هرچند راه‌حل‌هایی مثل ULID یا سرویس‌های متمرکز تولید شناسه این کار را مؤثر انجام می‌دهند. با وجود این ملاحظات، این معمولاً ساده‌ترین و مؤثرترین راه‌حل برای داده‌های زمانی مثل فید خبری است.

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

2) رسیدن به Latency کم (زیر 200 میلی‌ثانیه) در درخواست‌های فید

طراحی سطح بالای ما برای هر درخواست فید مستقیم پایگاه داده را کوئری می‌کند و این در مقیاس بالا گلوگاه عملکردی می‌سازد. با 100 میلیون DAU که هرکدام شاید 5 تا 10 بار در روز فید را رفرش می‌کنند، روزانه 500 میلیون تا 1 میلیارد درخواست فید داریم. حتی با ایندکس مناسب، جست‌وجو در میلیون‌ها مقاله و فیلتر بر اساس منطقه برای هر درخواست، زمان پاسخ را بسیار بیشتر از هدف 200 میلی‌ثانیه می‌کند.

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

رویکرد خوب: کش فید منطقه‌ای در Redis با TTL

وقتی به درخواست‌های کم‌تأخیر فکر می‌کنیم، اولین چیزی که باید به ذهن برسد کش است.

مقالات اخیر هر منطقه را با TTL (زمان زنده‌ماندن) در Redis (یا هر کش دیگر) ذخیره می‌کنیم. برای هر منطقه یک کلید جدا داریم، مثل feed:US و feed:UK، و جدیدترین مقالات را به‌صورت Sorted Set (مجموعه مرتب) بر اساس مهر زمانی نگه می‌داریم. وقتی کاربر فید را می‌خواهد، اول Redis را بررسی می‌کنیم و فقط در صورت Cache Miss به پایگاه داده برمی‌گردیم. نکته مهم این است که TTL روی کل فید اعمال می‌شود، نه روی مقاله‌های جداگانه (در Sorted Set امکان TTL جدا برای هر عضو وجود ندارد).

TTL این ورودی‌های کش را 30 دقیقه می‌گذاریم تا کش به‌طور معقول تازه بماند و بار پایگاه داده هم به‌طور محسوسی کم شود. نرخ Cache Hit باید بسیار بالا باشد، چون بیشتر کاربران مقالات اخیر را می‌خواهند. پاسخ Redis هم معمولاً در حد چند میلی‌ثانیه است و سقف 200 میلی‌ثانیه برای کل مسیر درخواست است. این روش حجم درخواست بسیار بیشتری را با عملکرد قابل قبول برای اکثر کاربران تحمل می‌کند.

این همان الگوی کلاسیک Read-through است: در Cache Miss، فید منطقه را از پایگاه داده کوئری می‌کنیم، با TTL در Redis می‌نویسیم و به کاربر برمی‌گردانیم. درخواست‌های بعدی همان منطقه تا انقضا از کش جواب می‌گیرند.

چون از Sorted Set در Redis استفاده می‌کنیم، صفحه‌بندی هم هنوز خوب کار می‌کند. با دستور ZREVRANGEBYSCORE و مقدار Cursor می‌توانیم N مقاله بعد از یک امتیاز مشخص را بگیریم.

چالش‌ها: اگرچه این رویکرد بار پایگاه داده را به‌طور محسوسی کم می‌کند، Cache Miss هنوز به کوئری گران پایگاه داده نیاز دارد که می‌تواند نیازمندی Latency را نقض کند. TTL هم یعنی کاربران ممکن است تا 30 دقیقه مقاله‌های جدید را نبینند، که با نیاز تازگی در یک پلتفرم خبری سازگار نیست، چون در آن تحویل به‌موقع محتوا بسیار مهم است.

علاوه بر این، هنگام انقضای کش، همه کاربرانی که فید یک منطقه را می‌خواهند هم‌زمان به پایگاه داده می‌رسند و مشکل Thundering Herd (هجوم گله‌ای) پیش می‌آید. صدها کوئری گران هم‌زمان پایگاه داده را زیر فشار می‌گذارند و ممکن است عملکرد برای چند دقیقه افت کند تا کش دوباره پر شود. تجربه کاربری هم ناهماهنگ می‌شود: بعضی درخواست‌ها سریع‌اند (Cache Hit) و بعضی بسیار کند (Cache Miss یا زمان بازسازی کش).

رویکرد عالی: فید از پیش‌محاسبه‌شده با CDC

مؤثرترین راه‌حل، فید هر منطقه را با استفاده از Change Data Capture (CDC) از قبل محاسبه و ذخیره می‌کند تا به‌روزرسانی‌ها فوری باشند. فیدهای از پیش‌محاسبه‌شده را در Redis به‌صورت Sorted Set نگه می‌داریم که شناسه مقالات و Metadata ضروری را دارند و بر اساس منطقه سازمان‌دهی شده‌اند. وقتی مقاله جدیدی منتشر می‌شود، CDC بدون انتظار برای انقضای TTL، کش‌های منطقه‌ای مربوط را فوراً به‌روز می‌کند.

روند کار این است: سرویس جمع‌آوری، مقالات جدید را در پایگاه داده ذخیره می‌کند و این کار رویدادهای CDC را فعال می‌کند. Feed Generation Worker ها این رویدادها را مصرف می‌کنند و فوراً تعیین می‌کنند کدام فیدهای منطقه‌ای باید بر اساس منطقه و ارتباط مقاله به‌روز شوند. سپس مقاله جدید را با مهر زمانی‌اش به‌عنوان امتیاز به Sorted Set مناسب در Redis اضافه می‌کنند و ترتیب زمانی خودکار حفظ می‌شود.

چون TTL دیگر کاربردی ندارد، باید راه دیگری برای جلوگیری از رشد نامحدود کش پیدا کنیم. کاری که می‌توانیم بکنیم این است که در هر فید منطقه‌ای فقط جدیدترین N مقاله (معمولاً 1000 تا 2000) را نگه داریم. هنگام افزودن مقاله جدید، با ZADD مقاله را با امتیاز مهر زمانی‌اش درج می‌کنیم و بلافاصله با ZREMRANGEBYRANK و اندیس‌های منفی، قدیمی‌ترین مقاله‌های بیش از حد مجاز را حذف می‌کنیم. این کار اندازه هر کش منطقه‌ای را قابل مدیریت نگه می‌دارد و هنوز محتوای کافی برای پیمایش چند صفحه‌ای کاربر دارد.

برای درخواست‌های فید، فقط از Sorted Set های از پیش‌محاسبه‌شده با ZREVRANGE می‌خوانیم که در کمتر از 5 میلی‌ثانیه تمام می‌شود. اگر به Metadata کامل مقاله نیاز داشته باشیم، می‌توانیم آن را مستقیم داخل کش بگذاریم یا با شناسه مقاله از یک کش ثانویه واکشی کنیم. این رویکرد زمان پاسخ زیر 200 میلی‌ثانیه را پایدار تضمین می‌کند و تازگی محتوا را هم لحظه‌ای می‌کند.

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

3) ظاهرشدن مقالات در فید ظرف 30 دقیقه پس از انتشار

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

بیشتر اوقات که این سؤال پرسیده می‌شود، به‌خصوص از داوطلبان Mid-level یا Junior، مصاحبه‌کننده می‌خواهد خط لوله جذب (Ingestion Pipeline) را «جعبه سیاه» فرض کنید. من ترجیح می‌دهم اینجا به آن بپردازم، چون از داوطلبان Senior معمولاً بیشتر پرسیده می‌شود که این بخش دست‌کم در سطح بالا چطور پیاده می‌شود. ببینیم چطور می‌توانیم زمان کشف را به‌شدت کاهش بدهیم.

رویکرد خوب: افزایش تناوب Poll کردن RSS با زمان‌بندی هوشمند

ساده‌ترین راه، افزایش چشمگیر تناوب Poll کردن RSS همراه با زمان‌بندی هوشمند بر اساس ویژگی ناشر است. به‌جای بررسی همه فیدها هر 3 تا 6 ساعت، یک سیستم Poll کردن لایه‌ای پیاده می‌کنیم:

  • ناشران با اولویت بالا (شبکه‌های خبری بزرگ و منابع اخبار فوری) هر 5 تا 10 دقیقه
  • منابع با اولویت متوسط هر 30 دقیقه
  • منابع با اولویت پایین (مجله‌های هفتگی و نشریات تخصصی) هر ساعت

سرویس جمع‌آوری، اولویت ناشر را در پایگاه داده نگه می‌دارد و الگوی تاریخی انتشار هر منبع را ردیابی می‌کند. ناشرانی مثل CNN، BBC یا Reuters که روزانه ده‌ها مقاله منتشر می‌کنند و اغلب خبرهای مهم را اول منتشر می‌کنند، «اولویت بالا» طبقه‌بندی می‌شوند و به صف Poll سریع می‌روند. برای هر لایه اولویت، Worker های جدا داریم. Worker های اولویت بالا حلقه‌های Poll مداوم اجرا می‌کنند و بین چرخه‌ها فقط 5 تا 10 دقیقه می‌خوابند.

روند کار:

  1. Worker های اولویت بالا از پایگاه داده، ناشران علامت‌خورده «اولویت بالا» را می‌گیرند و فید RSS آن‌ها را هر 5 تا 10 دقیقه با یک GET روی نشانی فید Poll می‌کنند.
  2. وقتی مقاله جدید شناسایی شد (با مقایسه GUID مقاله یا مهر زمانی انتشار با پایگاه داده)، بلافاصله وارد خط لوله جذب محتوا می‌شود.
  3. Worker ها هدرهای Last-Modified و ETag پاسخ‌های RSS را ردیابی می‌کنند تا اگر فید تغییر نکرده بود، پردازش بی‌مورد انجام ندهند.

چالش‌ها: این کار چالش‌های زیرساختی و هزینه‌ای قابل توجهی دارد. با بیش از 10 هزار ناشر که منابع اولویت بالا هر 5 تا 10 دقیقه Poll می‌شوند، به‌جای 2000 تا 3000 درخواست HTTP در ساعت، بیش از 100 هزار درخواست می‌فرستیم. این هزینه سرور را به‌طور محسوسی بالا می‌برد و ممکن است سرور ناشران کوچک‌تر را با درخواست زیاد زیر فشار بگذارد و حتی IP های ما مسدود شود.

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

رویکرد خوب: وب‌اسکرپینگ

برای گرفتن محتوا از ناشرانی که RSS ندارند یا به‌ندرت آن را به‌روز می‌کنند، باید از Web Scraping استفاده کنیم. یعنی برنامه‌نویسی‌شده وب‌سایت‌های خبری را می‌خوانیم و مقالات جدید را مستقیم از ساختار HTML استخراج می‌کنیم. سیستم اسکرپینگ پایگاه داده‌ای از الگوهای وب‌سایت و انتخابگرهای CSS برای شناسایی محتوای مقاله در سایت‌های خبری اصلی نگه می‌دارد.

زیرساخت اسکرپینگ شبیه تحلیل مسئله Web Crawler (خزنده وب) است. خزنده‌ای داریم که به صفحه اصلی و صفحات دسته‌بندی ناشر می‌رود و دنبال لینک مقالات جدید می‌گردد، یعنی عناصری با کلاس‌هایی مثل «article-title» یا «story-link» یا «news»، و نشانی‌ها، عنوان‌ها و مهر زمانی انتشار را استخراج می‌کند. بیشتر جزئیات را در نمودار انتزاعی می‌کنیم، چون تمرکز اصلی این مسئله نیست.

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

مثل رویکرد RSS، به زمان‌بند هوشمند نیاز داریم: سایت‌های خبری پربازدید هر 10 تا 15 دقیقه اسکرپ می‌شوند و سایت‌های کوچک‌تر شاید فقط ساعتی یک‌بار. به این ترتیب تازگی RSS را داریم و پوشش وب را هم به دست می‌آوریم.

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

رویکرد عالی: Webhook (مدل Push)

راه‌حل بهینه، مدل را از Pull به Push تغییر می‌دهد و Webhook پیاده می‌کند، یعنی ناشران بلافاصله بعد از انتشار محتوای جدید به ما خبر می‌دهند. اگر فرض کنیم واقعاً 100 میلیون DAU داریم، ناشران باید برای قرارگرفتن مقالاتشان روی پلتفرم ما تلاش کنند.

می‌توانیم یک endpoint وب‌هوک در POST /webhooks/article-published بسازیم که ناشران در لحظه انتشار مقاله، آن را صدا بزنند و Metadata مقاله یا حتی کل محتوا را بفرستند. به این ترتیب به‌جای اینکه ما دنبال مقاله جدید بگردیم، به ناشر اعتماد می‌کنیم که خودش به ما بگوید.

زیرساخت Webhook یک endpoint با دسترس‌پذیری بالا دارد که می‌تواند جهش ترافیک را هنگام انتشار خبرهای مهم و اطلاع‌رسانی هم‌زمان چند ناشر تحمل کند. این endpoint محتوای ورودی Webhook را اعتبارسنجی می‌کند، داده مقاله را استخراج می‌کند و بلافاصله آن را برای پردازش در خط لوله جذب استاندارد ما در صف می‌گذارد. برای جلوگیری از هرزنامه و اطمینان از اصالت محتوا، باید احراز هویت Webhook را با Shared Secret یا API Key پیاده کنیم.

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

همچنان RSS Polling و اسکرپینگ را به‌عنوان جایگزین برای ناشرانی که Webhook پشتیبانی نمی‌کنند نگه می‌داریم. نتیجه یک سیستم ترکیبی است که هر جا ممکن باشد به‌روزرسانی بلادرنگ می‌دهد و در جاهای دیگر Poll منظم انجام می‌دهد.

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

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

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

4) مدیریت کارآمد محتوای رسانه‌ای (تصویر و ویدیو)

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

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

رویکرد بد: ذخیره تصویر در پایگاه داده

تصاویر کوچک را مستقیم در پایگاه داده به‌صورت داده باینری کنار Metadata مقاله ذخیره می‌کنیم. هنگام جمع‌آوری، تصویر اصلی را دانلود می‌کنیم، اندازه‌اش را به ابعاد تصویر کوچک (مثلاً 300x200) تغییر می‌دهیم و داده باینری را در پایگاه داده می‌گذاریم.

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

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

رویکرد خوب: ذخیره در S3

تصاویر کوچک را در Amazon S3 ذخیره می‌کنیم و در پایگاه داده فقط به نشانی آن‌ها ارجاع می‌دهیم. هنگام جمع‌آوری مقاله، تصویر اصلی را دانلود می‌کنیم، یک تصویر کوچک بهینه برای وب (مثلاً 300x200) می‌سازیم، در S3 آپلود می‌کنیم و نشانی S3 را در Metadata مقاله ذخیره می‌کنیم.

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

چالش‌ها: کاربران جهانی هنگام بارگذاری تصاویر از Region های دور S3 تأخیر بالایی می‌بینند. هزینه Egress (خروج داده) از S3 با میلیون‌ها نمایش تصویر کوچک در روز بالا می‌رود. همچنین از تراکم‌های مختلف صفحه نمایش (Retina در برابر نمایشگر استاندارد) یا اتصال‌های شبکه کند پشتیبانی نمی‌شود.

رویکرد عالی: S3 به‌همراه CDN و چند اندازه تصویر

برای جلوگیری از تأخیر زیاد کاربران جهانی، از CDN برای تحویل تصاویر کوچک استفاده می‌کنیم. تصاویر را در S3 نگه می‌داریم و از طریق CloudFront CDN در سراسر جهان توزیع می‌کنیم. چند اندازه تصویر کوچک می‌سازیم (150x100 برای موبایل، 300x200 برای دسکتاپ و 600x400 برای نمایشگرهای Retina) و اجازه می‌دهیم CDN بر اساس دستگاه و تراکم نمایشگر، نسخه مناسب را تحویل بدهد.

CloudFront تصاویر را در Edge Location های سراسر جهان کش می‌کند و بارگذاری زیر 200 میلی‌ثانیه را در سطح جهانی ممکن می‌کند.

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

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

5) مدیریت جهش ترافیک در اخبار فوری

رویدادهای خبری فوری ترافیک عظیمی می‌سازند که می‌تواند روش‌های مقیاس‌دهی سنتی را از کار بیندازد. وقتی رویدادهای مهمی مثل انتخابات، بلایای طبیعی یا اخبار چهره‌های مشهور رخ می‌دهد، ترافیک عادی ما از 100 میلیون DAU ممکن است ظرف چند دقیقه به 10 میلیون کاربر هم‌زمان برسد. همه هم‌زمان آخرین به‌روزرسانی‌ها را می‌خواهند و این یک طوفان کامل از ترافیک خواندن می‌سازد که می‌تواند سیستم‌های ناآماده را از کار بیندازد.

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

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

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

فرض می‌کنیم هر استقرار منطقه‌ای باید 10 میلیون کاربر هم‌زمان را تحمل کند. حالا هر جزء طراحی را بررسی می‌کنیم: منابع لازم در اوج چقدر است، آیا طراحی فعلی نیاز را برآورده می‌کند، و اگر نه، چطور آن را مقیاس بدهیم؟

سرویس فید (لایه اپلیکیشن)

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

راه‌حل، مقیاس‌دهی افقی با Auto Scaling Group است. چند نمونه از Feed Service را پشت Load Balancer مستقر می‌کنیم و با Auto Scaling ابری، وقتی مصرف CPU یا حافظه از آستانه مشخصی گذشت، نمونه‌های جدید خودکار ساخته می‌شوند. با Load Balancing مناسب، 10 میلیون درخواست بین ده‌ها نمونه سرور اپلیکیشن پخش می‌شود و هرکدام بخش قابل مدیریتی از ترافیک را تحمل می‌کند.

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

لایه پایگاه داده

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

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

لایه کش (Redis)

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

راه‌حل Read Replica است. هر Redis Master منطقه‌ای چند Replica خواندنی می‌گیرد تا بار درخواست پخش شود. چون در هر منطقه فقط 2000 مقاله اخیر داریم، هر Master به‌راحتی می‌تواند کل محتوای منطقه را بدون Sharding (شاردینگ / افراز داده‌ها) پیچیده نگه دارد. چالش مقیاس فقط توان عملیاتی خواندن است.

اگر از Redis استفاده نکنم چه؟ جای نگرانی نیست، مفهوم همان است. از Consistent Hashing (هشینگ پایدار) برای شارد کردن داده بین چند نمونه استفاده کنید و مطمئن شوید هر نمونه یک یا دو Replica برای تحمل بار خواندن و خطا دارد.

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

پیکربندی ساده است: عملیات نوشتن، مثل مقالات جدید و به‌روزرسانی کش، به Master می‌روند و عملیات خواندن برای درخواست‌های فید، با الگوریتم‌هایی مثل Round-robin یا Least Connections بین همه Replica ها توزیع می‌شوند. با Redis Sentinel که کلاستر را مدیریت می‌کند، اگر Master خراب شود یکی از Replica ها خودکار به Master ارتقا می‌یابد. تأخیر Replication در Redis معمولاً در حد بازه کوتاه (زیر 200 میلی‌ثانیه) است، که برای فید خبری کاملاً قابل قبول است، چون کاربران چنین تأخیر کوچکی را متوجه نمی‌شوند.

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

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

بخش‌های اضافی بررسی عمیق

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

6) پشتیبانی از فید مبتنی بر دسته‌بندی (ورزش، سیاست، فناوری و غیره)

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

Google News بیش از 25 دسته نمایش می‌دهد و هر کدام روزانه صدها مقاله دارند. با 100 میلیون کاربر روزانه، ممکن است در ساعات اوج تا 10 میلیون درخواست برای دسته‌های مشخص داشته باشیم، مثلاً ورزش در فصل بازی‌ها، سیاست در انتخابات یا فناوری هنگام عرضه محصولات بزرگ. ساختار کش منطقه‌ای فعلی ما نمی‌تواند این فیلتر دقیق‌تر را کارآمد مدیریت کند.

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

رویکرد خوب: فیلتر دسته در پایگاه داده

ساده‌ترین راه‌حل، افزودن ستون category به جدول مقالات و فیلتر بر اساس دسته در زمان واقعی در Feed Service است. وقتی کاربر /feed?region=US&category=sports را می‌خواهد، سرویس این کوئری را اجرا می‌کند:

1
2
3
4
SELECT * FROM articles
WHERE region = 'US' AND category = 'sports'
ORDER BY published_at DESC
LIMIT 20

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

Feed Service با منطق فیلتر دسته بهبود می‌یابد. API انعطاف‌پذیرتر می‌شود و درخواست‌هایی مثل /feed?region=US&category=sports یا /feed?region=UK&category=technology را پشتیبانی می‌کند. کوئری پایگاه داده از یک ایندکس ترکیبی روی (region, category, published_at) استفاده می‌کند تا فیلتر و مرتب‌سازی کارآمد باشد.

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

کش‌کردن هم مشکل‌ساز می‌شود، چون نمی‌توانیم نتایج دسته‌بندی را مؤثر کش کنیم. هر ترکیب دسته و منطقه مدیریت کش جداگانه می‌خواهد. با 25 دسته در 10 منطقه، 250 کلید کش مختلف داریم که هرکدام الگوی ابطال و حجم ترافیک متفاوتی دارند. Cache Miss گران می‌شود، چون برای فیلتر دسته مشخص کوئری پایگاه داده را فعال می‌کند.

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

رویکرد مقیاس‌پذیرتر، فید هر ترکیب دسته و منطقه را از قبل محاسبه می‌کند و در Sorted Set های Redis ذخیره می‌کند. به‌جای فیلتر هم‌زمان پایگاه داده، Sorted Set های جداگانه‌ای مثل feed:sports:US، feed:politics:UK و feed:technology:CA نگه می‌داریم که مقالات از پیش فیلترشده و مرتب را دارند.

این معماری روی کش فید منطقه‌ای موجود ساخته می‌شود و جزئیات را گسترش می‌دهد. هنگام جذب مقاله، Feed Generation Worker ها هر مقاله را دسته‌بندی می‌کنند و چند Sorted Set را هم‌زمان به‌روز می‌کنند. وقتی مقاله ورزشی در آمریکا منتشر می‌شود، با شناسه مقاله به‌عنوان عضو و مهر زمانی به‌عنوان امتیاز، هم به feed:US (فید منطقه‌ای) و هم به feed:sports:US (فید مخصوص دسته) اضافه می‌شود.

درخواست‌های فید خیلی سریع می‌شوند، چون عملیات Sorted Set در Redis هستند. درخواست /feed?region=US&category=sports&limit=20 به ZREVRANGE feed:sports:US 0 19 ترجمه می‌شود که در کمتر از 5 میلی‌ثانیه تمام می‌شود. کاربران حتی در جهش ترافیک و مرور هم‌زمان دسته‌های خاص توسط میلیون‌ها نفر، پیوسته زمان پاسخ زیر 200 میلی‌ثانیه می‌گیرند.

چالش‌ها: محدودیت اصلی مصرف حافظه و پیچیدگی مدیریت کش است. با 25 دسته در 10 منطقه، به‌جای 10 مجموعه مرتب، بیش از 250 مجموعه مرتب جداگانه داریم. فید هر دسته تقریباً 1000 تا 2000 مقاله دارد که نیاز حافظه Redis را در مقایسه با فیدهای منطقه‌ای ساده به‌طور محسوسی بالا می‌برد.

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

رویکرد عالی: فیلتر دسته در حافظه روی کش منطقه‌ای

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

هنگام ذخیره مقالات در feed:US، به‌جای ذخیره فقط شناسه مقاله به‌عنوان عضو، Metadata کامل مقاله را به‌صورت رشته JSON ذخیره می‌کنیم. هر مقاله کش‌شده همه اطلاعات لازم برای فیلتر دسته را دارد: عنوان، توضیح، نشانی، دسته، منطقه و مهر زمانی انتشار. یک ورودی کش معمولی این شکل است:

1
2
3
4
5
6
7
8
9
{
  "id": "123",
  "title": "NBA Finals Game 7 Results",
  "description": "Warriors defeat Celtics in thrilling finale",
  "url": "https://espn.com/nba/finals/game7",
  "category": "sports",
  "region": "US",
  "published_at": "2024-06-21T22:30:00Z"
}

وقتی کاربر فید یک دسته مشخص مثل /feed?region=US&category=sports را می‌خواهد، Feed Service با ZREVRANGE feed:US 0 999 کل کش منطقه‌ای را می‌خواند تا 1000 مقاله اخیر را بگیرد. سپس این داده را در حافظه فیلتر می‌کند و فقط مقالاتی را که category === "sports" هستند نگه می‌دارد و به اندازه صفحه درخواستی برمی‌گرداند.

منطق فیلتر ساده و سریع است. خواندن 1000 مقاله کش‌شده از Redis کمتر از 10 میلی‌ثانیه طول می‌کشد و فیلتر در حافظه اپلیکیشن فقط 1 تا 2 میلی‌ثانیه به زمان پردازش اضافه می‌کند. برای دسته‌هایی که حضور مناسبی دارند، به‌راحتی می‌توانیم 20 تا 50 مقاله مرتبط را از کش منطقه‌ای بدون مراجعه به پایگاه داده پیدا کنیم.

این رویکرد حداقل تغییر در معماری فعلی می‌خواهد. خط لوله CDC ما همین الان کش‌های منطقه‌ای را پر می‌کند و فقط باید قالب داده ذخیره‌شده را طوری تغییر بدهیم که Metadata دسته را شامل شود. Feed Service هم با یک منطق فیلتر ساده که نتایج کش‌شده را پیش از صفحه‌بندی پردازش می‌کند، بهبود می‌یابد.

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

این مثالی است که نشان می‌دهد بهترین راه‌حل اغلب ساده‌ترین راه‌حل است.

7) تولید فید شخصی‌سازی‌شده بر اساس رفتار و ترجیحات کاربر

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

تابع رتبه‌بندی و امتیازدهی واقعی معمولاً یک مدل یادگیری ماشین است، اما برای هدف ما می‌توانیم آن را انتزاعی کنیم. در نهایت این مصاحبه MLE (مهندس یادگیری ماشین) نیست!

رویکرد بد: امتیازدهی بلادرنگ هنگام درخواست

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

پیاده‌سازی شامل ردیابی رفتار کاربر (کلیک، زمان خواندن، اشتراک‌گذاری) و ترجیحات صریح (موضوعات دنبال‌شده، ناشران مورد علاقه) است. هنگام درخواست فید، یک سرویس پیشنهاددهی مقالات اخیر را با عواملی مثل تطابق موضوع، اولویت ناشر و تازگی محتوا امتیاز می‌دهد. Collaborative Filtering (فیلتر مشارکتی) الگوهایی مثل «کاربرانی که A و B را خوانده‌اند با C هم تعامل دارند» را شناسایی می‌کند تا محتوای مشابه نشان بدهد.

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

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

فیدهای شخصی‌شده را برای کاربران فعال در Sorted Set های Redis مثل feed:user:12345 از قبل محاسبه می‌کنیم. Worker های پس‌زمینه با ورود مقالات جدید، این فیدها را مدام به‌روز می‌کنند و محتوا را به‌جای ترتیب زمانی، بر اساس ارتباط با علایق و الگوی خواندن هر کاربر امتیاز می‌دهند.

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

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

چالش‌ها: نیاز حافظه با کش اختصاصی برای میلیون‌ها کاربر بالا می‌رود. ذخیره 1000 مقاله برای هر کاربر در 50 میلیون کاربر فعال یعنی 50 میلیارد ورودی کش، که 200 تا 500 برابر بزرگ‌تر از کش دسته‌بندی است. کهنه‌شدن کش وقتی علایق سریع تغییر می‌کنند مشکل تجربه کاربری می‌سازد و فیدهای شخصی ممکن است خبرهای مهم جهانی را که همه باید ببینند از دست بدهند.

رویکرد عالی: بردار ترجیح سبک و ترکیب پویا فیدهای دسته‌بندی

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

برای مثال، کاربر علاقه‌مند به فناوری فیدی می‌گیرد که از 60 درصد feed:technology:US، 30 درصد feed:business:US و 10 درصد feed:trending:US ساخته شده است. الگوریتم ترکیب از بردار ترجیح کاربر برای تعیین نسبت‌های بهینه استفاده می‌کند. در اخبار فوری، سیستم وزن محتوای پرطرفدار (Trending) را موقتاً بالا می‌برد و در عین حال ترجیحات شخصی را حفظ می‌کند.

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

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

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