إصدارات `lua-resty-*` التغليفية تحرر المطورين من التخزين المؤقت متعدد المستويات

API7.ai

December 30, 2022

OpenResty (NGINX + Lua)

في المقالتين السابقتين، تعلمنا عن التخزين المؤقت في OpenResty ومشكلة انهيار التخزين المؤقت، والتي كانت جميعها على الجانب الأساسي. في تطوير المشاريع الفعلية، يفضل المطورون مكتبة جاهزة للاستخدام مع جميع التفاصيل المعالجة والمخفية ويمكن استخدامها لتطوير كود الأعمال مباشرة.

هذه فائدة من تقسيم العمل، حيث يركز مطورو المكونات الأساسية على بنية مرنة وأداء جيد واستقرار الكود دون الاهتمام بمنطق الأعمال العلوي؛ بينما يهتم مهندسو التطبيقات أكثر بتنفيذ الأعمال والتكرار السريع، على أمل ألا يتم تشتيت انتباههم بالتفاصيل الفنية المختلفة للطبقة السفلية. يمكن ملء الفجوة بينهما بواسطة مكتبات التغليف.

يواجه التخزين المؤقت في OpenResty نفس المشكلة. shared dict و lru caches مستقران وفعالان بما فيه الكفاية، ولكن هناك الكثير من التفاصيل التي يجب التعامل معها. يمكن أن تكون "الميل الأخير" لمهندسي تطوير التطبيقات شاقًا دون بعض التغليف المفيد. هنا يأتي دور أهمية المجتمع. المجتمع النشط سيتخذ المبادرة للعثور على الفجوات وملئها بسرعة.

lua-resty-memcached-shdict

لنعد إلى تغليف التخزين المؤقت. lua-resty-memcached-shdict هو مشروع OpenResty رسمي يستخدم shared dict لعمل طبقة تغليف لـ memcached، معالجة تفاصيل مثل انهيار التخزين المؤقت والبيانات المنتهية الصلاحية. إذا كانت بياناتك المخزنة مؤقتًا مخزنة في memcached في الخلفية، فيمكنك تجربة استخدام هذه المكتبة.

إنها مكتبة تم تطويرها بواسطة OpenResty رسميًا، ولكنها ليست في حزمة OpenResty بشكل افتراضي. إذا كنت تريد اختبارها محليًا، تحتاج إلى تنزيل الكود المصدري إلى مسار البحث المحلي لـ OpenResty أولاً.

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

ومع ذلك، هذه المكتبة lua-resty، على الرغم من كونها مشروع OpenResty رسمي، ليست مثالية:

  1. أولاً، لا يوجد تغطية لحالات الاختبار، مما يعني أن جودة الكود لا يمكن ضمانها بشكل متسق.
  2. ثانيًا، تعرض الكثير من معلمات الواجهة، مع 11 معلمة مطلوبة و7 معلمات اختيارية.
local memc_fetch, memc_store =
    shdict_memc.gen_memc_methods{
        tag = "my memcached server tag",
        debug_logger = dlog,
        warn_logger = warn,
        error_logger = error_log,

        locks_shdict_name = "some_lua_shared_dict_name",

        shdict_set = meta_shdict_set,  
        shdict_get = meta_shdict_get,  

        disable_shdict = false,  -- optional, default false

        memc_host = "127.0.0.1",
        memc_port = 11211,
        memc_timeout = 200,  -- in ms
        memc_conn_pool_size = 5,
        memc_fetch_retries = 2,  -- optional, default 1
        memc_fetch_retry_delay = 100, -- in ms, optional, default to 100 (ms)

        memc_conn_max_idle_time = 10 * 1000,  -- in ms, for in-pool connections,optional, default to nil

        memc_store_retries = 2,  -- optional, default to 1
        memc_store_retry_delay = 100,  -- in ms, optional, default to 100 (ms)

        store_ttl = 1,  -- in seconds, optional, default to 0 (i.e., never expires)
    }

