Zum Inhalt
wannacode

Skalierung

Kurz gesagt: Horizontale Skalierung stellt Kopien des Servers hinter einen Load Balancer; sie funktioniert, wenn der Server keinen eigenen Zustand behält, während Replikate der Datenbank hinterherhinken.

Problem

Black Friday und ein Server

An einem gewöhnlichen Tag bekommt der Shop etwa 50 Anfragen in der Sekunde, und ein Server genügt. Am Black Friday sind es zehnmal so viele (ein Beispiel, keine Messung). Die CPU steht bei 100 %, Seiten laufen in den Timeout, und Warenkörbe gehen verloren. Letztes Jahr hat die Firma eine größere Maschine gekauft. Sie kostete doppelt so viel, hielt ein Jahr, und sie ist immer noch eine: Wenn sie für ein Update neu startet, steht der Shop.

Wie bekommst du mehr Leistung, ohne auf eine größere Maschine zu warten, und ohne den Shop anzuhalten, wenn eine Maschine ausfällt?

Die Idee

Eine größere Maschine oder mehr Maschinen

Es gibt zwei Wege:

  • Vertikale Skalierung: eine größere Maschine mit mehr CPUs und Speicher. Der Code ändert sich nicht. Aber die Maschine hat eine Obergrenze, der Preis wächst schneller als die Leistung, und der Server bleibt einer: Fällt er aus, steht der Shop.
  • Horizontale Skalierung: mehrere identische Kopien des Servers, und davor ein Balancer (Load Balancer), der die Anfragen auf sie verteilt. Es gibt keine Obergrenze: Du fügst eine Kopie hinzu. Fällt eine Kopie aus, übernehmen die anderen.

Horizontale Skalierung hat eine Bedingung: Jede Kopie muss jede Anfrage annehmen können. Der Balancer weiß nicht, worum es in einer Anfrage geht, und verteilt sie der Reihe nach.

Der Balancer als echte Infrastruktur (nginx, ein Cloud-Load-Balancer, Kubernetes) ist ein Thema des DevOps-Kurses ZeroToOps. Hier ist er ein Objekt, das die Server der Reihe nach verteilt (Round Robin), damit du siehst, was mit dem Code passiert.

Sieh es dir an

Drei Kopien mit Speicher

Der Server hält die Warenkörbe in seinem Speicher, so wie der Shop die Sitzungen in einer Map hielt, in der Lektion über Sitzungen:

class AppServer {
carts = new Map(); // die Warenkörbe leben im Speicher dieses Servers
constructor(name) {
this.name = name;
}
add(session, product) {
if (!this.carts.has(session)) this.carts.set(session, []);
const cart = this.carts.get(session);
cart.push(product);
return `${this.name}: ${cart.join(", ")}`;
}
}
// der Balancer verteilt die Server der Reihe nach (Round Robin)
class Balancer {
next = 0;
constructor(servers) {
this.servers = [...servers];
}
pick() {
return this.servers[this.next++ % this.servers.length];
}
remove(server) {
this.servers.splice(this.servers.indexOf(server), 1);
}
}

Maria legt vier Produkte hinein. Der Balancer gibt die Anfragen an A, B, C und wieder A:

const balancer = new Balancer([new AppServer("A"), new AppServer("B"), new AppServer("C")]);
console.log(balancer.pick().add("maria", "Kaffee"));
console.log(balancer.pick().add("maria", "Tee"));
console.log(balancer.pick().add("maria", "Schokolade"));
console.log(balancer.pick().add("maria", "Honig"));
Ausgabe
A: Kaffee
B: Tee
C: Schokolade
A: Kaffee, Honig

Jede Kopie sieht ihren eigenen Warenkorb. B hat keinen Kaffee, C hat auch keinen Tee, und A sieht den Kaffee, weil Maria wieder bei ihm ist. An der Kasse ist der Warenkorb das, was gerade in der Kopie liegt, die die Anfrage annimmt.

Die Idee

Ein Server, der nichts Eigenes behält

Ein Server mit Zustand merkt sich etwas zwischen zwei Anfragen: den Warenkorb, die Sitzung, einen Zähler. Ein Server ohne Zustand (stateless) merkt sich nichts Eigenes. Alles, was bis zur nächsten Anfrage überleben muss, liegt draußen:

  • in einem gemeinsamen Speicher für die Sitzungen, den alle Kopien sehen (Redis);
  • in der Datenbank;
  • beim Client, signiert, damit er sich nicht ändern lässt (in der Lektion über JWT).

Dann können die Kopien einander vertreten: Jede nimmt jede Anfrage an, und wenn eine ausfällt, geht nichts verloren.

