سحر التواصل بين عمال NGINX: واحدة من أهم هياكل البيانات `shared dict`

API7.ai

October 27, 2022

OpenResty (NGINX + Lua)

كما قلنا في المقال السابق، فإن table هي بنية البيانات الوحيدة في Lua. وهذا يتوافق مع shared dict، وهي بنية البيانات الأكثر أهمية التي يمكنك استخدامها في برمجة OpenResty. فهي تدعم تخزين البيانات، القراءة، العد الذري، وعمليات الطابور.

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

عدة طرق لمشاركة البيانات

عند كتابة كود Lua في OpenResty، ستواجه حتمًا مشاركة البيانات بين Workers مختلفة في مراحل مختلفة من الطلب. قد تحتاج أيضًا إلى مشاركة البيانات بين كود Lua وC.

لذا، قبل أن نقدم بشكل رسمي واجهات برمجة تطبيقات shared dict، دعنا أولاً نفهم طرق مشاركة البيانات الشائعة في OpenResty ونعرف كيفية اختيار طريقة مشاركة بيانات أكثر ملاءمة وفقًا للوضع الحالي.

الأول هو المتغيرات في NGINX. يمكنها مشاركة البيانات بين وحدات C في NGINX. وبطبيعة الحال، يمكنها أيضًا مشاركة البيانات بين وحدات C ووحدة lua-nginx-module التي يوفرها OpenResty، كما في الكود التالي.

location /foo {
     set $my_var ''; # هذا السطر مطلوب لإنشاء $my_var في وقت التكوين
     content_by_lua_block {
         ngx.var.my_var = 123;
         ...
     }
 }

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

الثاني هو ngx.ctx، الذي يمكنه مشاركة البيانات بين مراحل مختلفة من نفس الطلب. إنه table عادي في Lua، لذا فهو سريع ويمكنه تخزين كائنات Lua المختلفة. دورة حياته على مستوى الطلب؛ عندما ينتهي الطلب، يتم تدمير ngx.ctx.

فيما يلي سيناريو استخدام نموذجي حيث نستخدم ngx.ctx لتخزين مكالمات مكلفة مثل متغيرات NGINX واستخدامها في مراحل مختلفة.

location /test {
     rewrite_by_lua_block {
         ngx.ctx.host = ngx.var.host
     }
     access_by_lua_block {
        if (ngx.ctx.host == 'api7.ai') then
            ngx.ctx.host = 'test.com'
        end
     }
     content_by_lua_block {
         ngx.say(ngx.ctx.host)
     }
 }

في هذه الحالة، إذا كنت تستخدم curl للوصول إليه.

curl -i 127.0.0.1:8080/test -H 'host:api7.ai'

ثم ستطبع test.com، مما يظهر أن ngx.ctx يشارك البيانات في مراحل مختلفة. بالطبع، يمكنك أيضًا تعديل المثال أعلاه بحفظ كائنات أكثر تعقيدًا مثل tables بدلاً من السلاسل البسيطة لترى إذا كانت تلبي توقعاتك.

ومع ذلك، ملاحظة خاصة هنا هي أن دورة حياة ngx.ctx على مستوى الطلب، لذا فهو لا يقوم بالتخزين المؤقت على مستوى الوحدة. على سبيل المثال، لقد ارتكبت خطأ باستخدام هذا في ملف foo.lua الخاص بي.

local ngx_ctx = ngx.ctx

local function bar()
    ngx_ctx.host =  'test.com'
end

يجب أن نستدعي ونخزن على مستوى الوظيفة.

local ngx = ngx

local function bar()
    ngx_ctx.host =  'test.com'
end

هناك الكثير من التفاصيل حول ngx.ctx، والتي سنستمر في استكشافها لاحقًا في قسم تحسين الأداء.

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

-- mydata.lua
local _M = {}

local data = {
    dog = 3,
    cat = 4,
    pig = 5,
}

function _M.get_age(name)
    return data[name]
end

return _M

التكوين في nginx.conf هو كما يلي.

location /lua {
     content_by_lua_block {
         local mydata = require "mydata"
         ngx.say(mydata.get_age("dog"))
     }
 }

