بریم یک توضیح کامل در این مورد با جمعبندی نظرات دوستانی که همراهی کردند داشته باشیم.
اول از همه مشکلی که اینجا داریم به Cache Stampede (هجوم همزمان درخواستها به دیتابیس بعد از منقضی شدن کش) معروف هست.
یعنی تا وقتی کش وجود داره همه چیز خوبه، ولی به محض اینکه TTL تموم بشه، ممکنه ۱۰۰۰ درخواست همزمان Cache Miss بخورن و به جای یک Query، هزار Query به دیتابیس ارسال بشه.
این سناریو معمولا زمانی خطرناکتر میشه که با یک Hot Key (کلیدی که تعداد بسیار زیادی درخواست به آن ارسال میشود) طرف باشیم. خیلی از راهکارها برای کلیدهای معمولی جواب میدن، اما برای Hot Keyها باید بیشتر مراقب بود چون یک اشتباه کوچک میتونه فشار زیادی روی دیتابیس ایجاد کنه.
اولین راهحلی که معمولا به ذهن میرسه استفاده از Mutex یا Distributed Lock (قفل سراسری بین چند سرور) هست. یعنی وقتی Cache Miss اتفاق افتاد فقط اولین درخواست اجازه داشته باشه از دیتابیس بخونه و کش رو مجدد پر کنه. بقیه درخواستها یا منتظر بمونن یا چند لحظه بعد دوباره کش رو بررسی کنن.
البته Lock به تنهایی مشکل رو کامل حل نمیکنه. فشار روی دیتابیس کم میشه اما ممکنه Latency کاربران افزایش پیدا کنه.
همچنین در پیادهسازی Lock باید به Failure Scenarioها هم فکر کرد. مثلا اگر سرویسی که Lock رو گرفته قبل از آزاد کردنش Crash کنه چه اتفاقی میفته؟ به همین دلیل معمولا Lockها خودشون TTL دارن تا سیستم در وضعیت Deadlock باقی نمونه.
برای همین معمولا از Request Coalescing (تجمیع درخواستهای مشابه) هم استفاده میشه. یعنی اگر ۱۰۰۰ درخواست همزمان برای یک داده وارد بشن، فقط یک درخواست واقعا به دیتابیس بره و بقیه منتظر نتیجه همون درخواست بمونن.
اگر تازگی داده خیلی حیاتی نباشه، Stale-While-Revalidate گزینه جذابتریه. در این حالت وقتی TTL تموم میشه، همچنان آخرین مقدار کش به کاربر برگردونده میشه و همزمان یک فرآیند در پسزمینه کش رو Refresh میکنه. اینطوری کاربر افزایش Latency رو حس نمیکنه.
راهکار دیگه Cache Warming یا Background Refresh هست. یعنی اصلا منتظر اولین درخواست نمیمونیم. قبل از اینکه TTL تموم بشه یک Job در پسزمینه کش رو مجدد میسازه و جایگزین میکنه. در این حالت عملا Cache Miss برای کاربران رخ نمیده.
برای کاهش احتمال بروز این مشکل هم میشه از Randomized TTL یا Jitter استفاده کرد. مثلا به جای اینکه همه کلیدها دقیقا ۱۰ دقیقه عمر داشته باشن، بعضی ۹ دقیقه و بعضی ۱۱ دقیقه عمر داشته باشن. این کار باعث میشه تعداد زیادی کلید دقیقا در یک لحظه منقضی نشن.
حالا یک سوال مهم اینجاست که اصلا چرا TTL داریم؟
معمولا TTL زمانی استفاده میشه که:
- داده ممکنه در دیتابیس تغییر کنه و نمیخوایم کش برای همیشه مقدار قدیمی نگه داره.
- نمیدونیم چه زمانی داده تغییر میکنه.
- میخوایم در نهایت کش خودش بهروز بشه حتی اگر فراموش کنیم آن را Invalidate کنیم.
اما اگر کنترل کامل روی مسیر آپدیت داده داشته باشیم، شاید اصلا نیازی به TTL نباشه.
مثلاً میشه از معماری Event-Driven استفاده کرد. یعنی هر زمان داده در دیتابیس تغییر کرد، یک Event منتشر بشه و همون لحظه کش هم آپدیت یا حذف بشه. در این مدل عملا کش همیشه تازه است و دیگر منتظر منقضی شدن TTL نمیمونیم.
البته این راهکار هم هزینه خودش رو داره. اگر Event از دست بره یا آپدیت کش با خطا مواجه بشه، ممکنه دیتابیس و کش با هم ناسازگار بشن. به همین دلیل خیلی از سیستمها حتی در کنار Event-Driven بودن، یک TTL بلندمدت هم قرار میدن تا اگر جایی مشکلی پیش اومد، کش نهایتا خودش اصلاح بشه.
اگر حجم داده خیلی زیاد نباشه میشه یک Hybrid Cache (کش چندلایه) هم داشت. مثلا:
- حافظه خود اپلیکیشن به عنوان L1 Cache
- و Redis به عنوان L2 Cache
در این حالت بسیاری از درخواستها حتی به Redis هم نمیرسن و مستقیما از حافظه سرویس پاسخ میگیرن. البته در این مدل باید به موضوع Cache Invalidation (هماهنگ نگه داشتن چند کش مختلف) هم فکر کرد.
در نهایت انتخاب راهکار تا حد زیادی به Trade-off بین Consistency (تازگی و صحت داده) و Availability (سرعت پاسخگویی و در دسترس بودن سرویس) بستگی داره. اگر داده بسیار حساس باشه شاید ترجیح بدیم درخواست منتظر Refresh شدن کش بمونه، اما اگر تجربه کاربری مهمتر باشه معمولا برگردوندن دادهای که چند ثانیه یا چند دقیقه از عمرش گذشته قابل قبوله.
به همین دلیل معمولا یک راهکار واحد وجود نداره و در سیستمهای پرترافیک از ترکیبی از همه راهکارها استفاده میشه.
Post #553
383
| AmirHossein | بیاید بیکار نباشیم، هر از گاهی یک سوال چالشی میفرستم که یکم ذهنمون درگیر بشه و یاد بگیریم. از آسون شروع کنیم😁 فرض کنید یک API داریم که دادههاش رو از Redis میخونه. داده مورد نظر هر ۱۰ دقیقه یک بار Expire میشه و بعد از Expire شدن، اولین درخواست میره دیتابیس…
- ❤ 8
- 🤣 1