SkillGridInformatik durch Machen lernen

Skalierung: mehr Last, mehr Maschinen, mehr Probleme

Vertikale und horizontale Skalierung, warum zustandslose Dienste sich leicht vervielfachen und Zustand nicht, Replikationsverzögerung und warum ein Shard-Schlüssel eine einmalige Entscheidung ist.

17 Min. · 55 XP

In architecture:1 hast du ein System aus Client, Server, API-Vertrag, Cache und Monolith gebaut. Es lief. Diese Lektion handelt von dem Tag, an dem es für zehntausend Menschen läuft statt für zehn. Hier steht kein neues Konzept: Hier steht, was die Konzepte aus architecture:1 und os:2 kosten, wenn man sie ernst nimmt.

Es gibt genau zwei Wachstumsrichtungen. Vertikal heißt eine größere Maschine: mehr Kerne, mehr RAM, schnellere Platten. Horizontal heißt mehr Maschinen derselben Größe. Vertikal wird zu Unrecht unterschätzt. Es erfordert keine Codeänderung, es erhält jede Annahme, die du schon getroffen hast, und ein einzelner moderner Server mit 64 Kernen und 512 GB RAM bedient eine erstaunliche Menge Verkehr. Ein Blog, ein kleiner Laden, ein internes Werkzeug und die meisten B2B-Produkte brauchen nie etwas anderes. Das Problem am vertikalen Weg ist, dass es eine Leiter mit einer obersten Sprosse ist: Irgendwann reicht die größte mietbare Maschine nicht mehr, der Preis pro Kern ist nicht mehr linear, sondern verdoppelt sich, und der Austausch dieser einen Maschine ist ein Wartungsfenster statt eines rollierenden Updates.

VERTICAL                      HORIZONTAL

  1 x 32 cores                  32 x 1 core

  limit: silicon                limit: coordination
  cost: superlinear             cost: linear plus a tax
  failure: one box               failure: 1 of 32 lost
  restart: minutes of downtime  restart: drain and replace
  ceiling: ~ a few hundred GB   ceiling: none in practiceBeide Richtungen vergrößern dasselbe System. Sie versagen unterschiedlich, und dieser Unterschied entscheidet meistens.

Hier ist der Teil, der alles entscheidet, und es ist dieselbe Idee wie zustandsloses HTTP aus architecture:1, nur von der anderen Seite gesehen. Ein zustandsloser Dienst behält zwischen Anfragen nichts: Eine Anfrage kommt an, bekommt eine Antwort, und der Prozess vergisst sie. Solch ein Dienst skaliert durch Ändern einer Zahl. Betreibe drei Kopien oder dreißig, und jede Kopie beantwortet jede Anfrage. Ein zustandsbehafteter Dienst merkt sich etwas, das zu einem bestimmten Nutzer gehört, und dieses Gedächtnis ist an eine Maschine gebunden. Nun hilft eine zusätzliche Maschine nicht von selbst: Du musst auch entscheiden, wo der Zustand lebt, wie die neue Maschine davon erfährt und was mit ihm passiert, wenn die Maschine stirbt, die ihn hält. Das ist die ganze Schwierigkeit der Skalierung in einem Satz: Computer lassen sich leicht vervielfachen, das Gedächtnis eines Gesprächs nicht.

Das klassische Beispiel ist der Warenkorb. Liegt der Warenkorb im Speicher des Serverprozesses, muss der Nutzer immer denselben Prozess treffen, sonst leert sich der Warenkorb. Die Lösung ist, den Zustand aus dem Prozess in etwas Gemeinsames zu verlagern: eine Datenbankzeile, einen Redis-Schlüssel oder ein signiertes Cookie, das der Client mitführt. Sobald der Warenkorb im gemeinsamen Speicher liegt, ist der Dienst davor wieder zustandslos und vervielfacht sich wie jeder andere. Beachte die Form dieses Schritts: Du hast den Zustand nicht verschwinden lassen, du hast ihn an den einen Ort verschoben, der Zustand haben darf. Dafür ist die Datenbank da, und deshalb wird sie zum nächsten Engpass.

