كيفية تجنب ظاهرة Cache Stampede؟

API7.ai

December 29, 2022

OpenResty (NGINX + Lua)

في المقال السابق، تعلمنا بعض تقنيات التحسين عالية الأداء باستخدام shared dict و lru cache. ومع ذلك، تركنا قضية كبيرة تستحق مقالتها اليوم، وهي "Cache Stampede".

ما هي Cache Stampede؟

لنتخيل سيناريو.

مصدر البيانات موجود في قاعدة بيانات MySQL، والبيانات المخزنة مؤقتًا موجودة في shared dict، وانتهاء صلاحية البيانات هو 60 ثانية. خلال الـ 60 ثانية من وجود البيانات في الذاكرة المؤقتة، جميع الطلبات تقوم بجلب البيانات من الذاكرة المؤقتة بدلاً من MySQL. ولكن بمجرد انتهاء الـ 60 ثانية، تنتهي صلاحية البيانات المخزنة مؤقتًا. إذا كان هناك عدد كبير من الطلبات المتزامنة، فلا يمكن استعلام أي بيانات من الذاكرة المؤقتة. عندها سيتم تشغيل وظيفة استعلام مصدر البيانات، وستذهب جميع هذه الطلبات إلى قاعدة بيانات MySQL، مما سيؤدي مباشرة إلى انسداد أو حتى توقف خادم قاعدة البيانات.

هذه الظاهرة يمكن أن تسمى "Cache Stampede"، وأحيانًا يشار إليها بـ Dog-Piling. لا يوجد أي من الأكواد المتعلقة بالذاكرة المؤقتة التي ظهرت في الأقسام السابقة معالجة مقابلة لها. فيما يلي مثال على الكود الزائف الذي لديه إمكانية حدوث Cache Stampede.

local value = get_from_cache(key)
if not value then
    value = query_db(sql)
    set_to_cache(value, timeout = 60)
end
return value

يبدو الكود الزائف كما لو أن المنطق صحيح، ولن يتم تشغيل Cache Stampede باستخدام اختبارات الوحدة أو الاختبارات الشاملة. فقط اختبار الضغط الطويل سيكشف المشكلة. كل 60 ثانية، ستشهد قاعدة بيانات زيادة منتظمة في الاستعلامات. ولكن إذا قمت بتعيين وقت انتهاء صلاحية أطول للذاكرة المؤقتة هنا، تقل فرص اكتشاف مشكلة عاصفة الذاكرة المؤقتة.

كيف يمكن تجنبها؟

لنقسم النقاش إلى عدة حالات مختلفة.

1. تحديث الذاكرة المؤقتة بشكل استباقي

في الكود الزائف أعلاه، يتم تحديث الذاكرة المؤقتة بشكل سلبي ويتم الذهاب إلى قاعدة البيانات لاستعلام البيانات الجديدة فقط عند الطلب ولكن يتم اكتشاف فشل في الذاكرة المؤقتة. لذلك، تغيير طريقة تحديث الذاكرة المؤقتة من سلبية إلى استباقية يمكن أن يتجاوز مشكلة Cache Stampede.

في OpenResty، يمكننا تنفيذ ذلك بهذه الطريقة.

أولاً، نستخدم ngx.timer.every لإنشاء مهمة مؤقتة تعمل كل دقيقة لجلب أحدث البيانات من قاعدة بيانات MySQL ووضعها في shared dict:

local function query_db(premature, sql)
    local value = query_db(sql)
    set_to_cache(value, timeout = 60)
end

local ok, err = ngx.timer.every(60, query_db, sql)

ثم، في منطق الكود الذي يعالج الطلب، نحتاج إلى إزالة الجزء الذي يستعلم عن MySQL والاحتفاظ فقط بجزء الكود الذي يحصل على ذاكرة shared dict المؤقتة.

local value = get_from_cache(key)
return value

القطعتان الزائفتان أعلاه يمكن أن تساعدنا في تجاوز مشكلة Cache Stampede. ولكن هذه الطريقة ليست مثالية، كل ذاكرة مؤقتة يجب أن تتوافق مع مهمة دورية (هناك حد أقصى لعدد المؤقتات في OpenResty)، ووقت انتهاء صلاحية الذاكرة المؤقتة ووقت دورة المهمة المجدولة يجب أن يتوافقا جيدًا. إذا حدث أي خطأ خلال هذه الفترة، قد تستمر الطلبات في الحصول على بيانات فارغة.

لذلك، في المشاريع الحقيقية، نستخدم عادةً القفل لحل مشكلة Cache Stampede. فيما يلي بعض الطرق المختلفة لإضافة القفل، يمكنك اختيار ما يناسبك وفقًا لاحتياجاتك.

2. lua-resty-lock

عندما يتعلق الأمر بإضافة الأقفال، قد تشعر بالصعوبة، معتقدًا أنها عملية ثقيلة، وماذا لو حدث تعارض وتعين عليك التعامل مع العديد من الاستثناءات.

يمكننا تخفيف هذا القلق باستخدام مكتبة lua-resty-lock في OpenResty لإضافة الأقفال. lua-resty-lock هي مكتبة resty في OpenResty، والتي تعتمد على shared dict وتوفر واجهة برمجة تطبيقات غير متزامنة للأقفال. لنلقي نظرة على مثال بسيط.

resty --shdict='locks 1m' -e 'local resty_lock = require "resty.lock"
                            local lock, err = resty_lock:new("locks")
                            local elapsed, err = lock:lock("my_key")
                            -- query db and update cache
                            local ok, err = lock:unlock()
                            ngx.say("unlock: ", ok)'