Sieh es dir an

Der Warenkorb im gemeinsamen Speicher

// der gemeinsame Speicher der Sitzungen (im echten Shop Redis)
class SessionStore {
carts = new Map();
add(session, product) {
if (!this.carts.has(session)) this.carts.set(session, []);
const cart = this.carts.get(session);
cart.push(product);
return [...cart];
}
}
class AppServer {
constructor(name, store) {
this.name = name;
this.store = store; // der Server behält nichts Eigenes
}
add(session, product) {
const cart = this.store.add(session, product);
return `${this.name}: ${cart.join(", ")}`;
}
}

Dieselben Anfragen, und nach der dritten fällt Server B aus:

const store = new SessionStore();
const b = new AppServer("B", store);
const balancer = new Balancer([new AppServer("A", store), b, new AppServer("C", store)]);
console.log(balancer.pick().add("maria", "Kaffee"));
console.log(balancer.pick().add("maria", "Tee"));
console.log(balancer.pick().add("maria", "Schokolade"));
balancer.remove(b); // Server B fällt aus
console.log(balancer.pick().add("maria", "Honig"));
Ausgabe
A: Kaffee
B: Kaffee, Tee
C: Kaffee, Tee, Schokolade
C: Kaffee, Tee, Schokolade, Honig

Jede Kopie sieht den ganzen Warenkorb. Fällt B aus, gibt der Balancer die Anfrage an C, und der Warenkorb ist vollständig, weil er nie in B war. list(cart) und [...cart] geben eine Kopie zurück: Der Server kann die Liste im Speicher nicht anfassen, genau wie bei einem echten Redis.

Probier es aus

Sag die Ausgabe voraus

Zwei Server mit Speicher, Jonas legt drei Produkte hinein. Was gibt die letzte Zeile aus?

const balancer = new Balancer([new AppServer("A"), new AppServer("B")]);
balancer.pick().add("ivan", "Kaffee");
balancer.pick().add("ivan", "Tee");
console.log(balancer.pick().add("ivan", "Honig"));
Antwort

Die Idee

Die Datenbank vervielfacht sich nicht so leicht

Die Kopien des Servers schreiben in eine Datenbank. Die Datenbank zu vervielfachen ist schwerer, weil die Schreibvorgänge an einer Stelle liegen müssen, sonst kämpfen zwei Schreibvorgänge gegeneinander.

Der übliche Schritt ist ein Lese-Replikat: eine Kopie der Datenbank, die die Änderungen der Hauptdatenbank bekommt und nur Lesevorgänge beantwortet. Die meisten Anfragen in einem Shop sind Lesevorgänge (der Katalog, Seiten, der Verlauf), also nehmen die Replikate einen großen Teil der Last ab. Wie die Änderungen gesendet werden, hast du in der Lektion über Replikation gesehen.

Der Preis ist die Replikationsverzögerung (Replication Lag): Eine Änderung erreicht das Replikat etwas später. Meist sind es Bruchteile einer Sekunde, unter Last auch Sekunden. Hier beträgt die Verzögerung 3 Ticks:

// die Hauptdatenbank: jeder Schreibvorgang läuft darüber
class Primary {
log = [];
insert(order, customer, now) {
this.log.push({ at: now, order, customer });
}
orders(customer) {
return this.log.filter((c) => c.customer === customer).map((c) => c.order);
}
}
// ein Lese-Replikat: Eine Änderung erreicht es lag Ticks nach dem Schreiben
class Replica {
constructor(primary, lag) {
this.primary = primary;
this.lag = lag;
}
orders(customer, now) {
const ids = [];
for (const c of this.primary.log) {
const arrived = c.at + this.lag <= now;
if (arrived && c.customer === customer)
ids.push(c.order);
}
return ids;
}
}
function show(ids) {
return ids.length === 0 ? "keine Bestellungen" : `Bestellungen [${ids.join(", ")}]`;
}

Maria zahlt in Tick 0 und öffnet sofort „Meine Bestellungen“, was vom Replikat liest:

const primary = new Primary();
const replica = new Replica(primary, 3);
primary.insert(1042, "maria", 0); // Maria zahlt in Tick 0
for (let now = 0; now <= 3; now++) {
console.log(`Tick ${now}, Replikat: ${show(replica.orders("maria", now))}`);
}
Ausgabe
Tick 0, Replikat: keine Bestellungen
Tick 1, Replikat: keine Bestellungen
Tick 2, Replikat: keine Bestellungen
Tick 3, Replikat: Bestellungen [1042]

