cat high-concurrency-on-a-small-budget.md

روزی ۲۰ میلیون صفحه با ۵۰۰ پراکسی

· 4 min read · asyncio · kafka · scaling

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

این نوشته درباره‌ی همان محدودیت است — نه درباره‌ی مقیاس‌پذیری به معنای «سرور بیشتر اضافه کن»، بلکه درباره‌ی حالتی که نمی‌توانی سرور بیشتری اضافه کنی.

اول، حساب سرانگشتی

پیش از هر خط کد، عدد را باز کنیم:

20,000,000 pages / 86,400 s   =  231 req/s   (sustained)
       231 req/s / 500 proxies =  0.46 req/s  (per proxy)

یعنی روزانه حدود ۲۳۱ درخواست در ثانیه به‌صورت پایدار، که روی ۵۰۰ پراکسی می‌شود کمتر از نیم درخواست در ثانیه برای هر کدام.

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

پس هدف از «بیشترین سرعت ممکن» به «کمترین واریانس ممکن» تغییر کرد.

چرا thread جواب نمی‌داد

کار ما تقریباً تماماً I/O-bound بود: منتظر ماندن پشت شبکه. با مدل thread-per-request، برای نگه‌داشتن چند هزار درخواست هم‌زمان به چند هزار ترد نیاز داشتیم و هزینه‌ی حافظه و context switch آن با منابعی که داشتیم جور در نمی‌آمد.

با asyncio و aiohttp، همان چند هزار اتصال هم‌زمان روی یک پروسه با مصرف حافظه‌ای در حد چند صد مگابایت قابل نگه‌داری بود. یک پروسه به‌ازای هر هسته، و همین.

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

پراکسی به‌عنوان منبع کمیاب

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

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

class ProxyLease:
    def __init__(self, url: str, max_inflight: int, min_gap: float):
        self.url = url
        self._sem = asyncio.Semaphore(max_inflight)
        self._min_gap = min_gap
        self._next_free = 0.0
        self.score = 1.0

    @asynccontextmanager
    async def acquire(self):
        async with self._sem:
            now = time.monotonic()
            if now < self._next_free:
                await asyncio.sleep(self._next_free - now)
            try:
                yield self.url
            finally:
                self._next_free = time.monotonic() + self._min_gap

روی این ساختار یک امتیازدهی ساده نشست: هر پاسخ موفق امتیاز پراکسی را کمی بالا می‌برد، هر خطای ۴۲۹ یا تایم‌اوت آن را پایین می‌آورد. پراکسی با امتیاز پایین مدتی کنار گذاشته می‌شد (circuit breaking) به‌جای اینکه با تکرار درخواست، بلاک شدنش قطعی شود.

این تغییر، بیش از هر بهینه‌سازی دیگری، نرخ خطا را پایین آورد — چون جلوی «تلاش مضاعف روی چیزی که خراب است» را می‌گرفت.

فشار برگشتی، یا چرا صف باید کران‌دار باشد

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

دو تصمیم این را حل کرد:

اول، هر صف داخلی کران‌دار شد. یک asyncio.Queue با maxsize مشخص. وقتی مصرف‌کننده عقب می‌ماند، put بلاک می‌شود و فشار به‌صورت طبیعی به عقب — تا خودِ زمان‌بند پراکسی — منتقل می‌شود. سیستم کند می‌شود، ولی نمی‌میرد.

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

نکته‌ی دوم بعداً بیش از آنچه فکر می‌کردیم به کار آمد.

چند تصمیم کوچک که اثر بزرگ داشتند

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

نوشتن دسته‌ای. نوشتن تک‌تک رکوردها در MongoDB، خط تولید را به دیتابیس گره می‌زد. با bulk_write در دسته‌های چندهزارتایی، هم توان عملیاتی بالا رفت و هم نوسان تأخیر کم شد.

Redis به‌عنوان حافظه‌ی کوتاه‌مدت. وضعیت «چه چیزی به‌تازگی دیده شده» و قفل‌های سبک آنجا بود، با TTL. هیچ‌وقت به‌عنوان منبع حقیقت استفاده نشد — فقط برای جلوگیری از کار تکراری.

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

delay = min(base * 2 ** attempt, cap) * (0.5 + random.random())

چه چیزی را اندازه می‌گرفتیم

معیار اصلی «تعداد صفحه در ثانیه» نبود. آن عدد فریبنده است — با یک موج خطای سریع هم بالا می‌رود.

معیارها اینها بودند:

  • نرخ پاسخ موفق در ساعت، نه نرخ درخواست
  • توزیع تأخیر در صدک‌های ۵۰ و ۹۵ و ۹۹
  • تعداد پراکسی سالم در هر لحظه
  • عمق صف در هر مرحله (بهترین شاخص هشدار زودهنگام)
  • تازگی داده: فاصله‌ی زمانی میان تغییر واقعی و ثبت آن

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

آنچه یاد گرفتم

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

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

و کند شدن، تقریباً همیشه، پاسخ درست است.

معماری

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

cd ..

بازگشت به صفحه‌ی اصلی