API-Design und Verträge Teil 2: Migration und Systemdesign

Track Konzepte · Architektur und Evolution · ca. 45 Min.

Worum es geht

Verträge ändern sich, und das gilt auch im Großen. Ein altes System (Legacy) wird nicht an einem Wochenende ersetzt. Du ersetzt es Stück für Stück und musst zwischendurch beweisen, dass der neue Teil dasselbe tut wie der alte. Und bevor du irgendetwas baust, rechnest du auf der Rückseite eines Bierdeckels, wie groß das Problem überhaupt ist.

Am Ende dieses zweiten Teils kannst du:

  • einen Parallel Run (Vergleich von altem und neuem System) und einen Strangler-Fig-Router (schrittweise Umleitung) bauen,
  • eine Überschlagsrechnung (back-of-the-envelope estimation) für ein System machen.

Was du aus Teil 1 brauchst: dass Schnittstellen Verträge sind, die sich nur kompatibel ändern lassen, und dass ein API Gateway ein gemeinsamer Eingang vor mehreren Services ist. Genau dort findet die schrittweise Migration statt.

Plane 45 Minuten ein: ca. 15 Minuten Lesen, ca. 30 Minuten für drei Übungen. Die Pause passt nach Übung 2.

Von JS/TS her gedacht

Beides kennst du aus dem Frontend-Alltag: Ein Feature Flag, das nur einen Teil der Nutzer ein neues Feature sehen lässt, ist ein kleiner Strangler. Und wenn du in einer Migration alten und neuen Code mit Promise.all parallel aufrufst und die Ergebnisse vergleichst, hast du einen Parallel Run gebaut.

Konzept JS/TS Python (hier)
Schrittweise Umleitung Reverse Proxy oder Router-Middleware leitet pro Pfad weiter Funktion, die pro Pfad und Nutzer entscheidet
Stabile Nutzer-Zuordnung Feature Flag mit Rollout-Prozent nutzer_id % 100 < prozent
Parallel Run Promise.all([alt(x), neu(x)]) und vergleichen beide Funktionen aufrufen, Ergebnisse mit Toleranz vergleichen
Überschlagsrechnung 1e6-Schreibweise im Taschenrechner Python als Taschenrechner

Konzept

Schritt 3: Schrittweise Migration statt Big Bang

Ein Big Bang (alles auf einmal umschalten, Legacy abschalten) hat drei Probleme: Du merkst Fehler erst, wenn alle Nutzer betroffen sind. Es gibt keinen einfachen Rückweg. Und das neue System muss komplett fertig sein, bevor es irgendeinen Nutzen bringt. Die Alternative sind drei Muster, die sich kombinieren lassen:

Strangler Fig (Würgefeige, benannt nach einer Pflanze, die einen Baum langsam überwächst): Vor das alte System kommt eine Fassade (ein Proxy oder Gateway). Sie leitet jede Anfrage nach Regeln an das alte oder das neue System. Du verschiebst die Regeln Stück für Stück, bis das alte System nichts mehr bekommt und abgeschaltet werden kann.

flowchart LR
    C["Client"] --> F["Fassade mit Routing-Regeln"]
    F -->|"Regeln noch nicht umgestellt"| A["Altes System (Legacy)"]
    F -->|"umgestellte Pfade und Nutzer"| N["Neues System"]
    A --> D[("gemeinsame Daten oder Sync")]
    N --> D

Eine typische Reihenfolge der Stufen (hier für den Pfad /bestellungen):

Stufe Wer bekommt das neue System? Was prüfst du?
0 niemand, nur Parallel Run (siehe unten) Gleiche Ergebnisse bei echtem Verkehr
1 5 % der Nutzer (Canary, kleine Testgruppe) Fehlerrate, Latenz
2 25 % dasselbe, mehr Last
3 50 % dasselbe
4 100 % Altes System läuft noch als Rückfall (Rollback) bereit
5 Altes System abschalten erst nach einer Beobachtungszeit