Drei Ticks lang sieht sie ihre Bestellung nicht. Sie zahlt ein zweites Mal oder schreibt dem Support, dass das Geld weg ist und es keine Bestellung gibt.

Sieh es dir an

Drei Kopien, ein gemeinsamer Speicher und das Replikat

myOrders(customer, now) {
if (this.justWrote(customer, now))
return this.primary.orders(customer);
return this.replica.orders(customer, now);
}
justWrote(customer, now) {
const last = this.lastWrite.get(customer);
return last !== undefined && now - last < this.lag;
}
Schritt für Schritt, in Worten
  1. Maria legt Kaffee hinein. Der Balancer schickt die Anfrage an A, und der Warenkorb bleibt im Speicher von A.
  2. Die nächste Anfrage (Tee) geht an B. B weiß nichts vom Kaffee: Sein Warenkorb enthält nur Tee.
  3. Die Lösung: Die Server behalten nichts. Der Warenkorb liegt in einem gemeinsamen Speicher, und jeder Server liest ihn von dort.
  4. Jetzt legt C Schokolade hinein und sieht den Kaffee und den Tee. Es ist egal, welcher Server die Anfrage annimmt.
  5. B fällt aus. Der Balancer nimmt ihn heraus, und der Warenkorb überlebt: Er war nie in B.
  6. Maria zahlt: Bestellung 1042 wird in Tick 0 in die Hauptdatenbank geschrieben. Das Replikat bekommt die Änderung 3 Ticks später.
  7. In Tick 1 öffnet sie „Meine Bestellungen“. Das Lesen geht an das Replikat, und dort ist die Bestellung noch nicht.
  8. In Tick 3 erreicht die Änderung das Replikat, und die Bestellung taucht auf. Bis dahin denkt Maria, die Zahlung sei fehlgeschlagen.
  9. Die Lösung: Wer gerade geschrieben hat, liest 3 Ticks lang von der Hauptdatenbank. Alle anderen lesen vom Replikat.
  10. Server ohne Zustand lassen sich vervielfachen. Die Datenbank für Schreibvorgänge bleibt eine, und ihre Replikate hinken immer etwas hinterher.

Die Idee

Wer geschrieben hat, liest von der Hauptdatenbank

Die meisten merken nicht, dass der Katalog eine halbe Sekunde alt ist. Aber Menschen erwarten, ihre eigene Änderung sofort zu sehen. Also: Wer gerade geschrieben hat, liest von der Hauptdatenbank, bis die Verzögerung vorbei ist. Alle anderen lesen vom Replikat. Das heißt Read-your-writes.

// wer gerade geschrieben hat, liest von der Hauptdatenbank
class OrderService {
lastWrite = new Map();
constructor(primary, replica, lag) {
this.primary = primary;
this.replica = replica;
this.lag = lag;
}
place(order, customer, now) {
this.primary.insert(order, customer, now);
this.lastWrite.set(customer, now);
}
myOrders(customer, now) {
if (this.justWrote(customer, now))
return this.primary.orders(customer);
return this.replica.orders(customer, now);
}
justWrote(customer, now) {
const last = this.lastWrite.get(customer);
return last !== undefined && now - last < this.lag;
}
}
function read(orders, customer, now) {
const from = orders.justWrote(customer, now) ? "Hauptdatenbank" : "Replikat";
return `Tick ${now}, ${customer} → ${from}: ${show(orders.myOrders(customer, now))}`;
}
const primary = new Primary();
const replica = new Replica(primary, 3);
const orders = new OrderService(primary, replica, 3);
orders.place(1042, "maria", 0);
for (let now = 0; now <= 3; now++) {
console.log(read(orders, "maria", now));
}
console.log(read(orders, "ivan", 1));
Ausgabe
Tick 0, maria → Hauptdatenbank: Bestellungen [1042]
Tick 1, maria → Hauptdatenbank: Bestellungen [1042]
Tick 2, maria → Hauptdatenbank: Bestellungen [1042]
Tick 3, maria → Replikat: Bestellungen [1042]
Tick 1, ivan → Replikat: keine Bestellungen

Maria sieht ihre Bestellung sofort, von der Hauptdatenbank. Jonas hat nichts geschrieben und liest vom Replikat: Die Last auf der Hauptdatenbank wächst nur mit den Leuten, die gerade geschrieben haben.

Die Idee

