Database Engineering

غلبه بر افزایش ترافیک: راهنمای فنی برای جلوگیری از طغیان کش ریدیس

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

برای توسعه‌دهندگان سطح متوسط تا پیشرفته، تنظیم زمان انقضای کوتاه به تنهایی به دلیل نیاز برنامه‌های مدرن به نرخ برخورد (Hit-rate) بالا، راه‌حل مناسبی نیست. در عوض، باید راهبردهای محکمی را برای کاهش این خطر پیاده‌سازی کنیم. در این مقاله، دو تکنیک مکمل را بررسی خواهیم کرد: قفل‌گذاری توزیع‌شده و انقضای احتمالی زودهنگام.

آناتومی طغیان کش

برای حل این مشکل، ابتدا باید مکانیسم آن را درک کنیم. فرض کنید صفحه‌ای محبوب دارای TTL (زمان تا انقضا) کش به مدت ۶۰ ثانیه است. در لحظه t=60s، کلید منقضی می‌شود. اگر ۱۰,۰۰۰ کاربر دقیقاً در آن میلی‌ثانیه به این صفحه درخواست دهند، تمام ۱۰,۰۰۰ رشته (Thread) کش را بررسی کرده، چیزی پیدا نمی‌کنند و به سمت پایگاه داده حرکت می‌کنند. حتی اگر پایگاه داده شما بتواند ۱,۰۰۰ کوئری در ثانیه را پردازش کند، ۱۰,۰۰۰ کوئری همزمان باعث ایجاد صف می‌شود و ممکن است برنامه را از کار بیندازد.

هدف این است که اطمینان حاصل کنیم تنها یک درخواست مسئول بازسازی داده‌هاست و سایر درخواست‌ها منتظر می‌مانند یا نسخه قدیمی‌تری را دریافت می‌کنند.

راهبرد ۱: قفل‌گذاری توزیع‌شده با Redlock

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

هنگام پیاده‌سازی این روش در Redis، استفاده از عملیات اتمی برای جلوگیری از شرایط مسابقه (Race Conditions) حیاتی است. دستور SET با پرچم‌های NX (تنظیم اگر وجود ندارد) و EX (انقضا) روش استاندارد برای پیاده‌سازی ایمن قفل است.

import redis

def get_data_with_lock(redis_client, key, data_fetcher):
    # تلاش برای کسب قفل با NX و انقضا برای جلوگیری از قفل‌شدگی مرده
    # EX زمان TTL را بر حسب ثانیه تنظیم می‌کند و اطمینان حاصل می‌کند که اگر فرآیند کرش کند، قفل آزاد شود
    if redis_client.set(lock_key, "locked", nx=True, ex=5):
        try:
            # تنها یک رشته وارد این بخش می‌شود
            data = data_fetcher()
            redis_client.set(key, data, ex=60) # تنظیم TTL کش
            return data
        finally:
            # آزادسازی قفل
            redis_client.delete(lock_key)
    else:
        # رشته دیگری در حال به‌روزرسانی کش است
        # گزینه الف: انتظار و تلاش مجدد (مسدودکننده)
        # گزینه ب: بازگرداندن کش قدیمی یا خطا (غیر مسدودکننده)
        return redis_client.get(key)

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

راهبرد ۲: انقضای احتمالی زودهنگام

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

همچنین می‌توانیم یک مکانیسم «بازسازی تنبل» (Lazy Refresh) را پیاده‌سازی کنیم. وقتی یک کلید دسترسی پیدا می‌کند، بررسی می‌کنیم که آیا به انقضا نزدیک است یا خیر (مثلاً در ۱۰٪ آخر TTL آن). اگر چنین باشد، یک رشته یا وظیفه پس‌زمینه را برای به‌روزرسانی داده‌ها به صورت ناهمگام راه‌اندازی می‌کنیم، در حالی که درخواست فعلی داده‌های موجود (کمی قدیمی) را برمی‌گرداند.

import time
import random

def get_data_probabilistic(redis_client, key, data_fetcher):
    data = redis_client.get(key)
    
    if not data:
        # خطای کش: بازسازی و تنظیم
        new_data = data_fetcher()
        # افزودن یک جیتر تصادفی کوچک به TTL (مثلاً ۶۰ تا ۷۰ ثانیه)
        ttl = random.randint(60, 70)
        redis_client.set(key, new_data, ex=ttl)
        return new_data
    
    # برخورد کش: بررسی اینکه آیا باید زودتر به‌روزرسانی کنیم
    ttl_remaining = redis_client.ttl(key)
    
    # اگر TTL باقی‌مانده کمتر از ۱۰٪ از حداکثر TTL اصلی باشد، به صورت ناهمگام به‌روزرسانی کنید
    if ttl_remaining < 7: 
        # در محیط تولید، از یک صف پیام یا اجرای‌کننده وظیفه ناهمگام در اینجا استفاده کنید
        schedule_async_refresh(key, data_fetcher)
        
    return data

def schedule_async_refresh(key, data_fetcher):
    # این تابع باید در یک رشته/فرآیند جداگانه اجرا شود
    # تا از مسدود کردن رشته درخواست اصلی جلوگیری شود
    new_data = data_fetcher()
    redis_client.set(key, new_data, ex=60)

نتیجه‌گیری

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

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

Share: