02Performance5 min

Admin-Listen fallen als Erstes um

Das interne Panel wird ohne Limits geschrieben, weil es am Anfang vier Zeilen hat. Genau dort fliegt alles auseinander, wenn das Produkt wächst.

Von Salva Castells

Titelbild: Marijovanni, CC BY-SA 4.0.
In diesem Beitrag

Ein Admin-Panel beginnt immer gleich: eine Tabelle, vier Testzeilen und ein select ohne where. Es funktioniert. Und weil es funktioniert, schaut es niemand je wieder an. Währenddessen wächst die Nutzertabelle von vier Zeilen auf vierzigtausend, und die interne Seite, die niemand misst, wird zur langsamsten des Produkts.

Ich musste das in vier verschiedenen Projekten reparieren, und immer aus denselben drei Gründen.

Das Admin wird nachlässig geschrieben, weil es niemand sieht

Die öffentliche Seite hat vom ersten Tag an Paginierung. Jemand reviewt sie, jemand misst sie, es gibt eine Core-Web-Vitals-Metrik, die schreit, wenn sie langsam ist. Das Admin hat nichts davon. Es benutzen zwei Leute, und diese zwei Leute gehen davon aus, dass es langsam ist, weil "es das Admin ist".

In einer Transkriptions-API, die ich pflege, bekamen die Server Actions getAdminUsers und getAdminFeedback limit als Parameter und reichten es direkt an die Query weiter. Ohne Obergrenze. Monatelang hatte es niemand angefasst, weil aus der UI immer eine 10 kam. Das Problem ist nicht die UI: Eine Server Action ist ein Endpoint. Jeder mit Admin-Session, oder irgendein Skript, konnte limit=1000000 anfordern und die komplette Tabelle im Speicher des Workers materialisieren.

Die Korrektur waren drei Zeilen, in einer Sicherheitsrunde:

export const ADMIN_LIST_MAX_LIMIT = 1000;

const cappedLimit = Math.min(Math.max(limit, 1), ADMIN_LIST_MAX_LIMIT);

Das Math.max(limit, 1) ist keine grundlose Paranoia: Ein negatives limit oder eine Null macht je nach Treiber auch seltsame Dinge. Wichtig ist, wo die Obergrenze lebt. Nicht in der Komponente, nicht im Hook: in der Schicht, die die Anfrage empfängt. Alles darüber ist ein Vorschlag.

Der count, der gratis aussieht und es nicht ist

Klassische Paginierung muss wissen, wie viele Seiten es gibt, und dafür braucht es ein SELECT COUNT(*). Du schreibst es, ohne nachzudenken, weil ein Count im Kopf billig ist. Ist er nicht: In Postgres durchläuft ein exakter Count die ganze Tabelle oder den ganzen Index, und es gibt keinen Weg, das zu vermeiden.

In einem internen Dokumentenmanager machte das Nutzer-Admin bei jedem Seitenaufruf drei Dinge parallel: die Seite mit Profilen holen, alle Firmen für den Selector holen und die Gesamtzahl der Profile zählen. Mit einer pageSize von 5. Das heißt, die gesamte Tabelle wurde durchlaufen, um "Seite 3 von 47" anzuzeigen.

Ich habe den Count entfernt und den Vertrag der Paginierung geändert:

setHasMore(profilesData.length === pageSize);

Man verliert das "von 47". Man gewinnt, dass die Query immer proportional zur Seitengröße ist. Der Weiter-Button hängt jetzt von hasMore ab statt von currentPage < totalPages, und der angezeigte Bereich wird aus dem berechnet, was du ohnehin schon in der Hand hast, ohne irgendetwas nachzufragen:

const endIndex = startIndex + companies.length - 1;

Niemand hat sich beschwert, die Gesamtzahl nicht zu sehen. Das ist eine Angabe, die in einem internen Panel absolut nichts entscheidet. Wenn du sie wirklich brauchst, gibt dir reltuples aus pg_class eine Schätzung zum Nulltarif; für "es gibt ungefähr 40k Nutzer" reicht das dicke.