Wie teilt man Nutzer in Prozente auf? Zufall pro Anfrage wäre falsch, denn derselbe Nutzer würde zwischen den Systemen hin und her springen (und plötzlich Daten sehen, die es im anderen System noch nicht gibt). Man nimmt etwas Stabiles pro Nutzer: nutzer_id % 100 ergibt eine Zahl von 0 bis 99, und wer unter dem Prozentwert liegt, bekommt das neue System. Bei 25 Prozent: Nutzer 5 und 24 sind im neuen System, Nutzer 25 und 130 (Rest 30) im alten, Nutzer 205 (Rest 5) wieder im neuen. Dasselbe nutzer_id landet bei jeder Anfrage im selben System, und beim Erhöhen auf 50 Prozent bleiben alle bisherigen Nutzer im neuen System.

Branch by Abstraction (Verzweigung per Abstraktion) löst dasselbe Problem innerhalb des Codes statt vor dem System: Alle Aufrufer benutzen eine Abstraktion (Interface). Dahinter liegen alte und neue Implementierung, und ein Schalter (Feature Flag) entscheidet. So kannst du im selben Code-Stand wechseln, ohne einen langen Branch:

Ausgabe: 7.

Parallel Run (auch Shadow Traffic) beantwortet die Prüfstein-Frage “Woher weißt du, dass der neue Teil dasselbe tut?”. Jede Anfrage geht an das alte System (dessen Antwort bekommt der Nutzer) und an das neue (dessen Antwort wird nur verglichen und verworfen). Weicht sie ab, wird die Abweichung protokolliert. So testest du das neue System mit echten Daten, ohne Risiko. Hier ein Beispiel mit einer Preisberechnung: Das alte System rechnet mit Fließkommazahlen, das neue mit ganzen Cent:

Ausgabe:

[(0.01, 0.01, 0.02), (2.5, 2.97, 2.98)]

Zwei von sechs Eingaben weichen ab, jeweils um einen Cent: Die Rundung unterscheidet sich. Das ist ein echter Fund, den kein Unit-Test mit “schönen” Werten gefunden hätte. Aber die einfache Version hat Lücken, die du in Übung 3 schließt:

  • Toleranz: Manche Abweichungen sind erlaubt (z. B. Fließkomma-Rauschen). Zu große Toleranz versteckt aber echte Fehler: Mit einer Toleranz von 0.05 wären beide Funde oben verschwunden.
  • Fehler: Wirft nur das neue System eine Exception, ist das eine Abweichung. Werfen beide denselben Fehlertyp, ist das keine.
  • Veränderte Eingaben: Beide Systeme bekommen dieselbe Eingabe. Verändert das alte sie (z. B. sortiert eine Liste in place), sieht das neue schon die veränderte Version und der Vergleich lügt. Beispiel: l.sort(); return l[0] liefert 1 und lässt aus [3, 1, 2] die Liste [1, 2, 3] zurück. Jedes System bekommt darum seine eigene Kopie (copy.deepcopy).

Schritt 4: Systemdesign-Methodik und Überschlagsrechnung

Die Frage “Entwirf ein System für 10.000 Requests pro Sekunde” ist keine Frage nach Technologien. Sie ist eine Frage nach der Reihenfolge:

  1. Anforderungen klären (requirements): Was genau passiert pro Request? Lesen oder Schreiben, in welchem Verhältnis? Wie groß sind Daten und Antworten? Gibt es Spitzen? Wie schnell muss es sein (Latenzziel), wie lange darf es ausfallen (siehe konzepte/09 SLO)?
  2. Überschlagsrechnung (back-of-the-envelope): Mit groben Annahmen Anfragen, Speicher und Bandbreite ausrechnen. Es geht um die Größenordnung, nicht um zwei Nachkommastellen.
  3. Engpass identifizieren (bottleneck): Welche Ressource zuerst an ihre Grenze kommt (Rechenleistung, Datenbank, Netzwerk, Speicher). Dort investierst du zuerst.
  4. Kapazität planen (capacity planning): Instanzen mit Reserve (mindestens eine mehr, damit der Ausfall einer Instanz nicht zum Ausfall des Systems wird, N+1).

Die Rechnung durchgeführt für ein Foto-Dienst-Beispiel, mit den Annahmen: 2 Millionen Uploads pro Tag, 3 MB pro Foto, jedes Foto wird im Schnitt 50-mal angesehen, Spitze ist das 4-Fache des Durchschnitts, ein Tag hat 86.400 Sekunden.

