Für alle, denen Redis kein Begriff ist, hier eine kurze Erklärung: Bei Redis handelt es sich um eine In-Memory-Datenbank, die mit Schlüssel-Wert-Paaren arbeitet.

Nona. So betrachtet eigentlich kein wirklich spannendes Thema. Deshalb ein kurzer Überblick, für was Redis alles verwendet wird:

  • Caching
  • Distributed Locking
  • Queueing
  • Throttling
  • PubSub’ing

Das klingt schon interessanter, nicht? Ich verwende für die Programmierung in C# das NuGet-Package von StackOverflow, welcher eines der populärsten auf dem Markt ist.

Die Verbindung zum Server wird mit Hilfe des „ConnectionMultiplexer“ aufgebaut:

var redis = ConnectionMultiplexer.Connect("localhost");
var db = redis.GetDatabase();

Nun können Werte gespeichert und gelesen werden:

db.StringSet("user", value);
var val = db.StringGet("user");

Die Methoden machen zwar den Anschein, dass nur strings gespeichert werden können, allerdings kann der Wert beispielsweise auch ein byte[] sein. Die Methoden haben das Präfix „String“, da es sich hier um einen einfachen Wert handelt. Es gibt weitere Präfixe wie „List“, „Hash“, „Set“, „SortedSet“ und „Stream“ die es ermöglichen, auch komplexere Strukturen einfach und performant zu behandeln.

https://github.com/StackExchange/StackExchange.Redis gibt einen guten und kurzen Überblick, was möglich ist und wie die Konzepte funktionieren.

Nachfolgend habe ich die wichtigsten Datentypen kurz beschrieben.

String*

String*-Methoden behandeln wie bereits oben erwähnt ein einfaches Schlüssel-Wert-Paar. Anders als der Name ev. vermuten lässt, können nicht nur Strings, sondern auch Zahlen und Binärdaten gespeichert werden. String bezieht sich auf den Key, der ein String sein muss.

Die maximale Größe eines Wertes ist 512 MB.

db.StringSet(key, value);
var value = db.StringGet(key);
var oldValue = db.StringGetSet(key);
var length = db.StringLength(key);
db.StringIncrement(key);
db.StringDecrement(key);

List*

Hierbei handelt es sich um klassische Listen, in die Elemente hinzugefügt und entfernt werden können. List*-Methoden stellen die Basis für eine TaskQueue dar, wie ich sie (Link weiter unten) programmiert habe. Neue Jobs werden links eingefügt. Zur Abarbeitung werden sie von rechts abgeholt (FIFO-Verfahren).

var value = db.ListLeftPop(key);
db.ListLeftPush(key, value);
var length = db.ListLength(key);
db.ListRemove(key, value);
var value = db.ListRightPop(key);
db.ListRightPush(key, value);
db.ListRightPopLeftPush(source, destination);
db.ListSetByIndex(key, index, value);

Set*

Sets sind ähnlich wie List mit dem Unterschied, dass jeder Wert eindeutig in der Liste ist und falls bereits vorhanden, überschrieben wird.

db.SetAdd(key, value);
var contains = db.SetContains(key, value);
var length = db.SetLength(key);
var members = db.SetMembers(key);
var value = db.SetPop(key);
var member = db.SetRandomMember(key);
db.SetRemove(key, value);

Hash*

Hash ist bereits ein fortgeschrittener Datentyp. Hiermit können auf einen Schlüssel mehrere Schlüssel-Wert-Paare hinterlegt werden. Ein Beispiel, welches hier oft verwendet wird, ist ein SessionState, wo zu einer Session mehrere Daten wie z. B. die UserID, der Name, … gespeichert werden. Jedes dieser Elemente ist einzeln abrufbar, was einen Vorteil gegenüber String* darstellt, wo beispielsweise alle diese Daten in ein JSON verpackt abgespeichert werden müssten.

db.HashIncrement(key, hashField);
db.HashDecrement(key, hasField);
db.HashDelete(key, hashField);
var exists = db.HashExists(key, hashField);
var value = db.HashGet(key, hashField);
var values = db.HashGet(key, hashFields);
var entries = db.HashGetAll(key);
var length = db.HashLength(key);
db.HashSet(key, hashField, hashValue);

Stream*

Streams kann man sich ähnlich wie eine Log-Datei vorstellen. Es können immer neue Elemente angehängt werden, die dann von einem oder mehreren sogenannten „Consumern“ gelesen werden können.

Jeder Eintrag in einem Stream kann mehrere Werte enthalten. Die ID bildet sich aus der aktuellen Zeit und einer zusätzlichen Zahl. Die Einträge im Stream können dann mit Angabe einer Start-ID und der Anzahl der zu lesenden Einträge abgerufen werden.

db.StreamAdd(key, field, value);
db.StreamAdd(key, nameValueEntries);
db.StreamRead(key, position, count: 10);

Spannend finde ich Streams in Kombination mit Consumer-Groups. Dies bedeutet:

  • es werden ein oder mehrere Consumer-Groups definiert
  • beim Erstellen einer Consumer-Group wird angegeben, ab welcher ID diese gelesen werden sollen
  • jede Consumer-Group kann wiederum mehrere Consumer beinhalten
  • je Consumer-Group erhält eine Nachricht immer nur ein Consumer
  • der Consumer quittiert (Acknowledge), dass er die Nachricht gelesen hat, womit die Nachricht für diese Consumer-Group als erledigt markiert ist