STATELESS SERVICE              STATEFUL SERVICE

  any replica answers            only the owner answers
  add a pod: instant             add a node: rebalance keys
  kill a pod: no loss            kill a node: lose its shard
  scale by count                 scale by partition

  cart lives in a shared store, not in the processDerselbe Dienst, nachdem sein Zustand aus dem Prozess in einen gemeinsamen Speicher verlagert wurde.

Sobald du mehrere identische Repliken hast, muss etwas entscheiden, welche jede Anfrage bekommt. Dieses Etwas ist ein Lastverteiler, und interessant ist nicht der Algorithmus, sondern die Wahl, worüber verteilt wird. Round Robin schickt die erste Anfrage an die erste Replik, die zweite an die zweite und so weiter: einfach und gut, wenn jede Anfrage etwa gleich viel kostet. Least Connections schickt die nächste Anfrage an die Replik mit den wenigsten laufenden Anfragen: besser, wenn die Kosten stark schwanken, weil eine langsame Anfrage nicht weiter Verkehr auf eine schon beschäftigte Replik zieht. Hashing nimmt einen Wert aus der Anfrage und bildet ihn auf eine Replik ab, und das ist die einzige der drei Varianten, die dir Klebrigkeit gibt.

ClientLoad balancerReplica AReplica Brequest without a session cookieleast connections, 41200 OKrequest with a session cookiehash the cookie, not the source IPsame replica as last time200 OK, cart still there

Ein Lastverteiler ist nur so nützlich wie seine Vorstellung davon, welche Repliken gesund sind. Die oberflächliche Prüfung ist ein TCP-Verbindungsaufbau: Nimmt der Port an, gilt die Replik als oben. Das ist fast wertlos, denn ein Prozess kann Verbindungen annehmen, während sein Datenbankpool erschöpft ist und jede Anfrage in einen Timeout läuft. Die nützliche Prüfung ist ein echter Endpunkt, der die Abhängigkeiten prüft, die die Replik zum Arbeiten braucht, und einen Fehler meldet, wenn sie fehlen. Sei auch dann vorsichtig mit der Reihenfolge: Eine zu eifrig fehlschlagende Prüfung nimmt eine gesunde Replik bei einem kurzen Datenbankausschlag heraus, und wenn sie bei allen zugleich fehlschlägt, hast du eine Verlangsamung in einen Totalausfall verwandelt.

Es gibt einen Unterschied, der das ganze Problem neu ordnet: Lesen und Schreiben skalieren nicht gleich, und sie müssen nicht gemeinsam skalieren. Die meisten Systeme lesen viel mehr, als sie schreiben. Eine Nachrichtenseite kann hunderttausend Seitenaufrufe je veröffentlichtem Artikel bedienen; ein Laden kann ein Produkt zehntausendmal zeigen, bevor sich sein Bestand ändert. Lesen ist außerdem von Natur aus parallel: Zehn verschiedene Nutzer, die dieselbe Zeile lesen, haben keinen Grund zu konkurrieren. Schreiben ist das Gegenteil, weil es gemeinsamen Zustand ändert und etwas die Reihenfolge festlegen muss. Der übliche erste Schritt ist deshalb die Trennung: ein Primary, der Schreibvorgänge annimmt, und mehrere Repliken, die ihn kopieren und Lesevorgänge bedienen. Beachte, dass das ein Tausch ist und kein kostenloses Mittagessen. Du hast gerade Lesekapazität gekauft, und der Preis ist, dass ein Lesevorgang nun einen leicht veralteten Wert zurückgeben kann.

-- writes go to the primary
INSERT INTO orders (id, user_id, total)
VALUES (9001, 42, 12.90);

-- reads may go to a replica, up to 200 ms behind
SELECT id, total
FROM   orders
WHERE  user_id = 42
ORDER  BY id DESC
LIMIT  20;

-- one connection per role, chosen explicitly
-- write pool -> primary.internal:5432
-- read pool  -> replica.internal:5432Lesen von Schreiben zu trennen sind ein paar Konfigurationszeilen und eine neue Fehlerklasse.

