scraping// distributed-crawler-redis-celery

طراحی خزنده توزیعشده با Redis، فیلتر بلوم (Bloom Filter) و صفهای مقیاسپذیر Celery

معماری سیستمهای خزش در مقیاس صدها میلیون URL با استفاده از ساختار داده احتمالی Bloom Filter برای کاهش ۹۸ درصدی مصرف رم و مدیریت نرخ درخواست اتمیک در Redis.

احسان شهبازی·۱۴۰۴/۰۷/۲۸·۱۳ دقیقه مطالعه
// SBU Data Science Lab

چالشهای معماری در خزش مقیاس میلیونی (URL Frontier)

در یک خزنده توزیعشده (Distributed Crawler) که صدها ورکر به صورت موازی لینکهای جدید را کشف و پیمایش میکنند، مدیریت URL Frontier (مرز خزش) پاشنه آشیل سیستم است. اگر برای جلوگیری از خزش تکراری لینکها (Deduplication) از یک ساختار داده SET معمولی در Redis استفاده کنید، ذخیره ۱۰۰ میلیون آدرس URL با میانگین طول ۸۰ بایت به همراه سربار اشارهگرهای جدول هش Redis، بیش از ۹ گیگابایت رم مصرف خواهد کرد.

علاوه بر مصرف حافظه، نبود هماهنگی متمرکز بین ورکرها باعث میشود دهها ورکر به صورت همزمان به یک دامنه واحد هجوم برده و در چند ثانیه کل رنج IPهای سرور را مسدود (Ban) کنند.

حذف تکراریها با پیچیدگی حافظه ثابت توسط فیلتر بلوم (Bloom Filter)

فیلتر بلوم (Bloom Filter) یک ساختار داده احتمالی فوقالعاده بهینه است که روی یک آرایه بیتی به طول mm و kk تابع هش مستقل کار میکند. زمانی که یک URL جدید وارد میشود، خروجی kk تابع هش محاسبه شده و بیتهای متناظر در آرایه برابر با 1 قرار میگیرند:

  • تضمین صفر بودن False Negative: اگر فیلتر بلوم اعلام کند که یک URL دیده نشده است، با قطعیت ۱۰۰ درصد آن لینک جدید است.
  • نرخ قابل تنظیم False Positive: با پذیرش احتمال خطای ناچیز ۰.۱ درصد (یعنی از هر ۱۰۰۰ لینک جدید، ۱ لینک به اشتباه تکراری تشخیص داده شود)، فضای مورد نیاز برای ۱۰۰ میلیون URL از ۹ گیگابایت به حدود ۱۷۱ مگابایت کاهش مییابد (کاهش ۹۸ درصدی مصرف رم!).
  • عملیات اتمیک با پیچیدگی $O(k)$: زمان بررسی و درج مستقل از تعداد کل عناصر موجود در فیلتر است.

اولویتبندی صفها، مدیریت نرخ درخواست (Rate Limiting) و نرمالسازی URL

پیش از ارسال هر آدرس به فیلتر بلوم، باید فرآیند URL Canonicalization انجام شود: حذف پارامترهای ردیابی مانند utm_source و fbclid، تبدیل حروف دامنه به حروف کوچک، حذف اسلش انتهایی و مرتبسازی الفبایی Query Parameterها. بدون این مرحله، یک صفحه واحد با پارامترهای نامرتب بارها خزش خواهد شد.

کنترل نرخ درخواست توزیعشده با اسکریپت Lua در Redis

برای رعایت سیاست Politeness و جلوگیری از ارسال بیش از NN درخواست در ثانیه به هر دامنه توسط کل کلاستر، نمیتوان از دستورهای جداگانه GET و INCR در پایتون استفاده کرد؛ زیرا بین دو دستور، شرایط رقابتی (Race Condition) رخ میدهد. اجرای اسکریپت Lua داخل هسته Redis اتمیک بودن بررسی و افزایش شمارنده را تضمین میکند.

پیادهسازی موتور خزش توزیعشده با Celery و Redis

در کد زیر، معماری کامل شامل نرمالسازی URL، فیلتر بلوم مبتنی بر Bitfield در Redis، محدودکننده نرخ اتمیک با Lua و صفهای مجزای Celery پیادهسازی شده است:

distributed_crawler.py
python
import hashlib
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
import redis
from celery import Celery

redis_client = redis.Redis(host='localhost', port=6379, db=0)
celery_app = Celery('distributed_crawler', broker='redis://localhost:6379/1')

# Atomic Token-Bucket / Window Rate Limiter in Lua
LUA_RATE_LIMIT_SCRIPT = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
    redis.call('PEXPIRE', key, window_ms)
end
if current > limit then
    return 0
end
return 1
"""
rate_limiter = redis_client.register_script(LUA_RATE_LIMIT_SCRIPT)

class RedisBloomDeduplicator:
    def __init__(self, key: str = 'crawler:bloom', bit_size: int = 1 << 30, hash_count: int = 7):
        self.key = key
        self.bit_size = bit_size
        self.hash_count = hash_count

    @staticmethod
    def canonicalize_url(url: str) -> str:
        parsed = urlparse(url.strip())
        filtered_query = sorted(
            [(k, v) for k, v in parse_qsl(parsed.query) if not k.startswith(('utm_', 'fbclid', 'gclid'))]
        )
        clean_path = parsed.path.rstrip('/') or '/'
        return urlunparse((parsed.scheme.lower(), parsed.netloc.lower(), clean_path, '', urlencode(filtered_query), ''))

    def _get_bit_offsets(self, canonical_url: str):
        digest = hashlib.sha256(canonical_url.encode('utf-8')).digest()
        h1 = int.from_bytes(digest[:8], 'big')
        h2 = int.from_bytes(digest, 'big')
        return [(h1 + i * h2) % self.bit_size for i in range(self.hash_count)]

    def check_and_add(self, url: str) -> bool:
        """Returns True if URL is new, False if already seen."""
        clean_url = self.canonicalize_url(url)
        offsets = self._get_bit_offsets(clean_url)
        pipe = redis_client.pipeline()
        for offset in offsets:
            pipe.setbit(self.key, offset, 1)
        previous_bits = pipe.execute()
        return not all(previous_bits)

deduplicator = RedisBloomDeduplicator()

@celery_app.task(bind=True, queue='crawl_frontier', max_retries=5)
def crawl_url_task(self, url: str):
    domain = urlparse(url).netloc
    allowed = rate_limiter(keys=[f'ratelimit:{domain}'], args=[5, 1000])
    if not allowed:
        # Re-queue with jitter delay if domain rate limit is exceeded
        raise self.retry(countdown=1.5)

    # Perform HTTP fetch and extract child links here
    discovered_links = [f'{url}/page-1', f'{url}/page-2']
    for link in discovered_links:
        if deduplicator.check_and_add(link):
            crawl_url_task.apply_async(args=[link], queue='crawl_frontier')

نکته کلیدی مهندسی: در معماری Celery برای خزش وب، هرگز کار دانلود شبکه (I/O-Bound) و کار پردازش و استخراج سنگین HTML/JSON (CPU-Bound) را در یک صف واحد قرار ندهید. ورکرهای دانلود را با استخر --pool=gevent و همزمانی بالا اجرا کنید و خروجی خام را به صف دیگری بفرستید که توسط ورکرهای --pool=prefork پردازش میشوند.