Dieses Prinzip, was ich schon aus RabbitMQ kannte, ermöglicht interessante Einsatzmöglichkeiten. Durch die Geschwindigkeit, mit der Redis die Daten speichert, können sehr viele Daten schnell gespeichert und zu einem späteren Zeitpunkt (oder wie auch immer) verarbeitet werden.

Transaktionen

Transaktionen in Redis sind anders als in klassischen Datenbanken. Redis arbeitet nur mit einem Core (OK, mehr oder weniger). Dadurch ergibt sich automatisch, dass es keine Probleme geben kann, die normalerweise durch mehrere gleichzeitig zugreifende Threads verursacht werden.

Damit sind Transaktionen in Redis ziemlich einfach abzubilden. Es werden einfach eine Reihe von Befehlen übergeben, die nacheinander durchgeführt werden. Andere Befehle müssen derweil warten, bis dieser Vorgang abgeschlossen ist.

Eine kleine Besonderheit gibt es allerdings und diese ist auch notwendig, damit das Ganze sauber funktioniert. Einer Transaktion können Bedingungen übergeben werden, die prüfen, ob der Änderungscode ausgeführt werden soll.

var trans = db.CreateTransaction();
trans.AddCondition(Condition.HashNotExists(sessionId, "SessionId"));
trans.HashSetAsync(sessionId, "SessionId", newId);
trans.Execute();

Es kann natürlich mehr als eine Bedingung und mehr als ein Änderungsbefehl innerhalb einer Transaktion geprüft bzw. ausgeführt werden.

Bei Transaktionen stehen nur die *Async-Methoden zur Verfügung, was angesichts dessen, was ich vorher beschrieben habe, auch einleuchtend ist.

„Real World“ Beispiel

Unter https://github.com/stenet/stef-redis habe ich einen Beispielcode erstellt, wie ein Distributed Lock und eine Task-Queue mit Hilfe von Redis gelöst werden kann. Dort sind auch Konzepte wie Transaktionen, Publish/Subscribe, Async, FireAndForget, … enthalten.

Performance

Nachfolgend ein Benchmark von meiner Redis-Instanz in einem Docker-Container, der auf meinem doch schon etwas älteren PC mit 3,40GHz mit 16 GB Arbeitsspeicher läuft.

Simuliert werden 100.000 Requests von 50 Clients und Pipeline von 1.

PING_INLINE: 89525.52 requests per second
PING_BULK: 87412.59 requests per second
SET: 90826.52 requests per second
GET: 89206.06 requests per second
INCR: 92250.92 requests per second
LPUSH: 94517.96 requests per second
RPUSH: 93109.87 requests per second
LPOP: 94073.38 requests per second
RPOP: 92936.80 requests per second
SADD: 92250.92 requests per second
HSET: 93896.71 requests per second
SPOP: 89928.05 requests per second
LPUSH (needed to benchmark LRANGE): 90661.83 requests per second
LRANGE_100 (first 100 elements): 39984.01 requests per second
LRANGE_300 (first 300 elements): 15686.27 requests per second
LRANGE_500 (first 450 elements): 11243.54 requests per second
LRANGE_600 (first 600 elements): 8123.48 requests per second
MSET (10 keys): 70077.09 requests per second

Ca. 90.000 Standardoperationen pro Sekunde ist schon ne Ansage, finde ich 😉 Aber … derselbe Test mit Pipeline von 4 ergibt Folgendes:

PING_INLINE: 375939.84 requests per second
PING_BULK: 349650.34 requests per second
SET: 353356.91 requests per second
GET: 369003.69 requests per second
INCR: 374531.84 requests per second
LPUSH: 384615.41 requests per second
RPUSH: 358422.91 requests per second
LPOP: 380228.12 requests per second
RPOP: 380228.12 requests per second
SADD: 381679.41 requests per second
HSET: 386100.38 requests per second
SPOP: 369003.69 requests per second
LPUSH (needed to benchmark LRANGE): 392156.88 requests per second
LRANGE_100 (first 100 elements): 68212.83 requests per second
LRANGE_300 (first 300 elements): 19379.85 requests per second
LRANGE_500 (first 450 elements): 13923.70 requests per second
LRANGE_600 (first 600 elements): 10406.91 requests per second
MSET (10 keys): 194174.77 requests per second

So um die 370.000 Requests pro Sekunde. HAMMER!

Pipeline bedeutet übrigens, dass ein Client nicht einen Befehl senden, auf das Ergebnis wartet und dann den nächsten Befehl sendet, sondern, dass alle Befehle (sofern möglich) gleichzeitig abgesendet werden und dann auf das Ergebnis von all diesen gewartet wird. Dies muss aber bei der Entwicklung bereits berücksichtigt werden. Dafür werden mehrere *Async-Befehle abgesetzt und dann am Ende auf alle Ergebnisse gewartet, anstatt dies nacheinander zu machen.