نظرًا لأن lua-resty-lock يتم تنفيذها باستخدام shared dict، نحتاج أولاً إلى الإعلان عن اسم وحجم shdict ثم استخدام طريقة new لإنشاء كائن lock جديد. في مقتطف الكود أعلاه، نمرر فقط المعلمة الأولى، وهي اسم shdict. طريقة new لديها معلمة ثانية، يمكن استخدامها لتحديد وقت انتهاء الصلاحية، ووقت انتهاء القفل، والعديد من المعلمات الأخرى. هنا نحتفظ بالقيم الافتراضية. هذه المعلمات تستخدم لتجنب التعارضات والاستثناءات الأخرى.

ثم يمكننا استدعاء طريقة lock لمحاولة الحصول على قفل. إذا نجحنا في الحصول على القفل، يمكننا التأكد من أن طلبًا واحدًا فقط يذهب إلى مصدر البيانات لتحديث البيانات في نفس اللحظة. ولكن إذا فشل القفل بسبب التصادم، انتهاء الوقت، إلخ، يتم جلب البيانات من الذاكرة المؤقتة القديمة وإعادتها إلى الطالب. هذا يقودنا إلى واجهة برمجة التطبيقات get_stale التي تم تقديمها في الدرس السابق.

local elapsed, err = lock:lock("my_key")
# إذا كان elapsed يساوي nil فهذا يعني أن القفل فشل. القيمة المرجعة لـ err هي إما timeout أو locked
if not elapsed and err then
    dict:get_stale("my_key")
end

إذا نجح lock، فمن الآمن استعلام قاعدة البيانات وتحديث النتائج إلى الذاكرة المؤقتة، وأخيرًا نستدعي واجهة unlock لإطلاق القفل.

بدمج lua-resty-lock و get_stale، لدينا الحل المثالي لمشكلة Cache Stampede. توثيق lua-resty-lock يعطي كودًا كاملاً للتعامل معها. إذا كنت مهتمًا، يمكنك التحقق منه هنا.

لنذهب أعمق ونرى كيف تنفذ واجهة lock القفل. عندما نواجه بعض التطبيقات المثيرة للاهتمام، نرغب دائمًا في رؤية كيفية تنفيذها في الكود المصدري، وهي إحدى مزايا المصدر المفتوح.

local ok, err = dict:add(key, true, exptime)
if ok then
    cdata.key_id = ref_obj(key)
    self.key = key
    return 0
end

كما ذكرنا في مقال shared dict، جميع واجهات برمجة التطبيقات لـ shared dict هي عمليات ذرية ولا داعي للقلق بشأن التصادم. لذلك، من الجيد استخدام shared dict لتحديد حالة الأقفال.

تنفيذ lock أعلاه يستخدم dict:add لمحاولة تعيين المفتاح: إذا لم يكن المفتاح موجودًا في الذاكرة المشتركة، add ستعود بنجاح، مما يشير إلى نجاح القفل؛ الطلبات المتزامنة الأخرى ستعود بالفشل عند الوصول إلى منطق سطر الكود dict:add، ثم يمكن للكود اختيار ما إذا كان سيعود مباشرة أو سيحاول عدة مرات بناءً على معلومات err المرجعة.

3. lua-resty-shcache

في تنفيذ lua-resty-lock أعلاه، تحتاج إلى التعامل مع القفل، إطلاق القفل، جلب البيانات القديمة، إعادة المحاولة، التعامل مع الاستثناءات، وغيرها من القضايا، وهو ما يزال مرهقًا.

هنا غلاف بسيط لك: lua-resty-shcache، وهي مكتبة lua-resty من Cloudflare، تقوم بطبقة من التغليف فوق القواميس المشتركة والتخزين الخارجي وتوفر وظائف إضافية للتسلسل وإلغاء التسلسل، لذلك لا داعي للقلق بشأن التفاصيل أعلاه:

local shcache = require("shcache")

local my_cache_table = shcache:new(
        ngx.shared.cache_dict
        { external_lookup = lookup,
          encode = cmsgpack.pack,
          decode = cmsgpack.decode,
        },
        { positive_ttl = 10,           -- تخزين البيانات الجيدة لمدة 10 ثوانٍ
          negative_ttl = 3,            -- تخزين البحث الفاشل لمدة 3 ثوانٍ
          name = 'my_cache',     -- ذاكرة مؤقتة "مسماة"، مفيدة للتصحيح / التقرير
        }
    )

local my_table, from_cache = my_cache_table:load(key)

تم استخراج نموذج الكود هذا من المثال الرسمي وتم إخفاء جميع التفاصيل. مكتبة التغليف هذه ليست الخيار الأفضل، ولكنها مادة تعليمية جيدة للمبتدئين. المقال التالي سيعرض بعض التغليفات الأخرى الأفضل والأكثر استخدامًا.

4. توجيهات NGINX

إذا كنت لا تستخدم مكتبة lua-resty في OpenResty، يمكنك أيضًا استخدام توجيهات تكوين NGINX لإضافة الأقفال وجلب البيانات القديمة: proxy_cache_lock و proxy_cache_use_stale. ومع ذلك، لا نوصي باستخدام توجيهات NGINX هنا، لأنها ليست مرنة بما فيه الكفاية، وأداؤها ليس جيدًا مثل كود Lua.

الخلاصة

مشكلة Cache Stampede، مثل مشكلة السباق التي ذكرناها مرارًا وتكرارًا من قبل، يصعب اكتشافها من خلال مراجعات الكود والاختبارات. أفضل طريقة لحلها هي تحسين الترميز الخاص بك أو استخدام مكتبة تغليف.

سؤال أخير: كيف تتعامل مع Cache Stampede وما شابه ذلك في اللغات والمنصات التي تعرفها؟ هل هناك طريقة أفضل من OpenResty؟ لا تتردد في مشاركتها معي في التعليقات.