Stell dir den Pull Request vor. Eine Migration, ein Dutzend Zeilen, ein neuer Index auf customer_id. Der Reviewer winkt ihn in etwa neunzig Sekunden durch, und warum auch nicht? Die Spalte steht in der WHERE-Klausel, also wirkt der Index offensichtlich richtig. Was niemand geöffnet hat, ist die Repository-Methode, für die der Index gedacht war. Die liegt drei Verzeichnisse weiter und hat eine Sortierung dazubekommen, seit sie zuletzt jemand gelesen hat. Ich glaube, genau dort fangen die meisten unserer Indexing-Bugs an. Ein Index ist eine Abmachung mit einer ganz bestimmten Query, und in einem typischen PHP-Projekt wird diese Abmachung in einer Datei festgehalten, während die Query in einer anderen lebt. Also prüft nie jemand, ob die beiden noch übereinstimmen.

order_events query from the source
SELECT * FROM order_events
WHERE customer_id = 8842
AND created_at > now() - interval '30 days'
ORDER BY created_at DESC;

Wie teuer diese Lücke ist, zeigt der aktuelle Medium-Artikel von Farhad Ali. Sein Team hatte eine PostgreSQL-Tabelle namens order_events mit rund vier Millionen Zeilen aus dem Lebenszyklus von Bestellungen, dazu eine Query, die die Events eines Kunden aus den letzten dreißig Tagen holte, die neuesten zuerst. Indexiert hatten sie customer_id allein. EXPLAIN ANALYZE zeigte, dass der Planner diesen Index ignorierte und die Arbeit auf die teure Tour erledigte, inklusive einer Sortierung der kompletten Historie des Kunden nach created_at, nachdem die Zeilen gefunden waren. Der Wechsel auf einen zusammengesetzten Index auf (customer_id, created_at DESC) brachte die Query von etwas über vier Sekunden auf 9 Millisekunden. Das Detail, das mir nicht aus dem Kopf geht: Es war schon das dritte Mal. Dieselbe Diskrepanz hatte vorher bereits eine Terminplanungs-App im Gesundheitswesen und ein internes Analytics-Tool für einen Logistikkunden erwischt.

Drei Projekte mit derselben Ursache sind ein Muster, und dieses Muster schiebe ich eher auf unsere Arbeitsweise als auf einen einzelnen Entwickler. Überleg mal, wie sich eine Query in einer Laravel- oder Symfony-App tatsächlich verändert. Jemand hängt orderByDesc('created_at') an eine Query-Builder-Kette, weil der Product Owner die neuesten Einträge oben sehen will. Ein paar Monate später fügt jemand anderes einen Scope hinzu, der eine zweite Tabelle joint. Im Diff sieht jede dieser Änderungen aus wie eine winzige Anpassung an der Darstellung. Keine davon fasst eine Migration an, also bleibt der Index genau so, wie er am Tag seiner Erstellung war, während die Query, für die er gebaut wurde, langsam zu einer anderen Query wird.

Das ORDER BY ist der heimtückische Teil, weil kaum jemand es als Performance-Entscheidung betrachtet. Es liest sich wie Formatierung. Für PostgreSQL ist es aber eine Anforderung: Entweder liefert ein Index die Zeilen bereits in dieser Reihenfolge, oder die Datenbank sammelt alle passenden Zeilen ein und sortiert sie selbst. Nimm einen langjährigen Kunden mit Zehntausenden Events. Diese Sortierung läuft bei jedem Seitenaufruf seines Dashboards, und wenn sich ein paar solcher Requests stauen, füllt sich dein PHP-FPM-Pool mit Workern, die alle nur auf Postgres warten.

Deshalb mein Vorschlag: Sorg dafür, dass Index und Query voneinander wissen. Die Repository-Methode bekommt einen Kommentar mit dem Namen des Index, auf den sie angewiesen ist, und die Migration einen Kommentar mit der Methode, für die sie gebaut wurde. Eine Änderung an der Builder-Kette dieser Methode sollte im Review dieselbe Frage auslösen wie eine Schemaänderung: Passt der Index noch zu dem, was diese Query verlangt? Wenn dein Team gern automatisiert, kann ein CI-Job eine Tabelle mit Testdaten füllen und EXPLAIN auf deine heißesten Queries loslassen. So oder so sollte derjenige, der die Sortierung ändert, auch derjenige sein, der über den Index nachdenken muss.

Das beste Gegenargument ist ernst zu nehmen, und das tue ich auch. Indizes sind geteilte Infrastruktur. Wer in einem Index pro Query denkt, landet bei einer Events-Tabelle mit vierzehn davon, und jedes INSERT muss alle vierzehn aktualisieren. Bei einem schreiblastigen Log wie order_events merkt man das an langsameren Inserts, einem geschäftigeren Autovacuum und einem Planner, der zwischen Beinahe-Duplikaten wählen muss. Dazu kommt, dass die Planner-Statistiken driften, sodass ein Index, der im März perfekt ist, im November übergangen werden kann. Koppelst du Indizes zu eng an einzelne Queries, tauschst du langsame Reads gegen langsame Writes und ein Schema, das sich niemand mehr anzufassen traut.

Ich lande trotzdem bei der Kopplung, und der Grund ist ausgerechnet der Wildwuchs. Indizes sammeln sich an, weil niemand weiß, wem sie gehören. Einen Index ohne benannten Abnehmer kann man nicht gefahrlos löschen, also passiert es nie, und der nächste Entwickler legt sicherheitshalber noch einen daneben. Wenn jeder Index die Queries auflistet, die von ihm abhängen, ist das Zusammenlegen von zweien schnell erledigt: Du weißt genau, für welche Statements du EXPLAIN ANALYZE erneut laufen lassen musst, und in pg_stat_user_indexes siehst du, welche nie gescannt werden. Erst wenn klar ist, wem welcher Index gehört, kannst du aufräumen. Unwissen beschert dir die vierzehn.

Ich kenne Teams, die das über Namenskonventionen lösen, andere, die eine Markdown-Datei neben den Migrationen pflegen, und mindestens eines, das jedes Quartal die idx_scan-Werte prüft und alles löscht, was immer noch bei null steht. Mich interessiert, was nach zwei Jahren und drei Entwicklergenerationen tatsächlich noch funktioniert. Wie erfährt in deiner Codebase jemand, der eine Query ändert, von welchem Index sie abhängt? Und hat das schon mal ein Vier-Sekunden-Dashboard abgefangen, bevor ein Kunde es gesehen hat?