في هذا المثال، mydata هي وحدة يتم تحميلها مرة واحدة فقط بواسطة عملية Worker، وجميع الطلبات التي تتم معالجتها بواسطة Worker بعد ذلك تشارك الكود والبيانات الخاصة بوحدة mydata.

وبطبيعة الحال، فإن المتغير data في وحدة mydata هو متغير على مستوى الوحدة يقع في الجزء العلوي من الوحدة، أي في بداية الوحدة، وهو قابل للوصول من قبل جميع الوظائف.

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

يمكننا تجربة ذلك مع المثال المبسط التالي.

-- mydata.lua
local _M = {}

local data = {
    dog = 3,
    cat = 4,
    pig = 5,
}

function _M.incr_age(name)
    data[name]  = data[name] + 1
    return data[name]
end

return _M

في الوحدة، نضيف وظيفة incr_age، التي تعدل البيانات في جدول data.

ثم، في الكود الذي يستدعي، نضيف السطر الأكثر أهمية ngx.sleep(5)، حيث sleep هي عملية yield.

location /lua {
     content_by_lua_block {
         local mydata = require "mydata"
         ngx.say(mydata. incr_age("dog"))
         ngx.sleep(5) -- yield API
         ngx.say(mydata. incr_age("dog"))
     }
 }

بدون هذا السطر من كود sleep (أو عمليات IO غير المحظورة الأخرى، مثل الوصول إلى Redis، إلخ)، لن تكون هناك عملية yield، ولن يكون هناك تنافس، وسيكون الإخراج النهائي متسلسلًا.

ولكن عندما نضيف هذا السطر من الكود، حتى لو كان فقط خلال 5 ثوانٍ من النوم، فمن المحتمل أن يقوم طلب آخر باستدعاء وظيفة mydata.incr_age وتعديل قيمة المتغير، مما يتسبب في أن تكون الأرقام النهائية غير متتالية. المنطق ليس بهذه البساطة في الكود الفعلي، والخطأ أكثر صعوبة في تحديده.

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

الطريقة الرابعة والأخيرة تستخدم shared dict لمشاركة البيانات التي يمكن مشاركتها بين عدة عمال.

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

lua_shared_dict dogs 10m;

shared dict أيضًا يقوم فقط بتخزين بيانات string ولا يدعم أنواع بيانات Lua المعقدة. هذا يعني أنه عندما أحتاج إلى تخزين أنواع بيانات معقدة مثل tables، سأضطر إلى استخدام JSON أو طرق أخرى لتسلسل وفك تسلسل البيانات، مما سيسبب خسارة كبيرة في الأداء.

على أي حال، لا يوجد حل سحري هنا، ولا توجد طريقة مثالية لمشاركة البيانات. يجب عليك الجمع بين عدة طرق وفقًا لاحتياجاتك وسيناريوهاتك.

Shared dict

لقد قضينا الكثير من الوقت في تعلم جزء مشاركة البيانات أعلاه، وقد يتساءل البعض: يبدو أنها ليست مرتبطة مباشرة بـ shared dict. أليس هذا خارج الموضوع؟

في الواقع، لا. فكر في الأمر: لماذا يوجد shared dict في OpenResty؟ تذكر أن الطرق الثلاث الأولى لمشاركة البيانات كلها على مستوى الطلب أو مستوى Worker الفردي. لذلك، في التطبيق الحالي لـ OpenResty، فقط shared dict يمكنه تحقيق مشاركة البيانات بين Workers، مما يتيح الاتصال بين Workers، وهذا هو قيمة وجوده.

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

بالعودة إلى shared dict، الذي يوفر أكثر من 20 واجهة برمجة تطبيقات Lua للعامة، وكلها ذرية، لذا لا داعي للقلق بشأن المنافسة في حالة وجود عدة Workers وارتفاع التزامن.

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

التالي، دعنا نستمر في النظر إلى واجهات برمجة تطبيقات shared dict، والتي يمكن تقسيمها إلى ثلاث فئات: قراءة/كتابة القاموس، عمليات الطابور، والإدارة.

قراءة/كتابة القاموس

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

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

بالإضافة إلى set، يوفر OpenResty أيضًا أربع طرق كتابة: safe_set، add، safe_add، وreplace. معنى البادئة safe هنا هو أنه إذا كانت الذاكرة ممتلئة، بدلاً من إزالة البيانات القديمة وفقًا لـ LRU، فإن الكتابة ستفشل وستعيد خطأ no memory.

