Tipps für eine 10-fache Leistungssteigerung in OpenResty: Die `Table`-Datenstruktur
API7.ai
December 9, 2022
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.
- 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. - 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.