Sharding verstehen, bevor man es braucht
Ab 2500 Servern muss ein Discord-Bot seine Verbindungen aufteilen. Ich war weit davon entfernt — und habe mich trotzdem damit beschäftigt. Was ich dabei über verteilte Zustände gelernt habe.
986 Wörter · 5 Min. Lesezeit
- yurna
- discord
- skalierung
Mein Bot lief 2021 auf einer niedrigen dreistelligen Zahl von Servern. Sharding — das Aufteilen der Gateway-Verbindung auf mehrere Prozesse — wird bei Discord ab 2500 Servern verpflichtend. Ich war also um Größenordnungen davon entfernt.
Trotzdem habe ich mich einen Sommer lang damit beschäftigt, und rückblickend war das eine der lehrreichsten Sachen des Jahres. Nicht wegen der Skalierung, sondern weil Sharding einen zwingt, über Zustand nachzudenken.
Was Sharding technisch ist
Ein Discord-Bot hält eine dauerhafte Verbindung zum Gateway und bekommt darüber alle Ereignisse. Ein Shard ist eine solche Verbindung, die für einen Teil der Server zuständig ist. Bei zwei Shards bekommt Shard 0 die eine Hälfte, Shard 1 die andere — die Aufteilung ergibt sich rechnerisch aus der Server-ID.
Praktisch heißt das: Man startet mehrere Prozesse, jeder verbindet sich mit einer Nummer, und jeder sieht nur seinen Teil der Welt.
const manager = new ShardingManager("./bot.js", {
token: process.env.DISCORD_TOKEN,
totalShards: "auto",
});
manager.spawn();Das war der einfache Teil. Der interessante Teil ist alles, was danach nicht mehr funktioniert.
Was kaputtgeht, sobald es mehr als einen Prozess gibt
Jede Statistik über alles. „Auf wie vielen Servern läuft der Bot?" ist plötzlich keine lokale Frage mehr. Jeder Shard kennt nur seine eigenen. Die Antwort muss man einsammeln:
const zahlen = await client.shard.fetchClientValues("guilds.cache.size");
const gesamt = zahlen.reduce((a, b) => a + b, 0);Jeder Zwischenspeicher im Prozess. Meine Abklingzeit für XP lag in einer Map im Speicher. Bei mehreren Prozessen hat jeder seine eigene. Solange die Zuordnung von Server zu Shard fest ist, geht das gut — sobald ein Wert nutzerbezogen und serverübergreifend ist, geht es schief.
Jede Sperre. Wenn zwei Vorgänge sich gegenseitig ausschließen sollen, reicht ein Flag im Speicher nicht mehr. Es braucht einen gemeinsamen Ort — die Datenbank oder einen Zwischenspeicherdienst.
Jede Zeitplanung. Ein Wecker, der alle fünfzehn Sekunden fällige Verlosungen sucht, läuft in jedem Prozess. Bei drei Shards wird jede Verlosung dreimal beendet. Entweder man macht die Aufgabe zustandssicher, oder man legt fest, dass nur ein bestimmter Shard sie ausführt.
Warnung
Der dritte Punkt ist der gefährlichste, weil er nicht auffällt. Doppelte Zeitgeber führen selten zu Fehlermeldungen, sondern zu doppelten Nachrichten, doppelten Auszahlungen, doppelten Einträgen. Wenn eine Aufgabe in mehreren Prozessen laufen kann, muss sie entweder unschädlich wiederholbar sein oder eine Sperre halten. Beides ist Arbeit, und beides fällt einem sonst erst im Betrieb auf.
Was ich daraufhin geändert habe, obwohl ich nicht geshardet habe
Und genau das ist der Punkt dieses Eintrags. Ich habe kein Sharding eingeschaltet — ich brauchte es nicht. Aber ich habe drei Sachen umgebaut, weil mir beim Durchdenken klar wurde, dass sie fragil sind.
Die Abklingzeiten wanderten aus dem Speicher. Erst in die Datenbank, später in einen Zwischenspeicherdienst mit Ablaufzeit. Nebeneffekt, der mich sofort überzeugt hat: Nach einem Neustart gelten sie weiter. Vorher war jeder Neustart eine kurze Phase, in der Sperren nicht griffen.
Fällige Aufgaben werden mit Anspruch abgeholt. Statt „lies alles Fällige und arbeite es ab" gilt jetzt: markiere den Datensatz als in Bearbeitung und arbeite ihn nur ab, wenn die Markierung tatsächlich von einem selbst gesetzt wurde. Damit kann derselbe Wecker in beliebig vielen Prozessen laufen.
UPDATE verlosungen
SET bearbeitet_von = ?, bearbeitet_am = CURRENT_TIMESTAMP
WHERE id = ? AND bearbeitet_von IS NULL;
-- 1 betroffene Zeile: gehört mir. 0 Zeilen: jemand anderes war schneller.Zahlen kommen aus der Datenbank, nicht aus dem Speicher. Serverzahl, Nutzerzahl, Befehlszähler — alles wird geschrieben und von dort gelesen, statt aus lokalen Zählern zu stammen.
Warum sich das gelohnt hat
Ein paar Jahre später hatte ich ein System aus drei getrennten Prozessen: dem Bot, einer Weboberfläche und einer Verwaltungsschnittstelle. Sie laufen nebeneinander, teilen sich eine Datenbank und wissen wenig voneinander.
Das ist strukturell dasselbe Problem wie Sharding — nur dass die Prozesse verschiedene Aufgaben haben statt verschiedener Server. Und weil ich diese Fragen im Sommer 2021 durchdacht hatte, war das kein Neuland: Zustand gehört in eine gemeinsame Quelle, Aufgaben brauchen einen Anspruch, Zahlen kommen von dort, wo sie geschrieben werden.
Ich halte deshalb nicht viel von der Regel, man solle sich mit Skalierung erst beschäftigen, wenn man sie braucht. In der harten Form — „bau es jetzt" — stimmt sie. In der weichen Form — „denk jetzt nicht darüber nach" — kostet sie einen die Gelegenheit, an einer konkreten Frage etwas zu lernen, das später überall gilt.
Der Test, den ich mir daraus gebaut habe
Aus dieser Beschäftigung ist eine Prüfung geworden, die ich seitdem regelmäßig auf eigenen Code anwende. Sie besteht aus einer einzigen Frage:
Was passiert, wenn dieser Code gleichzeitig zweimal läuft?
Für jeden Codepfad, der etwas verändert, gibt es genau drei zulässige Antworten:
„Nichts Schlimmes, das Ergebnis ist gleich." Der Vorgang ist unschädlich wiederholbar. Das ist der beste Fall und meistens erreichbar, wenn man Gesamtstände schreibt statt Zuwächse.
„Einer gewinnt, der andere merkt es." Es gibt eine Sperre oder eine bedingte Aktualisierung, und der Verlierer bekommt eine Rückmeldung.
„Das darf nicht passieren, deshalb ist es verhindert." Mit einer eindeutigen Bedingung in der Datenbank, nicht mit einer Prüfung im Code.
Wenn keine dieser drei Antworten zutrifft, ist der Code kaputt — nur eben noch nicht sichtbar.
Warum ich Zuwächse vermeide, wo es geht
Der wirksamste Einzeltipp aus dieser Zeit: Schreib Gesamtstände statt Zuwächse, wo immer es geht.
Ein Zuwachs — „addiere fünfzehn Punkte" — ist nicht wiederholbar. Wenn die Nachricht doppelt ankommt, ist das Ergebnis falsch, und man kann es hinterher nicht mehr feststellen.
Ein Gesamtstand — „setze auf 1250" — ist beliebig oft anwendbar. Doppelte Zustellung, wiederholte Verarbeitung, ein Neustart mitten im Vorgang: Das Ergebnis ist immer dasselbe.
Ich habe dieses Muster Jahre später in einem ganz anderen Zusammenhang wiedergefunden, bei der Datensammlung eines Spielservers: Die Schnittstelle nimmt Gesamtstände entgegen, keine Zuwächse. Deshalb kann derselbe Schnappschuss doppelt ankommen, ohne dass die Zahlen verfälscht werden. Das ist keine Kleinigkeit, sondern der Unterschied zwischen einem System, das man neu starten darf, und einem, bei dem man vorher nachdenken muss.
Was ich bewusst nicht gemacht habe
Ich habe kein Sharding eingebaut, das ich nicht brauchte. Mehrere Prozesse bedeuten mehr Speicherverbrauch, kompliziertere Fehlersuche und eine ganze Klasse zusätzlicher Fehlerquellen. Für dreihundert Server ist das reiner Verlust.
Der Unterschied zwischen „darüber nachdenken" und „einbauen" ist bei mir seitdem eine bewusste Trennung. Nachdenken kostet einen Abend. Einbauen kostet Wartung, und zwar dauerhaft.