پست

طراحی Web Crawler

طراحی یک خزنده وب برای جمع‌آوری متن ۱۰ میلیارد صفحه در کمتر از ۵ روز

طراحی Web Crawler

درک مسئله

Web Crawler (خزنده وب) برنامه‌ای است که با دانلود صفحات وب و دنبال کردن لینک‌ها از یک صفحه به صفحه‌ی دیگر، وب را به‌صورت خودکار پیمایش می‌کند. از آن برای ایندکس کردن وب در موتورهای جستجو، جمع‌آوری داده برای پژوهش، یا پایش تغییرات وب‌سایت‌ها استفاده می‌شود.

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

در این مسئله خزنده‌ای طراحی می‌کنیم که متن صفحات وب را برای آموزش یک LLM جمع می‌کند. شرکت‌هایی مثل OpenAI برای GPT-4، گوگل برای Gemini و Meta برای LLaMA از چنین سیستمی استفاده می‌کنند.

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

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

  • خزیدن وب با شروع از مجموعه‌ای مشخص از URLهای اولیه (Seed).
  • استخراج متن از هر صفحه و ذخیره‌ی آن برای پردازش‌های بعدی.

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

  • پردازش واقعی داده‌ی متنی (مثلاً آموزش LLM).
  • مدیریت داده‌های غیرمتنی مثل تصویر و ویدیو.
  • مدیریت محتوای پویا (مثلاً صفحات رندرشده با جاوااسکریپت).
  • مدیریت احراز هویت (مثلاً صفحاتی که نیاز به ورود دارند).

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

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

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

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

معمولاً توصیه می‌شود با محاسبات سرانگشتی (Back-of-the-envelope) شروع کنید. ولی من پیشنهاد می‌کنم این محاسبات را تا زمانی که برای حل یک مسئله‌ی مشخص لازم نشده عقب بیندازید. این کار از محاسبات بی‌مورد جلوگیری می‌کند و تمرکز شما را روی بخش‌هایی نگه می‌دارد که مستقیم روی راه‌حل اثر دارند. در Deep Dive‌ها نمونه‌هایی از زمان مناسب برای تخمین می‌بینید. هر روشی که انتخاب کنید، با مصاحبه‌کننده هم‌نظر باشید.

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

  • Fault Tolerance (تحمل خطا): مدیریت شکست‌ها و ادامه‌ی خزیدن بدون از دست رفتن پیشرفت.
  • ادب (Politeness): رعایت robots.txt و پرهیز از فشار بیش از حد به سرور وب‌سایت‌ها.
  • کارایی: خزیدن وب در کمتر از ۵ روز.
  • Scalability: پشتیبانی از ۱۰ میلیارد صفحه.

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

  • امنیت و محافظت از سیستم در برابر عوامل مخرب.
  • هزینه‌ی راه‌اندازی و رعایت محدودیت بودجه.
  • رعایت الزامات قانونی و مقررات حریم خصوصی.

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

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

رابط سیستم

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

  • ورودی: URLهای Seed برای شروع خزیدن.
  • خروجی: داده‌ی متنی استخراج‌شده از صفحات وب.

جریان داده

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

  1. یک URL را از Frontier بگیر و IP آن را از DNS بپرس.
  2. با استفاده از IP، HTML را از سرور خارجی دریافت کن.
  3. متن را از HTML استخراج کن.
  4. متن را در پایگاه داده ذخیره کن.
  5. URLهای لینک‌شده در صفحه را استخراج کن و به فهرست URLهای در انتظار خزیدن اضافه کن.
  6. مراحل ۱ تا ۵ را تا خزیده شدن همه‌ی URLها تکرار کن.

این جریان عمداً ساده است. هرچه جلوتر برویم کامل‌ترش می‌کنیم، ولی شروع ساده و گسترش تدریجی روش درستی است.

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

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

