مفاتيح الأداء العالي: `shared dict` و `lru` Cache

API7.ai

December 22, 2022

OpenResty (NGINX + Lua)

في المقال السابق، قدمت تقنيات تحسين OpenResty وأدوات ضبط الأداء، والتي تشمل string، table، Lua API، LuaJIT، SystemTap، flame graphs، وغيرها.

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

في مقال اليوم، دعونا نلقي نظرة على المكون الذي يلعب دورًا حاسمًا في تحسين الأداء - التخزين المؤقت، ونرى كيف يتم استخدامه وتحسينه في OpenResty.

التخزين المؤقت

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

هنا، أقترح عليك أن تتعرف على آليات التخزين المؤقت المختلفة لـ MySQL قبل أن تصمم نظام التخزين المؤقت الخاص بك. المادة التي أوصي بها هي الكتاب الممتاز High Performance MySQL: Optimization, Backups, and Replication. عندما كنت مسؤولًا عن قاعدة البيانات منذ سنوات عديدة، استفدت كثيرًا من هذا الكتاب، والعديد من سيناريوهات التحسين الأخرى لاحقًا استعارت أيضًا من تصميم MySQL.

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

بشكل عام، هناك مبدأان للتخزين المؤقت.

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

في OpenResty، تصميم واستخدام التخزين المؤقت يتبعان أيضًا هذين المبدأين. هناك مكونان للتخزين المؤقت في OpenResty: shared dict cache و lru cache. الأول يمكنه فقط تخزين كائنات النصوص، وهناك نسخة واحدة فقط من البيانات المخزنة مؤقتًا، والتي يمكن الوصول إليها من قبل كل عامل، لذلك غالبًا ما يتم استخدامها للاتصال بين العمال. الثاني يمكنه تخزين جميع كائنات Lua، ولكن يمكن الوصول إليها فقط داخل عملية عامل واحدة. هناك العديد من البيانات المخزنة مؤقتًا كما هو عدد العمال.

الجدولان التاليان يمكن أن يوضحا الفرق بين shared dict و lru cache:

اسم مكون التخزين المؤقتنطاق الوصولنوع البيانات المخزنة مؤقتًاهيكل البياناتيمكن الحصول على البيانات القديمةعدد APIsاستخدام الذاكرة
shared dictبين عدة عمالكائنات النصوصdict,queueنعم20+قطعة بيانات واحدة
lru cacheداخل عامل واحدجميع كائنات Luadictلا4n نسخة من البيانات (N = عدد العمال)

shared dict و lru cache ليسا جيدين أو سيئين. يجب استخدامهما معًا وفقًا لسيناريو عملك.

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

التالي، دعونا نلقي نظرة تفصيلية على هاتين الطريقتين للتخزين المؤقت.

Shared dict cache

في مقال Lua، قدمنا مقدمة محددة لـ shared dict، هنا مراجعة سريعة لاستخدامه:

$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
                               dict:set("Tom", 56)
                               print(dict:get("Tom"))'

تحتاج إلى الإعلان عن منطقة الذاكرة dogs في ملف تكوين NGINX مسبقًا، ثم يمكن استخدامها في كود Lua. إذا وجدت أن المساحة المخصصة لـ dogs غير كافية أثناء الاستخدام، تحتاج إلى تعديل ملف تكوين NGINX أولاً، ثم إعادة تحميل NGINX لتصبح فعالة. لأننا لا نستطيع التوسع والانكماش أثناء التشغيل.

التالي، دعونا نركز على عدة قضايا متعلقة بالأداء في التخزين المؤقت shared dict.

تسلسل البيانات المخزنة مؤقتًا

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

resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
                        dict:set("Tom", require("cjson").encode({a=111}))
                        print(require("cjson").decode(dict:get("Tom")).a)'

ومع ذلك، فإن عمليات التسلسل وفك التسلسل هذه تستهلك الكثير من وحدة المعالجة المركزية. إذا كانت هذه العمليات كثيرة لكل طلب، يمكنك رؤية استهلاكها على الرسم البياني للهب.

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

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

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

البيانات القديمة

هناك أيضًا طريقة get_stale لقراءة البيانات في shared dict. مقارنة بطريقة get، لديها قيمة إضافية للبيانات المنتهية الصلاحية:

resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
                            dict:set("Tom", 56, 0.01)
                            ngx.sleep(0.02)
                             local val, flags, stale = dict:get_stale("Tom")
                            print(val)'

في المثال أعلاه، يتم تخزين البيانات في shared dict لمدة 0.01 ثانية فقط، وتنتهي صلاحية البيانات بعد 0.02 ثانية من الإعداد. في هذه الحالة، لن يتم الحصول على البيانات من خلال واجهة get ولكن قد يتم الحصول على البيانات المنتهية الصلاحية من خلال get_stale. السبب في استخدام كلمة "قد" هنا هو أن المساحة التي تشغلها البيانات المنتهية الصلاحية لديها فرصة معينة لإعادة التدوير ثم استخدامها لبيانات أخرى. هذه هي خوارزمية LRU.