Ausgabe:

Uploads pro Sekunde: Durchschnitt 23.1 Spitze 92.6
Ansichten pro Sekunde: Durchschnitt 1157 Spitze 4630
Speicher pro Jahr in TB: 2190
Upload-Bandbreite in der Spitze in Mbit/s: 2222

Lies die Zahlen als Entscheidungen. 93 Uploads pro Sekunde und 4630 Ansichten pro Sekunde sind für heutige Server wenig. Der Engpass ist ein anderer: 2,2 Gbit/s allein für Uploads und über 2 Petabyte pro Jahr. Hier brauchst du Object Storage und ein CDN, nicht mehr Anwendungsserver. Hättest du nur “Requests pro Sekunde” gerechnet, hättest du den eigentlichen Engpass übersehen. (1 Byte sind 8 Bit, deshalb der Faktor 8, und ein Terabyte sind hier 10^12 Byte.)

Und die Prüfstein-Frage mit 10.000 Requests pro Sekunde: Die ersten Zahlen sind (1) Lese-Schreib-Verhältnis, (2) Bytes pro Request mal 10.000 für die Bandbreite, (3) Requests pro Instanz. Nimm an, eine Instanz schafft im Zielbetrieb 500 Requests pro Sekunde. Dann brauchst du 10.000 / 500 = 20 Instanzen, mit N+1 sind es 21. Dann kommt der klassische Engpass, die Datenbank, wenn jeder Request lesend dorthin geht. Ein Cache (Zwischenspeicher) mit Trefferquote (hit ratio) senkt diese Last:

Cache-Trefferquote Lesezugriffe pro Sekunde auf die Datenbank (bei 10.000 Requests)
0 % 10.000
90 % 1.000
95 % 500

Von 90 auf 95 Prozent halbiert sich die Datenbank-Last. Deshalb ist die Trefferquote eine der ersten Zahlen, nach denen du fragst.

Falle

  1. Parallel Run mit zu großer Toleranz oder mit geteilten Eingaben. Dann sind die Ergebnisse grün, obwohl das neue System etwas anderes tut.
  2. Zufall statt stabiler Zuordnung beim Strangler Fig. Nutzer springen zwischen Systemen hin und her.
  3. Nur Requests pro Sekunde rechnen. Speicher, Bandbreite und die Datenbank sind oft der eigentliche Engpass. Und: kein “es hängt davon ab” ohne Zahl, rechne grob und sage deine Annahmen laut.

Übungen

Übung 1: Parallel Run, ein Vergleicher mit Toleranz (ca. 12 Min.)

Schreibe vergleiche(alt, neu, eingaben, toleranz=0.0) für einen Parallel Run. Für jede Eingabe ruft die Funktion alt und neu auf und sammelt die Abweichungen als Liste von Tupeln (eingabe, alt_wert, neu_wert). Regeln:

  1. Jedes System bekommt eine eigene Kopie der Eingabe (copy.deepcopy). In der Abweichung steht die ursprüngliche Eingabe.
  2. Zahlen (int und float) gelten als gleich, wenn der Betrag der Differenz höchstens toleranz ist. In dict und list gilt das rekursiv. Dicts sind nur gleich, wenn sie dieselben Schlüssel haben, Listen nur bei gleicher Länge. Alles andere wird mit == verglichen.
  3. Fehler: Wirft ein System eine Exception, ist der Wert dieses Systems das Tupel ("Fehler", "<Name des Typs>"), z. B. ("Fehler", "ZeroDivisionError"). Werfen beide denselben Fehlertyp, ist das keine Abweichung. Sonst schon.

Trenne zwei Aufgaben: einen Aufruf, der Ergebnis oder Fehler liefert, und einen Vergleich, der sich bei Dicts und Listen selbst aufruft. Überlege, was “gleich” für zwei Fehler heißt.

import copy

