Performance Rescue-Projekt Case Study

Performance-Einbruch am Freitag um 16 Uhr: Anatomie eines Rescue-Projekts

Carola Schulte
Carola Schulte 1. August 2025 22 min Lesezeit

Freitag, 15:47 Uhr. Mein Telefon klingelt. „Carola, der Shop ist tot. Seit 20 Minuten. Wir verlieren gerade das Wochenend-Geschäft." Was folgte, waren 72 Stunden Debugging, eine Überraschung in der Datenbank und die Erkenntnis, dass manchmal die offensichtliche Ursache nicht die echte ist. Die 72 Stunden umfassten Triage, Stabilisierung, Root Cause Analysis, Fix und die ersten nachhaltigen Maßnahmen. Eine anonymisierte Case Study.

Jeder Performance-Einbruch erzählt eine Geschichte. Man muss nur zuhören.

Hinweis: Diese Case Study ist anonymisiert. Branche, Technologie-Stack, Zeitstempel und Details wurden verändert, um den Kunden zu schützen. Die technischen Learnings sind real.

Die Ausgangslage

Das Unternehmen: Ein mittelständischer E-Commerce-Anbieter mit ca. 15.000 Bestellungen pro Tag. PHP-Monolith, gewachsen über 8 Jahre, MySQL-Datenbank mit 200+ Tabellen, Redis für Sessions und Caching.

Das Team: 12 Entwickler, 2 DevOps, 1 DBA (Teilzeit). Klassische Struktur – jeder kennt „seinen" Bereich, aber niemand das Gesamtbild.

Der Zustand: Das System lief. Meistens. Es gab „bekannte Probleme" – langsame Suche, gelegentliche Timeouts beim Checkout. Aber es funktionierte. Bis zu diesem Freitag.

15k
Bestellungen/Tag
8 Jahre
Codebase-Alter
200+
DB-Tabellen
0
APM-Tools

Der Anruf

„Der Shop ist tot" ist keine hilfreiche Fehlerbeschreibung. Aber ich kannte den Kunden, und ich wusste: Wenn Thomas anruft, ist es ernst.

„Die Seite lädt nicht mehr. Timeout nach 30 Sekunden. Seit 15:30 Uhr. Wir haben nichts deployed. Wir haben nichts geändert. Es ist einfach passiert."

„Wir haben nichts geändert" – der Klassiker. Spoiler: Es hatte sich doch etwas geändert. Aber nicht dort, wo alle gesucht haben.

Die erste Stunde: Triage

Timeline: Freitag 15:47 – 17:00

15:47 – Anruf

Erster Kontakt. VPN-Zugang funktioniert noch. Gut.

15:52 – Erster Blick

SSH auf Webserver. top zeigt: CPU bei 100%, load average 47. Bei 8 Cores. Das ist schlecht.

15:58 – Prozesse

php-fpm Prozesse stapeln sich. 200+ Worker, alle „busy". Requests kommen rein, werden nicht fertig.

16:05 – Datenbank-Check

MySQL: 450 aktive Connections (Limit: 500). SHOW PROCESSLIST zeigt: Hunderte Queries auf dieselbe Tabelle. Alle „Sending data". Alle > 20 Sekunden.

16:12 – Die Tabelle

Es ist die product_views Tabelle. Analytics-Tracking. 180 Millionen Rows.

# Der erste Blick auf die Datenbank
mysql> SHOW PROCESSLIST;
+-------+------+-----------+--------+---------+------+----------------+--------------------------------------+
| Id    | User | Host      | db     | Command | Time | State          | Info                                 |
+-------+------+-----------+--------+---------+------+----------------+--------------------------------------+
| 45231 | app  | localhost | shop   | Query   |   23 | Sending data   | SELECT COUNT(*) FROM product_views.. |
| 45232 | app  | localhost | shop   | Query   |   21 | Sending data   | SELECT COUNT(*) FROM product_views.. |
| 45233 | app  | localhost | shop   | Query   |   19 | Sending data   | SELECT COUNT(*) FROM product_views.. |
...
(400+ rows)

„Sending data" bedeutet hier: MySQL scannt und filtert – nicht, dass bereits Daten zum Client fließen.

Jeder Request zur Produktseite triggerte einen COUNT(*) auf eine 180-Millionen-Zeilen-Tabelle. Ohne Index auf der WHERE-Clause. Full Table Scan. Bei jedem. Einzelnen. Seitenaufruf.

