Performance-Testergebnisse richtig lesen: Perzentile, HDR-Histogramme, Sättigung und Little's Law

Wie Sie Lasttest-Ergebnisse korrekt interpretieren: warum Mittelwerte täuschen, warum man Perzentile nicht mitteln darf, wie Sie den Sättigungspunkt finden und wie Sie Ergebnisse so berichten, dass daraus Maßnahmen folgen.

Ein Lasttest erzeugt viele Zahlen, und es ist leicht, die falschen zu berichten. „Durchschnittliche Antwortzeit 180 ms“ klingt gut. Gleichzeitig dauert eine von fünfzig Anfragen vier Sekunden, und die Checkout-Seite löst dreißig Anfragen aus. Der Test lief korrekt, aber er wurde falsch gelesen.

Dieser Artikel zeigt, wie Sie Ergebnisse so lesen, dass sie eine Entscheidung tragen. Er erklärt, was Perzentile aussagen und was nicht, wozu HDR-Histogramme da sind, wie Sie den Punkt finden, an dem ein System sättigt, wie Little's Law Ihre Zahlen plausibilisiert und wie Sie einen Bericht schreiben, aus dem eine Maßnahme folgt. Die Codebeispiele arbeiten mit k6-Ausgaben, die Konzepte gelten aber für jedes Werkzeug.

Warum der Mittelwert lügt

Latenzverteilungen sind fast nie symmetrisch. Die meisten Anfragen sind schnell. Ein kleiner Anteil ist langsam, etwa durch Garbage-Collection-Pausen, Cache-Misses, Sperrkonflikte, Wiederholungen oder eine kalte Verbindung. Der Mittelwert vermischt diese beiden Gruppen zu einer Zahl, die keine von beiden beschreibt.

Ein einfaches Beispiel: 99 % der Anfragen dauern 50 ms und 1 % dauert 3.000 ms. Der Mittelwert beträgt 0,99 × 50 + 0,01 × 3.000 = 79,5 ms. Diese Zahl wirkt gesund, doch keine einzige Anfrage dauert tatsächlich 79,5 ms, und das p99,9 liegt bei 3.000 ms. Der Mittelwert verdeckt genau die Nutzer, die eine schlechte Erfahrung machen.

Der Median (p50) beschreibt die typische Anfrage besser, ignoriert den Ausläufer der Verteilung aber vollständig. Für Nutzererlebnis und Kapazität ist meist gerade dieser Ausläufer entscheidend.

Perzentile und warum der Ausläufer zählt

Das p95 ist der Wert, unter dem 95 % der Messwerte liegen. Das p99 umfasst 99 % und so weiter. Ein p99 von 900 ms bedeutet, dass eine von hundert Anfragen 900 ms oder länger gedauert hat. Es sagt nichts darüber, wie viel länger. Deshalb sollten Sie auch p99,9 und das Maximum betrachten.

„Eine von hundert“ klingt selten. Aus Nutzersicht ist es das nicht, denn eine einzelne Seite oder User Journey löst viele Anfragen aus. Stößt eine Seite 20 unabhängige Backend-Aufrufe an, liegt die Wahrscheinlichkeit, dass mindestens einer davon langsamer als das p99 ist, bei 1 − 0,9920 ≈ 18,2 %. Fast jeder fünfte Seitenaufruf trifft auf den „seltenen“ langsamen Fall. Je stärker Aufrufe sich auffächern, desto mehr bestimmt der Ausläufer jedes einzelnen Dienstes das Nutzererlebnis.

Standardmäßig gibt k6 avg, min, med, max, p(90) und p(95) aus. Ergänzen Sie die höheren Perzentile in der Zusammenfassung am Testende:

summary.jsJavaScript
// Report the tail, not just avg and p(95): add to your k6 options
export const options = {
  summaryTrendStats: ['avg', 'min', 'med', 'p(90)', 'p(95)', 'p(99)', 'p(99.9)', 'max'],
};

Auch Thresholds sollten den Ausläufer adressieren. http_req_duration: ['p(95)<400', 'p(99)<1000'] ist ein deutlich aussagekräftigeres Bestanden/Nicht-bestanden-Kriterium als jede Bedingung auf den Mittelwert.

Perzentile niemals mitteln

Das ist der häufigste Fehler im Reporting. Angenommen, ein Dashboard zeigt das p99 pro Minute, und der Bericht nennt das „durchschnittliche p99 über den Test“. Diese Zahl ist kein Perzentil von irgendetwas. Perzentile lassen sich nicht arithmetisch kombinieren. Eine Minute mit sehr schlechtem Ausläufer und zwei saubere Minuten ergeben im Mittel einen Wert, der das tatsächliche p99 über den Lauf unterschätzt. Dasselbe Problem tritt auf, wenn Sie das p95 mehrerer Lastgeneratoren oder mehrerer Dienstinstanzen mitteln.

Um Zeitfenster oder Quellen korrekt zu kombinieren, brauchen Sie die zugrunde liegende Verteilung, nicht ihre Zusammenfassung. Entweder bewahren Sie alle Rohmesswerte auf, oder Sie führen pro Zeitfenster ein Histogramm und führen die Histogramme zusammen. Das Zusammenführen von Histogrammen ist exakt, denn die Summe der Bucket-Zählungen ergibt das Histogramm der kombinierten Daten.

HDR-Histogramme

Jeden Messwert zu speichern, ist für einen kurzen Test in Ordnung, wird aber über Stunden oder bei vielen Generatoren teuer. Ein einfaches Histogramm mit Buckets fester Breite braucht entweder zu viele Buckets oder verliert im Ausläufer an Genauigkeit. Gil Tenes HdrHistogram löst dieses Problem. Es erfasst Werte über einen großen Bereich (etwa 1 µs bis 60 s) mit einer festen Anzahl signifikanter Stellen. Bei 3 signifikanten Stellen ist jeder erfasste Wert auf 0,1 % seiner eigenen Größe genau. Ein Wert von 2 ms und einer von 20 s werden mit derselben relativen Genauigkeit erfasst. Der Speicherbedarf bleibt fest und klein, das Erfassen ist schnell, und Histogramme lassen sich exakt zusammenführen.

Das folgende Skript liest die Rohausgabe von k6 run --out json=results.json. Es baut ein Histogramm pro Minute sowie ein zusammengeführtes Histogramm und gibt die Perzentile aus. Zusätzlich gibt es den Mittelwert der p99-Werte pro Minute aus, damit Sie sehen, wie falsch diese Zahl ist.

percentiles.pyPython
"""Percentiles from raw k6 results using an HDR histogram.
    k6 run --out json=results.json script.js
    pip install hdrhistogram
    python percentiles.py results.json"""
import json
import sys
from collections import defaultdict

from hdrh.histogram import HdrHistogram

LOW, HIGH, DIGITS = 1, 60_000_000, 3  # 1 µs .. 60 s, 3 significant digits


def new_hist() -> HdrHistogram:
    return HdrHistogram(LOW, HIGH, DIGITS)


overall = new_hist()
per_minute = defaultdict(new_hist)

with open(sys.argv[1]) as f:
    for line in f:
        rec = json.loads(line)
        if rec.get("type") != "Point" or rec.get("metric") != "http_req_duration":
            continue
        micros = min(HIGH, max(LOW, int(rec["data"]["value"] * 1000)))  # k6 reports ms
        minute = rec["data"]["time"][:16]                       # 'YYYY-MM-DDTHH:MM'
        overall.record_value(micros)
        per_minute[minute].record_value(micros)

ms = lambda us: us / 1000
print(f"{'window':<17} {'count':>8} {'p50':>9} {'p95':>9} {'p99':>9} {'p99.9':>9} {'max':>9}")
for minute in sorted(per_minute):
    h = per_minute[minute]
    print(f"{minute:<17} {h.get_total_count():>8} "
          + " ".join(f"{ms(h.get_value_at_percentile(p)):>9.1f}" for p in (50, 95, 99, 99.9))
          + f" {ms(h.get_max_value()):>9.1f}")

# Correct way to combine windows: merge histograms, then read the percentile.
merged = new_hist()
for h in per_minute.values():
    merged.add(h)
avg_of_p99 = sum(h.get_value_at_percentile(99) for h in per_minute.values()) / len(per_minute)
print(f"\noverall p99 (merged histogram): {ms(merged.get_value_at_percentile(99)):.1f} ms")
print(f"average of per-minute p99s:     {ms(avg_of_p99):.1f} ms   <- not a percentile")

k6 meldet http_req_duration in Millisekunden als Gleitkommazahl. Das Skript speichert Mikrosekunden als Ganzzahlen, da HdrHistogram ganzzahlige Werte erfasst. Bei einem synthetischen Datensatz, in dem 1 % der Anfragen 3 Sekunden dauert, ungleichmäßig über drei Minuten verteilt, liegt das zusammengeführte p99 bei 3.000 ms. Der Mittelwert der p99-Werte pro Minute liegt bei etwa 1.050 ms, einer Zahl, die in den Daten nicht vorkommt.

Die Tabelle pro Minute ist für sich genommen nützlich. Ein p99, das bei konstanter Last stetig steigt, deutet auf ein Leck, eine wachsende Warteschlange oder einen volllaufenden Cache hin. Ein p99, das in einem Zeitfenster springt und sich dann erholt, deutet auf ein Ereignis wie ein Deployment, einen GC-Zyklus, einen Autoscaling-Schritt oder einen Noisy Neighbour hin.

Eine Einschränkung: Das Histogramm ist nur so ehrlich wie die Messwerte, die darin erfasst werden. Ist der Lastgenerator ein geschlossenes Modell, das aufhört zu senden, sobald das System langsamer wird, leidet er unter Coordinated Omission und weist den Ausläufer zu niedrig aus, egal wie genau das Histogramm ist. Verwenden Sie ein offenes Modell (in k6 einen Arrival-Rate-Executor), wenn Sie Latenz unter Last messen.

Den Sättigungspunkt finden

Jedes System hat einen Durchsatz, ab dem es nicht mehr mithält. Darunter bleibt die Latenz bei steigender Last ungefähr flach. In der Nähe dieses Punktes bilden sich Warteschlangen und die Latenz steigt steil an. Darüber hinaus wächst der Durchsatz nicht mehr (oder sinkt), die Latenz explodiert und es treten Fehler auf. Der Knick in der Kurve wird oft als Knie bezeichnet.

Um ihn zu finden, führen Sie einen Stufentest durch. Erhöhen Sie die Ankunftsrate in Stufen (zum Beispiel 100, 200, 300 … Anfragen/s), halten Sie jede Stufe lange genug, damit das System einen stabilen Zustand erreicht (mindestens einige Minuten), und erfassen Sie pro Stufe:

  • angebotene Last (gesendete Anfragen) und erreichten Durchsatz (erfolgreiche Antworten pro Sekunde),
  • p50-, p95- und p99-Latenz,
  • Fehlerrate sowie
  • Auslastung der wichtigsten Ressourcen.

Tragen Sie Latenz und erreichten Durchsatz gegen die angebotene Last auf. Das Knie liegt dort, wo der erreichte Durchsatz der angebotenen Last nicht mehr folgt oder das p95 schneller steigt als die Last. Eine sinnvolle Betriebsgrenze liegt deutlich unterhalb des Knies, nicht darauf. Die Warteschlangentheorie besagt, dass die Latenz stark ansteigt, wenn sich die Auslastung 100 % nähert. In einem einfachen Warteschlangenmodell mit einem Bediener ist die mittlere Wartezeit proportional zu ρ / (1 − ρ), wobei ρ die Auslastung ist. Von 50 % auf 90 % Auslastung verneunfacht sich dieser Faktor.

Sättigung mit der USE-Methode erklären

Zu wissen, wo das Knie liegt, hilft nur zur Hälfte. Das Team muss wissen, warum es dort liegt. Brendan Greggs USE-Methode liefert eine Checkliste: Prüfen Sie für jede Ressource die Utilisation (Auslastung, wie beschäftigt sie ist), die Saturation (Sättigung, wie viel Arbeit auf sie wartet) und die Errors (Fehler).

„Ressource“ bedeutet mehr als CPU und Speicher. In Web- und API-Systemen ist der Engpass oft eine logische Ressource:

  • Datenbank- oder HTTP-Connection-Pools (belegte Verbindungen, Wartezeit auf eine Verbindung),
  • Thread-Pools und Worker-Anzahlen,
  • Sperren und Konflikte auf Zeilenebene in der Datenbank,
  • Rate Limits nachgelagerter Anbieter sowie
  • Netzwerkbandbreite oder das Limit offener Dateideskriptoren am Load Balancer.

Ein klassisches Muster: Die Latenz steigt stark, während die CPU bei 50 % liegt. Das bedeutet nicht, dass Kapazität frei ist. Es bedeutet, dass der Engpass woanders liegt, oft in einem vollständig belegten Pool, vor dem sich Anfragen für eine Verbindung anstellen. Sättigungsmetriken (Warteschlangenlänge, Wartezeit) finden das. Auslastungsmetriken allein nicht.

Little's Law als Plausibilitätsprüfung

Little's Law besagt, dass in einem stabilen System die mittlere Anzahl der Elemente im System gleich der Ankunftsrate multipliziert mit der mittleren Verweildauer jedes Elements ist: L = λ × W. Das gilt unabhängig von der Verteilung der Ankünfte und Bedienzeiten und macht das Gesetz zu einer sehr nützlichen Prüfung.

Zwei praktische Anwendungen:

Nebenläufigkeit aus dem Durchsatz. Bei 400 Anfragen/s und einer mittleren Antwortzeit von 250 ms sind im Schnitt 400 × 0,25 = 100 Anfragen gleichzeitig in Bearbeitung. Hat Ihr Dienst insgesamt 64 Worker-Threads, gehen die Zahlen nicht auf, und Anfragen warten irgendwo, bevor sie verarbeitet werden. Meldet Ihr Test diesen Durchsatz mit deutlich weniger VUs, als das Ergebnis erfordert, überprüfen Sie den Test selbst.

Kapazität eines Pools. Umgestellt zu λ = L / W liefert das Gesetz den maximalen Durchsatz einer Ressource fester Größe. Ein Datenbank-Pool mit 20 Verbindungen, bei dem jede Abfrage eine Verbindung 50 ms lang belegt, kann höchstens 20 / 0,05 = 400 Abfragen pro Sekunde bedienen. Benötigt eine Anfrage eine solche Abfrage, sättigt der Dienst bei etwa 400 Anfragen/s, egal wie viele CPU-Kerne Sie hinzufügen. Oft sagt diese Rechnung das Knie voraus, bevor Sie den Test überhaupt ausführen, und zeigt, an welcher Stellschraube Sie drehen müssen.

Verwenden Sie für Little's Law Mittelwerte, keine Perzentile. Es ist eine der wenigen Stellen, an denen der Mittelwert die richtige Kennzahl ist.

Den Test prüfen, nicht nur das System

Bevor Sie einem Ergebnis vertrauen, stellen Sie sicher, dass der Lastgenerator nicht der Engpass war. Prüfen Sie, ob:

  • die erreichte Anfragerate der konfigurierten Ankunftsrate entspricht. k6 meldet dropped_iterations, wenn Iterationen nicht pünktlich starten konnten, meist weil maxVUs zu niedrig war;
  • CPU und Netzwerk des Lastgenerators deutlich unter der Sättigung blieben;
  • die Fehlerrate zusammen mit der Latenz berichtet wird. Schnelle Fehler lassen die Latenz besser aussehen, filtern Sie die Dauer daher auf erfolgreiche Antworten oder berichten Sie beides; und
  • die Testumgebung der Produktion in den relevanten Dimensionen ähnelt: Datenvolumen, Instanzgrößen, Verbindungslimits und Caches.

Einen Bericht schreiben, aus dem Maßnahmen folgen

Ein Performance-Bericht ist keine Sammlung von Diagrammen. Jedes Finding sollte beantworten: Was haben wir beobachtet, wie sicher sind wir, warum passiert es, was bedeutet es für das Geschäft und was sollte sich ändern? Eine bewährte Struktur:

  1. Zusammenfassung für Entscheider: Erfüllt das System seine Ziele bei der erwarteten Spitzenlast? Ja, nein oder unter welchen Bedingungen. Ein Absatz.
  2. Ziele und Lastmodell: die SLOs, gegen die getestet wurde, Traffic-Mix, Ankunftsraten und Testdauern sowie die Umgebung.
  3. Findings: ein Block pro Finding, nach Schweregrad geordnet, jeweils mit Nachweis und empfohlener Maßnahme.
  4. Kapazität: das gemessene Knie, die Engpassressource und der Spielraum gegenüber der geplanten Spitze.
  5. Einschränkungen: was der Test nicht abgedeckt hat und worin sich die Umgebung von der Produktion unterscheidet.
  6. Anhang: Rohdaten, Skripte und Lauf-IDs, damit sich die Ergebnisse reproduzieren lassen.

So kann ein einzelnes Finding aussehen. Die Zahlen sind ein illustratives Beispiel und stammen nicht aus einem echten Projekt (der Block ist im Original auf Englisch belassen, wie ihn viele internationale Teams erhalten):

report.txtText
FINDING F-02 · High · Capacity
Claim:      POST /v1/payments breaches its p95 SLO (400 ms) above ~560 req/s.
Evidence:   Stress run #3, 2026-10-14 14:05–14:40 UTC, staging (prod-equivalent sizing).
            p95 135 ms at 300 req/s -> 720 ms at 600 req/s; errors 0.1% -> 3.4%.
            App CPU max 58%; DB pool: 20/20 connections busy, pool wait p95 480 ms.
Cause:      DB connection pool saturation (Little's Law: 600 req/s x 40 ms query
            time = 24 connections needed > 20 available).
Impact:     Planned launch peak is 800 req/s -> SLO breach for most checkout traffic.
Action:     Raise pool to 50/node + pgbouncer; re-test at 800 req/s. Owner: platform team.
Confidence: High (reproduced in 3 runs; stable at 300 req/s for 4 h soak).

Das Finding formuliert eine Aussage mit klarem Schwellenwert, verweist auf den genauen Lauf, liefert den Nachweis auf Ressourcenebene, erklärt die Ursache mit einer Rechnung, die jeder nachprüfen kann, verknüpft sie mit dem Launch und benennt eine verantwortliche Stelle. Man kann ihm widersprechen, es aber nicht missverstehen.

Checkliste

  • Berichten Sie p50, p95, p99, p99,9 und das Maximum, nicht nur den Mittelwert.
  • Mitteln Sie niemals Perzentile. Führen Sie stattdessen Histogramme oder Rohdaten zusammen.
  • Verwenden Sie ein offenes Lastmodell, damit der Ausläufer ehrlich gemessen wird.
  • Finden Sie das Knie mit einem Stufentest und legen Sie die Betriebsgrenze darunter.
  • Erklären Sie das Knie mit Auslastungs- und Sättigungsmetriken für jede Ressource, einschließlich Pools.
  • Plausibilisieren Sie Ihre Zahlen mit Little's Law.
  • Stellen Sie sicher, dass der Lastgenerator nicht der Engpass war.
  • Formulieren Sie Findings als Aussage, Nachweis, Ursache, Auswirkung und Maßnahme.

Möchten Sie einen zweiten Blick auf Ihr System?

Fordern Sie den kostenlosen 20-Minuten-Check an: kurze schriftliche Befunde, nicht-invasiv, unverbindlich.

Kostenloser 20-Min-Check →