تو مقالهی قبلی سعی کردیم دادههامون رو بهصورت in-memory داخل اپلیکیشن نگه داریم.
هدفمون چی بود؟ اینکه ابزارهای third-party مثل Redis و Memcached رو حذف کنیم تا هم ریسپانستایم بهتر بشه و هم بار شبکه پایین بیاد. ولی خب، اونجا یکسری مشکلات داشتیم که مهمترینش inconsistency بود.
حالا میخوایم ببینیم شرکت Paylocity این چالش رو چطوری حل کرده و باعث شده که ویژگی tracking در Redis معرفی بشه.
پیشنهاد میکنم قبل از ادامه، حتماً مقالهی قبلی رو یه نگاهی بندازید.
داستان از این قراره که Paylocity هم دقیقاً همین نیاز ما رو داشت: ریسپانس تایم بهتر با نگهداری دیتا سمت اپلیکیشن. اونا هم مثل ما اومدن این ایده رو پیاده کنن، ولی به همون چالشهایی که گفتیم، برخوردن. در نهایت تیمشون تصمیم گرفت که کلاً بیخیال این روش بشه و بره سراغ Redis.
اینجوری مشکلات قبلی حل شد، ولی یه چالش جدید پیش اومد: هر درخواست به Redis یه رفتوبرگشت TCP داشت که باعث چند میلی ثانیه تأخیر میشد. برای بیشتر تیمهاشون این تاخیر چیز مهمی نبود، ولی یه تیم که درخواستهای سنگین داشت (مثلاً فیلترهای پیچیده توی هر صفحه)، دنبال سرعت بیشتر بود.
اینجا بود که به فکر یه راهحل ترکیبی افتادن: ترکیب in-memory cache سمت اپلیکیشن با Redis. حالا چالش اصلی این بود که چطور دادهها رو بین کش کلاینتها و Redis هماهنگ نگه دارن. راهحلشون برای این موضوع، استفاده از redis pub/sub بود.
ایدهشون این بود که هر وقت دادهای توی Redis تغییر میکنه، یه پیام از طریق pub/sub به همهی سرورها یا اینستنسها ارسال بشه تا کش لوکال خودشون رو بهروز کنن. اما این روش یه مشکل مهم داشت: اگه قرار بود کل کلید رو برای همهی کلاینتها بفرستن، بار شبکه به شدت بالا میرفت.
برای حل این مشکل، از مدل hash slot توی Redis Cluster الهام گرفتن. hash slot فضا رو به چند تا بخش (slot) تقسیم میکنه و بعد با یه تابع هش مثل CRC16، هر کلید رو توی یکی از این بخشها قرار میده. این مکانیزم توی Redis Cluster باعث میشه دادهها بین نودهای مختلف پخش بشن و عملکرد بهتری داشته باشیم.
تیم Paylocity هم به جای اینکه خود کلید رو توی پیام بفرسته، فقط آیدی hash slot مربوطه رو به کلاینتها ارسال میکرد. این کار باعث شد حجم هر پیام فقط ۲ بایت بشه! هر وقت یه کلید تغییر میکرد، حذف میشد یا منقضی میشد، یه پیام "invalid data" برای کلاینتها فرستاده میشد، همراه با یه timestamp که نشون میداد آخرین آپدیت اون slot کی بوده.
توی هر اپلیکیشن، یه آرایه بود که زمان آخرین بروزرسانی هر slot رو نگه میداشت و نشون میداد آخرین بار کی یه گروه از دادهها نامعتبر شده. حالا وقتی که میخواستن از cache استفاده کنند، زمان ذخیرهسازی داده رو با زمان نامعتبر شدن slot مقایسه میکردن. اگه دادهها قبل از نامعتبر شدن cache ذخیره شده بودن، اونها رو قدیمی در نظر میگرفتن. توی این حالت، دادهی قدیمی رو دور میریختن و از Redis نسخهی جدید رو میگرفتند.(lazy eviction)
این روش باعث شد که تأخیر کم بشه و دادهها همیشه بهروز بمونن، بدون اینکه پیچیدگی زیادی به سیستم اضافه بشه.
سالواتوره سانفیلیپو، خالق Redis، توی Redis Conf 2018 ارائهی Ben Malec از شرکت Paylocity رو دید و به این فکر افتاد که کشینگ سمت کلاینت میتونه آیندهی Redis باشه. سال ۲۰۱۹، وقتی توی خیابونای نیویورک قدم میزد، به خودش گفت: چرا این ایده رو توی خود Redis پیاده نکنم؟ شرکتهای بزرگی هستند که دیتاهاشون رو توی اپلیکیشن نگه میدارند تا سرعت بالا بره و بار سرور کم بشه، ولی Redis هنوز راه سادهای برای این کار نداشت.
سالواتوره از دو تا ایدهی بن الهام گرفت: گروه بندی کلیدها با hash slot و استفاده از pub/sub برای اطلاعرسانی تغییرات. بن با ۱۶ هزار slot کار کرده بود، ولی سالواتوره این رو به ۱۶ میلیون گروه (با ۲۴ بیت) ارتقا داد تا تغییرات دقیقتر بشن و هر پیام فقط چند کلید رو هدف بگیره. یعنی مثلا وقتی که شما یک سرور با 100 میلیون کلید داری، نباید پیامهایی که به خاطر تغییر یا حذف دادهها میاد، بیشتر از چند تا کلید رو در کش سمت مشتری تحت تاثیر قرار بدن.
همچنین به جای اینکه اپلیکیشن پیام بفرسته، تصمیم گرفت سرور خودش این کارو بکنه و به کلاینتها بگه چی تغییر کرده. این شد پایهی ویژگی Tracking توی Redis 6.
سالواتوره برگشت خونه، یه سند نوشت و شروع کرد اون رو پیاده کنه. برای اینکه پیامها خودکار برن، پروتکل جدید RESP3 رو آورد که میتونست پیامها رو مستقیم به کلاینتها بفرسته—یه چیزی که توی پروتکل قدیمیتر نبود. هدفش این بود که Redis بار رو از دوش اپلیکیشنها برداره و کشینگ رو برای همه سادهتر و بهینهتر کنه.
تیم توسعهی Redis اومد این ویژگی رو بهتر کرد و توی دو حالت ارائه داد:
اینجا Redis مثل یه ناظر دقیق عمل میکنه. هر وقت یه کلاینت با دستور GET به یه کلید دسترسی پیدا کنه، سرور این تعامل رو ثبت میکنه و توی جدولی به نام Invalidation Table ذخیره میکنه. این جدول مثل یه دفترچه یادداشته که مشخص میکنه کدوم کلاینتها روی چه کلیدهایی کار کردن.
حالا اگه یه کلید تغییر کنه—چه با دستور SET از یه کلاینت دیگه، چه با منقضی شدن TTL، یا حتی به خاطر محدودیت maxmemory—سرور این جدول رو چک میکنه و فقط به اون کلاینتهایی که احتمالاً اون کلید رو توی حافظه لوکالشون دارن، پیام میفرسته. یعنی دیگه خبری از ارسال پیامهای اضافه و غیرضروری نیست؛ همه چیز هدفمند و بهینه انجام میشه.
مزیت بزرگ این روش اینه که بار شبکه رو کم میکنه و جلوی ارسال پیامهای غیرضروری رو میگیره. تصور کنید یه برنامه با هزاران کلاینت داریم؛ اگه قرار بود هر تغییر به همه ارسال بشه، ترافیک شبکه بهشدت بالا میرفت. ولی با این روش، فقط کلاینتهای مرتبط مطلع میشن، و این یعنی بهینهسازی قابلتوجه در مصرف پهنای باند.
البته این دقت هزینهای هم داره: سرور باید بخشی از حافظه رو برای نگهداری این جدول مصرف کنه. برای اینکه این موضوع مدیریت بشه، Redis اندازهی جدول رو محدود میکنه. اگه جدول پر بشه، کلیدهای قدیمیتر رو با ارسال پیامهای بیاعتباری جعلی حذف میکنه تا جا برای ورودیهای جدید باز بشه.
این تعادل بین کارایی و مصرف منابع باعث میشه که حالت پیشفرض، گزینهی مناسبی برای سناریوهایی باشه که به دقت بالا نیاز دارن.
در مقابل، حالت انتشار (Broadcasting) رویکرد کاملاً متفاوتی داره و تغییرات رو به همهی کلاینتهای علاقهمند میرسونه.
توی این روش، سرور دیگه نیازی به ذخیرهی اطلاعات کلیدهای دسترسیشده توسط کلاینتها نداره، پس هیچ حافظهی اضافی مصرف نمیکنه—که این یه مزیت بزرگ برای سیستمهای بزرگ با منابع محدود حساب میشه.
به جای اینکه سرور برای هر کلاینت جداگانه ردیابی انجام بده، کلاینتها خودشون subscribe میکنن برای پیشوندهای خاصی مثل user: یا object:. وقتی کلیدی با این پیشوندها تغییر کنه، سرور یه پیام برای همهی کلاینتهای subscribe شده برای اون پیشوند میفرسته، بدون اینکه بررسی کنه واقعاً اون کلید رو ذخیره کردن یا نه.
این سادگی، مقیاسپذیری رو به بالاترین حد میرسونه، ولی در عوض پیامهای بیشتری ارسال میشه. مثلاً اگه کلیدی مثل user:1234 تغییر کنه، همه کلاینتهایی که به پیشوند user: متصل هستن، پیام میگیرن، حتی اگه فقط یکی از اونها اون کلید رو ذخیره کرده باشن.
این روش برای برنامههایی که میخوان بدون فشار آوردن به سرور، کش سمت کلاینت رو پیاده کنن، خیلی مناسبه. ولی از طرف دیگه، نیاز به مدیریت دقیقتر سمت کلاینت داره تا بتونه پیامهای غیرضروری رو فیلتر کنه.
یکی از نوآوریهای جذاب در Tracking، گزینه NOLOOP است که هم در حالت پیشفرض و هم در حالت انتشار کار میکنه. فرض کنید یک کلاینت، کلیدی رو با دستور SET تغییر میده و میخواد اون رو توی حافظه محلی خودش ذخیره کنه. بدون NOLOOP، سرور بلافاصله یه پیام Invalidate براش میفرسته و کلاینت مجبور میشه دادهای که خودش بهروز کرده رو حذف کنه. خب این یه پارادوکس اذیتکنندهس!
حالا اگه NOLOOP فعال باشه (مثلاً با دستور CLIENT TRACKING ON NOLOOP)، سرور دیگه این پیام رو برای کلاینتی که تغییر رو ایجاد کرده، نمیفرسته.
این فیچر Tracking با هر دو پروتکل RESP2 و RESP3 سازگاره، ولی تجربه استفاده از هر کدومشون خیلی فرق داره:
__redis__:invalidate وصل میشه. پیامها فقط به کلاینت مشخص شده با REDIRECT میرسن. ولی این مدل پیچیدگیهایی مثل مدیریت دو سوکت و احتمال بروز race conditions رو به همراه داره. برای کلاینتهای قدیمی که هنوز به RESP3 مهاجرت نکردن، این روش کار میکنه، ولی میتونه دستوپاگیر باشه.