Der Key-Value-Store Redis

Einführung mit Beispielen

Dieser Artikel ist der erste in einer Folge von zwölf Beiträgen, die ich für den Adventskalender des deutschen Debianforums geschrieben habe. Die ersten vier stammen vom Adventskalender 2022. Weitere acht habe ich zum Adventskalender 2023 beigetragen. Diese Artikel möchte ich hier mit leichten Anpassungen einem weiterem Publikum zugänglich machen. (Obwohl ich meine technischen Beiträge normalerweise auf Englisch schreibe, belasse ich diese im deutschsprachigen Original.)

Die Artikel setzen eine Installation von Debian 11 “Bullseye” oder 12 “Bookworm” voraus, können aber grösstenteils mit nur kleinen Anpassungen (Paketnamen) auch auf anderen Linux-Distributionen nachvollzogen werden.


In diesem Beitrag geht es um den Key-Value-Store Redis. Redis speichert Datenstrukturen, d.h. Daten unterschiedlicher Formen. Redis unterstützt eine Vielzahl von Datentypen, doch hier sollen zum Einstieg bloss folgende betrachtet werden:

Setup

Bevor wir mit diesen Datentypen arbeiten können, müssen wir aber Redis zuerst einmal installieren und starten. Hierfür gibt es das Package redis:

# apt install -y redis 
# systemctl start redis-server.service

Neben dem Redis-Server wird auch redis-cli installiert, womit wir hier arbeiten werden:

$ redis-cli
127.0.0.1:6379> 

Mit dem PING-Befehl lässt sich die Konnektivität überprüfen (der Prompt wird ab hier mit > abgekürzt):

> PING
PONG

Hilfe zu einem bestimmten Befehl gibt es mit dem HELP-Befehl:

> HELP PING
  PING [message]
  summary: Ping the server
  since: 1.0.0
  group: connection

Es gibt über 400 Befehle, die auf der Redis-Befehlsübersicht schön dokumentiert sind. Eine Manpage sucht man vergebens, redis-cli verfügt aber über ein --help-Flag.

Einfache Werte

Redis kann man sich wie eine grosse Map vorstellen. Eine Map speichert Daten als Schlüssel/Wert-Paare und kann somit als Verallgemeinerung des Arrays gesehen werden. (Bei einem Array sind die Schlüssel Zahlen von 0 bis n-1, wobei n die Anzahl der Elemente ist; bei einer Map kann man beliebige Schlüssel verwenden.)

Speichern wir also einige Werte mit SET ab:

> SET day 1
OK
> SET month December
OK
> SET year 2022
OK

Die Schlüssel können mit KEYS [wildcard] (ähnlich einem glob-Pattern) aufgelistet werden:

> KEYS *
1) "year"
2) "month"
3) "day"

Die Werte erhält man mit GET zurück:

> GET day
"1"

Zwar ist der Wert als String abgespeichert, der INCR-Befehl kann ihn aber zu einer Zahl umwandeln und um 1 erhöht wieder abspeichern:

> INCR day
(integer) 2
> GET day
"2"
> GET month
"December"

Listen

Eine Liste ist kein Array, sondern eine verkettete Liste. Darum ist der Zugriff auf ein Element eine Operation der Ordnung O(n), das Anhängen vorne und hinten erfolgt jedoch in konstanter Zeit, sprich O(1). Legen wir also eine todo-Liste an:

> LPUSH todo work eat sleep
(integer) 3

Wir erhalten sogleich die Anzahl erstellter Listenelemente zurück. Auf die Elemente einer Liste kann mit dem LRANGE-Befehl zugegriffen werden, indem man einen Start- und einen End-Index (jeweils inklusive) definiert, wobei der Index bei 0 beginnt, und -1 für das letzte Element steht:

> LRANGE todo 0 -1
1) "sleep"
2) "eat"
3) "work"

Betrachten wir die todo-Liste wie eine Queue, wo wir von links Aufgaben hineinschieben, und von rechts her erledigen. Eine Aufgabe haben wir jedoch vergessen, und diese muss priorisiert, d.h. per RPUSH am rechten Ende eingefügt werden!

> RPUSH todo "Tuerchen oeffnen"
(integer) 4
> LINDEX todo 3
1) "Tuerchen oeffnen"
> LRANGE todo 0 -1
1) "sleep"
2) "eat"
3) "work"
4) "Tuerchen oeffnen"

Da der Eintrag ein Leerzeichen enthält, muss er mit Anführungs- und Schlusszeichen umgeben sein. Mit LINDEX können wir direkt auf ein Element zugreifen.

Nun wollen wir aber auch eine Liste mit bereits erledigten Aufgaben anlegen:

> LPUSH done aufstehen
(integer) 1
> LRANGE done 0 -1
1) "aufstehen"

