Artikel

Redis optional machen war die beste Entscheidung des Jahres

Zwischenspeicher, Sitzungen, Sperren, Rate-Limits — alles lief über Redis. Bis ich gemerkt habe, dass ein zusätzlicher Pflichtdienst für Selbstbetreiber eine echte Hürde ist.

BlackZackBlackzack

910 Wörter · 5 Min. Lesezeit

  • architektur
  • leistung
  • betrieb

Redis war bei mir über die Zeit an vier Stellen gelandet: als Zwischenspeicher für Datenbankabfragen, als Ablage für Sitzungen, als Sperrmechanismus und für Rate-Limits. Alles davon sinnvoll, alles davon gut umgesetzt.

Und dann kam eine Rückfrage von jemandem, der den Bot selbst betreiben wollte: „Muss ich dafür wirklich noch eine zusätzliche Datenbank aufsetzen?"

Die ehrliche Antwort war: für seinen Anwendungsfall — ein Server, zwanzig Leute — überhaupt nicht. Aber mein Code startete ohne Redis nicht.

Was Redis tatsächlich beiträgt

Bevor man etwas optional macht, sollte man verstehen, was man verlieren würde.

Zwischenspeicher über Prozessgrenzen hinweg. Wenn drei Prozesse dieselben Daten brauchen, ist ein gemeinsamer Speicher besser als drei eigene. Bei einem einzelnen Prozess ist der Vorteil null.

Sitzungen, die einen Neustart überleben. Wichtig, wenn man häufig ausliefert. Ohne das müssen sich Nutzer nach jedem Neustart neu anmelden — oder man legt Sitzungen in die Datenbank, was ebenfalls funktioniert.

Sperren über Prozesse hinweg. Wenn zwei Prozesse dieselbe Aktion nicht gleichzeitig ausführen dürfen. Bei einem Prozess reicht eine Variable.

Zähler mit Ablaufzeit. Für Rate-Limits ideal, weil Erhöhen und Ablaufen atomar sind.

Die Muster sind: Alles, was Redis besonders gut kann, betrifft mehrere Prozesse. Bei einer kleinen Installation mit einem Prozess ist es reiner Zusatzaufwand.

Die Umstellung: eine Schnittstelle, zwei Implementierungen

Der Umbau war weniger Arbeit als erwartet, weil die Zugriffe ohnehin über eigene Funktionen liefen und nicht verstreut waren.

export function erzeugeSpeicher() {
  if (process.env.REDIS_URL) return redisSpeicher(process.env.REDIS_URL);
  return speicherImArbeitsspeicher();
}

Beide Varianten bieten dasselbe an: Werte mit Ablaufzeit setzen und lesen, Zähler erhöhen, eine Sperre versuchen. Die Speichervariante nutzt eine Map mit Zeitstempeln und einem Aufräumer.

Die entscheidende Frage bei so einem Ersatz ist nicht, ob er dieselbe Schnittstelle hat, sondern ob er dieselben Zusicherungen hat. Und das ist er nicht:

RedisArbeitsspeicher
überlebt Neustartjanein
gilt über Prozesse hinwegjanein
Sperre ist wirklich exklusivjanur im eigenen Prozess
Speicherbedarfeigener Dienstim selben Prozess

Warnung

Der gefährlichste Punkt ist die Sperre. Eine Sperre, die nur im eigenen Prozess gilt, sieht aus wie eine Sperre und ist keine. Solange nur ein Prozess läuft, ist das identisch — sobald jemand zwei startet, ist der Schutz still weg. Ich habe deshalb eine Warnung beim Start eingebaut: Wenn kein Redis konfiguriert ist und die Anwendung Anzeichen für mehrere Prozesse sieht, steht das ausdrücklich im Protokoll.

Was das gebracht hat

Für Selbstbetreiber: eine Voraussetzung weniger. Die Zahl der Installationsprobleme ist spürbar zurückgegangen, weil ein ganzer Themenblock entfallen ist.

Für mich beim Entwickeln: kein laufender Dienst mehr nötig, um lokal zu arbeiten. Das klingt nach einer Kleinigkeit und ist eine der Sachen, die die Hürde zum „mal eben eine Stunde dran arbeiten" senken.

