Prisma- und PostgreSQL-Last reduzieren, bevor Sie für eine größere Datenbank bezahlen
Fünfzehn praktische Techniken – von EXPLAIN ANALYZE und komplexen Indizes über N+1-Fixes, Zähler, Pools bis hin zu Vakuumoperationen – zur Reduzierung der Datenbankoperationen in Prisma.
Quellen: I Reduced Prisma Database Operations by 60% Without Upgrading My Database
In einer Prisma- und PostgreSQL-Anwendung wird die Datenbank oft zum ersten wirklichen Engpass. Eine Abfrage, die bei einigen hundert Zeilen in 5 ms fertig ist, kann bereits bei Millionen von Zeilen Sekunden in Anspruch nehmen – daraufhin besteht der Impuls, eine größere Instanz oder ein teureres Abo zu kaufen. Betrachten wir eine Marktplatz-App, deren Datenbankverkehr auf 5.000 bis 6.000 Operationen pro Tag ansteigt, was ausreicht, um bereits vor Ablauf des Monats die kostenfreie Nutzungsoption zu überlasten. Ein Wochenende mit Profilierung und Optimierung der Abfragen kann in einem solchen Fall die Anzahl der Operationen um mehr als 60 Prozent reduzieren, die API-Antwortzeiten beschleunigen und die Rechnung senken – ohne dass Hardware geändert werden muss.
Die unten aufgeführten Techniken, sortiert von Diagnose über Schema-Design bis hin zur Wartung, sind in der Regel diejenigen, die diesen Unterschied bewirken.
Messen, bevor Sie etwas ändern
Gehen Sie nicht davon aus, dass eine Abfrage langsam ist – messen Sie sie. Zwei Tools decken den größten Teil der Anforderungen ab:
EXPLAIN ANALYZEzeigt den Ausführungsplan von PostgreSQL zusammen mit der Anzahl der durchgescannten Zeilen, ob Indizes verwendet wurden, sowie die tatsächliche Laufzeit.- Das Abfragen-Logging von Prisma gibt den genauen SQL-Code wieder, den der Client erzeugt.
Die Ausführung von EXPLAIN ANALYZE auf einem Filter nach Verkäufer sieht so aus. Beachten Sie, dass ANALYZE den Befehl tatsächlich ausführt, weshalb Datenändernde Abfragen in einer Transaktion eingebettet werden sollten, die anschließend rückgängig gemacht wird.
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE seller_id = 123;
Auch eine ansprechend formatierte Prisma-Aufruf kann immer noch einen sequenziellen Scan der gesamten Tabelle verursachen; nur der Ausführungsplan gibt Aufschluss. pg_stat_statements (das Abfragen nach Gesamtlaufzeit sortiert), Prismas Abfrageanalysen sowie Dashboards wie das von Vercel helfen dabei, zu entscheiden, was zuerst behoben werden sollte. Für einen strukturierten Ansatz siehe die Ermittlung des eigentlichen Engpasses in einem langsamen Node.js-Endpunkt.
Indexieren Sie die tatsächlich ausgeführten Abfragen
Indizes sind in der Regel die maßgeblichste Änderung. Ohne einen solchen Index kann eine Abfrage wie diese dazu führen, dass PostgreSQL jede Zeile in orders überprüft:
SELECT *
FROM orders
WHERE seller_id = 123;
Ein einfacher B-Tree-Index auf der gefilterten Spalte ermöglicht es dem Planer, direkt zu den passenden Zeilen überzugehen:
CREATE INDEX idx_orders_seller
ON orders(seller_id);
Wenn die Anwendung regelmäßig mehrere Spalten gemeinsam filtert, dient ein komplexer Index dieser Kombination direkt:
CREATE INDEX idx_orders_seller_status
ON orders(seller_id, status);
Dadurch wird eine Bedingung sowohl bezüglich des Verkäufers als auch des Status zu einer präzisen Indexsuche:
WHERE seller_id = 123
AND status = 'PAID'
Die Reihenfolge der Spalten ist in einem kompositen Index wichtig. (seller_id, status) hilft auch bei Abfragen, die nur nach seller_id filtern, nicht jedoch bei Abfragen, die nur nach status filtern. Die Faustregel lautet: Indizieren Sie nur die Spalten, nach denen Sie tatsächlich filtern, sortieren oder verknüpfen – nicht alles; jeder Index kostet Speicherplatz und verlangsamt jede Schreiboperation in der Tabelle.
N+1-Abfragen beseitigen
Muster N+1 ist ein klassisches Problem bei ORMs. Sie laden eine Liste mit 100 Bestellungen:
const orders = await prisma.order.findMany();
und holen anschließend innerhalb einer Schleife nacheinander den Benutzer jeder Bestellung ab:
for (const order of orders) {
await prisma.user.findUnique({
where: {
id: order.userId
}
});
}
Das sind 101 Abfragen für eine Seite Daten. Wenn Sie Prisma bitten, die Beziehung zusammen mit den Bestellungen zu laden, entfällt die Schleife:
const orders = await prisma.order.findMany({
include: {
user: true
}
});
Ein einziger Aufruf ersetzt die Schleife und führt dabei zu deutlich geringerer Latenz sowie weniger Belastung. Je nach Version und Konfiguration führt Prisma den include-Befehl entweder als Join oder als eine zusätzliche gebündelte Abfrage aus; in jedem Fall entfallen die Hin- und Rückreisen pro Zeile.
Wählen Sie nur die benötigten Spalten aus
Die Verringerung der übertragenen Datenmenge ist einer der kostengünstigsten Ansätze. Ein einfacher findMany-Aufruf gibt jede Spalte jeder Zeile zurück:
await prisma.user.findMany();
Ein select-Befehl beschränkt das Ergebnis auf das, was der Aufrufer tatsächlich benötigt:
await prisma.user.findMany({
select: {
id: true,
name: true
}
});
Wenn in der Tabelle JSON-Blöcke, Markdown, Bilder oder langer Text enthalten sind, kann dies allein die Antwortzeiten erheblich verkürzen und gleichzeitig sensible Spalten aus den API-Antworten ausschließen.
Ersetzen Sie wiederholte Aggregationen durch gespeicherte Zähler
Analyse-Dashboards berechnen oft die Aggregationen bei jedem Seitenaufruf erneut:
COUNT(*)
SUM(amount)
AVG(...)
Das ist bei 100 Zeilen in Ordnung, aber bei Millionen problematisch. Beispielsweise bedeutet die Berechnung des Gesamtumsatzes bei jeder Anfrage, dass die gesamte Tabelle summiert werden muss:
SELECT SUM(amount)
FROM orders;
Die Alternative besteht darin, die Aggregatwerte zum Zeitpunkt der Schreibvorgänge auf dem neuesten Stand zu halten, indem Werte wie Gesamtbeträge direkt im Benutzerdatensatz gespeichert werden:
User
-------------
totalSales
totalOrders
totalRevenue
aktualisieren Sie diese Felder jedes Mal, wenn ein Kauf abgeschlossen wird, sodass die Lesevorgänge zu O(1)-Suchen statt zu Scans durch Millionen von Zeilen werden; im oben beschriebenen Szenario war dies eine der größten Einsparungen. Aktualisieren Sie Zähler atomar (zum Beispiel mit Prismas increment im selben Transaktionsablauf wie das Einfügen der Bestellung) und abgleichen Sie sie gelegentlich, da denormalisierte Gesamtbeträge abweichen können.
Paginieren Sie jede Liste
Tabellen bleiben nicht klein, daher geben Sie niemals eine unbegrenzte Liste wie diese zurück:
await prisma.product.findMany();
Verwenden Sie take und skip, um eine begrenzte Anzahl von Zeilen abzurufen; dieses Beispiel gibt die zweite Seite von insgesamt 20 zurück:
await prisma.product.findMany({
take: 20,
skip: 20
});
Bei großen Datensätzen oder Endlosscrolling ist Cursor-Paginierung vorzuziehen: Ein Offset lässt PostgreSQL jede übersprungene Zeile überspringen, während ein Cursor auf einer indizierten Spalte genau dort weitermacht, wo die letzte Seite endete.
Caching von selten verändernden Lesevorgängen
Die schnellste Abfrage ist die, die gar nicht ausgeführt wird. Daten, die nur selten geändert werden, eignen sich hervorragend für das Caching: Produktseiten, öffentliche Shops, Anwendungseinstellungen und Kategorielisten. Häufige Optionen sind Upstash Redis, das Next.js-Caching sowie ein in-prozessueller LRU-Cache. Das Herausnehmen häufig genutzter Lesevorgänge aus PostgreSQL verringert den Druck erheblich; der Preis dafür ist die Invalidierung der Daten, daher muss man entscheiden, wie veraltet die Daten sein dürfen, und sie erneut validieren, sobald sich Zeilen ändern.
Batch-Schreibvorgänge anstelle von Schleifen
Jeder wartende Aufruf ist eine Netzwerk-Rundreise, und diese summieren sich schnell. Das Einfügen von Datensätzen nacheinander in einer Schleife ist ein klassisches Beispiel:
for (const item of items) {
await prisma.item.create({
data: item
});
}
createMany fügt den gesamten Array in einer einzigen Anweisung ein:
await prisma.item.createMany({
data: items
});
Eine Rundreise ist besser als hundert. Beachten Sie, dass createMany keine verschachtelten Beziehungen erstellt und eine Anzahl anstelle der Zeilen zurückgibt; prüfen Sie die Dokumentation Ihrer Prisma-Version, falls Sie die Datensätze zurückhaben möchten.
Verwandte Schreibvorgänge in einer Transaktion gruppieren
Die Erstellung einer Bestellung beinhaltet oft weitere Schreibvorgänge, wie die Anpassung des Inventars, das Aktualisieren von Kontoständen und die Erstellung einer Quittung. Diese Änderungen gehören zusammen, und $transaction stellt sicher, dass entweder alle erfolgreich sind oder alle rückgängig gemacht werden:
await prisma.$transaction([
prisma.order.create(...),
prisma.user.update(...),
prisma.balance.update(...)
]);
Der eigentliche Vorteil liegt in der Konsistenz statt in der Geschwindigkeit: Ohne Aktualisierung des Gleichgewichts kann kein Ablauf fortgesetzt werden, wenn etwas auf halbem Weg fehlschlägt. Halten Sie Transaktionen kurz, da sie Sperrmechanismen sowie eine Verbindung beinhalten.
Wiederholte Verwendung eines einzigen Prisma Clients
Ein neuer PrismaClient pro Anfrage ist aufwändig und kann die Verbindungen erschöpfen. Zudem kann das Hot-Reloading in der Entwicklung bei jeder Dateiveränderung einen neuen Client erzeugen. Ein Singleton vermeidet beides. Importieren Sie den Client:
import { PrismaClient } from "@prisma/client";
Speichern Sie anschließend die Instanz außerhalb der Produktivumgebung in globalThis, damit neu geladene Module den bereits vorhandenen Client verwenden und keinen weiteren erstellen:
const globalForPrisma =
globalThis as unknown as {
prisma: PrismaClient | undefined;
};export const prisma =
globalForPrisma.prisma ??
new PrismaClient();if (process.env.NODE_ENV !== "production")
globalForPrisma.prisma = prisma;
Durch den as unknown as-Cast erhält globalThis eine typisierte prisma-Eigenschaft; in der Produktivumgebung wird das Modul nur einmal geladen, weshalb kein globaler Cache erforderlich ist.
Poolen Sie Verbindungen, insbesondere auf serverless-Plattformen
Serverless-Plattformen können Tausende von gleichzeitigen Funktionsinstanzen starten, wobei jede eigene Datenbankverbindung benötigt. Ohne Pools kann PostgreSQL bereits lange bevor das CPU-Problem auftritt, an Verbindungen scheitern. Sowohl Prisma Accelerate als auch PgBouncer befinden sich zwischen der Anwendung und der Datenbank und ermöglichen es vielen Anfragen, sich eine deutlich kleinere Anzahl echter PostgreSQL-Verbindungen zu teilen.
Indexieren Sie die JSONB-Felder, nach denen Sie filtern
Der JSONB-Typ von PostgreSQL ist sehr leistungsstark, doch das Filtern auf verschachtelten Feldern ohne Index bedeutet in der Regel einen vollständigen Tabellenscan. Wenn Sie regelmäßig in JSON-Dokumenten abfragen, fügen Sie einen GIN-Index auf die Spalte hinzu (oder einen Expressionsindex für eine bestimmte Schlüssel, nach der Sie häufig filtern), um langsame Dokumentsuchen in indizierte Abfragen zu verwandeln.
Nutzen Sie lieber findUnique für eindeutige Abfragen
Wenn Sie nach der Primärschlüssel oder einem anderen eindeutigen Feld abfragen, verwenden Sie diese Methode:
findUnique()
Dies teilt Prisma mit, dass die Abfrage auf eine Zeile abzielt, wodurch Prisma mehrere findUnique-Aufrufe aus derselben Aktion in eine einzige Abfrage zusammenfassen kann, was in GraphQL-Resolvern nützlich ist. Zum Beispiel:
await prisma.user.findUnique({
where: {
id
}
});
Wählen Sie diese Variante anstelle der folgenden, sobald das Feld eindeutig ist:
findFirst()
findFirst bleibt die richtige Wahl für nicht-eindeutige Filter, bei denen „die erste passende Zeile“ genau das ist, was Sie benötigen.
Archivieren Sie alte Zeilen anstelle eines dauerhaften Soft-Deletings
Soft-Deletings sind praktisch; Sie markieren einfach die Zeile:
isDeleted = true
Doch im Laufe der Jahre muss jede Abfrage gelöschte Zeilen ausschließen, wodurch Tabellen und Indizes an Umfang zunehmen. In Systemen mit hohem Datenvolumen sollten gelöschte Einträge regelmäßig in Archivtabellen verschoben werden: kleinere Tabellen bedeuten kleinere Indizes und schnellere Abfragen. Ein Teilindex für nicht-gelöschte Zeilen stellt eine Mittelstellung dar.
Halten Sie den Autovacuum in Ordnung
PostgreSQL verwendet MVCC (Multi-Version-Concurrency-Kontrolle), wodurch aktualisierte und gelöschte Zeilen nicht sofort entfernt werden; sie bleiben als „tote“ Tupel bestehen. Autovacuum ist der Hintergrundprozess, der sie bereinigt. Ein gut abgestimmter Autovacuum:
- bekommt den von „toten“ Tupeln belegten Speicher zurück;
- erneuert die Statistiken des Planers;
- hält Indizes effizient;
- sichert präzise Abfragenachweise.
Wenn man ihn vernachlässigt, entstehen Überlastungen und schlechte Abfragenpläne, insbesondere bei stark aktualisierten Tabellen wie den oben genannten Zählern.
Kernpunkte
Die meisten Leistungsprobleme von Datenbanken lassen sich nicht durch größere Server lösen; sie werden durch bessere Muster des Datenzugriffs behoben. Im obigen Szenario blieben Anbieter und Datenbankgröße unverändert. Die Verbesserungen stammten von:
- der Analyse teurer Abfragen anstelle von Vermutungen;
Zusammen führen sie zu mehr als 60 Prozent weniger Operationen, schnelleren APIs und der Möglichkeit, viel mehr Traffic zu verarbeiten. Bevor Sie skalieren, erfahren Sie, wie Ihre Datenbank tatsächlich genutzt wird; bestehende Abfragen verbergen oft den größten Teil der Leistung, die Sie benötigen.