def vergleiche(alt, neu, eingaben, toleranz=0.0):
    def aufruf(f, eingabe):
        try:
            return ("ok", f(copy.deepcopy(eingabe)))
        except Exception as exc:
            return ("fehler", type(exc).__name__)

    def gleich(a, b):
        if isinstance(a, dict) and isinstance(b, dict):
            return a.keys() == b.keys() and all(gleich(a[k], b[k]) for k in a)
        if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)):
            return len(a) == len(b) and all(gleich(x, y) for x, y in zip(a, b))
        zahl = lambda x: isinstance(x, (int, float)) and not isinstance(x, bool)
        if zahl(a) and zahl(b):
            return abs(a - b) <= toleranz
        return a == b

    abweichungen = []
    for e in eingaben:
        a, n = aufruf(alt, e), aufruf(neu, e)
        if a[0] != n[0]:
            gleiches = False
        elif a[0] == "fehler":
            gleiches = a[1] == n[1]
        else:
            gleiches = gleich(a[1], n[1])
        if not gleiches:
            zeige = lambda r: r[1] if r[0] == "ok" else ("Fehler", r[1])
            abweichungen.append((e, zeige(a), zeige(n)))
    return abweichungen

vergleiche

Die Kopie je System ist wichtig: Ohne sie sortiert alt die Liste in place, und neu sieht schon die sortierte Version. Dann meldet der Vergleich “keine Abweichung”, obwohl neu auf der Originaleingabe etwas anderes liefert.

Übung 2: Strangler-Fig-Router (ca. 10 Min.)

Baue den Router einer Fassade für eine schrittweise Migration. Die Klasse StranglerRouter hat:

  • stelle_um(prefix, prozent): setzt für einen Pfad-Präfix (z. B. "/bestellungen"), wie viel Prozent der Nutzer das neue System bekommen. Erlaubte Stufen: 0, 5, 25, 50, 100. Erhöhen darf man nur auf die nächsthöhere Stufe über der aktuellen (unbekannter Präfix: aktuell 0). Senken (Rollback) ist auf jede niedrigere Stufe erlaubt, gleiche Stufe ist erlaubt. Alles andere (unbekannte Stufe, Sprung über eine Stufe) löst ValueError aus.
  • ziel(pfad, nutzer_id): gibt "neu" oder "alt" zurück. Es gilt die Regel des längsten passenden Präfixes. Ein Präfix passt, wenn pfad == prefix oder pfad mit prefix + "/" beginnt (also /bestellungen passt auf /bestellungen/17, aber nicht auf /bestellungen-archiv). Passt kein Präfix: "alt". Bei gefundenem Präfix mit Prozentwert p gilt: "neu", wenn nutzer_id % 100 < p, sonst "alt".

Für stelle_um: Die Stufen haben eine feste Reihenfolge. Wie misst du den Abstand zwischen der aktuellen und der gewünschten Stufe? Für ziel: Sammle zuerst alle passenden Präfixe, dann entscheide, welcher gewinnt.

class StranglerRouter:
    STUFEN = (0, 5, 25, 50, 100)

    def __init__(self):
        self.regeln = {}

    def stelle_um(self, prefix, prozent):
        if prozent not in self.STUFEN:
            raise ValueError("unbekannte Stufe")
        aktuell = self.regeln.get(prefix, 0)
        if self.STUFEN.index(prozent) > self.STUFEN.index(aktuell) + 1:
            raise ValueError("nur die nächste Stufe erlaubt")
        self.regeln[prefix] = prozent

    def ziel(self, pfad, nutzer_id):
        passend = [p for p in self.regeln if pfad == p or pfad.startswith(p + "/")]
        if not passend:
            return "alt"
        laengster = max(passend, key=len)
        return "neu" if nutzer_id % 100 < self.regeln[laengster] else "alt"

StranglerRouter

nutzer_id % 100 < prozent ist stabil pro Nutzer und wächst monoton: Wer bei 25 Prozent im neuen System ist, bleibt es bei 50. Die Stufenregel erzwingt “schrittweise statt Big Bang”, der Rollback darf springen, weil ein Notfall nicht über Stufen laufen soll.

Übung 3: Überschlagsrechnung für einen URL-Shortener (ca. 8 Min.)

