سحر التواصل بين عمال NGINX: واحدة من أهم هياكل البيانات `shared dict`
API7.ai
October 27, 2022
كما قلنا في المقال السابق، فإن table
هي بنية البيانات الوحيدة في Lua. وهذا يتوافق مع shared dict
، وهي بنية البيانات الأكثر أهمية التي يمكنك استخدامها في برمجة OpenResty. فهي تدعم تخزين البيانات، القراءة، العد الذري، وعمليات الطابور.
بناءً على shared dict
، يمكنك تنفيذ التخزين المؤقت والاتصال بين عدة Worker
s، والحد من المعدل، وإحصاءات حركة المرور، وغيرها من الوظائف. يمكنك استخدام shared dict
كبديل بسيط لـ Redis، مع ملاحظة أن البيانات في shared dict
ليست دائمة، لذا يجب عليك مراعاة فقدان البيانات المخزنة.
عدة طرق لمشاركة البيانات
عند كتابة كود Lua في OpenResty، ستواجه حتمًا مشاركة البيانات بين Worker
s مختلفة في مراحل مختلفة من الطلب. قد تحتاج أيضًا إلى مشاركة البيانات بين كود 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
يشارك البيانات في مراحل مختلفة. بالطبع، يمكنك أيضًا تعديل المثال أعلاه بحفظ كائنات أكثر تعقيدًا مثل table
s بدلاً من السلاسل البسيطة لترى إذا كانت تلبي توقعاتك.
ومع ذلك، ملاحظة خاصة هنا هي أن دورة حياة 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 المعقدة. هذا يعني أنه عندما أحتاج إلى تخزين أنواع بيانات معقدة مثل table
s، سأضطر إلى استخدام JSON أو طرق أخرى لتسلسل وفك تسلسل البيانات، مما سيسبب خسارة كبيرة في الأداء.
على أي حال، لا يوجد حل سحري هنا، ولا توجد طريقة مثالية لمشاركة البيانات. يجب عليك الجمع بين عدة طرق وفقًا لاحتياجاتك وسيناريوهاتك.
Shared dict
لقد قضينا الكثير من الوقت في تعلم جزء مشاركة البيانات أعلاه، وقد يتساءل البعض: يبدو أنها ليست مرتبطة مباشرة بـ shared dict. أليس هذا خارج الموضوع؟
في الواقع، لا. فكر في الأمر: لماذا يوجد shared dict
في OpenResty؟ تذكر أن الطرق الثلاث الأولى لمشاركة البيانات كلها على مستوى الطلب أو مستوى Worker
الفردي. لذلك، في التطبيق الحالي لـ OpenResty، فقط shared dict
يمكنه تحقيق مشاركة البيانات بين Worker
s، مما يتيح الاتصال بين Worker
s، وهذا هو قيمة وجوده.
في رأيي، فهم سبب وجود التكنولوجيا ومعرفة الفروق والمزايا مقارنة بالتكنولوجيات المشابهة الأخرى هو أكثر أهمية بكثير من مجرد إتقان استدعاء واجهات برمجة التطبيقات التي توفرها. هذه الرؤية التقنية تمنحك درجة من البصيرة والاستبصار، ويمكن القول إنها فرق مهم بين المهندسين والمهندسين المعماريين.
بالعودة إلى 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
الأخرى التي تقوم بتغليف التخزين المؤقت؟
نرحب بمشاركة هذه المقالة مع زملائك وأصدقائك حتى نتمكن من التواصل والتحسين معًا.