التقييد الديناميكي للمعدل في OpenResty
API7.ai
January 6, 2023
في المقال السابق، قدمت لكم خوارزميات leaky bucket
وtoken bucket
، والتي تُستخدم بشكل شائع للتعامل مع حركة المرور المفاجئة. كما تعلمنا كيفية تنفيذ تحديد معدل الطلبات باستخدام تكوين NGINX. ومع ذلك، فإن استخدام تكوين NGINX يقتصر فقط على مستوى الإمكانية ولا يزال هناك طريق طويل ليكون مفيدًا.
المشكلة الأولى هي أن مفتاح تحديد المعدل يقتصر على نطاق من متغيرات NGINX ولا يمكن تعيينه بشكل مرن. على سبيل المثال، لا توجد طريقة لتعيين حدود سرعة مختلفة للمقاطعات المختلفة وقنوات العملاء المختلفة، وهو أمر مطلوب بشكل شائع مع NGINX.
مشكلة أخرى أكبر هي أن المعدل لا يمكن تعديله ديناميكيًا، ويتطلب كل تغيير إعادة تحميل خدمة NGINX. نتيجة لذلك، يمكن تنفيذ تحديد السرعة بناءً على فترات مختلفة فقط بشكل غير فعال من خلال البرامج النصية الخارجية.
من المهم فهم أن التكنولوجيا تخدم الأعمال، وفي نفس الوقت، تقود الأعمال التكنولوجيا. في وقت ولادة NGINX، كانت هناك حاجة قليلة لتعديل التكوين ديناميكيًا؛ كانت أكثر حول الوكيل العكسي، موازنة الحمل، استخدام الذاكرة المنخفضة، وغيرها من الاحتياجات المشابهة التي قادت نمو NGINX. من حيث هندسة التكنولوجيا والتنفيذ، لم يكن بإمكان أي شخص توقع الانفجار الهائل في الطلب على التحكم الديناميكي والدقيق في سيناريوهات مثل الإنترنت المحمول، إنترنت الأشياء، والخدمات المصغرة.
استخدام OpenResty للنصوص البرمجية Lua يعوض النقص في NGINX في هذا المجال، مما يجعله مكملاً فعالاً. هذا هو السبب في أن OpenResty يستخدم على نطاق واسع كبديل لـ NGINX. في المقالات القليلة القادمة، سأستمر في تقديم المزيد من السيناريوهات والأمثلة الديناميكية في OpenResty. لنبدأ بالنظر في كيفية استخدام OpenResty لتنفيذ تحديد المعدل الديناميكي.
في OpenResty، نوصي باستخدام lua-resty-limit-traffic لتحديد حركة المرور. يتضمن limit-req
(تحديد معدل الطلبات)، limit-count
(تحديد عدد الطلبات)، وlimit-conn
(تحديد الاتصالات المتزامنة)؛ ويوفر limit.traffic
لتجميع هذه الطرق الثلاث.
تحديد معدل الطلبات
لنبدأ بالنظر إلى limit-req
، الذي يستخدم خوارزمية leaky bucket
لتحديد معدل الطلبات.
في القسم السابق، قدمنا بإيجاز الكود الرئيسي لتنفيذ خوارزمية leaky bucket
في مكتبة resty هذه، والآن سنتعلم كيفية استخدام هذه المكتبة. أولاً، لنلقي نظرة على الكود النموذجي التالي.
resty --shdict='my_limit_req_store 100m' -e 'local limit_req = require "resty.limit.req"
local lim, err = limit_req.new("my_limit_req_store", 200, 100)
local delay, err = lim:incoming("key", true)
if not delay then
if err == "rejected" then
return ngx.exit(503)
end
return ngx.exit(500)
end
if delay >= 0.001 then
ngx.sleep(delay)
end'
نحن نعلم أن lua-resty-limit-traffic
يستخدم shared dict
لتخزين وإحصاء المفاتيح، لذلك نحتاج إلى الإعلان عن مساحة 100m
لـ my_limit_req_store
قبل أن نتمكن من استخدام limit-req
. هذا مشابه لـ limit-conn
وlimit-count
، حيث يحتاج كل منهما إلى مساحة shared dict
منفصلة للتمييز.
limit_req.new("my_limit_req_store", 200, 100)
السطر السابق من الكود هو أحد أكثر الأسطر أهمية. يعني ذلك أن shared dict
يسمى my_limit_req_store
يستخدم لتخزين الإحصائيات، ويتم تعيين المعدل في الثانية إلى 200
، بحيث إذا تجاوز 200
ولكن كان أقل من 300
(يتم حساب هذه القيمة من 200 + 100
)، سيتم وضعها في قائمة الانتظار، وإذا تجاوزت 300
، سيتم رفضها.
بعد الانتهاء من الإعداد، علينا معالجة الطلب من العميل. lim: incoming("key", true)
موجود هنا للقيام بذلك. incoming
له معاملان، نحتاج إلى قراءتهما بالتفصيل.
المعامل الأول، المفتاح المحدد من قبل المستخدم لتحديد المعدل، هو ثابت نصي في المثال أعلاه، مما يعني أن تحديد المعدل يجب أن يكون موحدًا لجميع العملاء. إذا كنت تريد تحديد المعدل وفقًا للمقاطعات والقنوات المختلفة، فمن السهل جدًا استخدام كلتا المعلومات كالمفتاح، وإليك الكود الزائف لتحقيق هذا المطلب.
local province = get_ province(ngx.var.binary_remote_addr)
local channel = ngx.req.get_headers()["channel"]
local key = province .. channel
lim:incoming(key, true)
بالطبع، يمكنك أيضًا تخصيص معنى المفتاح وشروط استدعاء incoming
، بحيث تحصل على تأثير مرن جدًا لتحديد المعدل.
لننظر إلى المعامل الثاني لدالة incoming
، وهو قيمة منطقية. القيمة الافتراضية هي false
، مما يعني أن الطلب لن يتم تسجيله في shared dict
للإحصاء؛ إنه مجرد تمرين. إذا تم تعيينه إلى true
، فسيكون له تأثير حقيقي. لذلك، في معظم الحالات، ستحتاج إلى تعيينه إلى true
بشكل صريح.
قد تتساءل عن سبب وجود هذا المعامل. فكر في سيناريو حيث تقوم بإعداد مثيلين مختلفين من limit-req
بمفاتيح مختلفة، أحد المفاتيح هو اسم المضيف والآخر هو عنوان IP العميل. ثم، عند معالجة طلب العميل، يتم استدعاء دوال incoming
لهذه المثيلات بالترتيب، كما هو موضح في الكود الزائف التالي.
local limiter_one, err = limit_req.new("my_limit_req_store", 200, 100)
local limiter_two, err = limit_req.new("my_limit_req_store", 20, 10)
limiter_one :incoming(ngx.var.host, true)
limiter_two:incoming(ngx.var.binary_remote_addr, true)
إذا تجاوز طلب المستخدم عتبة الكشف لـ limiter_one
ولكن تم رفضه بواسطة الكشف لـ limiter_two
، فيجب اعتبار استدعاء دالة limiter_one:incoming
تمرينًا ولا نحتاج إلى إحصائه.
في هذه الحالة، منطق الكود أعلاه ليس صارمًا بما فيه الكفاية. نحتاج إلى إجراء تمرين مسبق لجميع المحددات بحيث إذا تم تشغيل عتبة محددة يمكن أن ترفض طلب العميل، يمكنها العودة مباشرة.
for i = 1, n do
local lim = limiters[i]
local delay, err = lim:incoming(keys[i], i == n)
if not delay then
return nil, err
end
end
هذا هو كل ما يتعلق بالمعامل الثاني لدالة incoming
. هذا الكود هو الكود الأساسي لوحدة limit.traffic
، والتي تُستخدم لدمج عدة محددات معدل.
تحديد عدد الطلبات
لنلق نظرة على limit.count
، مكتبة لتحديد عدد الطلبات. تعمل مثل GitHub API Rate Limiting، التي تحد من عدد طلبات المستخدم في نافذة زمنية ثابتة. كالعادة، لنبدأ بكود نموذجي.
local limit_count = require "resty.limit.count"
local lim, err = limit_count.new("my_limit_count_store", 5000, 3600)
local key = ngx.req.get_headers()["Authorization"]
local delay, remaining = lim:incoming(key, true)
يمكنك أن ترى أن limit.count
وlimit.req
يستخدمان بشكل مشابه. نبدأ بتعريف shared dict
في nginx.conf
.
lua_shared_dict my_limit_count_store 100m;
ثم نقوم بـ new
كائن محدد، وأخيرًا نستخدم دالة incoming
لتحديد ومعالجة الطلب.
ومع ذلك، الفرق هو أن القيمة الثانية التي تُرجعها دالة incoming
في limit-count
تمثل الطلبات المتبقية، ويمكننا إضافة حقول إلى رأس الاستجابة وفقًا لذلك لإعطاء العميل إشارة أفضل.
ngx.header["X-RateLimit-Limit"] = "5000"
ngx.header["X-RateLimit-Remaining"] = remaining
تحديد عدد الاتصالات المتزامنة
limit.conn
هي مكتبة لتحديد عدد الاتصالات المتزامنة. تختلف عن المكتبتين المذكورتين سابقًا في أنها تحتوي على leaving API
خاص، والذي سأصفه بإيجاز هنا.
تحديد معدل الطلبات وعدد الطلبات، كما ذكرنا أعلاه، يمكن القيام به مباشرة في مرحلة access
. على عكس تحديد عدد الاتصالات المتزامنة، الذي يتطلب ليس فقط تحديد ما إذا تم تجاوز العتبة في مرحلة access
ولكن أيضًا استدعاء leaving API
في مرحلة log
.
log_by_lua_block {
local latency = tonumber(ngx.var.request_time) - ctx.limit_conn_delay
local key = ctx.limit_conn_key
local conn, err = lim:leaving(key, latency)
}
ومع ذلك، فإن الكود الأساسي لهذا API بسيط جدًا، وهو السطر التالي من الكود الذي يقلل عدد الاتصالات بواحد. إذا لم تقم بالتنظيف في مرحلة log
، سيستمر عدد الاتصالات في الزيادة وسيصل قريبًا إلى عتبة التزامن.
local conn, err = dict:incr(key, -1)
دمج محددات المعدل
هذا هو نهاية مقدمة كل من هذه الطرق الثلاث. أخيرًا، لنرى كيفية دمج limit.rate
، limit.conn
وlimit.count
. هنا نحتاج إلى استخدام دالة combine
في limit.traffic
.
local lim1, err = limit_req.new("my_req_store", 300, 200)
local lim2, err = limit_req.new("my_req_store", 200, 100)
local lim3, err = limit_conn.new("my_conn_store", 1000, 1000, 0.5)
local limiters = {lim1, lim2, lim3}
local host = ngx.var.host
local client = ngx.var.binary_remote_addr
local keys = {host, client, client}
local delay, err = limit_traffic.combine(limiters, keys, states)
يجب أن يكون هذا الكود سهل الفهم مع المعرفة التي حصلت عليها للتو. الكود الأساسي لدالة combine
، الذي ذكرناه في تحليل limit.rate
أعلاه، يتم تنفيذه بشكل أساسي بمساعدة دالة drill
ودالة uncommit
. هذا الدمج يسمح لك بتعيين عتبات ومفاتيح مختلفة لمحددات متعددة لتحقيق متطلبات أعمال أكثر تعقيدًا.
الخلاصة
لا يدعم limit.traffic
فقط محددات المعدل الثلاثة المذكورة اليوم، ولكن طالما أن محدد المعدل يحتوي على incoming
وuncommit API
، يمكن إدارته بواسطة دالة combine
لـ limit.traffic
.
أخيرًا، سأترك لكم سؤالاً للواجب المنزلي. هل يمكنك كتابة مثال يدمج محددات معدل token
وbucket
التي قدمناها من قبل؟ لا تتردد في كتابة إجابتك في قسم التعليقات لمناقشتها معي، وأنت أيضًا مرحب بك لمشاركة هذه المقالة مع زملائك وأصدقائك للتعلم والتبادل معًا.