Was skaliert und wann es weh tut

  • Skaliert: die Anwendungsserver, wenn sie keinen Zustand haben; die Lesevorgänge, mit Replikaten und einem Cache (in der Lektion über Caching).
  • Skaliert nicht leicht: die Schreibvorgänge in die Hauptdatenbank. Reicht eine Datenbank für die Schreibvorgänge nicht mehr, werden die Daten in Teile aufgeteilt (Sharding) oder in Services mit eigenen Datenbanken zerlegt (in der Lektion über Monolith und Services). Beides sind große Entscheidungen.
  • Versteckter Zustand: ein Cache im Speicher jeder Kopie (jede hat ihren eigenen alten Preis), Dateien, die auf die Platte einer Kopie hochgeladen werden, Zähler im Speicher, ein geplanter Job, der auf jeder Kopie startet und drei E-Mails statt einer verschickt.
  • Wann es weh tut: Kopien brauchen einen gemeinsamen Speicher, Replikate und Monitoring jeder Kopie. Reicht ein Server, ist horizontale Skalierung Komplexität ohne Gewinn. Zuerst misst du, wo der Engpass liegt. Manchmal ist die größere Maschine der billigere erste Schritt, und oft ist es ein Index in der Datenbank.

Probier es aus

Finde den Fehler

Der Shop erlaubt nur 5 Anmeldeversuche und sperrt dann. Der Penetrationstest zeigt, dass der Angreifer mit drei Kopien 15 Passwörter probiert. Welche Zeile hat den Fehler?

Wähle die Zeile mit dem Fehler.

Genauer, für Neugierige

Sticky Sessions. Der Balancer kann denselben Käufer jedes Mal an dieselbe Kopie schicken (per Cookie). Das flickt den Warenkorb, aber die Kopien können einander trotzdem nicht vertreten: Fällt eine Kopie aus, sind ihre Warenkörbe weg, und die Last wird ungleich verteilt.

Andere Wege, die eigenen Schreibvorgänge zu lesen. Der Client merkt sich die Nummer seines letzten Schreibvorgangs und liest von einem Replikat, das sie schon hat. Oder alle Lesevorgänge „meiner“ Daten (Profil, Bestellungen) gehen immer an die Hauptdatenbank. Oder die Replikation ist für die wichtigen Schreibvorgänge synchron: langsamer, aber ohne Verzögerung.

Autoscaling. Die Cloud fügt Kopien nach Last hinzu und entfernt sie. Das, der Balancer, die Prüfungen, ob eine Kopie lebt, und Kubernetes sind Infrastruktur, also gehören sie in den DevOps-Kurs ZeroToOps.

Die echten Dinge: Sitzungen in Redis sind fertig in Spring Session, in ASP.NET Core mit AddStackExchangeRedisCache und AddSession, in Django mit SESSION_ENGINE, in Express mit connect-redis. PostgreSQL, MySQL und SQL Server haben Lese-Replikate, und Frameworks lassen dich die Lesevorgänge dorthin leiten.

Übung

Der gemeinsame Versuchszähler

Löse es in deinem Editor.

Behebe den Fehler oben: Der Versuchszähler ist eine Klasse Attempts, die allen Kopien gemeinsam ist. Der Server fragt sie und gibt nach dem fünften Versuch "blocked" zurück. Lass drei Kopien hinter dem Balancer laufen und sieben falsche Anmeldungen als maria.

Ausgabe
Versuch 1 (A): falsches Passwort
Versuch 2 (B): falsches Passwort
Versuch 3 (C): falsches Passwort
Versuch 4 (A): falsches Passwort
Versuch 5 (B): falsches Passwort
Versuch 6 (C): gesperrt
Versuch 7 (A): gesperrt

Der sechste und siebte Versuch sind gesperrt, obwohl C und A sie zum zweiten Mal sehen.

Weitere Aufgaben

  • Ablauf. Ergänze eine Tick-Uhr und setze den Zähler 10 Ticks nach dem ersten Versuch zurück.
  • Hochgeladene Dateien. Der Server hält das Produktfoto auf seiner eigenen Platte. Was sieht ein Käufer, wenn der Balancer ihn an eine andere Kopie schickt? Wo sollte das Foto liegen?

Test

Teste dich

1. Was ist horizontale Skalierung?
2. Warum verschwindet der Warenkorb, wenn es drei Kopien des Servers gibt?
3. Maria zahlt und öffnet sofort „Meine Bestellungen“, und die Bestellung ist nicht da. Was ist am wahrscheinlichsten passiert?
4. Das Limit sind 5 Anmeldeversuche, und der Zähler liegt im Speicher jedes Servers. Wie viele Versuche bekommt ein Angreifer bei drei Kopien?
5. Was wird NICHT schneller, wenn du Kopien des Servers hinzufügst?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·