اجزای اصلی:

  • Frontier Queue (صف مرزی): صف URLهایی که باید خزیده شوند. با مجموعه‌ای از URLهای Seed شروع می‌کنیم و در طول خزیدن URLهای جدید اضافه می‌شوند. فناوری آن می‌تواند Kafka، Redis یا SQS باشد که بعداً انتخاب می‌کنیم.
  • Crawler (خزنده): صفحه را دریافت می‌کند، متن را استخراج می‌کند و URLهای جدید را برای افزودن به Frontier بیرون می‌کشد. در بخش بعد درباره مقیاس‌دهی آن برای ۱۰ میلیارد صفحه صحبت می‌کنیم.
  • DNS: نام دامنه را به IP تبدیل می‌کند تا خزنده بتواند صفحه را دریافت کند. بحث‌های جالبی درباره Cache کردن جستجوهای DNS، مدیریت خطاهای DNS و جلوگیری از فشار بیش از حد به سرورهای DNS وجود دارد که بعداً به آن‌ها می‌رسیم.
  • Web Page (صفحه‌ی وب): سرور خارجی که صفحات را میزبانی می‌کند. HTML را از این سرورها می‌گیریم و متن را استخراج می‌کنیم.
  • S3 Text Data: محل ذخیره‌ی متن استخراج‌شده. S3 را به‌عنوان Blob Storage انتخاب می‌کنیم چون بسیار مقیاس‌پذیر و بادوام است و برای نگهداری ارزان حجم زیادی از داده ساخته شده است. Google Cloud Storage یا Azure Blob Storage هم گزینه‌های مشابه‌اند.

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

بهتر است درباره URLهای Seed از مصاحبه‌کننده بپرسید: آن‌ها را در اختیار شما می‌گذارند یا خودتان باید پیدا کنید؟ تقریباً همیشه در اختیار شما قرار می‌گیرند، ولی این سؤال نشان می‌دهد مسئله را کامل در نظر گرفته‌اید. اگر لازم شد خودتان تهیه کنید، می‌توانید از محبوب‌ترین موتورهای جستجو، سایت‌های خبری، شبکه‌های اجتماعی یا فهرست‌های وب (Web Directory) شروع کنید.

Deep Dive های احتمالی

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

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

۱) چطور تحمل خطا داشته باشیم و پیشرفت را از دست ندهیم؟

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

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

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

مراحل پیشنهادی:

  • URL Fetching (دریافت URL): HTML صفحه را از سرور خارجی می‌گیرد. اگر مشکلی پیش بیاید، می‌توانیم فقط همین مرحله را دوباره اجرا کنیم. HTML خام را برای پردازش بعدی در Blob Storage ذخیره می‌کنیم.
  • Text and URL Extraction (استخراج متن و URL): متن را از HTML بیرون می‌کشد و URLهای لینک‌شده را برای افزودن به Frontier استخراج می‌کند. این‌که استخراج متن و URL باید دو مرحله‌ی جدا باشند محل بحث است، ولی هر دو کار ساده‌اند و می‌توانند با هزینه‌ی کم و موازی انجام شوند. من برای سادگی طراحی آن‌ها را در یک مرحله ادغام می‌کنم.

اگر در سؤال پردازش داده بودید، اولین فکر شما باید شکستن سیستم به مراحل کوچک‌تر در قالب Pipeline باشد. Pipelining اجازه می‌دهد شکست‌ها را در یک مرحله ایزوله کنید و همان مرحله را بدون از دست رفتن پیشرفت بقیه‌ی داده دوباره امتحان کنید.

برای این کار به یک State اضافی نیاز داریم. یک Metadata DB اضافه می‌کنیم (DynamoDB انتخاب خوبی است، PostgreSQL یا MySQL هم کار می‌کند) با جدولی برای URLهایی که دریافت و پردازش شده‌اند. در ابتدا این جدول لینک محل ذخیره‌ی HTML و لینک محل ذخیره‌ی متن را در Blob Storage نگه می‌دارد. این مهم است چون ذخیره‌ی HTML خام داخل خودِ صف یک Anti-pattern است. صف‌ها برای Payload حجیم بهینه نشده‌اند و ذخیره‌ی HTML در آن‌ها گران تمام می‌شود. به‌جای آن، پیام صف فقط شناسه‌ی URL در Metadata DB است.

نکته‌ی مهم این است که این طراحی علاوه بر Fault Tolerance، در برابر تغییر نیازها هم مقاوم است. فرض کنید تیم ML که این داده را مصرف می‌کند بخواهد روش استخراج متن را عوض کند، مثلاً متن جایگزین (Alt Text) تصاویر را هم وارد کند. چون مرحله‌ی استخراج متن جداست، می‌توانیم تابع استخراج را بدون تکرار بخش گران دریافت صفحات تعویض کنیم.

اگر دریافت URL شکست بخورد چه می‌شود؟

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