Metadaten holen statt des ganzen Objekts

Das dritte Muster ist subtiler. Eine Dateiliste lädt gut, bis die Dateien Inhalt haben. In einem Werkzeug für kollaborative Notizen rief die Seitenleiste getProjectTextFiles auf, das ein select ohne Projektion machte: Es holte also auch die Spalte content jeder Datei des Projekts. Um eine Liste von Namen anzuzeigen.

Die Lösung ist langweilig und funktioniert: eine zweite Funktion, die explizit die Spalten auswählt, die die Liste braucht.

return await db
  .select({
    id: textFiles.id,
    projectId: textFiles.projectId,
    name: textFiles.name,
    isTemp: textFiles.isTemp,
    deletedAt: textFiles.deletedAt,
    createdAt: textFiles.createdAt,
    updatedAt: textFiles.updatedAt,
  })
  .from(textFiles)
  .where(and(eq(textFiles.projectId, projectId), isNull(textFiles.deletedAt)));

Der Inhalt wird beim Auswählen einer Datei angefordert, nicht vorher. Und da taucht das Detail auf, das man erst sieht, wenn man es gemacht hat: Wenn der Nutzer schnell auf zwei Dateien klickt, kann die langsame Antwort der ersten die zweite überschreiben. Man muss vor dem Schreiben in den Store prüfen, ob die Datei noch die ausgewählte ist:

if (fullFile && useAppStore.getState().selectedFileId === fileId) {
  useAppStore.getState().setSelectedFile(fullFile);
}

Ohne diese Prüfung tauschst du ein Performance-Problem gegen einen Race-Bug, und der ist schlimmer, weil er sich nicht auf Kommando reproduzieren lässt.

Der Bug, der nur im Admin-Panel auftaucht

Eines der besten Beispiele dafür, warum das Admin Aufmerksamkeit verdient, lieferte mir ein Analytics-Dashboard, das ich mit unstable_cache gecacht hatte. Es funktionierte lokal und beim ersten Besuch in Produktion. Danach fiel es mit Invalid time value um.

unstable_cache serialisiert seine Payload nach JSON. Ein Date, das im kalten Durchlauf geschrieben wurde, kommt bei allen gecachten Lesevorgängen als string zurück. Und Intl.DateTimeFormat.format akzeptiert keinen String. Der Typ log: lastIngestedAt: Date | null war in Wirklichkeit Date | string | null. TypeScript konnte das nicht sehen, weil die Serialisierungsgrenze nicht typisiert ist.

Ich habe es behoben, indem ich beide Formen normalisiert und das Undarstellbare als fehlend behandelt habe, nicht als Ausnahme:

const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : formatter.format(date);

Ein Datumswert, der sich nicht anzeigen lässt, soll als "keine Daten" erscheinen und nicht die ganze Seite umwerfen. In einem Dashboard darf eine kaputte Metrik nicht die anderen zwölf mitreißen.

Was ich heute auf jede interne Liste anwende

  • Obergrenze in der Serverschicht. Ein limit, das von außen kommt, wird dort begrenzt, wo es eintritt, mit einer benannten Konstante. Die UI ist keine Validierung.
  • Standardmäßig kein count. Paginierung über hasMore. Wenn jemand die Gesamtzahl verlangt, bekommt er eine Schätzung, und nur wenn er danach fragt.
  • Explizite Projektion. Eine Liste wählt Spalten aus, niemals *. Der Blob wird beim Öffnen der Detailansicht angefordert.
  • Index auf der Spalte, nach der du filterst. In einer Zeiterfassungs-App gab es zusammengesetzte Indizes (user_id, date), aber die Queries, die nur nach user_id filterten, nutzten sie nicht gut aus; es brauchte den einfachen Index dazu.

Das Admin-Panel ist der Ort, auf den das Team schaut, wenn in Produktion etwas schiefgeht. Dass ausgerechnet dieser Teil des Systems das Produktionsvolumen nicht aushält, ist eine teure Ironie.