Für den Betrieb: ein Dienst weniger, der ausfallen kann. Und ein Ausfall ist damit kein Totalausfall mehr, sondern ein Verlust an Geschwindigkeit — was ich als Verbesserung sehe.

Das Muster dahinter

Aus dieser Umstellung ist bei mir eine Regel geworden, die ich seitdem auf jeden zusätzlichen Dienst anwende:

Ein Dienst, der nur Beschleunigung liefert, darf keine Voraussetzung sein. Zwischenspeicher, Suchindizes, Metriken — all das soll das System besser machen, wenn es da ist, und nicht verhindern, dass es überhaupt läuft.

Ein Dienst, der Korrektheit liefert, muss Voraussetzung sein. Eine Datenbank, die Daten hält, kann man nicht optional machen. Der Unterschied liegt in der Frage, ob das System ohne ihn falsch oder nur langsamer ist.

Die Kunst besteht darin, ehrlich einzuordnen, welcher Fall vorliegt. Bei den Sperren habe ich zunächst falsch eingeordnet: Ich hielt sie für Beschleunigung, tatsächlich sind sie Korrektheit. Deshalb die ausdrückliche Warnung im Protokoll statt einer stillschweigenden Notlösung.

Wie ich die Umstellung abgesichert habe

Ein Ersatz, der dieselbe Schnittstelle anbietet, ist nur so gut wie der Nachweis, dass er sich gleich verhält. Deshalb gibt es für beide Varianten denselben Satz Tests.

const varianten = [
 ["Arbeitsspeicher", () => speicherImArbeitsspeicher()],
 ["Redis", () => redisSpeicher(process.env.TEST_REDIS_URL)],
];
 
for (const [name, erzeugen] of varianten) {
 beschreibe(`${name}: Zähler mit Ablauf`, async () => {
  const speicher = erzeugen();
  gleich(await speicher.erhoeheMitAblauf("test", 1), 1);
  gleich(await speicher.erhoeheMitAblauf("test", 1), 2);
  await warte(1100);
  gleich(await speicher.erhoeheMitAblauf("test", 1), 1); // abgelaufen
 });
}

Der Redis-Teil wird übersprungen, wenn keine Testinstanz konfiguriert ist. Das ist wichtig, damit die Tests auf jedem Rechner laufen — auch auf einem, der genau das nicht installiert hat.

Diese doppelten Tests haben zwei Unterschiede aufgedeckt, die mir sonst entgangen wären: das Verhalten bei negativen Ablaufzeiten und die Frage, ob ein Schlüssel nach dem Ablauf sofort verschwindet oder erst beim nächsten Zugriff.

Wo die Grenze zwischen Beschleunigung und Korrektheit verläuft

Der schwierigste Teil dieser Umstellung war nicht der Code, sondern eine Einordnung: Welche Funktion ist Beschleunigung, welche ist Korrektheit?

Bei Zwischenspeichern ist es eindeutig — sie beschleunigen. Bei Rate-Limits auch, mit einer Ausnahme für Anmeldeversuche. Bei Sitzungen ist es eine Frage der Bequemlichkeit.

Bei Sperren ist es Korrektheit, und genau dort hatte ich zuerst falsch eingeordnet. Eine Sperre, die nur innerhalb eines Prozesses gilt, sieht aus wie eine Sperre und ist keine, sobald ein zweiter Prozess läuft.

Meine Lösung war kein technischer Kunstgriff, sondern Ehrlichkeit: Der Ersatz meldet beim Start, dass er nur prozesslokal wirkt. Wer mehrere Prozesse betreibt, sieht diese Zeile — und wer sie ignoriert, kann sich zumindest nicht darauf berufen, es nicht gewusst zu haben.

Was ich Jahre später davon hatte

Als ich später Systeme in Container gepackt und mehrfach umgezogen habe, war jedes Stück weniger Pflichtinfrastruktur bares Geld an Zeit. Ein Dienst, den man nicht braucht, muss beim Umzug nicht mit, nicht gesichert, nicht neu konfiguriert und nicht überwacht werden.

Das ist der eigentliche Grund, warum ich diese Umstellung als beste Entscheidung des Jahres bezeichne — sie hat nicht einmal etwas verbessert, was Nutzer merken. Sie hat eine Verpflichtung gestrichen, die ich jedes Mal mitgeschleppt hätte.