Wie man Cache Stampede vermeidet

API7.ai

December 29, 2022

OpenResty (NGINX + Lua)

Im vorherigen Artikel haben wir einige Hochleistungsoptimierungstechniken mit shared dict und lru cache kennengelernt. Allerdings haben wir ein bedeutendes Problem zurückgelassen, das heute seinen eigenen Artikel verdient: "Cache Stampede".

Was ist eine Cache Stampede?

Stellen wir uns ein Szenario vor.

Die Datenquelle befindet sich in einer MySQL-Datenbank, die zwischengespeicherten Daten sind in einem shared dict, und das Timeout beträgt 60 Sekunden. Während der 60 Sekunden, in denen die Daten im Cache sind, holen alle Anfragen die Daten aus dem Cache und nicht aus MySQL. Aber sobald die 60 Sekunden überschritten sind, laufen die zwischengespeicherten Daten ab. Wenn es eine große Anzahl gleichzeitiger Anfragen gibt, können keine Daten im Cache abgefragt werden. Dann wird die Abfragefunktion der Datenquelle ausgelöst, und all diese Anfragen gehen an die MySQL-Datenbank, was direkt dazu führt, dass der Datenbankserver blockiert oder sogar abstürzt.

Dieses Phänomen kann als "Cache Stampede" bezeichnet werden und wird manchmal auch als Dog-Piling bezeichnet. Keiner der in den vorherigen Abschnitten aufgetretenen Cache-bezogenen Codes hat eine entsprechende Behandlung. Das Folgende ist ein Beispiel für Pseudocode, der das Potenzial für eine Cache Stampede hat.

local value = get_from_cache(key)
if not value then
    value = query_db(sql)
    set_to_cache(value, timeout = 60)
end
return value

Der Pseudocode sieht so aus, als ob die Logik in Ordnung ist, und Sie werden mit Unit-Tests oder End-to-End-Tests keine Cache Stampede auslösen. Nur ein langer Stresstest wird das Problem aufdecken. Alle 60 Sekunden wird die Datenbank einen regelmäßigen Anstieg von Abfragen haben. Aber wenn Sie hier eine längere Cache-Ablaufzeit festlegen, verringern sich die Chancen, dass das Cache-Sturm-Problem erkannt wird.

Wie kann man es vermeiden?

Lassen Sie uns die Diskussion in mehrere verschiedene Fälle unterteilen.

1. Proaktive Aktualisierung des Caches

Im obigen Pseudocode wird der Cache passiv aktualisiert und geht nur dann zur Datenbank, um neue Daten abzufragen, wenn eine Anfrage gestellt wird, aber ein Cache-Fehler festgestellt wird. Daher kann das Problem der Cache Stampede umgangen werden, indem die Art und Weise, wie der Cache aktualisiert wird, von passiv auf aktiv geändert wird.

In OpenResty können wir es so implementieren.

Zuerst verwenden wir ngx.timer.every, um eine Timer-Aufgabe zu erstellen, die jede Minute ausgeführt wird, um die neuesten Daten aus der MySQL-Datenbank abzurufen und sie in das shared dict zu legen:

local function query_db(premature, sql)
    local value = query_db(sql)
    set_to_cache(value, timeout = 60)
end

local ok, err = ngx.timer.every(60, query_db, sql)

Dann müssen wir in der Logik des Codes, der die Anfrage verarbeitet, den Teil entfernen, der MySQL abfragt, und nur den Teil des Codes beibehalten, der den shared dict-Cache abruft.

local value = get_from_cache(key)
return value

Die obigen beiden Pseudocode-Ausschnitte können uns helfen, das Problem der Cache Stampede zu umgehen. Aber dieser Ansatz ist nicht perfekt, jeder Cache muss einer periodischen Aufgabe entsprechen (es gibt eine Obergrenze für die Anzahl der Timer in OpenResty), und die Cache-Ablaufzeit und die Zykluszeit der geplanten Aufgabe müssen gut übereinstimmen. Wenn es in dieser Zeit einen Fehler gibt, könnte die Anfrage weiterhin leere Daten erhalten.

Daher verwenden wir in realen Projekten normalerweise Sperren, um das Problem der Cache Stampede zu lösen. Hier sind einige verschiedene Sperrmethoden, Sie können Ihre eigene nach Bedarf auswählen.

2. lua-resty-lock

Wenn es darum geht, Sperren hinzuzufügen, könnten Sie sich schwierig fühlen, denken, dass es eine schwere Operation ist, und was, wenn ein Deadlock auftritt und Sie sich mit ziemlich vielen Ausnahmen befassen müssen.

Wir können diese Bedenken durch die Verwendung der lua-resty-lock-Bibliothek in OpenResty zur Hinzufügung von Sperren lindern. lua-resty-lock ist OpenResty's resty-Bibliothek, die auf einem shared dict basiert und eine nicht-blockierende Sperr-API bereitstellt. Schauen wir uns ein einfaches Beispiel an.

resty --shdict='locks 1m' -e 'local resty_lock = require "resty.lock"
                            local lock, err = resty_lock:new("locks")
                            local elapsed, err = lock:lock("my_key")
                            -- query db and update cache
                            local ok, err = lock:unlock()
                            ngx.say("unlock: ", ok)'