Replikationsverzögerung ist die Zeitspanne zwischen dem Eintreffen eines Schreibvorgangs auf dem Primary und seiner Sichtbarkeit auf einer Replik. Bei geringer Last sind das ein bis zwei Millisekunden, und niemand merkt es. Bei hoher Last wächst sie, weil der Primary mit Schreibvorgängen beschäftigt ist und die Replik sie einzeln und der Reihe nach anwenden muss. Zahlen, die man mit sich herumtragen sollte: Eine gesunde Streaming-Replik von PostgreSQL liegt unter etwa 10 ms; eine beschäftigte kann bei einer Schreibspitze mehrere hundert Millisekunden erreichen; eine Replik, die bei einem großen Stapel oder einer Schemaänderung zurückgefallen ist, kann Minuten zurückliegen und trotzdem als oben gemeldet werden. Verzögerung ist kein Fehler, den man behebt, sondern eine Eigenschaft davon, Daten über ein Netz auf eine zweite Maschine zu kopieren, die ebenfalls arbeitet.

Der Fehler, den die Verzögerung erzeugt, heißt Lesen der eigenen Schreibvorgänge, und er ist der häufigste Weg, auf dem eine Lesereplik ein Support-Ticket erzeugt. Ein Nutzer speichert eine Änderung, der Schreibvorgang geht an den Primary und gelingt, die Seite lädt neu, der Lesevorgang geht an eine Replik, die noch nicht aufgeholt hat, und der Nutzer sieht seine eigenen alten Daten. Aus seiner Sicht hat das Speichern schlicht nicht funktioniert, also macht er es noch einmal. Die ehrliche Lösung ist zu bemerken, dass der Nutzer nicht allgemein die neuesten Daten braucht, sondern dass seine eigenen Schreibvorgänge für ihn selbst sichtbar sind. Leite also Lesevorgänge für ein kurzes Fenster nach dem Schreiben dieses Nutzers an den Primary: ein paar Sekunden, oder bis die Replik meldet, dass sie über die Position dieses Schreibvorgangs hinaus aufgeholt hat. Beachte, was das kostet: Du gibst einen Teil der Lesekapazität auf, für die Teilmenge der Lesevorgänge, die auf einen Schreibvorgang desselben Nutzers folgen. Diese Teilmenge ist meist klein, und der Tausch lohnt sich.

Replikation kopiert den gesamten Datensatz auf jede Replik und skaliert damit Lesevorgänge. Für Schreibvorgänge tut sie nichts, weil jede Replik trotzdem jeden Schreibvorgang anwenden muss. Wenn Schreibvorgänge der Engpass sind, bleibt nur ein Schritt: aufhören, alle Daten an einem Ort zu halten. Teile die Daten selbst in Stücke, Shards genannt, jedes auf einer anderen Maschine, und jedes verarbeitet die Schreibvorgänge für sein eigenes Stück. Nun berührt ein Schreibvorgang einen Shard, und zehn Shards können zehnmal so viel Schreibverkehr aufnehmen. Das ist der Punkt, an dem eine einzelne logische Datenbank zu einer Flotte wird, durch die du selbst routen musst, und an dem die Anwendung Code enthält, der die Frage beantwortet, welcher Shard diese Zeile hält.

KEY            DISTRIBUTION     HOT SPOT          CROSS-SHARD QUERY

  user_id        even             none              joins on user_id only
  created_at     even over time   today only        every range scan
  country        skewed           one big country   any global report
  auto id        even at write    none              no locality at allVier mögliche Shard-Schlüssel für eine Bestelltabelle und der Preis, den jeder verlangt.

Der Shard-Schlüssel ist die Entscheidung, die beinahe unumkehrbar ist, und es lohnt sich zu verstehen, warum, bevor man eine trifft. Jede Abfrage der Anwendung muss nun entweder den Shard-Schlüssel nennen oder an jeden Shard geschickt und in ihren Ergebnissen zusammengeführt werden, was drastisch langsamer ist und schnell zum dominierenden Kostenfaktor wird. Der Shard-Schlüssel entscheidet also still, welche Abfragen die nächsten fünf Jahre schnell bleiben. Wähle user id für eine Bestelltabelle, und jede Abfrage nach Nutzer ist ein Nachschlagen in einem Shard, während ein Bericht über alle Bestellungen streuen und sammeln muss. Wähle created_at, und jeder Bericht nach Monat ist schnell, aber alle heutigen Schreibvorgänge landen auf einem Shard, genau dem, der ohnehin am heißesten ist. Den Schlüssel später zu ändern bedeutet, jede Zeile aus jedem Shard zu lesen und in anderer Anordnung zurückzuschreiben, während das System die ganze Zeit läuft und Verkehr bedient.