Aber warum jetzt? Der Code war seit Monaten derselbe. Die Tabelle wuchs langsam aber stetig. Was hatte sich geändert?

Die erste Maßnahme: Blutung stoppen

Debugging kann warten. Erst muss der Shop wieder laufen.

# Option 1: Query killen (temporär)
mysql> KILL 45231;
mysql> KILL 45232;
# ... 400 mal. Nicht praktikabel.

# Option 2: Feature deaktivieren
# Im Code: product_analytics.enabled = false
# Deployment: 4 Minuten

# Option 3: Tabelle umbenennen (brutal, aber schnell)
mysql> RENAME TABLE product_views TO product_views_broken;

Wir entschieden uns für Option 2. Feature-Flag existierte zum Glück. Um 16:24 war das Analytics-Feature deaktiviert, um 16:31 waren die alten Queries abgearbeitet, um 16:35 war der Shop wieder erreichbar.

48 min
Total Downtime
~€85k
Geschätzter Umsatzverlust
16:35
Shop wieder online

Der Shop lief wieder. Aber wir wussten noch nicht, warum es passiert war. Und das Analytics-Feature war jetzt tot.

Root Cause Analysis: Die eigentliche Ursache

Samstag, 09:00 Uhr. Zeit für die echte Analyse. Die Frage: Warum ist ein Query, der seit Monaten lief, plötzlich zum Problem geworden?

Hypothese 1: Traffic-Spike

# Requests pro Minute (aus nginx logs)
$ awk '{print $4}' access.log | cut -d: -f1,2,3 | uniq -c | tail -20

   1842 [xx/xxx/xxxx:15:30
   1891 [xx/xxx/xxxx:15:31
   1823 [xx/xxx/xxxx:15:32
   # ... normal, kein Spike

Ergebnis: Traffic war normal. Kein Marketing-Push, keine Kampagne, keine Bot-Attacke.

Hypothese 2: Tabellen-Wachstum

-- Tabellen-Größe über Zeit (aus Backup-Metadaten)
-- 01.06: 142M rows, 18 GB
-- 01.07: 158M rows, 20 GB
-- 01.08: 171M rows, 22 GB
-- 15.08: 183M rows, 24 GB  ← Tag des Incidents

Die Tabelle wuchs, aber linear. Kein plötzlicher Sprung. Das erklärt nicht den abrupten Einbruch.

Hypothese 3: Query Plan Change

Hier wurde es interessant.

mysql> EXPLAIN SELECT COUNT(*) FROM product_views
       WHERE product_id = 12345
       AND view_date > '2024-08-01';

+----+-------------+---------------+------+---------------+------+---------+------+-----------+-------------+
| id | select_type | table         | type | possible_keys | key  | key_len | ref  | rows      | Extra       |
+----+-------------+---------------+------+---------------+------+---------+------+-----------+-------------+
|  1 | SIMPLE      | product_views | ALL  | idx_product   | NULL | NULL    | NULL | 183847291 | Using where |
+----+-------------+---------------+------+---------------+------+---------+------+-----------+-------------+

type: ALL. Full Table Scan. Aber es gab einen Index auf product_id. Warum wurde er nicht verwendet?

mysql> SHOW INDEX FROM product_views;
+---------------+------------+--------------+--------------+-------------+
| Table         | Non_unique | Key_name     | Seq_in_index | Column_name |
+---------------+------------+--------------+--------------+-------------+
| product_views |          1 | idx_product  |            1 | product_id  |
| product_views |          1 | idx_date     |            1 | view_date   |
+---------------+------------+--------------+--------------+-------------+

mysql> SELECT COUNT(DISTINCT product_id) FROM product_views;
+----------------------------+
| COUNT(DISTINCT product_id) |
+----------------------------+
|                      18472 |
+----------------------------+

mysql> SELECT COUNT(*) FROM product_views WHERE product_id = 12345;
+----------+
| COUNT(*) |
+----------+
|    47823 |
+----------+

18.000 verschiedene Produkte. Pro Produkt durchschnittlich 10.000 Views. Der Query Optimizer entschied: Bei dieser Kardinalität ist ein Full Table Scan effizienter als ein Index Lookup.

Die Erkenntnis: Irgendwann wurden die Optimizer-Statistiken aktualisiert (automatisch im Hintergrund). Mit den neuen Zahlen entschied der Query Optimizer anders: Full Scan statt Index. Bei 180 Millionen Rows. Spoiler: War es nicht die bessere Wahl.

Der Beweis

# MySQL Error Log (Zeitstempel anonymisiert)
[...] [Note] InnoDB: Running background statistics collection
[...] [Note] InnoDB: Background statistics collection completed

# Kurz vor dem Incident.
# Der Query Optimizer hatte neue Zahlen. Und traf eine fatale Entscheidung.

Der Fix

Kurzfristig: Index erzwingen

-- Vorher (Query Optimizer entscheidet)
SELECT COUNT(*) FROM product_views
WHERE product_id = 12345 AND view_date > '2024-08-01';

-- Nachher (Index erzwungen)
SELECT COUNT(*) FROM product_views FORCE INDEX (idx_product)
WHERE product_id = 12345 AND view_date > '2024-08-01';

-- Ergebnis: 0.003 Sekunden statt 45 Sekunden

Mittelfristig: Composite Index

-- Neuer Index für die typische Query
-- Reihenfolge ist entscheidend: erst product_id (Equality), dann view_date (Range)
CREATE INDEX idx_product_date ON product_views (product_id, view_date);

-- Jetzt brauchen wir kein FORCE INDEX mehr
EXPLAIN SELECT COUNT(*) FROM product_views
WHERE product_id = 12345 AND view_date > '2024-08-01';

+----+-------------+---------------+-------+------------------+------------------+---------+------+-------+-------------+
| id | select_type | table         | type  | possible_keys    | key              | key_len | ref  | rows  | Extra       |
+----+-------------+---------------+-------+------------------+------------------+---------+------+-------+-------------+
|  1 | SIMPLE      | product_views | range | idx_product_date | idx_product_date | 12      | NULL | 47823 | Using index |
+----+-------------+---------------+-------+------------------+------------------+---------+------+-------+-------------+

type: range, Using index – der Query nutzt jetzt den Index optimal.

Langfristig: Architektur-Änderung

180 Millionen Rows in einer Tabelle, die bei jedem Produktaufruf abgefragt wird? Das ist ein Architektur-Problem, kein Index-Problem.

  • Partitionierung: Tabelle nach Monat partitionieren. Alte Daten in kalte Partitionen.
  • Aggregation: Vorberechnete Zähler statt Live-COUNT(*). Materialized Views oder separate Counter-Tabelle.
  • Caching: Redis für View-Counts mit TTL. Nicht bei jedem Request die DB fragen.
  • Async: Analytics asynchron verarbeiten. Queue statt synchroner DB-Write.
-- Counter-Tabelle statt Live-COUNT (vereinfachtes Beispiel)
CREATE TABLE product_view_counts (
    product_id INT PRIMARY KEY,
    view_count INT DEFAULT 0,
    last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- In der Praxis: inkrementell aggregieren (Redis INCR + Flush)
-- oder Streaming-Aggregation, nicht COUNT(*)-Recalc pro Produkt

Was wir gelernt haben

1. „Wir haben nichts geändert" ist nie wahr

Irgendwas ändert sich immer. Datenvolumen. Query Plans. Cron Jobs. OS Updates. Traffic Patterns. Die Frage ist nur: Was?

2. Automatische Prozesse können Probleme verursachen

Automatische Statistik- und Maintenance-Jobs liefen regelmäßig. 364 Tage lang ohne Problem. Am 365. Tag entschied der Optimizer anders. Automatisierung ist gut – aber man muss wissen, was automatisch passiert.

3. Ein Index ist keine Garantie

Der Index existierte. Er wurde nur nicht benutzt. EXPLAIN ist nicht optional – es ist Pflicht bei jeder kritischen Query.

4. Feature Flags retten Leben

Ohne das Feature Flag für Analytics hätten wir Code deployen müssen. Mit Feature Flag: 4 Minuten bis zur Entlastung. Bauen Sie Feature Flags in jedes nicht-kritische Feature ein.

5. Monitoring ≠ Alerting

Es gab Monitoring. Grafana-Dashboards, die niemand anschaute. Aber kein Alert für „Query Duration > 10s". Der Incident wurde von Kunden gemeldet, nicht vom System.

Die unbequeme Wahrheit: Ein Dashboard, das niemand anschaut, ist wertlos. Alerts, die keiner versteht, sind wertlos. Monitoring muss zu Aktionen führen – automatisch oder manuell. Alles andere ist Theater für Operations.

Das Nachspiel

Die Rechnung

Position Kosten
Umsatzverlust (48 min Downtime) ~85.000 €
Rescue-Einsatz (Wochenende) ~12.000 €
Follow-up Fixes (Composite Index, Caching) ~8.000 €
Reputation (unquantifizierbar) ???
Gesamt ~105.000 € + X

Was danach passierte

  • Woche 1: Composite Index deployed, Analytics wieder aktiviert
  • Woche 2: Alerting für Slow Queries (> 5s) eingerichtet
  • Woche 4: Counter-Tabelle implementiert, Live-COUNT eliminiert
  • Monat 3: Tabellen-Partitionierung für alle Analytics-Tabellen
  • Monat 6: APM-Tool (Datadog) eingeführt

Gesamtinvestition für nachhaltige Fixes: ca. 45.000 €. Hätte man das vorher investiert, wäre der Incident nie passiert. Aber so funktioniert Priorisierung leider nicht – bis es knallt.

Checkliste: Performance-Incident Response

Wenn der Shop brennt

Erste 5 Minuten:

  • SSH-Zugang funktioniert?
  • top / htop – CPU, Memory, Load
  • df -h – Disk voll?
  • DB-Connections: Limit erreicht?
  • SHOW PROCESSLIST – Was läuft?

Erste Entscheidung:

  • Kann ich die Ursache in < 5 Minuten fixen?
  • Wenn nein: Feature deaktivieren / Traffic umleiten / Rollback
  • Ziel: System stabilisieren, dann debuggen

Nach der Stabilisierung:

  • Logs sichern (werden rotiert!)
  • Timeline dokumentieren
  • Screenshots von Monitoring
  • EXPLAIN für verdächtige Queries

Warnsignale, die wir ignoriert hatten

Im Nachhinein gab es Zeichen. Es gibt immer Zeichen.

  • 3 Monate vorher: „Die Produktseite ist manchmal langsam" – niemand hat nachgeforscht
  • 6 Wochen vorher: DBA erwähnte „die Analytics-Tabelle wird groß" – keine Action
  • 2 Wochen vorher: Sporadische Timeouts im Checkout – „wahrscheinlich Netzwerk"
  • 1 Tag vorher: Slow Query Log hatte Einträge für product_views – niemand schaute rein
Die Lektion: Performance-Probleme kündigen sich an. Immer. Die Frage ist, ob jemand zuhört. Ein Slow Query Log, das niemand liest, ist wie ein Rauchmelder ohne Batterien.

Fazit

Dieser Incident war vermeidbar. Mit einem Composite Index (10 Minuten Arbeit). Mit einem Alert für Slow Queries (30 Minuten Setup). Mit einem Review der größten Tabellen (1 Stunde pro Quartal).

Stattdessen: 48 Minuten Downtime, 105.000 € Kosten, ein verlorenes Wochenende und ein Team, das jetzt weiß, wie sich Panik anfühlt.

Die gute Nachricht: Aus jedem Incident kann man lernen. Dieses Team hat gelernt. Sie haben jetzt Alerts, Monitoring, Partitionierung und einen gesunden Respekt vor Analytics-Tabellen.

Der beste Zeitpunkt, Performance-Monitoring einzurichten, war vor dem Incident. Der zweitbeste ist jetzt.

Takeaway: Wenn Ihr System „meistens läuft", ist das kein Erfolg – es ist eine Zeitbombe. Irgendwann ändert sich etwas. Traffic, Datenvolumen, ein Optimizer-Update. Und dann zählt, ob Sie vorbereitet sind.

Carola Schulte

Carola Schulte

Software-Architektin mit Erfahrung in Performance-Analyse und Rescue-Projekten. Ja, ich arbeite auch am Wochenende. Wenn es sein muss.

Beratung anfragen

Performance-Probleme?

Bevor es knallt: Ich analysiere Ihre kritischen Pfade, identifiziere Zeitbomben und baue mit Ihrem Team ein Monitoring auf, das seinen Namen verdient.

Performance-Analyse anfragen