Kommunikationsmagie zwischen NGINX-Workern: Eine der wichtigsten Datenstrukturen `shared dict`
API7.ai
October 27, 2022
Wie wir im vorherigen Artikel gesagt haben, ist die table
die einzige Datenstruktur in Lua. Dies entspricht dem shared dict
, das die wichtigste Datenstruktur ist, die Sie in der OpenResty-Programmierung verwenden können. Es unterstützt Datenspeicherung, -lesen, atomares Zählen und Warteschlangenoperationen.
Basierend auf dem shared dict
können Sie Caching und Kommunikation zwischen mehreren Worker
s sowie Ratenbegrenzung, Verkehrsstatistiken und andere Funktionen implementieren. Sie können shared dict
als einfaches Redis verwenden, mit der Ausnahme, dass die Daten im shared dict
nicht persistent sind, sodass Sie den Verlust gespeicherter Daten berücksichtigen müssen.
Verschiedene Möglichkeiten der Datenteilung
Beim Schreiben des OpenResty Lua-Codes werden Sie unweigerlich auf die Datenteilung zwischen verschiedenen Worker
s in verschiedenen Phasen der Anfrage stoßen. Möglicherweise müssen Sie auch Daten zwischen Lua- und C-Code teilen.
Bevor wir also die shared dict
-APIs formal vorstellen, lassen Sie uns zunächst die gängigen Methoden der Datenteilung in OpenResty verstehen und lernen, wie man eine geeignetere Methode der Datenteilung entsprechend der aktuellen Situation auswählt.
Die erste Möglichkeit sind Variablen in NGINX. Sie können Daten zwischen NGINX-C-Modulen teilen. Natürlich können sie auch Daten zwischen C-Modulen und dem von OpenResty bereitgestellten lua-nginx-module
teilen, wie im folgenden Code.
location /foo {
set $my_var ''; # diese Zeile ist erforderlich, um $my_var zur Konfigurationszeit zu erstellen
content_by_lua_block {
ngx.var.my_var = 123;
...
}
}
Die Verwendung von NGINX-Variablen zur Datenteilung ist jedoch langsam, da sie Hash-Lookups und Speicherzuweisungen beinhaltet. Außerdem hat dieser Ansatz die Einschränkung, dass er nur zur Speicherung von Zeichenketten verwendet werden kann und keine komplexen Lua-Typen unterstützt.
Die zweite Möglichkeit ist ngx.ctx
, das Daten zwischen verschiedenen Phasen derselben Anfrage teilen kann. Es ist eine normale Lua-table
, daher ist es schnell und kann verschiedene Lua-Objekte speichern. Sein Lebenszyklus ist anfragebasiert; wenn die Anfrage endet, wird ngx.ctx
zerstört.
Das Folgende ist ein typisches Anwendungsszenario, in dem wir ngx.ctx
verwenden, um teure Aufrufe wie NGINX-Variablen zu cachen und es in verschiedenen Phasen zu verwenden.
location /test {
rewrite_by_lua_block {
ngx.ctx.host = ngx.var.host
}
access_by_lua_block {
if (ngx.ctx.host == 'api7.ai') then
ngx.ctx.host = 'test.com'
end
}
content_by_lua_block {
ngx.say(ngx.ctx.host)
}
}
In diesem Fall, wenn Sie curl
verwenden, um darauf zuzugreifen.
curl -i 127.0.0.1:8080/test -H 'host:api7.ai'
Dann wird test.com
ausgegeben, was zeigt, dass ngx.ctx
Daten in verschiedenen Phasen teilt. Natürlich können Sie das obige Beispiel auch ändern, indem Sie komplexere Objekte wie table
s anstelle von einfachen Zeichenketten speichern, um zu sehen, ob es Ihren Erwartungen entspricht.
Ein besonderer Hinweis hier ist jedoch, dass der Lebenszyklus von ngx.ctx
anfragebasiert ist und es daher nicht auf Modulebene cached. Zum Beispiel habe ich den Fehler gemacht, dies in meiner foo.lua
-Datei zu verwenden.
local ngx_ctx = ngx.ctx
local function bar()
ngx_ctx.host = 'test.com'
end
Wir sollten auf Funktionsebene aufrufen und cachen.
local ngx = ngx
local function bar()
ngx_ctx.host = 'test.com'
end
Es gibt noch viele weitere Details zu ngx.ctx
, die wir später im Abschnitt zur Leistungsoptimierung weiter untersuchen werden.
Der dritte Ansatz verwendet modulweite Variablen, um Daten über alle Anfragen innerhalb desselben Worker
s hinweg zu teilen. Im Gegensatz zu den vorherigen NGINX-Variablen und ngx.ctx
ist dieser Ansatz etwas weniger verständlich. Aber keine Sorge, das Konzept ist abstrakt, und der Code kommt zuerst, also schauen wir uns ein Beispiel an, um eine modulweite Variable zu verstehen.
-- mydata.lua
local _M = {}
local data = {
dog = 3,
cat = 4,
pig = 5,
}
function _M.get_age(name)
return data[name]
end
return _M
Die Konfiguration in nginx.conf
ist wie folgt.
location /lua {
content_by_lua_block {
local mydata = require "mydata"
ngx.say(mydata.get_age("dog"))
}
}
In diesem Beispiel ist mydata
ein Modul, das nur einmal vom Worker
-Prozess geladen wird, und alle Anfragen, die danach vom Worker
verarbeitet werden, teilen den Code und die Daten des mydata
-Moduls.
Natürlich ist die Datenvariable im mydata
-Modul eine modulweite Variable, die sich auf der obersten Ebene des Moduls befindet, d.h. am Anfang des Moduls, und für alle Funktionen zugänglich ist.
Daher können Sie Daten, die zwischen Anfragen geteilt werden müssen, in die oberste Variable des Moduls legen. Es ist jedoch wichtig zu beachten, dass wir diesen Weg im Allgemeinen nur zur Speicherung von schreibgeschützten Daten verwenden. Wenn Schreiboperationen involviert sind, müssen Sie sehr vorsichtig sein, da es zu einem Race Condition
kommen kann, was ein schwieriger zu lokalisierender Fehler ist.
Wir können dies mit dem folgenden vereinfachten Beispiel erleben.
-- mydata.lua
local _M = {}
local data = {
dog = 3,
cat = 4,
pig = 5,
}
function _M.incr_age(name)
data[name] = data[name] + 1
return data[name]
end
return _M
Im Modul fügen wir die Funktion incr_age
hinzu, die die Daten in der data
-Tabelle ändert.
Dann fügen wir im aufrufenden Code die wichtigste Zeile ngx.sleep(5)
hinzu, wobei sleep
eine yield
-Operation ist.
location /lua {
content_by_lua_block {
local mydata = require "mydata"
ngx.say(mydata. incr_age("dog"))
ngx.sleep(5) -- yield API
ngx.say(mydata. incr_age("dog"))
}
}
Ohne diese Zeile sleep
-Code (oder andere nicht-blockierende IO-Operationen, wie z.B. den Zugriff auf Redis usw.), gäbe es keine yield
-Operation, keinen Wettbewerb, und die endgültige Ausgabe wäre sequentiell.
Aber wenn wir diese Zeile Code hinzufügen, selbst wenn es nur innerhalb von 5 Sekunden Schlaf ist, wird wahrscheinlich eine andere Anfrage die Funktion mydata.incr_age
aufrufen und den Wert der Variablen ändern, wodurch die endgültigen Ausgabezahlen diskontinuierlich werden. Die Logik ist im tatsächlichen Code nicht so einfach, und der Fehler ist viel schwieriger zu lokalisieren.
Daher empfehle ich, Ihre modulweiten Variablen schreibgeschützt zu halten, es sei denn, Sie sind sicher, dass es keine yield
-Operation dazwischen gibt, die die Kontrolle an die NGINX-Ereignisschleife abgibt.
Der vierte und letzte Ansatz verwendet shared dict
, um Daten zu teilen, die zwischen mehreren Workern geteilt werden können.
Dieser Ansatz basiert auf einer Rot-Schwarz-Baum-Implementierung, die gut performt, aber ihre Einschränkungen hat: Sie müssen die Größe des gemeinsamen Speichers in der NGINX-Konfigurationsdatei im Voraus deklarieren, und dies kann zur Laufzeit nicht geändert werden:
lua_shared_dict dogs 10m;
Das shared dict
cached auch nur string
-Daten und unterstützt keine komplexen Lua-Datentypen. Das bedeutet, dass ich, wenn ich komplexe Datentypen wie table
s speichern muss, JSON oder andere Methoden zur Serialisierung und Deserialisierung verwenden muss, was natürlich zu einem erheblichen Leistungsverlust führt.
Jedenfalls gibt es hier kein Allheilmittel und keine perfekte Methode zur Datenteilung. Sie müssen mehrere Methoden entsprechend Ihren Anforderungen und Szenarien kombinieren.
Shared dict
Wir haben viel Zeit damit verbracht, den Teil der Datenteilung oben zu lernen, und einige von Ihnen mögen sich fragen: Es scheint, dass sie nicht direkt mit dem shared dict
zusammenhängen. Ist das nicht themenfremd?
Eigentlich nicht. Bitte denken Sie darüber nach: Warum gibt es ein shared dict
in OpenResty? Erinnern Sie sich daran, dass die ersten drei Methoden der Datenteilung alle auf der Anfrageebene oder der einzelnen Worker
-Ebene liegen. Daher kann in der aktuellen Implementierung von OpenResty nur das shared dict
die Datenteilung zwischen Worker
s ermöglichen, was die Kommunikation zwischen Worker
s ermöglicht, und das ist der Wert seiner Existenz.
Meiner Meinung nach ist das Verständnis, warum eine Technologie existiert, und das Herausfinden ihrer Unterschiede und Vorteile im Vergleich zu anderen ähnlichen Technologien weitaus wichtiger als nur das Beherrschen der APIs, die sie bereitstellt. Diese technische Vision gibt Ihnen ein gewisses Maß an Voraussicht und Einsicht und ist wohl ein wichtiger Unterschied zwischen Ingenieuren und Architekten.
Zurück zum shared dict
, das mehr als 20 Lua-APIs öffentlich bereitstellt, die alle atomar sind, sodass Sie sich keine Sorgen über Wettbewerb im Fall von mehreren Worker
s und hoher Parallelität machen müssen.
Diese APIs haben alle detaillierte offizielle Dokumentation, daher werde ich nicht auf alle eingehen. Ich möchte noch einmal betonen, dass kein technischer Kurs das sorgfältige Lesen der offiziellen Dokumentation ersetzen kann. Niemand kann diese zeitaufwendigen und dummen Verfahren überspringen.
Als nächstes schauen wir uns die shared dict
-APIs an, die in drei Kategorien unterteilt werden können: Dict-Lese-/Schreiboperationen, Warteschlangenoperationen und Verwaltung.
Dict-Lese-/Schreiboperationen
Schauen wir uns zunächst die Dict-Lese- und -Schreiboperationen an. In der ursprünglichen Version gab es nur APIs für Dict-Lese- und -Schreiboperationen, die häufigsten Funktionen von gemeinsamen Wörterbüchern. Hier ist das einfachste Beispiel.
$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", 56)
print(dict:get("Tom"))'
Zusätzlich zu set
bietet OpenResty vier Schreibmethoden: safe_set
, add
, safe_add
und replace
. Die Bedeutung des safe
-Präfixes hier ist, dass, wenn der Speicher voll ist, anstatt die alten Daten nach LRU zu eliminieren, der Schreibvorgang fehlschlägt und einen no memory
-Fehler zurückgibt.
Zusätzlich zu get
bietet OpenResty auch die get_stale
-Methode zum Lesen von Daten, die im Vergleich zur get
-Methode einen zusätzlichen Rückgabewert für abgelaufene Daten hat.
value, flags, stale = ngx.shared.DICT:get_stale(key)
Sie können auch die delete
-Methode aufrufen, um den angegebenen Schlüssel zu löschen, was set(key, nil)
entspricht.
Warteschlangenoperationen
Wenden wir uns den Warteschlangenoperationen zu, die später zu OpenResty hinzugefügt wurden und eine ähnliche Schnittstelle wie Redis bieten. Jedes Element in einer Warteschlange wird durch ngx_http_lua_shdict_list_node_t
beschrieben.
typedef struct {
ngx_queue_t queue;
uint32_t value_len;
uint8_t value_type;
u_char data[1];
} ngx_http_lua_shdict_list_node_t;
Ich habe den PR dieser Warteschlangen-APIs im Artikel gepostet. Wenn Sie daran interessiert sind, können Sie der Dokumentation, den Testfällen und dem Quellcode folgen, um die spezifische Implementierung zu analysieren.
Es gibt jedoch keine entsprechenden Codebeispiele für die folgenden fünf Warteschlangen-APIs in der Dokumentation, daher werde ich sie hier kurz vorstellen.
lpush``/``rpush
bedeutet, Elemente an beiden Enden der Warteschlange hinzuzufügen.lpop``/``rpop
, die Elemente an beiden Enden der Warteschlange entfernen.llen
, die die Anzahl der Elemente in der Warteschlange zurückgibt.
Vergessen wir nicht ein weiteres nützliches Werkzeug, das wir im letzten Artikel besprochen haben: Testfälle. Wir können normalerweise den entsprechenden Code in einem Testfall finden, wenn er nicht in der Dokumentation steht. Die Warteschlangenbezogenen Tests befinden sich genau in der Datei 145-shdict-list.t
.
=== TEST 1: lpush & lpop
--- http_config
lua_shared_dict dogs 1m;
--- config
location = /test {
content_by_lua_block {
local dogs = ngx.shared.dogs
local len, err = dogs:lpush("foo", "bar")
if len then
ngx.say("push success")
else
ngx.say("push err: ", err)
end
local val, err = dogs:llen("foo")
ngx.say(val, " ", err)
local val, err = dogs:lpop("foo")
ngx.say(val, " ", err)
local val, err = dogs:llen("foo")
ngx.say(val, " ", err)
local val, err = dogs:lpop("foo")
ngx.say(val, " ", err)
}
}
--- request
GET /test
--- response_body
push success
1 nil
bar nil
0 nil
nil nil
--- no_error_log
[error]
Verwaltung
Die letzte Verwaltungs-API wurde ebenfalls später hinzugefügt und ist eine beliebte Anforderung in der Community. Ein typisches Beispiel ist die Nutzung des gemeinsamen Speichers. Wenn ein Benutzer beispielsweise 100M
Speicherplatz als shared dict
anfordert, reicht dieser 100M
aus? Wie viele Schlüssel sind darin gespeichert, und welche Schlüssel sind das? Das sind alles echte Fragen.
Für diese Art von Problem hofft das OpenResty-Team, dass Benutzer Flammendiagramme verwenden, um sie zu lösen, d.h. auf eine nicht-invasive Weise, um die Codebasis effizient und sauber zu halten, anstatt eine invasive API bereitzustellen, die die Ergebnisse direkt zurückgibt.
Aber aus benutzerfreundlicher Sicht sind diese Verwaltungs-APIs dennoch unerlässlich. Schließlich sind Open-Source-Projekte dazu da, Produktanforderungen zu lösen, nicht um die Technologie selbst zu präsentieren. Schauen wir uns also die folgenden Verwaltungs-APIs an, die später hinzugefügt werden.
Zuerst ist get_keys(max_count?)
, das standardmäßig nur die ersten 1024
Schlüssel zurückgibt; wenn Sie max_count
auf 0
setzen, gibt es alle Schlüssel zurück. Dann kommen capacity
und free_space
, die beide Teil des lua-resty-core
-Repositorys sind, sodass Sie sie require
müssen, bevor Sie sie verwenden.
require "resty.core.shdict"
local cats = ngx.shared.cats
local capacity_bytes = cats:capacity()
local free_page_bytes = cats:free_space()
Sie geben die Größe des gemeinsamen Speichers (die in lua_shared_dict
konfigurierte Größe) und die Anzahl der freien Seitenbytes zurück. Da der shared dict
seitenweise zugewiesen wird, kann es selbst dann, wenn free_space
0
zurückgibt, noch Platz in den zugewiesenen Seiten geben. Daher repräsentiert sein Rückgabewert nicht, wie viel gemeinsamer Speicher belegt ist.
Zusammenfassung
In der Praxis verwenden wir oft mehrstufiges Caching, und das offizielle OpenResty-Projekt hat auch ein Caching-Paket. Können Sie herausfinden, welche Projekte das sind? Oder kennen Sie einige andere lua-resty
-Bibliotheken, die Caching kapseln?
Sie sind eingeladen, diesen Artikel mit Ihren Kollegen und Freunden zu teilen, damit wir uns austauschen und gemeinsam verbessern können.