ETags, wann und wie
Garnet hat kürzlich die native Unterstützung für ETag-basierte Befehle angekündigt.
Native ETags in einem Cache-Speicher ermöglichen reale Anwendungsfälle wie die Aufrechterhaltung der Cache-Konsistenz, die Reduzierung der Netzwerkauslastung und die Vermeidung von vollwertigen Transaktionen für verschiedene Anwendungen.
Garnet bietet native ETag-Unterstützung für rohe Zeichenfolgen (Daten, die mit Operationen wie GET und SET hinzugefügt und abgerufen werden). Sie ist nicht für Objekte (wie sortierte Sets, Hashes, Listen) verfügbar. Dieses Feature ist ohne Migration verfügbar, sodass Ihre vorhandenen Schlüssel-Wert-Paare sofort von ETags profitieren können. Die Dokumentation der ETag-API finden Sie hier.
Dieser Artikel untersucht, wann und wie Sie dieses neue Garnet-Feature für Ihre aktuellen und zukünftigen Anwendungen nutzen können.
Warum diesen Artikel lesen?
Wenn Sie versuchen,
- Ihren Cache konsistent mit Ihrer Backend-Datenbank zu halten
- Die Auslastung der Netzwerkkapazität für Caching zu reduzieren.
- Zwischengespeicherte Datensätze basierend auf clientseitiger Update-Logik atomar zu aktualisieren.
Diese Szenarien werden wir unten nacheinander behandeln.
Den Cache konsistent mit seiner Backend-Datenbank halten
In einer verteilten Umgebung ist es üblich, einen Cache vor Ihrer Hauptdatenbank als einziger Wahrheitsquelle zu haben. Typischerweise greifen mehrere Client-Anwendungen gleichzeitig auf den Cache und die Datenbank zu. Wenn jeder Client auf einen anderen Satz von Schlüsseln zugreifen würde, können die Caches leicht konsistent gehalten werden: Ein Client schreibt einfach in die Datenbank und aktualisiert dann den Cache.
In Fällen, in denen ein Schlüssel von mehreren Clients aktualisiert werden kann, kann der naive Ansatz, zuerst die Datenbank zu aktualisieren und dann den Cache zu aktualisieren, zu subtilen Race Conditions führen. Insbesondere bestimmt der Client, der zuletzt in den Cache schreibt, den endgültigen Zustand des Caches. Dieser zwischengespeicherte Zustand entspricht möglicherweise nie dem endgültigen Zustand der Datenbank für denselben Schlüssel!
Um das obige Szenario zu verdeutlichen, betrachten wir eine Cache-Datenbank-Konfiguration, bei der 2 Clients mit dem Paar interagieren. Beide folgen demselben Protokoll, bei dem sie bei Schreibvorgängen zuerst die Datenbank und dann den Cache aktualisieren. Wir bezeichnen jeden Client mit c1 und c2, die Anfrage zur Aktualisierung der Datenbank als D und die Anfrage zur Aktualisierung des Caches als C.
Alles ist gut für Sequenzen, bei denen der letzte Schreiber der Datenbank auch der letzte Schreiber des Caches ist
Wenn jedoch der letzte Schreiber der Datenbank NICHT der letzte Schreiber des Caches ist, führen wir eine *permanente* Inkonsistenz zwischen dem Cache und der Datenbank ein, wie das folgende Diagramm zeigt
Beachten Sie in der obigen Sequenz, dass c2 den letzten Schreibvorgang in der Datenbank ausführt. c1 führt jedoch den letzten Schreibvorgang in den Cache aus. Infolgedessen sind Cache und Datenbank nicht mehr synchron.
Zur Behandlung solcher Fälle können wir uns auf die neu eingeführte ETag-Funktion in Garnet verlassen, um eine logische Uhr um die Updates zu konstruieren, die die Cache-Konsistenz sicherstellen (*vorausgesetzt, Ihre Datenbank unterstützt ebenfalls ETags oder eine andere Form von serverseitigen Transaktionen).
In einem solchen Szenario sollte der Client unsere SETIFGREATER API hier verwenden, wenn er mit dem Cache interagiert. SETIFGREATER sendet ein Schlüssel-Wert-Paar zusammen mit einem ETag vom Client und setzt den Wert nur, wenn das gesendete ETag größer ist als das, das derzeit im Cache für dieses Schlüssel-Wert-Paar gespeichert ist.
Jeder Client folgt nun dem folgenden Protokoll
- Die Datenbank speichert ein Paar (Wert, ETag) auf dem Server
- Verwenden Sie eine Transaktion (oder
SETIFGREATERoderSETIFMATCHAPI) auf der Datenbank, um(oldValue, etag)atomar in(newValue, etag+1)zu aktualisieren und das neue ETag an den Client zurückzugeben. - Verwenden Sie das aus unserem vorherigen Aufruf abgerufene ETag als Argument für SETIFGREATER, um den Cache zu aktualisieren, sodass der Cache nur aktualisiert wird, wenn das neue Tag größer ist als das, was derzeit im Cache gespeichert ist.
Wenn jeder Client das obige Protokoll befolgt. Wir können sicherstellen, dass nur der letzte/neueste Datenbank-Schreibvorgang im Cache reflektiert wird, was zu eventualer Konsistenz führt. Die gleiche Sequenz von Ereignissen wie zuvor, aber mit den Clients, die unser neues aktualisiertes Protokoll befolgen, ist unten gezeigt
Reduzierung der Netzwerkkapazitätsauslastung für Caching
Jeder Netzwerkaufruf hat Kosten: die Menge der übertragenen Daten und die Entfernung, über die sie zurückgelegt wird. In leistungskritischen Szenarien ist es vorteilhaft, Daten nur dann abzurufen, wenn sie sich im Cache geändert haben, wodurch die Bandbreitennutzung und Netzwerklatenz reduziert werden.
Szenario: Cache-Invalidierung
Betrachten Sie die folgende Konfiguration
Überblicksdiagramm
Sequenzdiagramm
In Abwesenheit von ETags wird die gesamte Nutzlast für k1 bei jedem Lesezugriff zurückgegeben, unabhängig davon, ob sich der Wert für k1 geändert hat.
Während dies bei der Übertragung kleiner Nutzlasten (z. B. 100 Byte Daten in einem lokalen Hochbandbreitennetzwerk) möglicherweise keine Rolle spielt, wird es signifikant, wenn Sie **mehrere Maschinen haben, die größere Nutzlasten (z. B. jeweils 1 MB) über einen Cloud-Anbieter ausgeben** . Sie zahlen die Kosten für die ausgehende Datenübertragung, die Bandbreitennutzung und erleben Verzögerungen aufgrund der Übertragung größerer Datenmengen.
Um dies zu beheben, bietet Garnet die GETIFNOTMATCH API hier, mit der Sie Daten nur abrufen können, wenn sie sich seit Ihrer letzten Abfrage geändert haben. Server 1 kann das ETag, das in der anfänglichen Nutzlast empfangen wurde, im Anwendungsspeicher speichern und GETIFNOTMATCH verwenden, um die lokale Kopie nur dann zu aktualisieren, wenn sich der Wert geändert hat.
Dieser Ansatz ist besonders vorteilhaft in schreiblastigen Systemen, in denen sich Daten selten ändern. Für häufig aktualisierte Schlüssel kann die reguläre GET API jedoch weiterhin bevorzugt werden, da aktualisierte Daten immer übertragen werden müssen.
Schauen Sie sich das ETag-Caching-Beispiel an, um die Verwendung der GETIFNOTMATCH API in Aktion zu sehen.
Vermeidung kostspieliger Transaktionen bei nicht-atomaren Operationen
Cache-Speicher wie Garnet verlassen sich auf Schlüsselschlüssel- (oder Bucket-Schlüssel-) Sperren auf dem Server, um atomare Aktualisierungen eines Schlüssel-Wert-Paares durch mehrere Clients zu gewährleisten. Oft möchten wir einen entfernten Wert lesen, einige lokale Berechnungen durchführen, die den Wert aktualisieren, und dann den neuen Wert zurück auf den Server schreiben. Aufgrund der Kosten für den Netzwerkhin-und-zurück-Verkehr und der Möglichkeit, dass Clients jederzeit abstürzen, ist es nicht möglich, eine serverseitige Sperre für eine so lange Dauer zu halten. ETags bieten eine Alternative zu Transaktionen, wenn Sie mit solchen Anwendungsfällen arbeiten.
Szenario: Gleichzeitige Aktualisierungen desselben Werts
Stellen Sie sich vor, mehrere Clients ändern gleichzeitig ein XML-Dokument, das in Garnet gespeichert ist.
Zum Beispiel
- Client 1 liest das XML, aktualisiert Feld A und schreibt es zurück.
- Client 2 liest dasselbe XML, aktualisiert Feld B und schreibt es gleichzeitig zurück.
Ohne ETags kann die folgende Ereignissequenz auftreten
- Client 1 liest den Wert
v0für den Schlüsselk1. - Client 1 modifiziert Feld A und erstellt eine lokale Kopie
v1. - Client 2 liest denselben Wert
v0, bevor Client 1v1schreibt. - Client 2 modifiziert Feld B und erstellt eine weitere lokale Kopie
v2. - Entweder Client 1 oder Client 2 schreibt seine Version zurück auf den Server, überschreibt möglicherweise die Änderungen des anderen, da
v1undv2beide keine Änderungen des anderen enthalten.
Diese Race Condition führt zu verlorenen Updates. Mit ETags können Sie die SETIFMATCH API hier verwenden, um einen **Vergleich-und-Austausch**-Mechanismus zu implementieren, der garantiert, dass keine Updates verloren gehen.
- Client 1 liest den Wert
v0für den Schlüsselk1. - Client 1 modifiziert Feld A und erstellt eine lokale Kopie
v1. - Client 2 liest denselben Wert
v0, bevor Client 1v1schreibt. - Client 2 modifiziert Feld B und erstellt eine weitere lokale Kopie
v2. - Client 1 führt ein
SETIFMATCHdurch, um seine Aktualisierung zu installieren, was erfolgreich ist. - Client 2 führt ein
SETIFMATCHdurch, um seine Aktualisierung zu installieren, was fehlschlägt, da sich das ETag des Servers nun geändert hat. - Client 2 versucht es erneut mit dem aktualisierten Wert und schafft es schließlich, seine Änderungen auf den Wert anzuwenden.
Die folgenden Codeausschnitte zeigen, wie dies erreicht werden kann.
Beispielcode
static async Task Client(string userKey)
{
Random random = new Random();
using var redis = await ConnectionMultiplexer.ConnectAsync(GarnetConnectionStr);
var db = redis.GetDatabase(0);
// Initially read the latest ETag
var res = await EtagAbstractions.GetWithEtag<ContosoUserInfo>(userKey);
long etag = res.Item1;
ContosoUserInfo userInfo = res.Item2;
while (true)
{
token.ThrowIfCancellationRequested();
(etag, userInfo) = await ETagAbstractions.PerformLockFreeSafeUpdate<ContosoUserInfo>(
db, userKey, etag, userInfo, (ContosoUserInfo info) =>
{
info.TooManyCats = info.NumberOfCats % 5 == 0;
});
await Task.Delay(TimeSpan.FromSeconds(random.Next(0, 15)), token);
}
}
Unterstützende Methoden
public static async Task<(long, T?)> GetWithEtag<T>(IDatabase db, string key)
{
var executeResult = await db.ExecuteAsync("GETWITHETAG", key);
if (executeResult.IsNull) return (-1, default(T));
RedisResult[] result = (RedisResult[])executeResult!;
long etag = (long)result[0];
T item = JsonSerializer.Deserialize<T>((string)result[1]!)!;
return (etag, item);
}
public static async Task<(long, T)> PerformLockFreeSafeUpdate<T>(IDatabase db, string key, long initialEtag, T initialItem, Action<T> updateAction)
{
// Compare and Swap Updating
long etag = initialEtag;
T item = initialItem;
while (true)
{
// perform custom action, since item is updated to it's correct latest state by the server this action is performed exactly once on
// an item before it is finally updated on the server.
// NOTE: Based on your application's needs you can modify this method to update a pure function that returns a copy of the data and does not use mutations as side effects.
updateAction(item);
var (updatedSuccesful, newEtag, newItem) = await _updateItemIfMatch(db, etag, key, item);
etag = newEtag;
if (!updatedSuccesful)
item = newItem!;
else
break;
}
return (etag, item);
}
private static async Task<(bool updated, long etag, T?)> _updateItemIfMatch<T>(IDatabase db, long etag, string key, T value)
{
string serializedItem = JsonSerializer.Serialize<T>(value);
RedisResult[] res = (RedisResult[])(await db.ExecuteAsync("SETIFMATCH", key, serializedItem, etag))!;
// successful update does not return updated value so we can just return what was passed for value.
if (res[1].IsNull)
return (true, (long)res[0], value);
T deserializedItem = JsonSerializer.Deserialize<T>((string)res[1]!)!;
return (false, (long)res[0], deserializedItem);
}
Jeder Lese-(zusätzliche Logik/Modifikation)-Schreibaufruf beginnt damit, zuerst das neueste ETag und den neuesten Wert für einen Schlüssel mit GETWITHETAG hier zu lesen, dann wickelt er die Aktualisierungslogik in eine Callback-Aktion ein und ruft dann die Methode PerformLockFreeSafeUpdate in ETagAbstractions auf, um die Aktualisierung sicher anzuwenden.
Intern führt die Methode PerformLockFreeSafeUpdate eine Schleife aus, die die Daten abruft, Ihre Aktualisierung am Objekt vornimmt und eine SETIFMATCH-Anfrage sendet. Der Server aktualisiert den Wert dann nur, wenn Ihr ETag angibt, dass Sie zum Zeitpunkt Ihrer Entscheidung Ihre Aktualisierung auf der neuesten Kopie der Daten vorgenommen haben. Wenn der Server feststellt, dass zwischen Ihrem Lesen und Schreiben Änderungen am Wert vorgenommen wurden, sendet der Server die neueste Kopie der Daten zusammen mit dem aktualisierten ETag. Ihr Client-Code wendet die Änderungen dann erneut auf die neueste Kopie an und sendet die Anfrage zur Aktualisierung zurück an den Server. Diese Form der Aktualisierung garantiert, dass sich schließlich alle Änderungen nacheinander auf dem Server synchronisieren.
In einem schreiblastigen System, in dem die Konkurrenz um denselben Schlüssel nicht hoch ist, wird diese Aktualisierung im allerersten Schleifendurchlauf durchgeführt und ist einfacher zu verwalten als eine benutzerdefinierte Transaktion. In einem Szenario mit starker Schlüsselkonkurrenz kann dies jedoch zu mehreren Versuchen führen, auf die neueste Kopie zu schreiben, insbesondere wenn die Logik zwischen Ihrem Lesen und Schreiben langsam ist.
ETags sind eher niedrigstufige Primitive, mit denen Sie Abstraktionen erstellen können, die es Ihnen ermöglichen, logische Uhren und sperrfreie Transaktionen zu erstellen, die auf Ihre Bedürfnisse zugeschnitten sind. Wenn Sie sich in den oben genannten üblichen verteilten Szenarien wiederfinden, haben Sie jetzt ein weiteres Werkzeug in Ihrem Werkzeugkasten, das Ihnen hilft, Ihre Skalierungsbedürfnisse zu bewältigen.