معظم المعلمات المعروضة يمكن تبسيطها عن طريق "إنشاء معالج memcached جديد". الطريقة الحالية لتغليف جميع المعلمات عن طريق إلقائها على المستخدم ليست صديقة للمستخدم، لذا أرحب بالمطورين المهتمين للمساهمة بطلبات سحب لتحسين هذا.

أيضًا، يتم ذكر المزيد من التحسينات في الاتجاهات التالية في وثائق هذه المكتبة المغلقة.

  1. استخدام lua-resty-lrucache لزيادة التخزين المؤقت على مستوى Worker، بدلاً من مجرد التخزين المؤقت على مستوى server باستخدام shared dict.
  2. استخدام ngx.timer للقيام بعمليات تحديث التخزين المؤقت بشكل غير متزامن.

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

lua-resty-mlcache

بعد ذلك، دعونا نقدم تغليف التخزين المؤقت الشائع الاستخدام في OpenResty: lua-resty-mlcache، الذي يستخدم shared dict و lua-resty-lrucache لتنفيذ آلية تخزين مؤقت متعددة الطبقات. دعونا نلقي نظرة على كيفية استخدام هذه المكتبة في المثالين التاليين.

local mlcache = require "resty.mlcache"

local cache, err = mlcache.new("cache_name", "cache_dict", {
    lru_size = 500,    -- حجم التخزين المؤقت L1 (Lua VM)
    ttl = 3600,   -- 1h ttl للضربات
    neg_ttl  = 30,     -- 30s ttl للفشل
})
if not cache then
    error("failed to create mlcache: " .. err)
end

لنلق نظرة على الكود الأول. بداية هذا الكود تقدم مكتبة mlcache وتحدد المعلمات للتهيئة. نضع هذا الكود عادةً في مرحلة init ونحتاج إلى القيام بذلك مرة واحدة فقط.

بالإضافة إلى المعلمتين المطلوبتين، اسم التخزين المؤقت واسم القاموس، المعلمة الثالثة هي قاموس مع 12 خيارًا اختياريًا يستخدم القيم الافتراضية إذا لم يتم ملؤها. هذا أكثر أناقة من lua-resty-memcached-shdict. إذا كنا نصمم الواجهة بأنفسنا، سيكون من الأفضل تبني نهج mlcache - الحفاظ على الواجهة بسيطة قدر الإمكان مع الاحتفاظ بمرونة كافية.

هنا الكود الثاني، وهو الكود المنطقي عند معالجة الطلب.

local function fetch_user(id)
    return db:query_user(id)
end

local id = 123
local user , err = cache:get(id , nil , fetch_user , id)
if err then
    ngx.log(ngx.ERR , "failed to fetch user: ", err)
    return
end

if user then
    print(user.id) -- 123
end

كما ترى، يتم إخفاء التخزين المؤقت متعدد الطبقات، لذا تحتاج إلى استخدام كائن mlcache لجلب التخزين المؤقت وتعيين وظيفة الاستدعاء عند انتهاء صلاحية التخزين المؤقت. يمكن إخفاء المنطق المعقد وراء هذا تمامًا.

قد تكون فضوليًا حول كيفية تنفيذ هذه المكتبة داخليًا. بعد ذلك، دعونا نلقي نظرة أخرى على بنية وتنفيذ هذه المكتبة. الصورة التالية هي شريحة من حديث قدمه Thibault Charbonnier، مؤلف mlcache، في OpenResty Con 2018.

بنية mlcache

كما ترى من الرسم البياني، يقسم mlcache البيانات إلى ثلاث طبقات، وهي L1، L2 و L3.

التخزين المؤقت L1 هو lua-resty-lrucache، حيث يكون لكل Worker نسخته الخاصة، ومع N Workers، هناك N نسخ من البيانات، لذا هناك تكرار للبيانات. نظرًا لأن تشغيل lrucache داخل Worker واحد لا يؤدي إلى تشغيل الأقفال، فإنه يتمتع بأداء أعلى وهو مناسب كتخزين مؤقت من المستوى الأول.

