Schlüssel zu hoher Leistung: `shared dict` und `lru` Cache
API7.ai
December 22, 2022
Im vorherigen Artikel habe ich die Optimierungstechniken und Leistungstuning-Tools von OpenResty vorgestellt, die string
, table
, Lua API
, LuaJIT
, SystemTap
, Flame Graphs
usw. umfassen.
Dies sind die Grundlagen der Systemoptimierung, und Sie müssen sie gut beherrschen. Allerdings reicht es nicht aus, sie nur zu kennen, um echten Geschäftsszenarien gerecht zu werden. In einem komplexeren Geschäftsumfeld ist die Aufrechterhaltung einer hohen Leistung eine systematische Arbeit, die nicht nur die Optimierung von Code und Gateway-Ebene umfasst. Es wird verschiedene Aspekte wie Datenbank, Netzwerk, Protokoll, Cache, Festplatte usw. betreffen, was die Bedeutung eines Architekten ausmacht.
Im heutigen Artikel werfen wir einen Blick auf die Komponente, die eine sehr wichtige Rolle bei der Leistungsoptimierung spielt – den Cache, und sehen, wie er in OpenResty verwendet und optimiert wird.
Cache
Auf der Hardwareebene verwenden die meisten Computerhardware Caches, um die Geschwindigkeit zu erhöhen. Zum Beispiel haben CPUs mehrstufige Caches, und RAID-Karten haben Lese- und Schreibcaches. Auf der Softwareebene ist die Datenbank, die wir verwenden, ein sehr gutes Beispiel für das Cache-Design. Es gibt Caches in der SQL-Statement-Optimierung, im Indexdesign und bei den Festplatten-Lese- und Schreibvorgängen.
Hier empfehle ich Ihnen, sich mit den verschiedenen Caching-Mechanismen von MySQL vertraut zu machen, bevor Sie Ihren eigenen Cache entwerfen. Das Material, das ich Ihnen empfehle, ist das ausgezeichnete Buch High Performance MySQL: Optimization, Backups, and Replication. Als ich vor vielen Jahren für die Datenbank verantwortlich war, habe ich sehr von diesem Buch profitiert, und viele andere Optimierungsszenarien haben später auch vom Design von MySQL profitiert.
Zurück zum Caching: Wir wissen, dass ein Caching-System in einer Produktionsumgebung die beste Lösung basierend auf seinen Geschäftsszenarien und Systemengpässen finden muss. Es ist eine Kunst des Ausgleichs.
Im Allgemeinen gibt es zwei Prinzipien beim Caching.
- Das erste ist, dass der Cache so nah wie möglich an der Benutzeranfrage sein sollte. Zum Beispiel sollten Sie keine HTTP-Anfragen senden, wenn Sie einen lokalen Cache verwenden können. Senden Sie sie an den Ursprungsserver, wenn Sie CDN verwenden können, und senden Sie sie nicht an die Datenbank, wenn Sie den OpenResty-Cache verwenden können.
- Das zweite ist, zu versuchen, diesen Prozess und den lokalen Cache zu verwenden, um das Problem zu lösen. Denn über Prozesse, Maschinen und sogar Serverräume hinweg wird der Netzwerk-Overhead des Caches sehr groß sein, was in Hochlastszenarien sehr offensichtlich sein wird.
In OpenResty folgt das Design und die Verwendung des Caches ebenfalls diesen beiden Prinzipien. Es gibt zwei Cache-Komponenten in OpenResty: shared dict
Cache und lru
Cache. Ersterer kann nur String-Objekte zwischenspeichern, und es gibt nur eine Kopie der zwischengespeicherten Daten, auf die jeder Worker zugreifen kann, daher wird er oft für die Datenkommunikation zwischen Workern verwendet. Letzterer kann alle Lua-Objekte zwischenspeichern, aber sie können nur innerhalb eines einzelnen Worker-Prozesses abgerufen werden. Es gibt so viele zwischengespeicherte Daten wie es Worker gibt.
Die folgenden zwei einfachen Tabellen können den Unterschied zwischen shared dict
und lru
Cache veranschaulichen:
Cache-Komponentenname | Zugriffsbereich | Cache-Datentyp | Datenstruktur | Abgelaufene Daten können abgerufen werden | Anzahl der APIs | Speichernutzung |
---|---|---|---|---|---|---|
shared dict | Zwischen mehreren Workern | String-Objekte | dict,queue | ja | 20+ | ein Stück Daten |
lru cache | innerhalb eines einzelnen Workers | alle Lua-Objekte | dict | nein | 4 | n Kopien der Daten (N = Anzahl der Worker) |
shared dict
und lru
Cache sind nicht gut oder schlecht. Sie sollten je nach Ihrem Szenario zusammen verwendet werden.
- Wenn Sie keine Daten zwischen Workern teilen müssen, dann kann
lru
komplexe Datentypen wie Arrays und Funktionen zwischenspeichern und hat die höchste Leistung, daher ist es die erste Wahl. - Aber wenn Sie Daten zwischen Workern teilen müssen, können Sie basierend auf dem
lru
Cache einenshared dict
Cache hinzufügen, um eine zweistufige Cache-Architektur zu bilden.
Als nächstes werfen wir einen detaillierten Blick auf diese beiden Caching-Methoden.
Shared dict
Cache
Im Lua-Artikel haben wir eine spezifische Einführung in shared dict
gegeben, hier ist eine kurze Wiederholung seiner Verwendung:
$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", 56)
print(dict:get("Tom"))'
Sie müssen den Speicherbereich dogs
in der NGINX-Konfigurationsdatei im Voraus deklarieren, und dann kann er im Lua-Code verwendet werden. Wenn Sie feststellen, dass der Speicherplatz, der dogs
zugewiesen wurde, während der Verwendung nicht ausreicht, müssen Sie zuerst die NGINX-Konfigurationsdatei ändern und dann NGINX neu laden, damit die Änderungen wirksam werden. Da wir zur Laufzeit nicht erweitern und verkleinern können.
Als nächstes konzentrieren wir uns auf mehrere leistungsbezogene Probleme im Shared-Dict-Cache.
Serialisierung von Cache-Daten
Das erste Problem ist die Serialisierung von Cache-Daten. Da im shared dict
nur string
-Objekte zwischengespeichert werden können, müssen Sie, wenn Sie ein Array zwischenspeichern möchten, beim Setzen einmal serialisieren und beim Abrufen einmal deserialisieren:
resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", require("cjson").encode({a=111}))
print(require("cjson").decode(dict:get("Tom")).a)'
Solche Serialisierungs- und Deserialisierungsoperationen sind jedoch sehr CPU-intensiv. Wenn so viele Operationen pro Anfrage durchgeführt werden, können Sie ihren Verbrauch im Flame-Graph sehen.
Wie kann man diesen Verbrauch in Shared-Dictionaries vermeiden? Es gibt hier keine gute Methode, entweder um das Array auf Geschäftsebene nicht in das Shared-Dictionary zu legen; oder um die Strings manuell in das JSON-Format zu bringen. Natürlich bringt dies auch Leistungsverbrauch durch String-Verkettung mit sich und kann mehr Bugs verursachen.
Die meisten Serialisierungen können auf Geschäftsebene zerlegt werden. Sie können den Inhalt des Arrays aufteilen und als Strings im Shared-Dictionary speichern. Wenn das nicht funktioniert, können Sie das Array auch in lru
zwischenspeichern und den Speicherplatz gegen die Bequemlichkeit und Leistung des Programms eintauschen.
Darüber hinaus sollte der Schlüssel im Cache so kurz und aussagekräftig wie möglich sein, um Platz zu sparen und das spätere Debugging zu erleichtern.
Abgelaufene Daten
Es gibt auch eine get_stale
-Methode zum Lesen von Daten im shared dict
. Im Vergleich zur get
-Methode hat sie einen zusätzlichen Rückgabewert für abgelaufene Daten:
resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", 56, 0.01)
ngx.sleep(0.02)
local val, flags, stale = dict:get_stale("Tom")
print(val)'
Im obigen Beispiel werden die Daten nur für 0.01
Sekunden im shared dict
zwischengespeichert, und die Daten sind nach 0.02
Sekunden nach dem Setzen abgelaufen. Zu diesem Zeitpunkt werden die Daten nicht über die get
-Schnittstelle abgerufen, aber abgelaufene Daten können möglicherweise über get_stale
abgerufen werden. Der Grund, warum ich hier das Wort "möglicherweise" verwende, ist, dass der von abgelaufenen Daten belegte Speicherplatz eine gewisse Chance hat, recycelt und dann für andere Daten verwendet zu werden. Dies ist der LRU
-Algorithmus.
Wenn Sie dies sehen, haben Sie möglicherweise Zweifel: Was nützt es, abgelaufene Daten abzurufen? Vergessen Sie nicht, dass wir im shared dict
zwischengespeicherte Daten speichern. Selbst wenn die zwischengespeicherten Daten abgelaufen sind, bedeutet dies nicht, dass die Quelldaten aktualisiert werden müssen.
Zum Beispiel sind die Quelldaten in MySQL gespeichert. Nachdem wir die Daten aus MySQL abgerufen haben, setzen wir eine Timeout-Zeit von fünf Sekunden im shared dict
. Wenn die Daten dann ablaufen, haben wir zwei Möglichkeiten:
- Wenn die Daten nicht vorhanden sind, gehen Sie erneut zu MySQL, um sie abzufragen, und legen Sie das Ergebnis in den Cache.
- Bestimmen Sie, ob sich die MySQL-Daten geändert haben. Wenn es keine Änderungen gibt, lesen Sie die abgelaufenen Daten im Cache, ändern Sie deren Ablaufzeit und lassen Sie sie weiterhin gültig sein.
Letzteres ist eine optimiertere Lösung, die so wenig wie möglich mit MySQL interagiert, sodass alle Client-Anfragen Daten aus dem schnellsten Cache erhalten.
Zu diesem Zeitpunkt wird die Frage, wie man feststellt, ob sich die Daten in der Datenquelle geändert haben, zu einem Problem, das wir berücksichtigen und lösen müssen. Als nächstes nehmen wir den lru
-Cache als Beispiel, um zu sehen, wie ein tatsächliches Projekt dieses Problem löst.
lru
Cache
Es gibt nur 5 Schnittstellen für den lru
-Cache: new
, set
, get
, delete
und flush_all
. Nur die get
-Schnittstelle bezieht sich auf das oben genannte Problem. Lassen Sie uns zunächst verstehen, wie diese Schnittstelle verwendet wird:
resty -e 'local lrucache = require "resty.lrucache"
local cache, err = lrucache.new(200)
cache:set("dog", 32, 0.01)
ngx.sleep(0.02)
local data, stale_data = cache:get("dog")
print(stale_data)'
Sie können sehen, dass im lru
-Cache der zweite Rückgabewert der get-Schnittstelle direkt stale_data
ist, anstatt wie bei shared dict
in zwei verschiedene APIs, get
und get_stale
, unterteilt zu sein. Eine solche Schnittstellenkapselung ist freundlicher für die Verwendung abgelaufener Daten.
In tatsächlichen Projekten empfehlen wir im Allgemeinen die Verwendung von Versionsnummern, um verschiedene Daten zu unterscheiden. Auf diese Weise ändert sich auch ihre Versionsnummer, nachdem sich die Daten geändert haben. Zum Beispiel kann ein modifizierter Index in etcd als Versionsnummer verwendet werden, um zu markieren, ob sich die Daten geändert haben. Mit dem Konzept der Versionsnummer können wir eine einfache sekundäre Kapselung des lru
-Caches vornehmen. Sehen Sie sich zum Beispiel den folgenden Pseudocode an, der aus lrucache entnommen wurde:
local function (key, version, create_obj_fun, ...)
local obj, stale_obj = lru_obj:get(key)
-- Wenn die Daten nicht abgelaufen sind und die Versionsnummer sich nicht geändert hat, geben Sie die zwischengespeicherten Daten direkt zurück
if obj and obj._cache_ver == version then
return obj
end
-- Wenn die Daten abgelaufen sind, aber immer noch abgerufen werden können, und die Versionsnummer sich nicht geändert hat, geben Sie die abgelaufenen Daten im Cache direkt zurück
if stale_obj and stale_obj._cache_ver == version then
lru_obj:set(key, obj, item_ttl)
return stale_obj
end
-- Wenn keine abgelaufenen Daten gefunden werden oder die Versionsnummer sich geändert hat, holen Sie die Daten aus der Datenquelle
local obj, err = create_obj_fun(...)
obj._cache_ver = version
lru_obj:set(key, obj, item_ttl)
return obj, err
end
Aus diesem Code können Sie ersehen, dass wir durch die Einführung des Konzepts der Versionsnummer abgelaufene Daten vollständig nutzen, um den Druck auf die Datenquelle zu verringern und eine optimale Leistung zu erzielen, wenn sich die Versionsnummer nicht ändert.
Darüber hinaus gibt es in der obigen Lösung eine potenziell große Optimierung, dass wir den Schlüssel und die Versionsnummer trennen und die Versionsnummer als Attribut des Werts verwenden.
Wir wissen, dass der konventionellere Ansatz darin besteht, die Versionsnummer in den Schlüssel zu schreiben. Zum Beispiel ist der Wert des Schlüssels key_1234
. Diese Praxis ist sehr verbreitet, aber in der OpenResty-Umgebung ist dies eine Verschwendung. Warum sagen wir das?
Geben Sie ein Beispiel, und Sie werden verstehen. Wenn sich die Versionsnummer jede Minute ändert, wird key_1234
nach einer Minute zu key_1235
, und in einer Stunde werden 60 verschiedene Schlüssel und 60 Werte neu generiert. Dies bedeutet auch, dass Lua GC die Lua-Objekte hinter 59 Schlüssel-Wert-Paaren recyceln muss. Objekterstellung und GC werden mehr Ressourcen verbrauchen, wenn Sie häufiger aktualisieren.
Natürlich können diese Verbräuche auch einfach vermieden werden, indem die Versionsnummer vom Schlüssel in den Wert verschoben wird. Unabhängig davon, wie häufig ein Schlüssel aktualisiert wird, existieren nur zwei feste Lua-Objekte. Es ist ersichtlich, dass solche Optimierungstechniken sehr raffiniert sind. Hinter den einfachen und raffinierten Techniken müssen Sie jedoch die API und den Caching-Mechanismus von OpenResty tief verstehen.
Zusammenfassung
Obwohl die Dokumentation von OpenResty relativ detailliert ist, müssen Sie erfahren und verstehen, wie sie mit dem Geschäft kombiniert werden kann, um den größten Optimierungseffekt zu erzielen. In vielen Fällen gibt es in der Dokumentation nur ein oder zwei Sätze, wie z. B. abgelaufene Daten, aber sie werden einen großen Leistungsunterschied ausmachen.
Haben Sie also ähnliche Erfahrungen gemacht, als Sie OpenResty verwendet haben? Hinterlassen Sie uns gerne eine Nachricht, um sie mit uns zu teilen, und teilen Sie diesen Artikel gerne, damit wir gemeinsam lernen und Fortschritte machen können.