رویکرد اول، Timer در حافظه: ساده‌ترین (و بدترین) راه این است که با یک Timer در حافظه چند ثانیه صبر کنیم و دوباره امتحان کنیم. این روش علاوه بر مشکل ادب که بعداً می‌آید، قوی نیست، چون اگر خزنده از کار بیفتد Timer را از دست می‌دهیم. همچنین بسیار محتمل است که چند ثانیه بعد هم دریافت موفق نشود. باید هوشمندتر باشیم و نوعی Exponential Backoff پیاده کنیم.

رویکرد دوم، Retry دستی با Kafka: Kafka به‌صورت آماده از Retry پشتیبانی نمی‌کند، ولی می‌توانیم خودمان پیاده‌اش کنیم. یک Topic جدا برای URLهای ناموفق و یک سرویس جدا که از آن می‌خواند و با Exponential Backoff دوباره امتحان می‌کند. برای تعیین مدت انتظار، زمان دریافت بعدی را در خود پیام ذخیره می‌کنیم. وقتی Consumer پیام را می‌خواند، زمان را بررسی می‌کند و اگر در آینده بود تا آن موقع صبر می‌کند. این کار می‌کند ولی پیاده‌سازی و نگهداری آن پیچیده است. خوشبختانه سرویس‌هایی هستند که این کار را آماده انجام می‌دهند.

رویکرد سوم، SQS: SQS از Retry با Exponential Backoff قابل‌تنظیم به‌صورت آماده پشتیبانی می‌کند و نیازی به نوشتن منطق Retry نیست. پیام‌هایی که پردازش نمی‌شوند در پایان هر Visibility Timeout (پیش‌فرض ۳۰ ثانیه) دوباره امتحان می‌شوند و این مهلت پس از هر Retry به‌صورت نمایی بیشتر می‌شود: ۳۰ ثانیه، ۲ دقیقه، ۵ دقیقه و تا ۱۵ دقیقه. این استراتژی پردازش پیام‌ها را بدون فشار آوردن به سیستم کارآمدتر می‌کند.

البته نمی‌خواهیم تا ابد تلاش کنیم. برای جلوگیری از تأخیرهای بیش از حد، رایج است که Backoff نمایی را به یک سقف محدود کنیم. بعد از تعداد مشخصی شکست (که با ApproximateReceiveCount تعیین می‌شود)، پیام به Dead Letter Queue یا DLQ می‌رود و غیرقابل پردازش در نظر گرفته می‌شود. برای مسئله‌ی ما، بعد از ۵ بار Retry سایت را آفلاین فرض می‌کنیم و دیگر نمی‌خزیمش.

اگر خزنده از کار بیفتد چه می‌شود؟

پاسخ ساده است: یک نمونه‌ی جدید بالا می‌آوریم. فقط باید مطمئن شویم URLی که نیمه‌کاره مانده گم نمی‌شود.

خبر خوب این است که URL تا وقتی تأیید نشود که یک خزنده آن را دریافت کرده و HTML در Blob Storage ذخیره شده، در صف می‌ماند. پس اگر خزنده‌ای بیفتد، خزنده‌ی دیگری URL را برمی‌دارد. مکانیزم دقیق این کار در هر فناوری فرق دارد. دو فناوری محبوب را در سطح بالا بررسی می‌کنیم:

  • Apache Kafka: پیام‌ها را در یک Log نگه می‌دارد و حتی پس از خوانده شدن حذف نمی‌کند. خزنده‌ها پیشرفتشان را با Offset دنبال می‌کنند و تا وقتی URL با موفقیت دریافت و پردازش نشده، Offset جلو نمی‌رود. اگر خزنده بیفتد، خزنده‌ی بعدی از همان جایی که قبلی متوقف شده ادامه می‌دهد و داده‌ای از بین نمی‌رود.
  • SQS: پیام‌ها تا وقتی صریحاً حذف نشوند در صف می‌مانند. Visibility Timeout پیام را بعد از دریافت از دید خزنده‌های دیگر پنهان می‌کند. اگر خزنده پیش از تأیید پردازش بیفتد، پیام بعد از انقضای Timeout خودکار دوباره دیده می‌شود و خزنده‌ی دیگری می‌تواند آن را بگیرد. وقتی HTML در Blob Storage ذخیره شد، خزنده پیام را از صف حذف می‌کند و دیگر پردازش نمی‌شود.

همین موضوع برای Parser Workerها هم صادق است. URL تا وقتی تأیید نشود که پردازش شده و متن در Blob Storage ذخیره شده، در صف می‌ماند.