التخزين المؤقت L2 هو shared dict. جميع Workers يشاركون نسخة واحدة من البيانات المخزنة مؤقتًا وسيتم استعلام التخزين المؤقت L2 إذا لم يتم ضرب التخزين المؤقت L1. يوفر ngx.shared.DICT واجهة برمجية تستخدم أقفال الدوران لضمان ذرية العمليات، لذا لا داعي للقلق بشأن حالات السباق هنا.

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

من منظور الطلب:

  • أولاً، سيتم استعلام التخزين المؤقت L1 داخل Worker وإرجاعه مباشرة إذا تم ضرب L1.
  • إذا لم يتم ضرب L1 أو فشل التخزين المؤقت، يتم استعلام التخزين المؤقت L2 بين Workers. إذا تم ضرب L2، يتم إرجاعه وتخزين النتيجة في L1.
  • إذا لم يتم ضرب L2 أيضًا أو تم إبطال التخزين المؤقت، يتم استدعاء وظيفة الاستدعاء للبحث عن البيانات من مصدر البيانات وكتابتها في التخزين المؤقت L2، وهي وظيفة طبقة البيانات L3.

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

ومع ذلك، على الرغم من أن mlcache تم تنفيذها بشكل مثالي، لا يزال هناك نقطة ألم - تسلسل وإلغاء تسلسل البيانات. هذه ليست مشكلة مع mlcache، ولكن الفرق بين lrucache و shared dict، الذي ذكرناه مرارًا وتكرارًا. في lrucache، يمكننا تخزين أنواع بيانات Lua المختلفة، بما في ذلك table؛ ولكن في shared dict، يمكننا فقط تخزين السلاسل النصية.

التخزين المؤقت L1، lrucache، هو طبقة البيانات التي يلمسها المستخدمون، ونريد تخزين جميع أنواع البيانات فيه، بما في ذلك string، table، cdata، وما إلى ذلك. المشكلة هي أن L2 يمكنه فقط تخزين السلاسل النصية، وعندما يتم رفع البيانات من L2 إلى L1، نحتاج إلى إجراء طبقة تحويل من السلاسل النصية إلى أنواع البيانات التي يمكننا تقديمها مباشرة للمستخدم.

لحسن الحظ، أخذ mlcache هذا الوضع في الاعتبار وقدم وظائف اختيارية l1_serializer في واجهات new و get، مصممة خصيصًا للتعامل مع معالجة البيانات عند رفع L2 إلى L1. يمكننا رؤية الكود النموذجي التالي، الذي استخرجته من مجموعة حالات الاختبار الخاصة بي.

local mlcache = require "resty.mlcache"

local cache, err = mlcache.new("my_mlcache", "cache_shm", {
l1_serializer = function(i)
    return i + 2
end,
})

local function callback()
    return 123456
end

local data = assert(cache:get("number", nil, callback))
assert(data == 123458)

دعني أشرح بسرعة. في هذه الحالة، تقوم وظيفة الاستدعاء بإرجاع الرقم 123456؛ في new، تقوم وظيفة l1_serializer التي قمنا بتعيينها بإضافة 2 إلى الرقم الوارد قبل تعيين التخزين المؤقت L1، مما يصبح 123458. مع مثل هذه الوظيفة التسلسلية، يمكن أن تكون البيانات أكثر مرونة عند التحويل بين L1 و L2.

الخلاصة

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

أخيرًا، فكر في هذا السؤال: هل طبقة القاموس المشترك للتخزين المؤقت ضرورية؟ هل يمكن استخدام lrucache فقط؟ لا تتردد في ترك تعليق ومشاركة رأيك معي، وأنت أيضًا مرحب بك لمشاركة هذه المقالة مع المزيد من الأشخاص للتواصل والتقدم معًا.