Kommunikationsmagie zwischen NGINX-Workern: Eine der wichtigsten Datenstrukturen `shared dict`

API7.ai

October 27, 2022

OpenResty (NGINX + Lua)

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 Workers 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 Workers 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 tables 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 Workers 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 tables 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 Workers ermöglichen, was die Kommunikation zwischen Workers 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 Workers 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.