Ein URL-Shortener (kurz.example/abc123 leitet auf eine lange Adresse weiter). Deine Annahmen stehen im Code unten. Rechne in Python (oder im Kopf) und trage vier Werte als Tupel (a, b, c, d) ein:

  • a: Lese-Requests pro Sekunde in der Spitze (Weiterleitungen), auf eine ganze Zahl gerundet.
  • b: Speicher in Terabyte nach jahre Jahren, auf 2 Nachkommastellen gerundet (1 TB = 10^12 Byte).
  • c: Ausgehende Bandbreite in der Spitze in Mbit/s, nur für die Weiterleitungs-Antworten, auf 1 Nachkommastelle gerundet (1 Mbit = 10^6 Bit).
  • d: Anzahl Instanzen für die gesamte Spitzenlast (Lesen und Schreiben), aufgerundet, plus eine Reserve-Instanz (N+1).

Gehe in der Reihenfolge des Textes vor: erst Anfragen pro Tag, dann pro Sekunde im Durchschnitt, dann mit dem Spitzenfaktor. Frage dich bei d, welche Anfragen eine Instanz alle bedienen muss, und bei c, wie viele Bit ein Byte hat.

import math
neue_links_pro_tag = 4_000_000
lesen_pro_neuem_link = 100
spitzenfaktor = 3
bytes_pro_eintrag = 500
jahre = 5
antwort_bytes = 400
instanz_rps = 1000
sekunden_pro_tag = 86_400

schreiben_spitze = neue_links_pro_tag / sekunden_pro_tag * spitzenfaktor
lesen_spitze = neue_links_pro_tag * lesen_pro_neuem_link / sekunden_pro_tag * spitzenfaktor
a = round(lesen_spitze)
b = round(neue_links_pro_tag * 365 * jahre * bytes_pro_eintrag / 1e12, 2)
c = round(lesen_spitze * antwort_bytes * 8 / 1e6, 1)
d = math.ceil((lesen_spitze + schreiben_spitze) / instanz_rps) + 1
antwort = (a, b, c, d)
antwort

Lesen: 4 Mio mal 100 sind 400 Mio pro Tag, durch 86.400 sind knapp 4630 pro Sekunde im Schnitt, mal 3 sind etwa 13.889 in der Spitze. Speicher: 4 Mio mal 365 mal 5 sind 7,3 Milliarden Einträge, mal 500 Byte sind 3,65 TB. Bandbreite: 13.889 mal 400 Byte mal 8 Bit sind etwa 44,4 Mbit/s. Instanzen: Lesen und Schreiben zusammen sind etwa 14.028 Requests pro Sekunde, geteilt durch 1000 sind 14,03, aufgerundet 15, plus Reserve 16. Der Engpass sind hier nicht Speicher oder Bandbreite (gering), sondern die Leselast und damit die Datenbank (ein Cache für beliebte Links ist der erste Hebel).

Merksatz

Migriere mit Strangler Fig und Parallel Run in kleinen Stufen mit Rollback, und rechne vor jedem Entwurf Anfragen, Speicher, Bandbreite und Instanzen grob aus, um den Engpass zu finden.

Prüfstein

  1. Wie migrierst du ein Legacy-System schrittweise, und wie weißt du, dass der neue Teil dasselbe tut wie der alte?
  2. Wie entwirfst du ein System für 10.000 Requests pro Sekunde, und welche Zahlen rechnest du zuerst aus?

Quelle: quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 6 (Migration: Strangler Fig, Branch by Abstraction, Parallel Run, schrittweise statt Big Bang; Systemdesign-Methodik: Anforderungen klären, Überschlagsrechnung, Engpässe, Kapazitätsplanung; beide Prüfsteine).

Über die Quelle hinaus (allgemeines Fachwissen): die Stufen und die stabile Prozent-Zuordnung beim Strangler Fig, die Beschreibung von Canary und Shadow Traffic, die vier Schritte der Systemdesign-Methodik und N+1. Alle Zahlen im Text (Foto-Dienst, Cache-Trefferquoten, Preis-Abweichungen, Routing-Beispiele) stammen aus dem Ausführen des Codes dieser Lektion. Die Annahmen im Foto-Dienst und im URL-Shortener sind frei gewählte Rechenbeispiele, keine Messwerte realer Systeme. Die 20.000 Aufrufe pro Sekunde und die Instanzleistungen sind ausgedachte Randbedingungen.

Zurück zu Teil 1: API-Stile und Kompatibilität.