نصائح لتحسين أداء OpenResty بمقدار 10 أضعاف: هيكل البيانات `Table`
API7.ai
December 9, 2022
في OpenResty، بالإضافة إلى المشكلات المتكررة في الأداء مع عمليات string
، فإن عمليات table
تشكل أيضًا عقبة في الأداء. في المقالات السابقة، قمنا بتغطية الوظائف المتعلقة بـ table
بشكل متقطع ولكن ليس بشكل خاص من حيث تحسين الأداء. اليوم، سأأخذكم في جولة حول تأثير عمليات table
على الأداء.
هناك سببان رئيسيان يجعلان المطورين يعرفون القليل عن تحسينات الأداء المتعلقة بـ table
مقارنة بعمليات string
.
- Lua المستخدمة في OpenResty هي فرع LuaJIT الذي يتم صيانته ذاتيًا، وليس LuaJIT القياسية أو Lua القياسية. معظم المطورين لا يدركون الفرق ويميلون إلى استخدام مكتبة
table
القياسية في Lua لكتابة كود OpenResty. - سواء في LuaJIT القياسية أو في فرع LuaJIT الذي يتم صيانته بواسطة OpenResty نفسها، فإن الوثائق المتعلقة بعمليات
table
مخفية بعمق ويصعب على المطورين العثور عليها. ولا توجد أمثلة للكود في الوثائق، لذا يحتاج المطورون إلى البحث عن أمثلة في المشاريع مفتوحة المصدر.
هاتان النقطتان تجعلان تعلم OpenResty حاجزًا إدراكيًا مرتفعًا نسبيًا، مما يؤدي إلى نتائج متطرفة - يمكن لمطوري OpenResty المخضرمين كتابة كود عالي الأداء للغاية، بينما أولئك الذين بدأوا للتو سيتساءلون عما إذا كان الأداء العالي لـ OpenResty حقيقيًا. ولكن بعد تعلم هذا الدرس، يمكنك بسهولة عبور حاجز الإدراك وتحقيق تحسين في الأداء بمقدار 10 أضعاف.
قبل الدخول في تفاصيل تحسين table
، أود أن أؤكد على مبدأ بسيط متعلق بتحسين table
.
حاول إعادة استخدام الجداول وتجنب إنشاء جداول غير ضرورية.
سنقدم التحسين من حيث إنشاء table
، إدراج العناصر، تفريغها، واستخدام الحلقات.
إنشاء مصفوفة مسبقًا
الخطوة الأولى هي إنشاء مصفوفة. في Lua، الطريقة التي ننشئ بها المصفوفة بسيطة.
local t = {}
السطر السابق من الكود ينشئ مصفوفة فارغة. يمكنك أيضًا إضافة البيانات المهيأة عند الإنشاء:
local color = {first = "red", "blue", third = "green", "yellow"}
ومع ذلك، فإن الطريقة الثانية أكثر تكلفة من حيث الأداء لأنها تتضمن تخصيص المساحة، resize
و rehash
للمصفوفة في كل مرة يتم فيها إضافة أو حذف عنصر من المصفوفة.
إذن كيف يجب تحسينها؟ مساحة مقابل الوقت هي فكرة تحسين شائعة. نظرًا لأن الاختناق في الأداء هنا هو التخصيص الديناميكي لمساحة المصفوفة، فيمكننا إنشاء مصفوفة بحجم محدد مسبقًا. على الرغم من أن هذا قد يهدر بعض مساحة الذاكرة، إلا أن التخصيصات المتعددة للمساحة، resize
، و rehash
يمكن دمجها في واحدة، مما يجعلها أكثر كفاءة.
تمت إضافة الدالة table.new(narray, nhash)
في LuaJIT.
هذه الدالة تقوم بتخصيص مساحة المصفوفة والهاش المحددة مسبقًا بدلاً من النمو الذاتي عند إدراج العناصر. هذا هو المقصود من المعلمتين narray
و nhash
.
إليك مثال بسيط لمعرفة كيفية استخدامها. نظرًا لأن هذه الدالة هي امتداد لـ LuaJIT، نحتاج إلى استدعاء ما يلي قبل استخدامها.
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 100 do
t[i] = i
end
أيضًا، لأن OpenResty السابقة لم تكن مربوطة بالكامل بـ LuaJIT ولا تزال تدعم Lua القياسية، فإن بعض الأكواد القديمة ستفعل ذلك من أجل التوافق. إذا لم يتم العثور على الدالة table.new
، فسيتم محاكاة دالة فارغة لضمان توحيد المتصل.
local ok, new_tab = pcall(require, "table.new")
if not ok then
new_tab = function (narr, nrec) return {} end
end
حساب الفهرس يدويًا
بمجرد الحصول على كائن table
، تكون الخطوة التالية هي إضافة العناصر إليه. الطريقة الأكثر مباشرة لإدراج عنصر هي استدعاء الدالة table.insert
:
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 100 do
table.insert(t, i)
end
بدلاً من ذلك، يمكن الحصول على طول المصفوفة الحالية أولاً وإدراج العناصر باستخدام الفهارس:
local new_tab = require "table.new"
local t = new_tab(100, 0)
for i = 1, 100 do
t[#t + 1] = i
end
ومع ذلك، فإن كلاهما يحتاج إلى حساب طول المصفوفة أولاً ثم إضافة عناصر جديدة. تعقيد الوقت لهذه العملية هو O(n)
. في مثال الكود أعلاه، ستقوم الحلقة for
بحساب طول المصفوفة 100
مرة، لذا فإن الأداء ليس جيدًا، وكلما كانت المصفوفة أكبر، كان الأداء أسوأ.
لنلقي نظرة على كيفية حل مكتبة lua-resty-redis
الرسمية لهذه المشكلة.
local function _gen_req(args)
local nargs = #args
local req = new_tab(nargs * 5 + 1, 0)
req[1] = "*" .. nargs .. "\r\n"
local nbits = 2
for i = 1, nargs do
local arg = args[i]
req[nbits] = "$"
req[nbits + 1] = #arg
req[nbits + 2] = "\r\n"
req[nbits + 3] = arg
req[nbits + 4] = "\r\n"
nbits = nbits + 5
end
return req
end
هذه الدالة تقوم بإنشاء المصفوفة req
مسبقًا، حيث يتم تحديد حجمها بواسطة معلمات الإدخال للدالة، لضمان إهدار أقل قدر ممكن من المساحة.
تستخدم المتغير nbits
للحفاظ على فهرس req
يدويًا، بدلاً من استخدام الدالة المضمنة في Lua table.insert
والمشغل #
للحصول على الطول.
يمكنك أن ترى في الحلقة for
، بعض العمليات مثل nbits + 1
تقوم بإدراج العناصر مباشرة كفهارس والحفاظ على الفهرس في القيمة الصحيحة في النهاية مع nbits = nbits + 5
.
ميزة هذا واضحة، حيث يتم حذف عملية O(n)
للحصول على حجم المصفوفة والوصول إليها مباشرة باستخدام الفهرس، ويصبح تعقيد الوقت O(1)
. العيب أيضًا واضح، حيث يقلل من قابلية قراءة الكود، ويزيد من احتمال الخطأ، لذا فهو سيف ذو حدين.
إعادة استخدام table
واحد
نظرًا لأن تكلفة إنشاء table
مرتفعة جدًا، فإننا نرغب بشكل طبيعي في إعادة استخدامها قدر الإمكان. ومع ذلك، هناك شروط لإعادة الاستخدام. نحتاج أولاً إلى تنظيف البيانات الأصلية في table
لتجنب التأثير على المستخدم التالي.
هنا تأتي الدالة table.clear
لتكون مفيدة. من اسمها يمكنك أن ترى ما تفعله، فهي تقوم بمسح جميع البيانات في المصفوفة، ولكن طول المصفوفة لا يتغير. أي إذا كنت تستخدم table.new(narray, nhash)
لإنشاء مصفوفة بطول 100، بعد مسحها، سيكون الطول لا يزال 100
.
لإعطائك فكرة أفضل عن تنفيذها، قمت بتقديم مثال للكود أدناه والذي يتوافق مع Lua القياسية:
local ok, clear_tab = pcall(require, "table.clear")
if not ok then
clear_tab = function (tab)
for k, _ in pairs(tab) do
tab[k] = nil
end
end
end
كما ترى، تقوم الدالة clear
بتعيين كل عنصر إلى nil
.
بشكل عام، نضع هذا النوع من table
الدوري في المستوى الأعلى من الوحدة. بهذه الطريقة، عند استخدام الوظائف في الوحدة، يمكنك تحديد ما إذا كنت ستستخدمها مباشرة أو بعد مسحها، اعتمادًا على حالتك.
لنلقي نظرة على مثال تطبيقي عملي. الكود التالي شبه الكود مأخوذ من بوابة API للخدمات الصغيرة مفتوحة المصدر Apache APISIX، وهذا هو منطقها عند تحميل المكونات الإضافية.
local local_plugins = core.table.new(32, 0)
local function load(plugin_names, wasm_plugin_names)
local processed = {}
for _, name in ipairs(plugin_names) do
if processed[name] == nil then
processed[name] = true
end
end
for _, attrs in ipairs(wasm_plugin_names) do
if processed[attrs.name] == nil then
processed[attrs.name] = attrs
end
end
core.log.warn("new plugins: ", core.json.delay_encode(processed))
for name, plugin in pairs(local_plugins_hash) do
local ty = PLUGIN_TYPE_HTTP
if plugin.type == "wasm" then
ty = PLUGIN_TYPE_HTTP_WASM
end
unload_plugin(name, ty)
end
core.table.clear(local_plugins)
core.table.clear(local_plugins_hash)
for name, value in pairs(processed) do
local ty = PLUGIN_TYPE_HTTP
if type(value) == "table" then
ty = PLUGIN_TYPE_HTTP_WASM
name = value
end
load_plugin(name, local_plugins, ty)
end
-- sort by plugin's priority
if #local_plugins > 1 then
sort_tab(local_plugins, sort_plugin)
end
for i, plugin in ipairs(local_plugins) do
local_plugins_hash[plugin.name] = plugin
if enable_debug() then
core.log.warn("loaded plugin and sort by priority:",
" ", plugin.priority,
" name: ", plugin.name)
end
end
_M.load_times = _M.load_times + 1
core.log.info("load plugin times: ", _M.load_times)
return true
end
يمكنك أن ترى أن المصفوفة local_plugins
هي المتغير الأعلى لوحدة plugin
. في بداية دالة التحميل، يتم مسح table
، ويتم إنشاء قائمة جديدة من المكونات الإضافية بناءً على الوضع الحالي.
tablepool
حتى الآن، لقد أتقنت تحسين الدوران عبر table
واحد. ثم يمكنك الذهاب خطوة أبعد واستخدام تجمع ذاكرة مؤقت للحفاظ على عدة جداول متاحة للوصول في أي وقت، وهي وظيفة lua-tablepool
الرسمية.
يوضح الكود التالي الاستخدام الأساسي لتجمع الجداول. يمكننا جلب table
من تجمع محدد وإعادته عند الانتهاء من استخدامه:
local tablepool = require "tablepool"
local tablepool_fetch = tablepool.fetch
local tablepool_release = tablepool.release
local pool_name = "some_tag"
local function do_sth()
local t = tablepool_fetch(pool_name, 10, 0)
-- -- using t for some purposes
tablepool_release(pool_name, t)
end
يستخدم tablepool
عدة من الطرق التي قدمناها سابقًا، ولديه أقل من مئة سطر من الكود، لذا أوصي بشدة بالبحث ودراسته بنفسك. هنا، سأقدم بشكل رئيسي واجهتي برمجة التطبيقات (APIs) الخاصة به.
الأولى هي طريقة fetch
، التي تأخذ نفس الوسيطات مثل table.new
، ولكن مع pool_name
إضافي. ستقوم fetch
باستدعاء table.new
لإنشاء مصفوفة جديدة إذا لم تكن هناك مصفوفة حرة في التجمع.
tablepool.fetch(pool_name, narr, nrec)
الثانية هي release
، وهي دالة تعيد table
إلى التجمع. من بين وسيطاتها، الأخير no_clear
، يستخدم لتكوين ما إذا كان سيتم استدعاء table.clear
لمسح المصفوفة.
tablepool.release(pool_name, tb, [no_clear])
انظر كيف أن جميع الطرق التي قدمناها سابقًا أصبحت مترابطة الآن؟ ومع ذلك، كن حذرًا من عدم إساءة استخدام tablepool
لهذا السبب. لا يتم استخدام tablepool
كثيرًا في المشاريع الحقيقية. على سبيل المثال، لا يتم استخدامه بواسطة Kong، ولدى APISIX فقط بعض الاستدعاءات المتعلقة به. في معظم الحالات، حتى بدون تغليف هذه الطبقة tablepool
، يكفي استخدامنا.
الخلاصة
تحسين الأداء، منطقة صعبة في OpenResty، هي نقطة ساخنة. اليوم قدمت نصائح تحسين الأداء المتعلقة بـ table
. آمل أن تساعدك في مشروعك الفعلي.
أخيرًا، فكر في سؤال: هل يمكنك إجراء اختبار أداء بنفسك ومقارنة الفرق في الأداء قبل وبعد استخدام تقنيات تحسين الأداء المتعلقة بـ table
؟ مرة أخرى، مرحبًا بترك تعليق والتواصل معي حيث أرغب في معرفة ممارستك وآرائك. مرحبًا بمشاركة هذه المقالة حتى يتمكن المزيد من الأشخاص من المشاركة فيها معًا.