نصائح لتحسين أداء OpenResty بمقدار 10 أضعاف: هيكل البيانات `Table`

API7.ai

December 9, 2022

OpenResty (NGINX + Lua)

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

هناك سببان رئيسيان يجعلان المطورين يعرفون القليل عن تحسينات الأداء المتعلقة بـ table مقارنة بعمليات string.

  1. Lua المستخدمة في OpenResty هي فرع LuaJIT الذي يتم صيانته ذاتيًا، وليس LuaJIT القياسية أو Lua القياسية. معظم المطورين لا يدركون الفرق ويميلون إلى استخدام مكتبة table القياسية في Lua لكتابة كود OpenResty.
  2. سواء في 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؟ مرة أخرى، مرحبًا بترك تعليق والتواصل معي حيث أرغب في معرفة ممارستك وآرائك. مرحبًا بمشاركة هذه المقالة حتى يتمكن المزيد من الأشخاص من المشاركة فيها معًا.