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: def __init__(self, name): self.name = name self.carts = {} # die Warenkörbe leben im Speicher dieses Servers
def add(self, session, product): cart = self.carts.setdefault(session, []) cart.append(product) return f"{self.name}: {', '.join(cart)}"# der Balancer verteilt die Server der Reihe nach (Round Robin)class Balancer: def __init__(self, servers): self.servers = list(servers) self.next = 0
def pick(self): server = self.servers[self.next % len(self.servers)] self.next += 1 return server
def remove(self, server): self.servers.remove(server)Maria legt vier Produkte hinein. Der Balancer gibt die Anfragen an A, B, C und wieder A:
balancer = Balancer([AppServer("A"), AppServer("B"), AppServer("C")])print(balancer.pick().add("maria", "Kaffee"))print(balancer.pick().add("maria", "Tee"))print(balancer.pick().add("maria", "Schokolade"))print(balancer.pick().add("maria", "Honig"))A: KaffeeB: TeeC: SchokoladeA: Kaffee, HonigJede 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: def __init__(self): self.carts = {}
def add(self, session, product): cart = self.carts.setdefault(session, []) cart.append(product) return list(cart)class AppServer: def __init__(self, name, store): self.name = name self.store = store # der Server behält nichts Eigenes
def add(self, session, product): cart = self.store.add(session, product) return f"{self.name}: {', '.join(cart)}"Dieselben Anfragen, und nach der dritten fällt Server B aus:
store = SessionStore()b = AppServer("B", store)balancer = Balancer([AppServer("A", store), b, AppServer("C", store)])print(balancer.pick().add("maria", "Kaffee"))print(balancer.pick().add("maria", "Tee"))print(balancer.pick().add("maria", "Schokolade"))balancer.remove(b) # Server B fällt ausprint(balancer.pick().add("maria", "Honig"))A: KaffeeB: Kaffee, TeeC: Kaffee, Tee, SchokoladeC: Kaffee, Tee, Schokolade, HonigJede 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?
balancer = Balancer([AppServer("A"), AppServer("B")])balancer.pick().add("ivan", "Kaffee")balancer.pick().add("ivan", "Tee")print(balancer.pick().add("ivan", "Honig"))Der Balancer gibt A, B, A. Der Kaffee und der Honig liegen auf A, und der Tee blieb auf B. A sieht nur seine eigenen zwei.
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:
@dataclassclass Change: at: int order: int customer: str
# die Hauptdatenbank: jeder Schreibvorgang läuft darüberclass Primary: def __init__(self): self.log = []
def insert(self, order, customer, now): self.log.append(Change(now, order, customer))
def orders(self, customer): return [c.order for c in self.log if c.customer == customer]
# ein Lese-Replikat: Eine Änderung erreicht es lag Ticks nach dem Schreibenclass Replica: def __init__(self, primary, lag): self.primary = primary self.lag = lag
def orders(self, customer, now): ids = [] for c in self.primary.log: arrived = c.at + self.lag <= now if arrived and c.customer == customer: ids.append(c.order) return idsdef show(ids): return f"Bestellungen {ids}" if ids else "keine Bestellungen"Maria zahlt in Tick 0 und öffnet sofort „Meine Bestellungen“, was vom Replikat liest:
primary = Primary()replica = Replica(primary, 3)primary.insert(1042, "maria", 0) # Maria zahlt in Tick 0for now in range(4): print(f"Tick {now}, Replikat: {show(replica.orders('maria', now))}")Tick 0, Replikat: keine BestellungenTick 1, Replikat: keine BestellungenTick 2, Replikat: keine BestellungenTick 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
def my_orders(self, customer, now): if self.just_wrote(customer, now): return self.primary.orders(customer) return self.replica.orders(customer, now)
def just_wrote(self, customer, now): last = self.last_write.get(customer) return last is not None and now - last < self.lagdef add(self, session, product): cart = self.carts.setdefault(session, []) cart.append(product) return f"{self.name}: {', '.join(cart)}"def add(self, session, product): cart = self.store.add(session, product) return f"{self.name}: {', '.join(cart)}"def orders(self, customer, now): ids = [] for c in self.primary.log: arrived = c.at + self.lag <= now if arrived and c.customer == customer: ids.append(c.order) return idsdef my_orders(self, customer, now): if self.just_wrote(customer, now): return self.primary.orders(customer) return self.replica.orders(customer, now)
def just_wrote(self, customer, now): last = self.last_write.get(customer) return last is not None and now - last < self.lagSchritt für Schritt, in Worten
- Maria legt Kaffee hinein. Der Balancer schickt die Anfrage an A, und der Warenkorb bleibt im Speicher von A.
- Die nächste Anfrage (Tee) geht an B. B weiß nichts vom Kaffee: Sein Warenkorb enthält nur Tee.
- Die Lösung: Die Server behalten nichts. Der Warenkorb liegt in einem gemeinsamen Speicher, und jeder Server liest ihn von dort.
- Jetzt legt C Schokolade hinein und sieht den Kaffee und den Tee. Es ist egal, welcher Server die Anfrage annimmt.
- B fällt aus. Der Balancer nimmt ihn heraus, und der Warenkorb überlebt: Er war nie in B.
- Maria zahlt: Bestellung 1042 wird in Tick 0 in die Hauptdatenbank geschrieben. Das Replikat bekommt die Änderung 3 Ticks später.
- In Tick 1 öffnet sie „Meine Bestellungen“. Das Lesen geht an das Replikat, und dort ist die Bestellung noch nicht.
- In Tick 3 erreicht die Änderung das Replikat, und die Bestellung taucht auf. Bis dahin denkt Maria, die Zahlung sei fehlgeschlagen.
- Die Lösung: Wer gerade geschrieben hat, liest 3 Ticks lang von der Hauptdatenbank. Alle anderen lesen vom Replikat.
- 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 Hauptdatenbankclass OrderService: def __init__(self, primary, replica, lag): self.primary = primary self.replica = replica self.lag = lag self.last_write = {}
def place(self, order, customer, now): self.primary.insert(order, customer, now) self.last_write[customer] = now
def my_orders(self, customer, now): if self.just_wrote(customer, now): return self.primary.orders(customer) return self.replica.orders(customer, now)
def just_wrote(self, customer, now): last = self.last_write.get(customer) return last is not None and now - last < self.lagdef read(orders, customer, now): source = "Hauptdatenbank" if orders.just_wrote(customer, now) else "Replikat" return f"Tick {now}, {customer} → {source}: {show(orders.my_orders(customer, now))}"primary = Primary()replica = Replica(primary, 3)orders = OrderService(primary, replica, 3)orders.place(1042, "maria", 0)for now in range(4): print(read(orders, "maria", now))print(read(orders, "ivan", 1))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 BestellungenMaria 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.
Der Versuchszähler liegt im Speicher der Kopie. Der Balancer gibt die Versuche der Reihe nach an A, B und C, und jede Kopie zählt nur ihre eigenen. Der Zähler ist Zustand, also muss er allen Kopien gemeinsam sein: in Redis (mit INCR und einer TTL) oder in der Datenbank. Die Passwortprüfung stammt aus der Lektion über Passwörter.
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.
Versuch 1 (A): falsches PasswortVersuch 2 (B): falsches PasswortVersuch 3 (C): falsches PasswortVersuch 4 (A): falsches PasswortVersuch 5 (B): falsches PasswortVersuch 6 (C): gesperrtVersuch 7 (A): gesperrtDer 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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Sitzungen, Cookies und CSRF · JWT und seine Fallen · Monolith, modularer Monolith, Services · Caching
- Geht weiter in: ADR: Architekturentscheidungen festhalten · Trade-offs · Deine erste Architekturaufgabe
- Aufgaben zum Thema: Softwarearchitektur