با توجه به پشتیبانی داخلی SQS از Retry و Exponential Backoff و سادگی پیکربندی Visibility Timeout، برای سیستم خودمان SQS را انتخاب می‌کنیم.

در انتخاب فناوری معمولاً جواب درست یا غلطی وجود ندارد. مهم Trade-offها و توانایی شما در توجیه تصمیم است. اگر جزئیات نحوه‌ی مدیریت Retry در Kafka یا SQS را نمی‌دانید هم مشکلی نیست؛ احتمالاً اینجا جای مناسبی برای عمیق شدن نیست.

۲) چطور ادب و رعایت robots.txt را تضمین کنیم؟

اول ببینیم ادب و robots.txt چیست.

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

robots.txt فایلی است که وب‌سایت‌ها برای ارتباط با خزنده‌ها استفاده می‌کنند. در این فایل مشخص است کدام صفحات مجاز یا غیرمجاز برای خزیدن‌اند و خزنده‌ها هر چقدر می‌توانند سایت را بخزند. نمونه‌ای از این فایل:

1
2
3
User-agent: *
Disallow: /private/
Crawl-delay: 10

خط User-agent مشخص می‌کند قوانین برای کدام خزنده است. اینجا * یعنی همه‌ی خزنده‌ها. خط Disallow صفحاتی را که نباید خزید مشخص می‌کند، که در اینجا هر چیز داخل مسیر /private/ است. خط Crawl-delay تعداد ثانیه‌هایی را که خزنده باید بین دو درخواست صبر کند تعیین می‌کند، که اینجا ۱۰ ثانیه است.

برای تضمین ادب و رعایت robots.txt دو کار باید انجام دهیم:

  • احترام به robots.txt: پیش از خزیدن هر صفحه باید فایل robots.txt را بررسی کنیم. اگر اجازه نداریم، صفحه را رد می‌کنیم. دستور Crawl-delay را هم رعایت می‌کنیم و بین درخواست‌ها به‌اندازه‌ی مشخص‌شده صبر می‌کنیم.
  • Rate Limiting (محدودسازی نرخ درخواست): تعداد درخواست به هر دامنه را محدود می‌کنیم. استاندارد صنعت حداکثر ۱ درخواست در ثانیه برای هر دامنه است.

با احترام به robots.txt شروع می‌کنیم. باید فایل robots.txt هر دامنه‌ای را که می‌خزیم ذخیره کنیم. در مصاحبه ممکن است بحث شود که چطور به‌روزرسانی‌های این فایل را مرتب بررسی کنید. برای سادگی فرض می‌کنیم یک‌بار دانلود می‌شود.

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

  • آیا خزنده مجاز به خزیدن صفحه است؟ ساده است. دستورهای Disallow را بررسی می‌کنیم. اگر صفحه غیرمجاز بود، پیام را تأیید (Ack) می‌کنیم تا از صف حذف شود و به URL بعدی می‌رویم.
  • چقدر باید بین درخواست‌ها صبر کنیم؟ این کمی پیچیده‌تر است. باید Crawl-delay را بررسی کنیم و بین درخواست‌ها به‌اندازه‌ی مشخص‌شده صبر کنیم.

برای مدیریت Crawl-delay باید State دیگری اضافه کنیم. یک جدول Domain به Metadata DB اضافه می‌کنیم که آخرین زمان خزیدن هر دامنه را نگه می‌دارد. اگر URLی از صف بیرون بیاید که دامنه‌اش را همین چند لحظه پیش، در بازه‌ی Crawl-delay، خزیده‌ایم، آن را با پارامتر DelaySeconds در SQS دوباره وارد صف می‌کنیم تا بعداً به آن برگردیم.

مراحل به این ترتیب است:

  1. robots.txt دامنه را دریافت کن.
  2. آن را تجزیه (Parse) و در Metadata DB ذخیره کن.
  3. وقتی URLی از صف بیرون آمد، قوانین ذخیره‌شده‌ی دامنه را در Metadata DB بررسی کن.
  4. اگر URL مجاز نبود، پیام را Ack کن و به URL بعدی برو.
  5. اگر مجاز بود، Crawl-delay را بررسی کن.
  6. اگر از آخرین خزیدن به‌اندازه‌ی Crawl-delay نگذشته، URL را با DelaySeconds دوباره وارد صف کن تا زمان در دسترس شدنش مشخص شود.
  7. اگر زمان کافی گذشته، صفحه را بخز و زمان آخرین خزیدن دامنه را به‌روز کن.

