`lua-resty-*`-Kapselung befreit Entwickler von mehrstufigem Caching
API7.ai
December 30, 2022
In den vorherigen beiden Artikeln haben wir uns mit dem Caching in OpenResty und dem Cache-Stampede-Problem beschäftigt, die beide auf der grundlegenden Seite liegen. In der tatsächlichen Projektentwicklung bevorzugen Entwickler eine sofort einsatzbereite Bibliothek, bei der alle Details behandelt und verborgen sind und die direkt zur Entwicklung von Geschäftslogik verwendet werden kann.
Dies ist ein Vorteil der Arbeitsteilung: Die Entwickler der Basiskomponenten konzentrieren sich auf eine flexible Architektur, gute Leistung und Code-Stabilität, ohne sich um die darüberliegende Geschäftslogik zu kümmern; während die Anwendungsingenieure sich mehr auf die Geschäftsimplementierung und schnelle Iteration konzentrieren und hoffen, nicht durch verschiedene technische Details der unteren Ebene abgelenkt zu werden. Die Lücke dazwischen kann durch Wrapper-Bibliotheken gefüllt werden.
Das Caching in OpenResty steht vor demselben Problem. shared dict
und lru caches
sind stabil und effizient genug, aber es gibt zu viele Details, die behandelt werden müssen. Die "letzte Meile" für Anwendungsentwicklungsingenieure kann ohne einige nützliche Kapselungen mühsam sein. Hier kommt die Bedeutung der Community ins Spiel. Eine aktive Community wird die Lücken aktiv finden und schnell füllen.
lua-resty-memcached-shdict
Kommen wir zurück zur Cache-Kapselung. lua-resty-memcached-shdict
ist ein offizielles OpenResty-Projekt, das shared dict
verwendet, um eine Ebene der Kapselung für memcached
zu schaffen, und Details wie Cache-Stampede und abgelaufene Daten behandelt. Wenn Ihre zwischengespeicherten Daten zufällig im Backend in memcached
gespeichert sind, können Sie diese Bibliothek ausprobieren.
Es ist eine offiziell von OpenResty entwickelte Bibliothek, aber sie ist nicht standardmäßig im OpenResty-Paket enthalten. Wenn Sie sie lokal testen möchten, müssen Sie ihren Quellcode zuerst in den lokalen OpenResty-Suchpfad herunterladen.
Diese Kapselungsbibliothek ist dieselbe Lösung, die wir im vorherigen Artikel erwähnt haben. Sie verwendet lua-resty-lock
, um gegenseitigen Ausschluss zu gewährleisten. Im Falle eines Cache-Fehlers geht nur eine Anfrage zu memcached
, um die Daten abzurufen, und vermeidet so Cache-Stürme. Die veralteten Daten werden an den Endpunkt zurückgegeben, wenn die neuesten Daten nicht abgerufen werden können.
Diese lua-resty
-Bibliothek ist jedoch, obwohl ein offizielles OpenResty-Projekt, nicht perfekt:
- Erstens hat sie keine Testfallabdeckung, was bedeutet, dass die Codequalität nicht konsistent gewährleistet werden kann.
- Zweitens bietet sie zu viele Schnittstellenparameter, mit 11 erforderlichen und 7 optionalen Parametern.
local memc_fetch, memc_store =
shdict_memc.gen_memc_methods{
tag = "my memcached server tag",
debug_logger = dlog,
warn_logger = warn,
error_logger = error_log,
locks_shdict_name = "some_lua_shared_dict_name",
shdict_set = meta_shdict_set,
shdict_get = meta_shdict_get,
disable_shdict = false, -- optional, default false
memc_host = "127.0.0.1",
memc_port = 11211,
memc_timeout = 200, -- in ms
memc_conn_pool_size = 5,
memc_fetch_retries = 2, -- optional, default 1
memc_fetch_retry_delay = 100, -- in ms, optional, default to 100 (ms)
memc_conn_max_idle_time = 10 * 1000, -- in ms, for in-pool connections,optional, default to nil
memc_store_retries = 2, -- optional, default to 1
memc_store_retry_delay = 100, -- in ms, optional, default to 100 (ms)
store_ttl = 1, -- in seconds, optional, default to 0 (i.e., never expires)
}
Die meisten der exponierten Parameter könnten durch "Erstellen eines neuen memcached
-Handlers" vereinfacht werden. Die aktuelle Art, alle Parameter zu kapseln, indem sie dem Benutzer übergeben werden, ist nicht benutzerfreundlich, daher würde ich es begrüßen, wenn interessierte Entwickler PRs beitragen, um dies zu optimieren.
Weitere Optimierungen werden in der Dokumentation dieser Kapselungsbibliothek in den folgenden Richtungen erwähnt.
- Verwenden Sie
lua-resty-lrucache
, um denWorker
-Level-Cache zu erhöhen, anstatt nur denServer
-Level-shared dict
-Cache. - Verwenden Sie
ngx.timer
, um asynchrone Cache-Aktualisierungsoperationen durchzuführen.
Die erste Richtung ist ein sehr guter Vorschlag, da die Cache-Leistung innerhalb des Workers besser ist; der zweite Vorschlag ist etwas, das Sie basierend auf Ihrem tatsächlichen Szenario berücksichtigen müssen. Ich empfehle jedoch im Allgemeinen nicht die zweite Option, nicht nur weil es eine Begrenzung der Anzahl der Timer gibt, sondern auch weil, wenn die Aktualisierungslogik hier fehlschlägt, der Cache nie wieder aktualisiert wird, was große Auswirkungen hat.
lua-resty-mlcache
Als nächstes stellen wir eine in OpenResty häufig verwendete Cache-Kapselung vor: lua-resty-mlcache
, die shared dict
und lua-resty-lrucache
verwendet, um einen mehrschichtigen Cache-Mechanismus zu implementieren. Schauen wir uns an, wie diese Bibliothek in den folgenden beiden Codebeispielen verwendet wird.
local mlcache = require "resty.mlcache"
local cache, err = mlcache.new("cache_name", "cache_dict", {
lru_size = 500, -- size of the L1 (Lua VM) cache
ttl = 3600, -- 1h ttl for hits
neg_ttl = 30, -- 30s ttl for misses
})
if not cache then
error("failed to create mlcache: " .. err)
end
Schauen wir uns das erste Codebeispiel an. Der Anfang dieses Codes führt die mlcache
-Bibliothek ein und setzt die Parameter für die Initialisierung. Wir würden diesen Code normalerweise in die init
-Phase legen und ihn nur einmal ausführen.
Neben den beiden erforderlichen Parametern, Cache-Name und Dictionary-Name, gibt es einen dritten Parameter, ein Dictionary mit 12 Optionen, die optional sind und Standardwerte verwenden, wenn sie nicht ausgefüllt werden. Dies ist viel eleganter als lua-resty-memcached-shdict
. Wenn wir die Schnittstelle selbst entwerfen würden, wäre es besser, den Ansatz von mlcache
zu übernehmen – die Schnittstelle so einfach wie möglich zu halten, während genügend Flexibilität erhalten bleibt.
Hier ist das zweite Codebeispiel, das den logischen Code darstellt, wenn die Anfrage verarbeitet wird.
local function fetch_user(id)
return db:query_user(id)
end
local id = 123
local user , err = cache:get(id , nil , fetch_user , id)
if err then
ngx.log(ngx.ERR , "failed to fetch user: ", err)
return
end
if user then
print(user.id) -- 123
end
Wie Sie sehen können, ist der mehrschichtige Cache verborgen, daher müssen Sie das mlcache
-Objekt verwenden, um den Cache abzurufen und die Callback-Funktion festzulegen, wenn der Cache abläuft. Die komplexe Logik dahinter kann vollständig verborgen werden.
Sie mögen neugierig sein, wie diese Bibliothek intern implementiert ist. Schauen wir uns als nächstes die Architektur und Implementierung dieser Bibliothek an. Das folgende Bild ist eine Folie aus einem Vortrag von Thibault Charbonnier, dem Autor von mlcache
, auf der OpenResty Con 2018.
Wie Sie dem Diagramm entnehmen können, teilt mlcache
die Daten in drei Schichten auf, nämlich L1
, L2
und L3
.
Der L1
-Cache ist lua-resty-lrucache
, wo jeder Worker
seine eigene Kopie hat, und mit N
Worker
s gibt es N
Kopien der Daten, daher gibt es Datenredundanz. Da der Betrieb von lrucache
innerhalb eines einzelnen Worker
s keine Sperren auslöst, hat er eine höhere Leistung und eignet sich als erster Cache.
Der L2
-Cache ist ein shared dict
. Alle Worker
s teilen sich eine einzige Kopie der zwischengespeicherten Daten und fragen den L2
-Cache ab, wenn der L1
-Cache nicht trifft. ngx.shared
.DICT bietet eine API, die Spinlocks verwendet, um die Atomarität der Operationen zu gewährleisten, daher müssen wir uns hier keine Sorgen über Race Conditions machen.
Der L3
ist der Fall, in dem der L2
-Cache ebenfalls nicht trifft, und die Callback-Funktion ausgeführt werden muss, um die Datenquelle, wie eine externe Datenbank, abzufragen und dann in L2
zu cachen. Hier wird lua-resty-lock
verwendet, um sicherzustellen, dass nur ein Worker
zur Datenquelle geht, um die Daten zu holen, und so Cache-Stürme zu vermeiden.
Aus der Perspektive einer Anfrage:
- Zuerst wird der L1-Cache innerhalb des
Worker
s abgefragt und direkt zurückgegeben, wenn derL1
trifft. - Wenn
L1
nicht trifft oder der Cache fehlschlägt, wird derL2
-Cache zwischen denWorker
s abgefragt. WennL2
trifft, wird zurückgegeben und das Ergebnis inL1
gecacht. - Wenn
L2
ebenfalls nicht trifft oder der Cache ungültig ist, wird eine Callback-Funktion aufgerufen, um die Daten aus der Datenquelle zu holen und in denL2
-Cache zu schreiben, was die Funktion derL3
-Datenebene ist.
Sie können auch aus diesem Prozess ersehen, dass Cache-Aktualisierungen passiv durch Endpunktanfragen ausgelöst werden. Selbst wenn eine Anfrage den Cache nicht abrufen kann, können nachfolgende Anfragen die Aktualisierungslogik auslösen, um die Cache-Sicherheit zu maximieren.
Obwohl mlcache
perfekt implementiert ist, gibt es immer noch einen Schmerzpunkt – die Serialisierung und Deserialisierung von Daten. Dies ist kein Problem von mlcache
, sondern der Unterschied zwischen lrucache
und shared dict
, den wir wiederholt erwähnt haben. In lrucache
können wir verschiedene Lua-Datentypen speichern, einschließlich table
; aber in shared dict
können wir nur Strings speichern.
L1, der lrucache
-Cache, ist die Ebene der Daten, die Benutzer berühren, und wir möchten alle Arten von Daten darin cachen, einschließlich string
, table
, cdata
und so weiter. Das Problem ist, dass L2
nur Strings speichern kann, und wenn die Daten von L2
auf L1
angehoben werden, müssen wir eine Ebene der Konvertierung von Strings zu Datentypen durchführen, die wir direkt an den Benutzer geben können.
Glücklicherweise hat mlcache
diese Situation berücksichtigt und bietet optionale Funktionen l1_serializer
in den new
- und get
-Schnittstellen, die speziell dafür entwickelt sind, die Datenverarbeitung zu behandeln, wenn L2
auf L1
angehoben wird. Wir können das folgende Beispielcode sehen, den ich aus meinem Testfallset extrahiert habe.
local mlcache = require "resty.mlcache"
local cache, err = mlcache.new("my_mlcache", "cache_shm", {
l1_serializer = function(i)
return i + 2
end,
})
local function callback()
return 123456
end
local data = assert(cache:get("number", nil, callback))
assert(data == 123458)
Lassen Sie mich dies schnell erklären. In diesem Fall gibt die Callback-Funktion die Zahl 123456
zurück; in new
wird die von uns festgelegte l1_serializer
-Funktion die eingehende Zahl um 2
erhöhen, bevor der L1
-Cache gesetzt wird, was zu 123458
wird. Mit einer solchen Serialisierungsfunktion können die Daten flexibler zwischen L1
und L2
konvertiert werden.
Zusammenfassung
Mit mehreren Cache-Ebenen kann die serverseitige Leistung maximiert werden, und viele Details sind dazwischen verborgen. An diesem Punkt spart uns eine stabile und effiziente Wrapper-Bibliothek viel Aufwand. Ich hoffe auch, dass die beiden heute vorgestellten Wrapper-Bibliotheken Ihnen helfen werden, das Caching besser zu verstehen.
Zum Schluss denken Sie über diese Frage nach: Ist die gemeinsame Dictionary-Ebene des Caches notwendig? Ist es möglich, nur lrucache
zu verwenden? Hinterlassen Sie gerne einen Kommentar und teilen Sie Ihre Meinung mit mir, und Sie sind auch eingeladen, diesen Artikel mit mehr Menschen zu teilen, um zu kommunizieren und gemeinsam Fortschritte zu machen.