Von JSON-Dateien zu einer richtigen Datenbank
Zwei beschädigte Dateien, ein verlorener Punktestand und die Frage, warum eigentlich alle Datenbanken benutzen. Der Umzug von JSON auf MySQL — mit allem, was ich dabei zum ersten Mal verstanden habe.
1249 Wörter · 6 Min. Lesezeit
- yurna
- datenbank
- architektur
Mein Bot speicherte alles in Dateien. XP-Punkte in levels.json, Servereinstellungen in guilds.json, Verwarnungen in warns.json. Das funktionierte erstaunlich lange, und ich habe es lange verteidigt: Kein Server, kein Passwort, keine Verbindung, keine zusätzliche Software. Einfach ein Objekt, das man liest und schreibt.
Beendet wurde diese Phase nicht durch eine Erkenntnis, sondern durch zwei kaputte Dateien innerhalb von zehn Tagen.
Wie eine JSON-Datei kaputtgeht
Der Vorgang ist unspektakulär und deshalb tückisch. Mein Code sah so aus:
function speichern(daten) {
fs.writeFileSync("./daten/levels.json", JSON.stringify(daten, null, 2));
}writeFileSync öffnet die Datei, kürzt sie auf null Byte und schreibt dann den neuen Inhalt. Zwischen „auf null Byte gekürzt" und „vollständig geschrieben" liegt ein Moment. Wenn in diesem Moment der Prozess stirbt — Absturz, Neustart, Speicherproblem —, bleibt eine halbe Datei zurück. Beim nächsten Start wirft JSON.parse einen Fehler, und mein Bot startete gar nicht mehr.
Der zweite Fall war subtiler: Zwei Vorgänge haben gleichzeitig geschrieben. Node.js ist einfädig, aber sobald irgendwo await steht, kann zwischen Lesen und Schreiben etwas anderes laufen. Ergebnis: Der eine liest den Stand, der andere liest denselben Stand, beide schreiben — und die Änderung des Ersten ist weg. Das ist die klassische verlorene Aktualisierung, und ich habe sie zum ersten Mal an verschwundenen XP-Punkten gesehen.
Warnung
Wenn man aus irgendeinem Grund bei Dateien bleiben muss, dann wenigstens atomar: in eine temporäre Datei schreiben und danach umbenennen. rename ist auf denselben Dateisystemen eine unteilbare Operation — entweder die alte oder die neue Datei, nie eine halbe.
fs.writeFileSync("./daten/levels.json.tmp", inhalt);
fs.renameSync("./daten/levels.json.tmp", "./daten/levels.json");Das löst das Absturzproblem. Das Problem mit gleichzeitigen Änderungen löst es nicht.
Was eine Datenbank tatsächlich für einen tut
Ich hatte vorher eine ziemlich vage Vorstellung davon, was eine Datenbank ist — „ein Programm, das Daten speichert". Was ich beim Umzug verstanden habe, waren vor allem drei Sachen, die ich vorher selbst hätte bauen müssen.
Sie schreibt entweder ganz oder gar nicht. Eine Transaktion ist entweder durchgelaufen oder nicht passiert. Es gibt keinen halben Zustand. Genau das, woran meine Dateien gestorben sind.
Sie kann gleichzeitige Zugriffe. Zwei Vorgänge, die denselben Datensatz ändern wollen, werden nacheinander abgearbeitet, nicht durcheinander. Und wenn man will, kann man einen Wert erhöhen lassen, ohne ihn vorher zu lesen:
UPDATE mitglieder SET xp = xp + 15 WHERE gilde = ? AND nutzer = ?;Diese eine Zeile ersetzt Lesen, Rechnen, Schreiben — und damit die ganze Klasse von Fehlern, die mich die XP-Punkte gekostet hat.
Sie kann suchen, ohne alles zu laden. Meine JSON-Datei musste ich komplett in den Speicher holen, um eine Rangliste zu bauen. Bei einer Datenbank ist das eine Abfrage mit Sortierung und Grenze, und der Rest bleibt auf der Platte:
SELECT nutzer, xp FROM mitglieder WHERE gilde = ? ORDER BY xp DESC LIMIT 10;Der Umzug selbst
Ich habe MySQL genommen, weil es das war, was in jedem Tutorial stand und was mein Hoster mitbrachte. Rückblickend hätte SQLite für diesen Zeitpunkt völlig gereicht — dazu später mehr, das ist eine Entscheidung, die ich Jahre danach noch einmal in die andere Richtung getroffen habe.
Das Schema war zunächst klein:
CREATE TABLE gilden (
id VARCHAR(20) PRIMARY KEY,
prefix VARCHAR(5) NOT NULL DEFAULT '!',
sprache VARCHAR(5) NOT NULL DEFAULT 'de',
willkommen_kanal VARCHAR(20) NULL
);
CREATE TABLE mitglieder (
gilde VARCHAR(20) NOT NULL,
nutzer VARCHAR(20) NOT NULL,
xp INT UNSIGNED NOT NULL DEFAULT 0,
muenzen INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (gilde, nutzer)
);Zwei Dinge daran habe ich damals zum ersten Mal bewusst entschieden, und beide haben sich als richtig erwiesen.
Der zusammengesetzte Primärschlüssel aus Gilde und Nutzer. Ein Nutzer kann auf mehreren Servern sein, und seine Punkte gehören jeweils zu einem Server. Ohne diesen Schlüssel hätte ich später Datensätze doppelt gehabt und es erst gemerkt, wenn jemand sich beschwert.
IDs als Text, nicht als Zahl. Discord-IDs sind 64-Bit-Zahlen, und JavaScript kann so große Ganzzahlen in number nicht sicher darstellen. Wer sie als Zahl behandelt, bekommt bei bestimmten IDs Rundungsfehler — und dann gehören Punkte dem falschen Nutzer. Das ist ein Fehler, der selten auftritt und deshalb unmöglich zu finden ist, wenn man ihn nicht kennt.
Was beim Umzug schiefging
Verbindungen sind endlich. Meine erste Fassung hat für jede Abfrage eine neue Verbindung aufgebaut und danach vergessen, sie zu schließen. Nach ein paar Stunden Betrieb kam Too many connections. Die Lösung heißt Verbindungspool: eine feste Anzahl offener Verbindungen, die reihum benutzt werden.
Der erste Import war unvollständig. Ich habe die JSON-Dateien mit einem Skript eingelesen und in die Tabellen geschrieben. Was ich nicht bedacht hatte: In den Dateien standen auch Einträge für Server, auf denen der Bot längst nicht mehr war, und für Nutzer, die es nicht mehr gab. Die sind alle mitgekommen. Aufräumen musste ich hinterher.
Ich habe zu spät Indizes gesetzt. Die Rangliste war anfangs langsam, weil ORDER BY xp DESC über die ganze Tabelle ging. Ein Index auf (gilde, xp) hat aus einer Abfrage von mehreren hundert Millisekunden eine von wenigen gemacht. Dass Indizes kein Zauber sind, sondern schlicht eine sortierte Zusatzstruktur, habe ich hier zum ersten Mal begriffen.
Der Teil, den ich unterschätzt habe
Mit einer Datenbank kommt eine neue Verantwortung, die ich vorher nicht hatte: Das Schema ist jetzt ein eigener Zustand, der sich mitentwickeln muss.
Bei JSON-Dateien fügt man ein Feld hinzu, indem man ein Feld hinzufügt. Bei einer Datenbank braucht es eine Änderung an der Tabelle, und die muss auf jedem System nachgezogen werden, auf dem der Code läuft. Ich habe das anfangs von Hand gemacht — per Konsole eine Spalte hinzugefügt und gehofft, dass ich es auf dem Testsystem auch nicht vergesse. Das ist ungefähr fünfmal gutgegangen und beim sechsten Mal nicht.
Wie ich das später gelöst habe — erst mit Migrationsdateien, dann mit einem Werkzeug, das Schema und Code aus einer Quelle erzeugt — ist ein Thema für spätere Einträge. Hier ist nur der Punkt wichtig, an dem mir klar wurde, dass es ein Thema ist.
Die Fragen, die ich mir beim Schemaentwurf hätte stellen sollen
Mein erstes Schema ist an drei Stellen zu kurz gesprungen, und alle drei ließen sich später nur mit Migrationen beheben.
Was passiert, wenn ein Wert fehlt? Ich hatte überall Standardwerte gesetzt, auch dort, wo „nicht gesetzt" etwas anderes bedeutet als „null". Bei einem Willkommenskanal ist das ein Unterschied: nicht konfiguriert heißt „keine Nachricht schicken", der Wert 0 heißt gar nichts.
Woran erkenne ich, wann etwas passiert ist? Meine ersten Tabellen hatten keine Zeitstempel. Kein erstellt_am, kein geaendert_am. Das rächt sich beim ersten Fehler, den man zeitlich einordnen will, und beim ersten Zwischenspeicher, der wissen muss, ob sein Stand noch aktuell ist.
Was ist eigentlich eindeutig? Ich hatte in einer Tabelle keinen eindeutigen Schlüssel gesetzt, weil „das kann ja nicht doppelt vorkommen". Es kam doppelt vor, nach einem Neustart mitten in einem Vorgang. Eine Datenbank kann Eindeutigkeit erzwingen — das ist einer ihrer Hauptzwecke, und ich habe ihn zunächst nicht genutzt.
Was ich mir seitdem angewöhnt habe
Vier Dinge, die in jeder Tabelle stehen, die ich anlege:
- Ein eindeutiger Schlüssel, der die Fachlichkeit abbildet — nicht nur eine laufende Nummer.
- Zeitstempel für Anlage und letzte Änderung.
- Ausdrückliche Nullbarkeit. Für jede Spalte eine Entscheidung, ob „nicht gesetzt" ein gültiger Zustand ist.
- Ein Index für jede Abfrage, die ich tatsächlich mache. Nicht auf Vorrat, sondern nach dem Blick in die Abfragen.
Und eine Regel, die aus dem Import stammt: Beim Übernehmen alter Daten wird gefiltert, nicht kopiert. Meine JSON-Dateien enthielten Einträge für Server, auf denen der Bot längst nicht mehr war, und für Nutzer, die es nicht mehr gab. Ein Import ist die beste Gelegenheit, so etwas loszuwerden — danach ist es Datenbestand und wird mitgeschleppt.
Was ich mitnehme
Die JSON-Phase war kein Fehler. Sie hat mich anderthalb Jahre getragen, und in dieser Zeit hätte eine Datenbank nur zusätzliche Komplexität bedeutet.
Der Fehler war, sie zu lange zu verteidigen. Die Signale waren eindeutig: beschädigte Dateien, verlorene Änderungen, langsame Ranglisten. Ich habe sie einzeln behandelt, statt zu fragen, ob sie zusammengehören. Das ist ein Muster, das ich seitdem bei mir kenne — mehrere kleine Probleme mit derselben Wurzel wirken einzeln immer harmloser als das Grundproblem.