عند رؤية هذا، قد يكون لديك شك: ما فائدة الحصول على البيانات المنتهية الصلاحية؟ لا تنس أن ما نخزنه في shared dict هو بيانات مخزنة مؤقتًا. حتى إذا انتهت صلاحية البيانات المخزنة مؤقتًا، فهذا لا يعني أن البيانات الأصلية يجب أن يتم تحديثها.

على سبيل المثال، يتم تخزين مصدر البيانات في MySQL. بعد الحصول على البيانات من MySQL، قمنا بتعيين مهلة خمس ثوانٍ في shared dict. ثم، عندما تنتهي صلاحية البيانات، لدينا خياران:

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

الأخير هو حل أكثر تحسينًا يمكن أن يتفاعل مع MySQL بأقل قدر ممكن بحيث تحصل جميع طلبات العملاء على البيانات من أسرع تخزين مؤقت.

في هذه الحالة، كيف نحكم على ما إذا كانت البيانات في مصدر البيانات قد تغيرت أصبحت مشكلة نحتاج إلى التفكير فيها وحلها. التالي، دعونا نأخذ lru cache كمثال لنرى كيف يحل مشروع فعلي هذه المشكلة.

lru Cache

هناك فقط 5 واجهات لـ lru cache: new، set، get، delete، و flush_all. فقط واجهة get مرتبطة بالمشكلة أعلاه. دعونا أولاً نفهم كيف يتم استخدام هذه الواجهة:

resty -e 'local lrucache = require "resty.lrucache"
local cache, err = lrucache.new(200)
cache:set("dog", 32, 0.01)
ngx.sleep(0.02)
local data, stale_data = cache:get("dog")
print(stale_data)'

يمكنك أن ترى أنه في lru cache، القيمة الثانية التي تُرجعها واجهة get هي مباشرة stale_data، بدلاً من تقسيمها إلى واجهتي برمجة مختلفتين، get و get_stale، مثل shared dict. مثل هذه التغليف للواجهة أكثر ملاءمة لاستخدام البيانات المنتهية الصلاحية.

نوصي عمومًا باستخدام أرقام الإصدار للتمييز بين البيانات المختلفة في المشاريع الفعلية. بهذه الطريقة، سيتغير رقم الإصدار أيضًا بعد تغيير البيانات. على سبيل المثال، يمكن استخدام فهرس معدل في etcd كرقم إصدار للعلامة على ما إذا كانت البيانات قد تغيرت. مع مفهوم رقم الإصدار، يمكننا عمل تغليف ثانوي بسيط لـ lru cache. على سبيل المثال، انظر إلى الكود الزائف التالي، مأخوذ من lrucache

local function (key, version, create_obj_fun, ...)
    local obj, stale_obj = lru_obj:get(key)
    -- إذا لم تنته صلاحية البيانات ولم يتغير رقم الإصدار، قم بإرجاع البيانات المخزنة مؤقتًا مباشرة
    if obj and obj._cache_ver == version then
        return obj
    end

    -- إذا انتهت صلاحية البيانات، ولكن لا يزال يمكن الحصول عليها، ولم يتغير رقم الإصدار، قم بإرجاع البيانات المنتهية الصلاحية في التخزين المؤقت مباشرة
    if stale_obj and stale_obj._cache_ver == version then
        lru_obj:set(key, obj, item_ttl)
        return stale_obj
    end

    -- إذا لم يتم العثور على بيانات منتهية الصلاحية، أو تغير رقم الإصدار، قم بالحصول على البيانات من مصدر البيانات
    local obj, err = create_obj_fun(...)
    obj._cache_ver = version
    lru_obj:set(key, obj, item_ttl)
    return obj, err
end

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

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

نعلم أن الطريقة الأكثر تقليدية هي كتابة رقم الإصدار في المفتاح. على سبيل المثال، قيمة المفتاح هي key_1234. هذه الممارسة شائعة جدًا، ولكن في بيئة OpenResty، هذا إهدار. لماذا تقول ذلك؟

أعط مثالًا، وسوف تفهم. إذا تغير رقم الإصدار كل دقيقة، فإن key_1234 ستصبح key_1235 بعد دقيقة واحدة، وسيتم إنشاء 60 مفتاحًا مختلفًا و 60 قيمة في ساعة واحدة. هذا يعني أيضًا أن Lua GC تحتاج إلى إعادة تدوير كائنات Lua وراء 59 زوجًا من المفاتيح والقيم. إنشاء الكائنات وإعادة التدوير سيستهلك المزيد من الموارد إذا كنت تقوم بالتحديث بشكل متكرر.

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

الخلاصة

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

لذلك، هل كان لديك تجربة مماثلة عند استخدام OpenResty؟ نرحب بترك تعليق لمشاركتها معنا، ونرحب بمشاركة هذه المقالة، دعونا نتعلم ونتقدم معًا.