Rate Limiting چطور؟

باید محدودیت نرخ ۱ درخواست در ثانیه برای هر دامنه را هم رعایت کنیم. با چندین خزنده این کمی پیچیده‌تر می‌شود، چون در تئوری هر N خزنده می‌توانند هم‌زمان به یک دامنه درخواست بدهند.

می‌توانیم یک مکانیزم Rate Limiting سراسری و مخصوص دامنه با استفاده از یک Data Store متمرکز (مثل Redis) بسازیم که تعداد درخواست‌ها به هر دامنه در هر ثانیه را ثبت کند. هر خزنده پیش از درخواست این Store را بررسی می‌کند تا از حد مجاز رد نشده باشد. برای ردیابی تعداد درخواست‌ها در هر ثانیه از الگوریتم Sliding Window استفاده می‌کنیم. اگر از حد رد شده باشد، خزنده تا ثانیه‌ی بعد صبر می‌کند.

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

راه‌حل نسبتاً ساده‌ای وجود دارد: Jitter. با افزودن مقدار کمی تصادف به الگوریتم، از رفتار همگام بین خزنده‌ها جلوگیری می‌کنیم. این کار با افزودن تأخیر تصادفی به درخواست هر خزنده انجام می‌شود تا همه هم‌زمان دوباره تلاش نکنند.

۳) چطور به ۱۰ میلیارد صفحه مقیاس بدهیم و در کمتر از ۵ روز بخزیمشان؟

من معمولاً پیشنهاد می‌کنم بحث مقیاس را برای انتهای مصاحبه نگه دارید. آن موقع تصویر کامل‌تری از سیستم دارید و می‌توانید آگاهانه‌تر تصمیم بگیرید کجا مقیاس بدهید. مثلاً اگر مقیاس را در اولین Deep Dive بررسی می‌کردیم، ممکن بود گلوگاه‌هایی مثل Parser Workerها را که در Deep Dive‌های بعدی آمدند از دست بدهیم.

چون مقیاس‌پذیری و کارایی به هم مرتبط‌اند، این دو نیازمندی را با هم بررسی می‌کنیم.

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

برای پاسخ، باید بدانیم این کار I/O-bound است. با میانگین اندازه‌ی صفحه‌ی ۲ مگابایت که در نیازمندی‌ها گرفتیم، می‌توانیم تخمین بزنیم پهنای باند ما کجا محدود می‌شود.

در AWS یک Instance بهینه‌شده برای شبکه می‌تواند حدود ۴۰۰ گیگابیت بر ثانیه را مدیریت کند. یعنی از نظر شبکه، یک Instance می‌تواند حدود ( 400 / 8 / 2 ) برابر ۲۵ هزار صفحه در ثانیه را پوشش دهد. این عدد عظیمی است ولی در عمل به‌احتمال زیاد قابل‌دستیابی نیست.

هیچ راهی نیست که از این پهنای باند به‌طور کامل استفاده کنیم. محدودیت‌های عملی روی تعداد درخواست در ثانیه وجود دارد، مثل Latency پاسخ سرور، حل DNS، Rate Limiting، ادب و Retry. اینجا خیلی تقریبی حرف می‌زنیم، ولی فرض کنیم بتوانیم ۳۰٪ از پهنای باند را مصرف کنیم. آن‌وقت ( 25000 \times 0.3 = 7500 ) صفحه در ثانیه خواهیم داشت.

برای تخمین تعداد ماشین‌های لازم، کل صفحات را بر تعداد صفحه‌ای که در ثانیه می‌خزیم تقسیم می‌کنیم: ( 10^{10} / 7500 \approx 1{,}333{,}333 ) ثانیه، یعنی حدود ۱۵٫۴ روز برای یک ماشین. این مقیاس خطی است، پس با تقسیم بر تعداد ماشین‌ها زمان کل به دست می‌آید: ( 15.4 / 4 \approx 3.85 ) روز با ۴ ماشین. این زمان نیاز ۵ روزه‌ی ما را برآورده می‌کند.

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

Parser Workerها چطور؟

این بخش آسان‌تر است، چون کار نسبتاً ساده است: HTML را از Blob Storage بگیرند، متن را استخراج کنند و دوباره در Blob Storage ذخیره کنند. باید مطمئن شویم این بخش با خزنده‌ها هم‌گام است. به‌جای تخمین تعداد لازم، می‌توانیم آن را به‌صورت پویا بر اساس تعداد صفحات در صف پردازش بالا و پایین ببریم. این کار با Lambda، ECS Task یا هر فناوری Serverless دیگری شدنی است.

