در بعضی از پروژهها، وقتی بحث کش کردن (Caching) پیش میاد، اولین چیزی که به ذهن می رسد، استفاده از یک کش توزیعشده مثل memcached یا redis هست. ولی در این پروژه، تصمیم گرفتم بدون استفاده از هیچ سیستم کش خارجی دیگری، cache رو مستقیماً داخل خود اپلیکیشن و بهصورت In-Memory پیادهسازی کنم. دلیلشم این بود که هم تاخیر شبکه (Latency) کمتر بشه، هم وابستگی به ابزارهای خارجی نداشته باشیم.
با توجه به اینکه برخی از دادههای پروژه نرخ تغییر پایینی داشتند، تصمیم گرفتم این دادهها رو بهصورت In-Memory در هر Instance ذخیره کنم. این روش باعث بهبود عملکرد خواندن داده ها شد و بعد از آن دیگر نیازی به دریافت داده از Central cache و ایجاد ریکوست اضافه روی Network نبود.
۱. مصرف حافظه
برای هندل کردن load، ما اپلیکیشن را scale کرده بودیم و در نتیجه داده ها به صورت مجزا روی Instance های مختلف نگهداری میشد. یعنی اگه حجم دادههای کششده مثلاً ۲ مگابایت بود و ۴ Instance داشتیم، در مجموع ۸ مگابایت حافظه برای این دادهها مصرف میشد. در حالی که اگر از یک کش مرکزی استفاده میکردیم، فقط ۲ مگابایت حافظه مصرف میشد.
۲. ناسازگاری دادهها هنگام بهروزرسانی
یکی از مشکلات اصلی این بود که وقتی دادهها آپدیت میشدند، این تغییرات باید روی همهی Instanceها اعمال میشد. اما از آنجایی که هر Instance کش مخصوص به خودش رو داشت، وقتی دادهها بهروزرسانی میشدند، فقط کش همون Instanceای که درخواست بهش رسیده بود آپدیت میشد و بقیهی Instanceها هنوز دادههای قدیمی رو نگه میداشتن. این قضیه باعث میشد که دادهها بین Instanceها ناسازگار بشن (inconsistency).
۳. پاک شدن cache بعد از هربار ریست اپلیکیشن
وقتی اپلیکیشنها به هر دلیلی ریاستارت میشدن، کش In-Memory پاک میشد و تا وقتی که کش دوباره ساخته بشه، ممکن بود تجربه کاربر رو خراب کنه و فشار زیادی هم به دیتابیس وارد کنه. برای اینکه این مشکل پیش نیاد، نیاز بود از روشهایی مثل cache warming استفاده کنیم تا کش از قبل آماده باشه.
۱. استفاده از Message Broker برای هماهنگسازی Cache
یکی از روشهایی که میشد امتحان کرد این بود که وقتی دادهها آپدیت میشدن، یه پیام توی Message Broker (مثلاً Kafka یا RabbitMQ یا همچنان Redis به صورت pub/sub ) فرستاده بشه. بعد بقیه instanceها این پیام رو دریافت کنن و cache خودشون رو آپدیت کنن.
این روش چند تا مزیت داشت:
- کاهش بار روی Network: دیگه لازم نبود برای گرفتن دادهها به یک تردپارتی روی شبکه ریکوست بزنیم.
- بهینه برای بارهای read-heavy: از اونجایی که تعداد درخواستهای خواندن خیلی بیشتر از درخواستهای نوشتن بود، این روش عملکرد بهتری داشت.
اما این روش معایب خودش رو هم داشت:
- وابستگی به یک ابزار جدید: برای هماهنگسازی کش بین اینستنسها، باید RabbitMQ یا Kafka رو راهاندازی و نگهداری میکردیم که این برخلاف هدف اولیهمون (کاهش وابستگی به ابزارهای خارجی) بود.
- پیچیدگی پیادهسازی مکانیزم فالبک (Fallback): نیاز بود که مکانیزم فالبک پیادهسازی کنیم تا مطمئن بشیم پیامهای آپدیت ِداده، همیشه به درستی منتشر میشن و هیچ چیزی از دست نمیره.
۲. هماهنگسازی با فراخوانی مستقیم بین اینستنسها
یکی از روشهای دیگه این بود که وقتی دادهها بهروزرسانی میشد، بقیهی اینستنسها رو از طریق API فراخوانی کنیم تا کش خودشون رو بهروزرسانی کنن. ولی این روش دو مشکل مهم داشت:
- افزایش پیچیدگی مدیریت cache: هماهنگ کردن و نگهداری این سیستم خیلی سخت بود.
- افزایش latency و سربار شبکه: درخواستهای بین اینستنسها باعث میشد تا latency بیشتر بشه و کارایی سیستم پایین بیاد.
۳. استفاده از job ها برای بهروزرسانی Cache
یک روش دیگه این بود که یک job به صورت دورهای (مثلا هر ۵ دقیقه یکبار) روی هر اینستنس اجرا بشه و دادههای جدید یا آپدیت شده رو از دیتابیس بخونه و کش خودش رو بهروزرسانی کنه.
مزایا:
- عدم نیاز به ابزار جانبی
- مدیریت سادهتر بهروزرسانی cache: مدیریت کش راحتتر میشد چون دیگه نیازی به ارسال پیام بین اینستنسها نبود.
اما این روش هم مشکلات خودش رو داشت:
- تاخیر در بهروزرسانی دادهها: چون بهروزرسانی کش به صورت دورهای انجام میشد، ممکن بود مثلاً حداکثر ۵ دقیقه اختلاف داده بین اینستنسها وجود داشته باشه.
- افزایش سربار روی دیتابیس: هر اینستنس مجبور بود به صورت دورهای روی دیتابیس کوئری بزنه که این میتونست بار زیادی روی دیتابیس ایجاد کنه.
در صورتی که Eventual Consistency را قبول کنیم و برامون Availability مهمتر باشه، روش Job دورهای میتونه تا حد بسیار خوبی قابل قبول باشه، اما اگر بخواهیم Strong Consistency داشته باشیم، نیاز به روشهای بهتری داریم .
با در نظر گرفتن تمام این چالشها، در نهایت تصمیم گرفتم که از یک کش مرکزی مثل Redis استفاده کنم.
این تصمیم به چند دلیل گرفته شد:
- مدیریت سادهتر cache: با استفاده از کش توزیعشده دیگه نیازی به هماهنگسازی دستی بین اینستنسها نبود.
- حفظ یکپارچگی دادهها: همه اینستنسها به یک منبع داده یکسان دسترسی داشتن.
- بهینهسازی مصرف حافظه: به جای اینکه دادهها جداگانه تو هر اینستنس ذخیره بشن، دادهها فقط یه بار تو Redis ذخیره میشدن.
استفاده از In-Memory Cache تجربه جالبی بود، اما در نهایت Distributed Cache انتخاب بهتری برای این سناریو محسوب میشود. با این حال، در برخی موارد خاص، In-Memory Cache همچنان میتواند مزایای خود را داشته باشد، بهویژه زمانی که تنها یک Instance در حال اجرا باشد، یا نرخ تغییر دادهها بسیار پایین باشد، یا زمانی که Latency برای سیستم حیاتی باشد.
همچنین، استفاده ترکیبی از این دو روش نیز میتواند مفید باشد. به عنوان مثال، میتوان از In-Memory Cache به عنوان L1 Cache و از Redis به عنوان L2 Cache استفاده کرد. در این روش، دادههایی که بیشترین درخواست را دارند، ابتدا در حافظه هر Instance ذخیره میشوند (L1 Cache) و در صورت نیاز به دادههای جدید، از Redis (L2 Cache) بازیابی میشوند. این رویکرد میتواند تعادل مناسبی بین سرعت دسترسی بالا و هماهنگی دادهها برقرار کند.
نظر شما چیه؟ تجربه مشابهی تو استفاده از In-Memory Cache داشتید؟