بالإضافة إلى get، يوفر OpenResty أيضًا طريقة get_stale لقراءة البيانات، والتي لديها قيمة إضافية للبيانات المنتهية الصلاحية مقارنة بطريقة get.

value, flags, stale = ngx.shared.DICT:get_stale(key)

يمكنك أيضًا استدعاء طريقة delete لحذف المفتاح المحدد، وهو ما يعادل set(key, nil).

عمليات الطابور

بالانتقال إلى عمليات الطابور، تمت إضافتها لاحقًا إلى OpenResty وتوفر واجهة مشابهة لـ Redis. يتم وصف كل عنصر في الطابور بواسطة ngx_http_lua_shdict_list_node_t.

typedef struct {
    ngx_queue_t queue;
    uint32_t value_len;
    uint8_t value_type;
    u_char data[1];
} ngx_http_lua_shdict_list_node_t;

لقد نشرت PR لواجهات برمجة تطبيقات الطابور هذه في المقال. إذا كنت مهتمًا بهذا، يمكنك متابعة الوثائق، حالات الاختبار، والكود المصدري لتحليل التنفيذ المحدد.

ومع ذلك، لا توجد أمثلة كود مقابلة لواجهات برمجة تطبيقات الطابور الخمسة التالية في الوثائق، لذا سأقدمها باختصار هنا.

  • lpush``/``rpush يعني إضافة عناصر في كلا طرفي الطابور.
  • lpop``/``rpop، الذي يخرج العناصر من كلا طرفي الطابور.
  • llen، الذي يشير إلى عدد العناصر المرجعة للطابور.

دعونا لا ننسى أداة أخرى مفيدة ناقشناها في المقال السابق: حالات الاختبار. يمكننا عادةً العثور على الكود المقابل في حالة اختبار إذا لم يكن في الوثائق. الاختبارات المتعلقة بالطابور موجودة بالضبط في ملف 145-shdict-list.t.

=== TEST 1: lpush & lpop
--- http_config
    lua_shared_dict dogs 1m;
--- config
    location = /test {
        content_by_lua_block {
            local dogs = ngx.shared.dogs

            local len, err = dogs:lpush("foo", "bar")
            if len then
                ngx.say("push success")
            else
                ngx.say("push err: ", err)
            end

            local val, err = dogs:llen("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:lpop("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:llen("foo")
            ngx.say(val, " ", err)

            local val, err = dogs:lpop("foo")
            ngx.say(val, " ", err)
        }
    }
--- request
GET /test
--- response_body
push success
1 nil
bar nil
0 nil
nil nil
--- no_error_log
[error]

الإدارة

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

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

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

أولاً get_keys(max_count?)، التي تعيد بشكل افتراضي أول 1024 مفتاحًا فقط؛ إذا قمت بتعيين max_count إلى 0، فستعيد جميع المفاتيح. ثم تأتي capacity وfree_space، وكلاهما جزء من مستودع lua-resty-core، لذا تحتاج إلى require قبل استخدامها.

require "resty.core.shdict"

local cats = ngx.shared.cats
local capacity_bytes = cats:capacity()
local free_page_bytes = cats:free_space()

تعيد حجم الذاكرة المشتركة (الحجم المحدد في lua_shared_dict) وعدد البايتات من الصفحات الحرة. نظرًا لأن shared dict يتم تخصيصه بالصفحات، حتى إذا أعاد free_space 0، فقد يكون هناك مساحة في الصفحات المخصصة. وبالتالي، فإن قيمته المرجعة لا تمثل مقدار الذاكرة المشتركة المستخدمة.

الخلاصة

في الممارسة العملية، نستخدم غالبًا التخزين المؤقت متعدد المستويات، ومشروع OpenResty الرسمي لديه أيضًا حزمة تخزين مؤقت. هل يمكنك معرفة أي المشاريع هي؟ أو هل تعرف بعض مكتبات lua-resty الأخرى التي تقوم بتغليف التخزين المؤقت؟

نرحب بمشاركة هذه المقالة مع زملائك وأصدقائك حتى نتمكن من التواصل والتحسين معًا.