DNS را فراموش نکنید

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

  • DNS Caching: جستجوهای DNS را در خزنده‌ها Cache می‌کنیم تا تعداد درخواست‌های DNS کم شود. این‌طور همه‌ی URLهای یک دامنه از همان جستجو استفاده می‌کنند.
  • چند ارائه‌دهنده‌ی DNS: از چند ارائه‌دهنده به‌صورت Round-robin استفاده می‌کنیم. این کار بار را بین آن‌ها پخش می‌کند و خطر رسیدن به محدودیت نرخ را کم می‌کند.

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

حالا روی کارایی تمرکز می‌کنیم. برای بیشترین کارایی باید وقتمان را صرف خزیدن صفحاتی نکنیم که قبلاً خزیده‌ایم.

اول می‌توانیم پیش از وارد کردن URL به صف، Metadata DB را بررسی کنیم که آیا قبلاً خزیده شده است یا نه. اگر بله، از آن رد می‌شویم. این بهینه‌سازی ساده زمان زیادی صرفه‌جویی می‌کند.

اما وقتی URLهای مختلف به یک صفحه اشاره می‌کنند چه؟ این در وب رایج است. مثلاً http://example.com و http://www.example.com ممکن است یک صفحه باشند. حتی دامنه‌های کاملاً متفاوت هم گاهی دقیقاً محتوای یکسان دارند (واقعیتی شاید ناخوشایند درباره اینترنت). برای این موارد نمی‌توانیم فقط URLها را مقایسه کنیم، بلکه باید Hash محتوا را مقایسه کنیم. دو گزینه داریم.

گزینه اول، Hash محتوا در Metadata DB: محتوای صفحه را Hash می‌کنیم و در جدول URL در Metadata DB ذخیره می‌کنیم. وقتی URL جدیدی را دریافت کردیم، محتوایش را Hash می‌کنیم و با Hashهای موجود مقایسه می‌کنیم. اگر مطابقت داشت، صفحه را رد می‌کنیم. برای سریع بودن جستجو، روی ستون Hash یک Index می‌سازیم تا Hash جدید به‌سرعت پیدا شود.

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

گزینه دوم، Bloom Filter: Bloom Filter یک ساختار داده‌ی احتمالاتی است که اجازه می‌دهد بررسی کنیم عنصری عضو یک مجموعه هست یا نه. این ساختار می‌تواند قطعاً بگوید عنصری در مجموعه نیست، ولی در تأیید عضویت فقط به‌صورت احتمالی جواب می‌دهد. این برای کاربرد ما مناسب است. Hash محتوای صفحات خزیده‌شده را در Bloom Filter نگه می‌داریم. وقتی URL جدیدی را دریافت می‌کنیم، محتوا را Hash می‌کنیم و Bloom Filter را بررسی می‌کنیم. اگر Hash در آن نبود، قطعاً این صفحه را قبلاً نخزیده‌ایم. اگر بود، احتمالاً قبلاً خزیده‌ایم و می‌توانیم رد شویم. از نظر فناوری، Redis یک ساختار Bloom Filter داخلی دارد که می‌توانیم برای این منظور استفاده کنیم.

چالش: Bloom Filter می‌تواند False Positive داشته باشد، یعنی بگوید صفحه‌ای را خزیده‌ایم در حالی که واقعاً نخزیده‌ایم. می‌توان استدلال کرد این Trade-off برای مزیت عملکردی قابل قبول است و با بزرگ‌تر کردن Filter و افزایش تعداد توابع Hash، احتمال False Positive را کم کرد.

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

آخرین مورد، Crawler Trap

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

راه‌حل ساده است: برای خزنده‌ها حداکثر عمق (Max Depth) تعیین می‌کنیم. یک فیلد depth به جدول URL در Metadata DB اضافه می‌کنیم و هر بار که لینکی را دنبال می‌کنیم آن را یکی زیاد می‌کنیم. اگر عمق از آستانه‌ای بیشتر شد، خزیدن آن صفحه را متوقف می‌کنیم. این کار نمی‌گذارد در Crawler Trap گیر کنیم.

Deep Diveهای اضافی