Da das Türchen nun auch schon geöffnet wäre, können wir diese Aufgabe aus der todo-Liste entfernen und der done-Liste hinzufügen. Damit uns hier nicht ein Schuft dazwischenfunkt, machen wir dafür eine Transaktion mit MULTI:

> MULTI
> RPOP done
QUEUED
> DISCARD
OK

Das ging schief: Statt auf todo wurde hier RPOP auf done verwendet. Zum Glück kann man die Transaktion mit DISCARD rückgängig machen. Dieses mal aber richtig, und dann mit EXEC auch tatsächlich ausführen:

> MULTI
> RPOP todo
QUEUED
> LPUSH done "Tuerchen oeffnen"
QUEUED
> EXEC
1) "Tuerchen oeffnen"
2) (integer) 2

Die beiden Ergebnisse erhalten wir erst ganz am Schluss. Das Ergebnis stimmt aber:

> LRANGE todo 0 -1
1) "sleep"
2) "eat"
3) "work"
> LRANGE done 0 -1
1) "Tuerchen oeffnen"
2) "aufstehen"

Praktisch ist das nicht. Einfacher ginge es mit dem RPOPLPUSH-Befehl, der das letzte Element von der ersten Liste entfernt und der zweiten List als erstes Element hinzufügt:

> RPOPLPUSH todo done
"work"

Dann wäre das mit der Arbeit ja für heute auch schon erledigt… Aber wir wollen noch Sets betrachten.

Sets

Ein Set ist eine Menge, d.h. eine Sammlung von Werten, in der jeder Wert eindeutig ist. Beschäftigen wir uns mit Zahlenreihen, genauer mit der 2er- und der 3er-Reihe. Mit SADD können wir einem Set Werte hinzufügen. Das Set wird bei Bedarf gleich erstellt:

> SADD two-times-table 2 4 6 8 10 12 14 16 18 20
(integer) 10
> SADD three-times-table 3 6 9 12 15 18 21 24 27 30
(integer) 10

Die grundlegenden Mengenoperationen Schnittmenge, Vereinigungsmenge und Differenzmenge können mit den Befehlen SINTER, SUNION und SDIFF erstellt werden:

> SUNION two-times-table three-times-table
 1) "2"
 2) "3"
 3) "4"
 4) "6"
 5) "8"
 6) "9"
 7) "10"
 8) "12"
 9) "14"
10) "15"
11) "16"
12) "18"
13) "20"
14) "21"
15) "24"
16) "27"
17) "30"
> SINTER two-times-table three-times-table
1) "6"
2) "12"
3) "18"
> SDIFF three-times-table two-times-table
1) "3"
2) "9"
3) "15"
4) "21"
5) "24"
6) "27"
7) "30"

Die drei Befehle gibt es auch mit dem Präfix STORE, womit man die Mengen auch gleich ablegen kann.

Hashes

Mit dem Hash können Werte mit Unterwerten abgespeichert werden. Im Gegensatz zu einer Liste speichert man damit eher heterogene Werte ab, z.B. Eigenschaften von Forenteilnehmern.

Mit HSET kann ein neuer hash erstellt werden; der Befehl erwartet paarweise Schlüssel/Wert-Paare als weitere Argumente:

> HSET paedubucher posts 624 location Schweiz license GFDL
(integer) 3

Mit HGETALL können dann alle Schlüssel und Werte von einem Hash zurückgegeben werden:

> HGETALL paedubucher
HGETALL paedubucher
1) "posts"
2) "624"
3) "location"
4) "Schweiz"
5) "license"
6) "GFDL"

Nun mag es so aussehen, dass hier einfach eine Ansammlung von Werten gespeichert wird. Wir haben es aber tatsächlich mit Schlüssel/Wert-Paaren zu tun, wie die HGET-Funktion das demonstriert:

> HGET paedubucher posts
"624"

Unter dem Schlüssel posts verbirgt sich also eine Zahl, die wir aufgrund dieses Beitrags erhöhen wollen. Hierzu nehmen wir den HINCRBY-Befehl:

> HINCRBY paedubucher posts 1
(integer) 625

Bonus: Export

Redis ist kein Datenfriedhof, sondern interagiert sehr gut mit der Aussenwelt. Neben zahlreichen Sprachbindungen gibt es auch die Möglichkeit, Daten im CSV- oder JSON-Format zu exportieren:

$ redis-cli --csv HGETALL paedubucher
"posts","625","location","Schweiz","license","GFDL"

$ redis-cli --json HGETALL paedubucher
{"posts":"625","location":"Schweiz","license":"GFDL"}

Wie findet Ihr das? Habt Ihr schon eigene Erfahrungen mit Redis gemacht? Könnt Ihr das einsetzen? Wie konfiguriert Ihr Redis? Verwendet Ihr es als Cache, oder speichert er die Daten im RDB- oder AOF-Format persistent ab? Habt Ihr schon einmal die Lua-Integration verwendet, oder sonstige Sprachanbindungen? Verwendet Ihr Redis gar als Message Queue?