Tipps für eine 10-fache Leistungssteigerung in OpenResty: Die `Table`-Datenstruktur

API7.ai

December 9, 2022

OpenResty (NGINX + Lua)

In OpenResty sind neben den häufigen Leistungsproblemen bei string-Operationen auch table-Operationen ein Leistungsengpass. In früheren Artikeln haben wir table-bezogene Funktionen sporadisch behandelt, jedoch nicht speziell im Hinblick auf Leistungsverbesserungen. Heute werde ich Sie durch die Leistungsauswirkungen von table-Operationen führen.

Es gibt zwei Hauptgründe, warum Entwickler weniger über table-bezogene Leistungsoptimierungen wissen als über string-Operationen.

  1. Das in OpenResty verwendete Lua ist ein selbst gepflegter LuaJIT-Zweig und nicht der Standard-LuaJIT oder Standard-Lua. Die meisten Entwickler sind sich des Unterschieds nicht bewusst und neigen dazu, die Standard-Lua-table-Bibliothek zu verwenden, um OpenResty-Code zu schreiben.
  2. Sowohl im Standard-LuaJIT als auch im von OpenResty selbst gepflegten LuaJIT-Zweig sind die Dokumentationen zu table-Operationen tief versteckt und für Entwickler schwer zu finden. Außerdem gibt es in der Dokumentation keinen Beispielcode, sodass Entwickler in Open-Source-Projekten nach Beispielen suchen müssen.

Diese beiden Punkte machen das Erlernen von OpenResty zu einer relativ hohen kognitiven Hürde, was zu polarisierten Ergebnissen führt – erfahrene OpenResty-Entwickler können sehr leistungsstarken Code schreiben, während Neulinge sich fragen, ob die hohe Leistung von OpenResty wirklich existiert. Aber nachdem Sie diese Lektion gelernt haben, können Sie die Wahrnehmungshürde leicht überwinden und eine 10-fache Leistungssteigerung erreichen.

Bevor wir uns mit den Details der table-Optimierung befassen, möchte ich ein einfaches Prinzip der table-bezogenen Optimierung betonen.

Versuchen Sie, Tabellen wiederzuverwenden und unnötige Tabellenerstellungen zu vermeiden.

Wir werden die Optimierung in Bezug auf die Erstellung von table, das Einfügen von Elementen, das Leeren und die Schleifenverwendung einführen.

Vorab generiertes Array

Der erste Schritt besteht darin, ein Array zu erstellen. In Lua ist die Art und Weise, wie wir ein Array erstellen, einfach.

local t = {}

Die obige Codezeile erstellt ein leeres Array. Sie können auch die initialisierten Daten direkt bei der Erstellung hinzufügen:

local color = {first = "red", "blue", third = "green", "yellow"}

Die zweite Schreibweise ist jedoch in Bezug auf die Leistung kostspieliger, da sie die Speicherzuweisung, resize und rehash des Arrays bei jedem Hinzufügen und Löschen von Array-Elementen beinhaltet.

Wie sollte es also optimiert werden? Raum gegen Zeit ist ein gängiger Optimierungsansatz. Da der Leistungsengpass hier die dynamische Zuweisung von Array-Speicherplatz ist, können wir ein Array mit einer bestimmten Größe vorab generieren. Obwohl dies etwas Speicherplatz verschwenden kann, können die mehrfache Speicherzuweisung, resize und rehash in einem Schritt kombiniert werden, was viel effizienter ist.

Die Funktion table.new(narray, nhash) in LuaJIT wurde hinzugefügt.

Diese Funktion weist den angegebenen Array- und Hash-Speicherplatz vorab zu, anstatt sich selbst zu vergrößern, wenn Elemente eingefügt werden. Dies ist die Bedeutung ihrer beiden Parameter, narray und nhash.

Hier ist ein einfaches Beispiel, um zu sehen, wie man sie verwendet. Da diese Funktion eine LuaJIT-Erweiterung ist, müssen wir sie zuerst wie folgt einbinden, bevor wir sie verwenden können.

local new_tab = require "table.new"
 local t = new_tab(100, 0)
 for i = 1, 100 do
   t[i] = i
 end

Da das frühere OpenResty LuaJIT nicht vollständig gebunden hat und weiterhin Standard-Lua unterstützt, wird in einigen älteren Codes dies aus Kompatibilitätsgründen gemacht. Wenn die Funktion table.new nicht gefunden wird, wird eine leere Funktion simuliert, um die Einheitlichkeit des Aufrufers zu gewährleisten.

local ok, new_tab = pcall(require, "table.new")
  if not ok then
    new_tab = function (narr, nrec) return {} end
  end

Manuelle Berechnung des table-Index

Sobald Sie ein table-Objekt haben, besteht der nächste Schritt darin, Elemente hinzuzufügen. Die direkteste Methode, ein Element einzufügen, besteht darin, die Funktion table.insert aufzurufen:

local new_tab = require "table.new"
 local t = new_tab(100, 0)
 for i = 1, 100 do
   table.insert(t, i)
 end

Alternativ können Sie zuerst die Länge des aktuellen Arrays ermitteln und Elemente mit Indizes einfügen:

local new_tab = require "table.new"
 local t = new_tab(100, 0)
 for i = 1, 100 do
   t[#t + 1] = i
 end

Beide Methoden müssen jedoch zuerst die Länge des Arrays berechnen und dann neue Elemente hinzufügen. Diese Operation hat eine Zeitkomplexität von O(n). Im obigen Codebeispiel wird die for-Schleife die Länge des Arrays 100 Mal berechnen, sodass die Leistung nicht gut ist, und je größer das Array ist, desto schlechter wird die Leistung.

Schauen wir uns an, wie die offizielle lua-resty-redis-Bibliothek dieses Problem löst.

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

Diese Funktion generiert das Array req vorab, dessen Größe durch die Eingabeparameter der Funktion bestimmt wird, um sicherzustellen, dass so wenig Speicherplatz wie möglich verschwendet wird.

Es verwendet die Variable nbits, um den Index von req manuell zu verwalten, anstatt die eingebaute Lua-Funktion table.insert und den #-Operator zu verwenden, um die Länge zu ermitteln.

Sie können sehen, dass in der for-Schleife einige Operationen wie nbits + 1 Elemente direkt als Indizes einfügen und den Index am Ende mit nbits = nbits + 5 auf den richtigen Wert halten.

Der Vorteil davon ist offensichtlich, es lässt die O(n)-Operation zur Ermittlung der Array-Größe weg und greift stattdessen direkt mit dem Index darauf zu, und die Zeitkomplexität wird zu O(1). Der Nachteil ist ebenfalls offensichtlich, es verringert die Lesbarkeit des Codes, und die Fehlerwahrscheinlichkeit wird stark erhöht, sodass es ein zweischneidiges Schwert ist.

Wiederverwendung einer einzelnen table

Da der Aufwand für die Erstellung einer table so hoch ist, möchten wir sie natürlich so weit wie möglich wiederverwenden. Es gibt jedoch Bedingungen für die Wiederverwendung. Wir müssen zuerst die ursprünglichen Daten in der table bereinigen, um Auswirkungen auf den nächsten Benutzer zu vermeiden.

Hier kommt die Funktion table.clear ins Spiel. Aus ihrem Namen können Sie ersehen, was sie tut: Sie löscht alle Daten im Array, aber die Länge des Arrays ändert sich nicht. Das heißt, wenn Sie ein Array der Länge 100 mit table.new(narray, nhash) generieren, bleibt die Länge nach dem Löschen immer noch 100.

Um Ihnen ein besseres Verständnis ihrer Implementierung zu vermitteln, habe ich unten ein Codebeispiel gegeben, das mit Standard-Lua kompatibel ist:

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

Wie Sie sehen können, setzt die clear-Funktion jedes Element auf nil.

Im Allgemeinen platzieren wir diese Art von zyklischer table auf der obersten Ebene eines Moduls. Auf diese Weise können Sie, wenn Sie die Funktionen im Modul verwenden, je nach Situation entscheiden, ob Sie sie direkt verwenden oder nach dem Löschen verwenden.

Schauen wir uns ein praktisches Anwendungsbeispiel an. Der folgende Pseudo-Code stammt aus dem Open-Source-Microservices-API-Gateway Apache APISIX, und dies ist seine Logik beim Laden des Plugins.

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

Sie können sehen, dass das Array local_plugins die oberste Variable für das plugin-Modul ist. Zu Beginn der load-Funktion wird die table gelöscht, und basierend auf der aktuellen Situation wird eine neue Liste von Plugins generiert.

tablepool

Bis jetzt haben Sie die Optimierung des Durchlaufs einer einzelnen Tabelle gemeistert. Dann können Sie einen Schritt weiter gehen und einen Cache-Pool verwenden, um mehrere Tabellen für den jederzeitigen Zugriff bereitzuhalten, was die Funktion des offiziellen lua-tablepool ist.

Der folgende Code zeigt die grundlegende Verwendung eines Tabellenpools. Wir können eine Tabelle aus einem bestimmten Pool abrufen und sie nach der Verwendung wieder freigeben:

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 verwendet mehrere der zuvor vorgestellten Methoden, und es hat weniger als hundert Codezeilen, daher empfehle ich dringend, es selbst zu suchen und zu studieren. Hier werde ich hauptsächlich seine beiden APIs vorstellen.

Die erste ist die fetch-Methode, die die gleichen Argumente wie table.new verwendet, jedoch mit einem zusätzlichen pool_name. fetch ruft table.new auf, um ein neues Array zu erstellen, wenn es im Pool kein freies Array gibt.

tablepool.fetch(pool_name, narr, nrec)

Die zweite ist release, eine Funktion, die die Tabelle wieder in den Pool zurückgibt. Unter ihren Argumenten wird das letzte, no_clear, verwendet, um zu konfigurieren, ob table.clear aufgerufen wird, um das Array zu löschen.

tablepool.release(pool_name, tb, [no_clear])

Sehen Sie, wie alle zuvor vorgestellten Methoden jetzt miteinander verbunden sind? Seien Sie jedoch vorsichtig, tablepool aus diesem Grund nicht zu missbrauchen. tablepool wird in realen Projekten nicht viel verwendet. Zum Beispiel wird es von Kong nicht verwendet, und APISIX hat nur wenige Aufrufe, die damit zusammenhängen. In den meisten Fällen reicht es aus, auch ohne die Kapselung dieser Ebene tablepool, für uns zu verwenden.

Zusammenfassung

Leistungsoptimierung, ein schwieriges Gebiet in OpenResty, ist ein heißes Thema. Heute habe ich table-bezogene Leistungsoptimierungstipps vorgestellt. Ich hoffe, sie können Ihnen bei Ihrem tatsächlichen Projekt helfen.

Abschließend eine Frage: Können Sie selbst einen Leistungstest durchführen und die Leistungsunterschiede vor und nach der Verwendung von table-bezogenen Optimierungstechniken vergleichen? Nochmals, ich freue mich darauf, Ihre Praxis und Ansichten zu erfahren. Teilen Sie diesen Artikel gerne, damit mehr Menschen daran teilnehmen können.