نمی‌توانم همه‌چیز را در این راهنما پوشش دهم. چند Deep Dive دیگر که می‌توانید خودتان بررسی کنید:

  • مدیریت محتوای پویا: خیلی از وب‌سایت‌ها با فریم‌ورک‌های جاوااسکریپتی مثل React یا Angular ساخته شده‌اند. یعنی محتوای صفحه در HTML بازگشتی از سرور نیست و جاوااسکریپت آن را به‌صورت پویا بارگذاری می‌کند. برای این کار باید از یک Headless Browser مثل Puppeteer برای رندر صفحه و استخراج محتوا استفاده کنیم.
  • پایش سلامت سیستم: باید سلامت سیستم را پایش کنیم تا مطمئن شویم همه‌چیز درست کار می‌کند. می‌توانیم از سرویس‌های مانیتورینگ مثل Datadog یا New Relic برای نظارت بر عملکرد خزنده‌ها و Parser Workerها استفاده کنیم و در صورت بروز مشکل هشدار بگیریم.
  • مدیریت فایل‌های حجیم: بعضی وب‌سایت‌ها فایل‌های بسیار بزرگی دارند که شاید نخواهیم دانلود کنیم. می‌توانیم قبل از دانلود با هدر Content-Length اندازه‌ی فایل را بسنجیم و فایل‌های خیلی بزرگ را رد کنیم.
  • به‌روزرسانی‌های مستمر: نیازمندی ما خزیدن یک‌باره است، ولی ممکن است بخواهیم به‌روز نگه داشتن مداوم داده را هم در نظر بگیریم، مثلاً چون می‌خواهیم مدل را هر ماه دوباره آموزش دهیم یا چون خزنده برای یک موتور جستجو است. پیشنهاد می‌کنم یک مؤلفه‌ی جدید به نام URL Scheduler اضافه کنید که مسئول زمان‌بندی URLها برای خزیدن است. به‌جای این‌که Parser Workerها مستقیم URLها را وارد صف کنند، آن‌ها را در Metadata DB ثبت می‌کنند و URL Scheduler بر اساس معیارهایی مثل زمان آخرین خزیدن و محبوبیت، تصمیم می‌گیرد کدام URLها دوباره خزیده شوند.

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

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

Mid-level:

  • عرض در برابر عمق: نامزد این سطح بیشتر روی عرض تمرکز می‌کند (حدود ۸۰٪ عرض در برابر ۲۰٪ عمق). باید بتوانید طراحی سطح بالایی بسازید که نیازمندی‌های عملکردی تعریف‌شده را برآورده کند، ولی با خیلی از اجزا فقط در حد آشنایی سطحی کار می‌کنید.
  • بررسی مبانی: مصاحبه‌کننده مدتی را صرف بررسی مبانی می‌کند تا مطمئن شود می‌دانید هر جزء چه کاری می‌کند. مثلاً اگر صف اضافه کنید، ممکن است بپرسد صف چه می‌کند و در سطح بالا چطور کار می‌کند. به عبارتی، مصاحبه‌کننده هیچ دانشی را بدیهی فرض نمی‌کند.
  • ترکیب راننده‌بودن و سرنشین‌بودن: در مراحل ابتدایی باید خودتان مصاحبه را هدایت کنید، ولی انتظار نمی‌رود مشکلات طراحی را با دقت بالا تشخیص دهید. به همین دلیل مصاحبه‌کننده در مراحل بعدی، هنگام بررسی طرح شما، هدایت را به عهده می‌گیرد.
  • انتظار برای Web Crawler: نامزد سطح E4 باید جریان داده‌ی سطح بالا را بفهمد و سیستم ساده‌ای (مثل طراحی سطح بالای ما) بسازد که بتواند وب را کارآمد بخزد. باید بتواند درباره اصول ادب و رعایت robots.txt بحث کند. باید ایده‌ای از مقیاس‌دهی سیستم داشته باشد، ولی لزوماً انتظار نمی‌رود وارد جزئیات فناوری‌های صف و Rate Limiting شود.

Senior:

  • عمق تخصص: انتظارات به دانش عمیق‌تر تغییر می‌کند، حدود ۶۰٪ عرض و ۴۰٪ عمق. باید در حوزه‌هایی که تجربه‌ی عملی دارید وارد جزئیات فنی شوید و درک عمیقی از مفاهیم و فناوری‌های کلیدی مسئله نشان دهید.
  • طراحی سیستم پیشرفته: باید با اصول طراحی پیشرفته‌ی سیستم آشنا باشید. مثلاً دانستن نحوه‌ی کار صف‌ها یا Caching ضروری است. مصاحبه‌کننده می‌داند که چیزهای ابتدایی (REST API، نرمال‌سازی داده و مانند آن) را بلدید، پس سریع از آن‌ها عبور کنید تا برای بخش‌های جالب وقت بماند.
  • تشریح تصمیم‌های معماری: باید مزایا و معایب انتخاب‌های معماری را به‌روشنی توضیح دهید، به‌ویژه اثرشان روی Scalability، عملکرد و قابلیت نگهداری. تصمیم‌هایتان را توجیه می‌کنید و Trade-offها را توضیح می‌دهید.
  • حل مسئله و ابتکار عمل: باید مهارت قوی حل مسئله و رویکرد فعال نشان دهید، یعنی چالش‌های احتمالی طرح را پیش‌بینی کنید و بهبود پیشنهاد دهید. باید در شناسایی و رفع گلوگاه‌ها، بهینه‌سازی عملکرد و تضمین قابلیت اطمینان سیستم مسلط باشید.
  • انتظار برای Web Crawler: نامزد Senior باید بتواند درباره طراحی سطح بالا صحبت کند و بعد وارد جزئیات ادب و رعایت robots.txt شود. باید درباره مقیاس‌دهی سیستم و خزیدن کارآمد صفحات در بازه‌ی زمانی ۵ روزه بحث کند.

Staff و بالاتر:

  • تأکید بر عمق: از نامزد Staff+ انتظار می‌رود وارد ظرافت‌های طراحی سیستم شود. من حدود ۴۰٪ عرض و ۶۰٪ عمق می‌خواهم. ایده این است که حتی اگر این مسئله‌ی خاص را قبلاً حل نکرده‌اید، آن‌قدر مسئله‌ی واقعی حل کرده‌اید که بتوانید با تکیه بر تجربه‌تان با اطمینان راه‌حل طراحی کنید.
  • کاربرد عملی فناوری: باید بدانید از چه فناوری‌هایی استفاده کنید، نه فقط در تئوری بلکه در عمل، و بتوانید از تجربه‌های گذشته توضیح دهید چطور برای حل مسائل مشخص به کار می‌روند. تجربه‌ی شما باید گفتگو را هدایت کند و درک روشنی از پیکربندی ابزارها در سناریوهای واقعی نشان دهد.
  • فعال‌بودن در سطح بالا: در این سطح، ابتکار عمل در سطح استثنایی انتظار می‌رود. باید مسائل را مستقل شناسایی و حل کنید، چالش‌های اصلی طراحی را بشناسید و نه فقط به مشکلات پاسخ دهید، بلکه آن‌ها را پیش‌بینی کنید و راه‌حل پیشگیرانه بیاورید. مصاحبه‌کننده فقط برای تمرکز دادن مداخله می‌کند، نه هدایت.
  • حل مسئله‌ی پیچیده و تصمیم‌گیری: مهارت حل مسئله‌ی شما باید در سطح بالا باشد. نه فقط چالش‌های فنی پیچیده را حل کنید، بلکه تصمیم‌هایی آگاهانه بگیرید که Scalability، عملکرد، قابلیت اطمینان و نگهداری را در نظر می‌گیرد.
  • طراحی پیشرفته و Scalability: رویکرد شما باید روی مقیاس‌پذیری و قابلیت اطمینان، به‌ویژه تحت بار بالا، متمرکز باشد. این یعنی درک کامل سیستم‌های توزیع‌شده، Load Balancing، استراتژی‌های Caching و دیگر مفاهیم پیشرفته برای ساخت سیستم‌های قوی و مقیاس‌پذیر.
  • انتظار برای Web Crawler: از نامزد Staff+ انتظار عمق و کیفیت بالا در راه‌حل‌ها، به‌ویژه برای سناریوهای پیچیده‌ای که بحث شد، می‌رود. نامزدهای عالی دست‌کم در بیش از ۳ حوزه‌ی کلیدی عمیق می‌شوند و نه‌تنها مهارت، بلکه تفکر نوآورانه و توانایی یافتن راه‌حل بهینه را نشان می‌دهند. یکی از شاخص‌های مهم سطح Staff+، میزان بینش و دانشی است که نامزد به بحث می‌آورد. معیار خوب این است که مصاحبه‌کننده با درک یا دیدگاه تازه‌ای از گفتگو بیرون برود. اگر همه‌ی Deep Dive‌های بالا را پوشش دهید (حتی نه با همین عمق)، در موقعیت خوبی هستید.