Cache: کش چیه؟ چرا کش کنیم؟ یک لایه با سرعت بسیار بیشتر
فرض کن یک صفحه داری که برای ساختنش باید بری سراغ دیتابیس، چند تا جدول رو join کنی، نتیجه رو پردازش کنی، و بعد HTML بسازی. این کار شاید ۲۰۰ میلیثانیه طول بکشه. حالا اگه این صفحه رو هزار نفر در ثانیه ببینن، و محتواش هر بار عوض نشه، داری همون کار سنگین رو هزار بار در ثانیه تکرار میکنی، درحالیکه جواب همیشه یکیه. کش دقیقاً همین اتلاف رو حل میکنه: جواب رو یک بار حساب میکنی، جایی نگهش میداری که دسترسی بهش خیلی سریعتره، و دفعههای بعد بهجای محاسبهی دوباره، همون جواب آماده رو برمیگردونی.
این ایده بهقدری پایهایه که توی تقریباً هر لایهای از یک سیستم، از مرورگر گرفته تا CDN تا سرور تا دیتابیس، یک نسخهی متفاوت از همین ترفند رو میبینی.
کش توی مرورگر و سمت کاربر
اولین لایهای که هر کاربر باهاش برخورد میکنه، کش مرورگره. وقتی یک فایل CSS یا تصویر یا فونت رو دانلود میکنی، مرورگر بر اساس هدرهای HTTP مثل Cache-Control و ETag تصمیم میگیره که آیا لازمه دوباره از سرور بگیرتش یا همون نسخهی محلی رو نشون بده. برای همین دفعهی دوم که یک سایت رو باز میکنی، خیلی سریعتر لود میشه — چون مرورگر اصلاً درخواست جدیدی نمیفرسته، یا اگه بفرسته، سرور فقط میگه «چیزی عوض نشده» (کد وضعیت ۳۰۴) و دوباره کل فایل رو نمیفرسته.
کش توی CDN
لایهی بعدی که خیلی وقتها دیده نمیشه، CDN است — سرویسهایی مثل Cloudflare یا ArvanCloud که یک نسخه از فایلهای استاتیک سایتت (تصویر، CSS، JS، و حتی گاهی HTML) رو روی سرورهای پخششده در نقاط مختلف جغرافیایی نگه میدارن. وقتی کاربری از یک شهر دیگه به سایتت وصل میشه، بهجای اینکه درخواستش تا سرور اصلی بره، نزدیکترین edge server جواب رو از کشش برمیگردونه. این هم سرعت رو بالا میبره و هم فشار روی سرور اصلی رو کم میکنه.
کش سمت سرور (Application Cache)
توی خود اپلیکیشن، معمولاً از ابزارهایی مثل Redis یا Memcached استفاده میشه تا نتیجهی محاسبات سنگین یا کوئریهای تکراری رو نگه دارن. مثلاً اگه یک API داری که لیست محصولات پرفروش رو برمیگردونه، بهجای اینکه هر بار این لیست رو از دیتابیس محاسبه کنی، یک بار حسابش میکنی و توی Redis با یک TTL (زمان انقضا) ذخیرهاش میکنی:
async function getTopProducts() {
const cached = await redis.get('top_products');
if (cached) return JSON.parse(cached);
const products = await db.query('SELECT ... ORDER BY sales DESC LIMIT 10');
await redis.set('top_products', JSON.stringify(products), 'EX', 300); // 5 دقیقه
return products;
}
این الگو رو cache-aside میگن: اول کش رو چک میکنی، اگه خالی بود از منبع اصلی میگیری و توی کش میذاری. رایجترین الگوی کش توی دنیای بکاند همینه، چون منطق سادهست و کنترل کامل دستته.
کش توی دیتابیس
خود دیتابیسها هم لایههای کش داخلی دارن که معمولاً کمتر بهشون فکر میکنیم. مثلاً PostgreSQL یک بخش به اسم shared buffers داره که صفحات پرکاربرد دیتابیس رو توی رم نگه میداره تا لازم نباشه هر بار از دیسک بخونه. MySQL هم مکانیزمهای مشابهی داره. این یعنی حتی وقتی فکر میکنی داری «مستقیم از دیتابیس» میخونی، احتمالاً بخشی از اون داده از قبل توی حافظهی خود دیتابیس کش شده و دیسک اصلاً درگیر نشده.
کش توی اپلیکیشنهای موبایل
توی اپهای موبایل هم همین منطق تکرار میشه، فقط با اسمهای دیگه. مثلاً یک اپلیکیشن ممکنه لیست محصولات یا فید خبری رو یک بار از سرور بگیره و توی حافظهی محلی گوشی (یا یک دیتابیس سبک مثل SQLite) نگه داره، تا کاربر بدون اینترنت هم بتونه محتوای قبلی رو ببینه، و تا وقتی دادهی جدیدی نیومده، دوباره از سرور درخواست نده. این هم برای تجربهی کاربری بهتره (لود سریعتر) و هم برای مصرف داده و باتری صرفهجویی میکنه.
چیزی که واقعاً برای یک مهندس نرمافزار جذابه: invalidation
جملهی معروفی توی دنیای برنامهنویسی هست که میگه فقط دو چیز سخت توی علوم کامپیوتر وجود داره: invalidate کردن کش، نامگذاری متغیرها، و off-by-one errors. این جمله شوخیه، ولی نکتهی جدی پشتش اینه: نگهداشتن داده توی کش کار سختی نیست؛ سخت اینه که بفهمی دقیقاً کِی اون داده دیگه معتبر نیست و باید پاکش کنی یا آپدیتش کنی.
فرض کن قیمت یک محصول توی دیتابیس عوض بشه، ولی نسخهی کششدهاش هنوز قیمت قدیمی رو نشون بده. اگه TTL طولانی گذاشته باشی، این عدمتطابق میتونه دقیقهها یا حتی ساعتها طول بکشه. راهحلهای رایج برای این مشکل چندتاست: یکی اینکه هر وقت داده توی دیتابیس آپدیت شد، مستقیماً همون کلید رو از کش پاک کنی (invalidate on write). یکی دیگه اینه که از TTL کوتاهتر استفاده کنی تا حداکثر «قدیمیبودن» داده محدود باشه. روش سوم که پیچیدهتره ولی قویتره، استفاده از event-based invalidation است: وقتی یک تغییر توی سیستم اتفاق میفته، یک event منتشر میشه و هر سرویسی که کش مرتبط داره، خودش رو آپدیت میکنه.
یک مشکل کمتر شناختهشده: cache stampede
این یکی از اون چیزهاییه که وقتی برای اولین بار باهاش برخورد میکنی، واقعاً غافلگیرت میکنه. فرض کن یک کلید کش با TTL پنج دقیقهای منقضی بشه، و دقیقاً همون لحظه هزار درخواست همزمان برسه. چون کش خالیه، هر هزار درخواست میرن سراغ منبع اصلی (مثلاً دیتابیس) تا همون مقدار رو دوباره محاسبه کنن — درحالیکه فقط یکی از این محاسبات کافی بود. این پدیده رو cache stampede یا thundering herd میگن، و میتونه یک دیتابیس سالم رو در عرض چند ثانیه از پا دربیاره.
راهحل رایج برای این مشکل یک قفل موقته: اولین درخواستی که میبینه کش خالیه، یک lock میگیره و شروع میکنه به محاسبه؛ بقیهی درخواستها یا منتظر میمونن تا اون قفل آزاد بشه، یا موقتاً همون مقدار قدیمی (حتی اگه منقضی شده) رو برمیگردونن تا محاسبهی جدید تموم بشه. به این تکنیک دوم گاهی stale-while-revalidate میگن، و توی خیلی از CDN ها و کتابخانههای کش هم پیادهسازی شده.
سیاستهای حذف: چه چیزی رو نگه داریم، چه چیزی رو دور بریزیم
چون حافظهای که کش توش نگهداری میشه (چه RAM چه هر چیز دیگه) محدوده، وقتی کش پر میشه باید یک چیزی رو حذف کنی تا جا برای دادهی جدید باز بشه. رایجترین سیاست LRU (Least Recently Used) است: چیزی که مدت طولانیتری استفاده نشده، اول حذف میشه. یک سیاست دیگه LFU (Least Frequently Used) است که بهجای «آخرین بار کِی استفاده شده» به «چند بار استفاده شده» نگاه میکنه. انتخاب بین این دو به رفتار واقعی ترافیکت بستگی داره — مثلاً برای یک فید خبری که محتوای پرطرفدار مدام عوض میشه، LRU معمولاً منطقیتره؛ برای دادههایی که یک زیرمجموعهی ثابت همیشه پرتکرارن، LFU میتونه بهتر عمل کنه.
جمعبندی
نکتهی جالب کش اینه که هیچوقت یک تصمیم یکباره نیست. هر لایهای از سیستم —از مرورگر تا CDN تا اپلیکیشن تا دیتابیس— نسخهی خودش رو از همین ایدهی ساده پیاده میکنه: یک بار محاسبه کن، سریع نگه دار، و مراقب باش که کِی این نسخهی سریع دیگه با واقعیت همخونی نداره. بخش سخت کش هیچوقت «نگهداشتن» نبوده؛ همیشه «دونستن کِی باید فراموشش کنی» بوده.