Da lua-resty-lock mit einem shared dict implementiert ist, müssen wir zuerst den Namen und die Größe des shdict deklarieren und dann die new-Methode verwenden, um ein neues lock-Objekt zu erstellen. Im obigen Codeausschnitt übergeben wir nur den ersten Parameter, den Namen des shdict. Die new-Methode hat einen zweiten Parameter, der verwendet werden kann, um die Ablaufzeit, die Timeout-Zeit für die Sperre und viele andere Parameter festzulegen. Hier behalten wir die Standardwerte bei. Diese Parameter werden verwendet, um Deadlocks und andere Ausnahmen zu vermeiden.

Dann können wir die lock-Methode aufrufen, um zu versuchen, eine Sperre zu erhalten. Wenn wir die Sperre erfolgreich erwerben, können wir sicherstellen, dass nur eine Anfrage gleichzeitig zur Datenquelle geht, um die Daten zu aktualisieren. Aber wenn das Sperren aufgrund von Vorrang, Timeout usw. fehlschlägt, dann werden die Daten aus dem veralteten Cache geholt und an den Anfragenden zurückgegeben. Dies bringt uns zu der get_stale-API, die in der vorherigen Lektion eingeführt wurde.

local elapsed, err = lock:lock("my_key")
# elapsed to nil bedeutet, dass das Sperren fehlgeschlagen ist. Der Rückgabewert von err ist einer von timeout, locked
if not elapsed and err then
    dict:get_stale("my_key")
end

Wenn lock erfolgreich ist, dann ist es sicher, die Datenbank abzufragen und die Ergebnisse im Cache zu aktualisieren, und schließlich rufen wir die unlock-Schnittstelle auf, um die Sperre freizugeben.

Die Kombination von lua-resty-lock und get_stale bietet uns die perfekte Lösung für das Problem der Cache Stampede. Die Dokumentation für lua-resty-lock gibt einen sehr vollständigen Code zur Handhabung. Wenn Sie interessiert sind, können Sie ihn hier einsehen.

Lassen Sie uns tiefer gehen und sehen, wie die lock-Schnittstelle das Sperren implementiert. Wenn wir auf einige interessante Implementierungen stoßen, wollen wir immer sehen, wie sie im Quellcode implementiert ist, was einer der Vorteile von Open Source ist.

local ok, err = dict:add(key, true, exptime)
if ok then
    cdata.key_id = ref_obj(key)
    self.key = key
    return 0
end

Wie im Artikel über shared dict erwähnt, sind alle APIs von shared dict atomare Operationen, und es besteht keine Notwendigkeit, sich um Konkurrenz zu sorgen. Daher ist es eine gute Idee, shared dict zu verwenden, um den Zustand von Sperren zu markieren.

Die obige Implementierung von lock verwendet dict:add, um zu versuchen, den Schlüssel zu setzen: Wenn der Schlüssel nicht im gemeinsamen Speicher existiert, gibt add Erfolg zurück, was bedeutet, dass das Sperren erfolgreich war; andere gleichzeitige Anfragen werden fehlschlagen, wenn sie die Logik der dict:add-Codezeile erreichen, und dann kann der Code basierend auf den zurückgegebenen err-Informationen wählen, ob er direkt zurückkehrt oder mehrmals wiederholt.

3. lua-resty-shcache

In der obigen Implementierung von lua-resty-lock müssen Sie Sperren, Entsperren, Abrufen abgelaufener Daten, Wiederholungen, Ausnahmebehandlung und andere Probleme handhaben, was immer noch ziemlich mühsam ist.

Hier ist eine einfache Verpackung für Sie: lua-resty-shcache, eine lua-resty-Bibliothek von Cloudflare, die eine Schicht der Kapselung auf shared dictionaries und externen Speicher bietet und zusätzliche Funktionen für Serialisierung und Deserialisierung bereitstellt, so dass Sie sich nicht um die obigen Details kümmern müssen:

local shcache = require("shcache")

local my_cache_table = shcache:new(
        ngx.shared.cache_dict
        { external_lookup = lookup,
          encode = cmsgpack.pack,
          decode = cmsgpack.decode,
        },
        { positive_ttl = 10,           -- cache good data for 10s
          negative_ttl = 3,            -- cache failed lookup for 3s
          name = 'my_cache',     -- "named" cache, useful for debug / report
        }
    )

local my_table, from_cache = my_cache_table:load(key)

Dieser Beispielcode wurde aus dem offiziellen Beispiel extrahiert und hat alle Details verborgen. Diese Cache-Kapselungsbibliothek ist nicht die beste Wahl, aber sie ist gutes Lernmaterial für Anfänger. Der folgende Artikel wird einige andere bessere und häufiger verwendete Kapselungen vorstellen.

4. NGINX-Direktiven

Wenn Sie nicht die lua-resty-Bibliothek von OpenResty verwenden, können Sie auch die NGINX-Konfigurationsdirektiven für Sperren und das Abrufen abgelaufener Daten verwenden: proxy_cache_lock und proxy_cache_use_stale. Wir empfehlen jedoch nicht, die NGINX-Direktive hier zu verwenden, da sie nicht flexibel genug ist und ihre Leistung nicht so gut ist wie Lua-Code.

Zusammenfassung

Cache Stampedes, wie das Rennproblem, das wir zuvor wiederholt erwähnt haben, sind schwer durch Code-Reviews und Tests zu erkennen. Der beste Weg, sie zu lösen, ist, Ihren Code zu verbessern oder eine Kapselungsbibliothek zu verwenden.

Eine letzte Frage: Wie handhaben Sie Cache Stampede und Ähnliches in den Sprachen und Plattformen, mit denen Sie vertraut sind? Gibt es einen besseren Weg als OpenResty? Teilen Sie es mir gerne in den Kommentaren mit.