Praxis

  1. Ein Team betreibt einen PostgreSQL-Primary und drei Leserepliken. Nutzer melden, dass sie nach dem Bearbeiten ihres Profils die Seite neu laden, die alten Werte sehen und es eine Sekunde später korrekt wird. Was passiert wirklich?

    Hinweise
    • Der Schreibvorgang war erfolgreich; frag, welcher Server das Neuladen beantwortet hat.
    • Vergleiche den Zustand der Kopien, nicht die Geschwindigkeit des Netzes.
    • Es geht darum, wer liest, nicht darum, Kopien schneller zu machen.
  2. Ordne jedem Mechanismus das Problem zu, das er wirklich löst, und den Preis, den er verlangt.

    Hinweise
    • Einer kopiert den ganzen Datensatz, der andere teilt ihn.
    • Ein Cache ändert, was du liest, nicht wo die Wahrheit liegt.
    • Nur einer der vier kostet dich eine dauerhafte Entscheidung über die Abfrageform.
  3. Ein Laden hat 8 000 Nutzer pro Tag, eine Datenbank und kein Caching. Die Spitzenlast beträgt 40 Anfragen pro Sekunde. Bringe die Antworten in die Reihenfolge, in der ein sorgfältiger Ingenieur sie versuchen würde.

    1. 1Einen Cache vor die sich wiederholenden Lesevorgänge setzen
    2. 2Leserepliken ergänzen, sobald gerade die Lesevorgänge nicht mehr hineinpassen
    3. 3Die fehlenden Indizes ergänzen und die langsamste Abfrage reparieren
    4. 4Die Daten erst sharden, wenn Schreibvorgänge und nicht Lesevorgänge der Engpass bleiben
    5. 5Auf eine größere Maschine wechseln, wenn die Messung weiterhin sagt, dass die Datenbank voll ist
    6. 6Messen, wofür die 40 Anfragen pro Sekunde wirklich Zeit aufwenden
    Hinweise
    • Fang mit dem Schritt an, der strukturell gar nichts ändert.
    • Miss, bevor du umziehst, und teile Daten erst, wenn Kopieren nicht mehr hilft.
    • Repliken und Shards sind die letzten zwei Sprossen, nicht die ersten.

Fragen zu dieser Lektion

Ist das kostenlos?

Ja. Die erste Ebene jedes Fachs ist kostenlos und braucht kein Konto. Tiefere Ebenen und die höheren Stufen sind kostenpflichtig — genau das hält das Projekt am Leben.

Für welches Alter ist das gedacht?

Für neugierige Jugendliche ab etwa 12 Jahren und für Erwachsene, die bei null anfangen. Programmiererfahrung wird nicht vorausgesetzt; die Lektionen zu Netzwerken und Physik brauchen keine Mathematik über Schulniveau.

Muss ich schon programmieren können?

Nein. Logik, Physik und Netzwerke beginnen bei null. Code schreibst du im Bereich Programmiersprachen — und auch der fängt vorne an.

Muss ich etwas installieren?

Nein. Alles läuft im Browser: Theorie, Übungen und die interaktiven Simulationen. Es gibt nichts herunterzuladen und nichts einzurichten.

Wie lange dauert ein Fach?

Eine Lektion dauert 10 bis 15 Minuten. Eine Junior-Stufe umfasst etwa 12 bis 20 Lektionen, also einige Wochen regelmäßiger Übung statt eines Abends.

Muss ich der Reihenfolge folgen?

Innerhalb einer Stufe öffnen sich die Ebenen nacheinander, weil jede auf der vorherigen aufbaut. Zwischen den Fächern bist du frei: beginne mit Logik, Physik oder Netzwerken.