perf: Checkout haelt gleichzeitigem Andrang stand

Ausgangsfrage: Was passiert, wenn 20 oder 100 Leute zugleich bestellen?
20 waren unproblematisch, 100 nicht. Drei Engpaesse, alle im Checkout.

1. Serialisierungskonflikte landeten ungefiltert bei der Kundschaft

Die Bestellanlage laeuft unter "Serializable". Bricht die Datenbank eine
von zwei widerspruechlichen Transaktionen ab (P2034), ist das die
VORGESEHENE Antwort auf Gleichzeitigkeit und kein Sachfehler — erwartet
wird, dass der Aufrufer es noch einmal versucht. Genau das fehlte: der
Abbruch wurde direkt als 409 durchgereicht ("bitte in ein paar Sekunden
erneut versuchen"). Weil alle Bestellungen desselben Produkts auf
dieselbe Lagerbestandszeile zugreifen, sind solche Konflikte bei Andrang
der Normalfall — ein Teil der Kundschaft haette ausgerechnet im Moment
der Kaufentscheidung eine Fehlermeldung bekommen.

Jetzt bis zu vier Versuche mit wachsender, zufaellig gestreuter
Wartezeit. Ohne den Zufallsanteil starten alle abgebrochenen Anfragen
gleichzeitig neu und kollidieren wieder. Wiederholt wird ausschliesslich
dieser Fehlerfall: "ausverkauft" und "Gutschein aufgebraucht" sind
Sachentscheidungen und faenden beim zweiten Versuch dasselbe vor.

2. Zu enger Verbindungspool, zu knappe Transaktionsfristen

Der Postgres-Adapter lief mit der Voreinstellung von node-postgres: 10
Verbindungen. Eine Bestellung haelt ihre Verbindung ueber die gesamte
Transaktion, und Prisma gibt einer Transaktion nur 2 Sekunden, um
ueberhaupt eine zu bekommen (maxWait), sowie 5 Sekunden zum Ausfuehren.
Sind alle zehn belegt, scheitern weitere Checkouts nicht an der Sache,
sondern am Warten.

Pool auf 20 (PostgreSQL erlaubt voreingestellt 100 Verbindungen, daneben
brauchen CronJob, Sicherung und Konsole noch welche; die Anwendung laeuft
mit einer Instanz). maxWait 10s, timeout 15s — Puffer fuer die
Warteschlange, nicht fuer lange Rechnungen. Dazu eine Zeitgrenze beim
Holen einer Verbindung, damit sich unter Last nichts mehr lautlos
aufstaut.

Der MariaDB-Zweig bleibt unveraendert: sein Adapter nimmt entweder die
Verbindungszeichenkette ODER ein Einstellungsobjekt, nicht beides.
Produktiv laeuft PostgreSQL.

3. Drei Regressionstests

Wiederholung fuehrt zum Erfolg, nach vier Versuchen wird aufgegeben, und
bei "ausverkauft" wird NICHT wiederholt.

tsc: 0 Fehler. vitest: 336/336 gruen (zuvor 333). eslint: 0 Warnungen.
"next build" laeuft durch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-19 06:10:41 +02:00
1 parent cbc81caa1a
commit 9b10a9f0c5
3 files changed
+187 -9

No files matched your search

+53
View File
@@ -10,6 +10,7 @@
* ohne dass irgendwo ein Fehler sichtbar wurde. * ohne dass irgendwo ein Fehler sichtbar wurde.
*/ */
import { describe, test, expect, beforeEach, vi } from "vitest"; import { describe, test, expect, beforeEach, vi } from "vitest";
import { Prisma } from "@/generated/prisma/client";
const sessionsCreate = vi.fn(); const sessionsCreate = vi.fn();
const sessionsExpire = vi.fn(); const sessionsExpire = vi.fn();
@@ -215,6 +216,58 @@ describe("Checkout-Route", () => {
}); });
}); });
describe("Wenn viele gleichzeitig bestellen", () => {
/**
* Bei "Serializable" bricht die Datenbank eine von zwei widerspruechlichen
* Transaktionen ab (P2034). Das ist die vorgesehene Antwort auf
* Gleichzeitigkeit, kein Sachfehler — und weil alle Bestellungen desselben
* Produkts auf dieselbe Bestandszeile zugreifen, ist das bei Andrang der
* Normalfall. Frueher landete jeder dieser Abbrueche als Fehlermeldung bei
* der Kundschaft.
*/
function serializationConflict(): Error {
return new Prisma.PrismaClientKnownRequestError("could not serialize access", {
code: "P2034",
clientVersion: "7",
});
}
test("wiederholt die Bestellanlage und kommt durch", async () => {
const runTransaction = async (arg: unknown) =>
typeof arg === "function" ? (arg as (tx: typeof db) => unknown)(db) : arg;
db.$transaction
.mockRejectedValueOnce(serializationConflict())
.mockRejectedValueOnce(serializationConflict())
.mockImplementation(runTransaction);
const response = await post();
expect(response.status).toBe(200);
expect(db.$transaction).toHaveBeenCalledTimes(3);
expect(sessionsCreate).toHaveBeenCalledOnce();
});
test("gibt nach vier vergeblichen Versuchen auf, statt endlos zu wiederholen", async () => {
db.$transaction.mockRejectedValue(serializationConflict());
const response = await post();
expect(response.status).toBe(409);
expect(db.$transaction).toHaveBeenCalledTimes(4);
});
test("wiederholt NICHT, wenn die Ware schlicht ausverkauft ist", async () => {
// Eine Sachentscheidung faende beim zweiten Versuch dasselbe Ergebnis
// vor — die Wiederholung wuerde die Antwort nur verzoegern.
db.inventory.updateMany.mockResolvedValue({ count: 0 });
const response = await post();
expect(response.status).toBe(409);
expect(db.$transaction).toHaveBeenCalledOnce();
});
});
describe("Wenn Stripe nicht mitspielt", () => { describe("Wenn Stripe nicht mitspielt", () => {
test("loest die Bestellung auf und meldet ehrlich, dass nichts abgebucht wurde", async () => { test("loest die Bestellung auf und meldet ehrlich, dass nichts abgebucht wurde", async () => {
// Regression: frueher blieb die Bestellung als "NEU" ohne Sitzungs-ID // Regression: frueher blieb die Bestellung als "NEU" ohne Sitzungs-ID
+94 -8
View File
@@ -31,6 +31,42 @@ class DiscountCodeExhaustedError extends Error {}
// ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall. // ist nur die freundlichere Fehlermeldung fuer den haeufigeren Fall.
const SERIALIZATION_CONFLICT_CODE = "P2034"; const SERIALIZATION_CONFLICT_CODE = "P2034";
/**
* Wie oft eine an einem Serialisierungskonflikt gescheiterte Bestellanlage
* wiederholt wird, bevor die Kundschaft einen Fehler sieht.
*
* Ein solcher Konflikt ist KEIN Fehler, sondern die vorgesehene Antwort der
* Datenbank auf zwei gleichzeitige Transaktionen, die sich widersprechen
* koennten — bei "Serializable" bricht sie eine davon ab, damit nicht beide
* auf veralteten Staenden weiterrechnen. Erwartet wird, dass der Aufrufer es
* einfach noch einmal versucht.
*
* Genau das fehlte: der Abbruch wurde direkt als 409 an die Kundschaft
* durchgereicht ("bitte in ein paar Sekunden erneut versuchen"). Bei wenigen
* Bestellungen faellt das nie auf, bei 100 gleichzeitigen schon — alle
* greifen auf dieselbe Lagerbestandszeile zu, Konflikte sind dann der
* Normalfall und nicht die Ausnahme. Ein Teil der Kundschaft bekaeme
* ausgerechnet im Moment der Kaufentscheidung eine Fehlermeldung.
*
* Vier Versuche, weil jeder die Transaktion vollstaendig neu ausfuehrt
* (inklusive frischer Bestandspruefung) und viermal hintereinander zu
* verlieren unwahrscheinlich ist. Bleibt es dabei, ist der Andrang so gross,
* dass "gleich nochmal" die ehrliche Antwort ist.
*/
const SERIALIZATION_MAX_ATTEMPTS = 4;
/**
* Wartezeit zwischen zwei Versuchen, mit Zufallsanteil.
*
* Ohne den Zufallsanteil starten alle gleichzeitig abgebrochenen Anfragen
* ihren naechsten Versuch im selben Moment und kollidieren erneut — dasselbe
* Problem, nur verschoben.
*/
function serializationBackoffMs(attempt: number): number {
const base = 25 * 2 ** (attempt - 1);
return base + Math.random() * base;
}
/** /**
* Lebensdauer der Stripe-Sitzung. Stripe laesst zwischen 30 Minuten und 24 * Lebensdauer der Stripe-Sitzung. Stripe laesst zwischen 30 Minuten und 24
* Stunden zu und nimmt ohne Angabe die 24 Stunden. * Stunden zu und nimmt ohne Angabe die 24 Stunden.
@@ -49,6 +85,37 @@ const SERIALIZATION_CONFLICT_CODE = "P2034";
*/ */
const CHECKOUT_SESSION_TTL_MINUTES = 32; const CHECKOUT_SESSION_TTL_MINUTES = 32;
/**
* Fuehrt die Bestellanlage aus und wiederholt sie, wenn die Datenbank sie
* wegen eines Serialisierungskonflikts abgebrochen hat.
*
* Wiederholt wird AUSSCHLIESSLICH dieser eine Fehlerfall. "Ausverkauft" und
* "Gutschein aufgebraucht" sind Sachentscheidungen — sie faenden beim zweiten
* Versuch dasselbe Ergebnis vor, und eine Wiederholung wuerde die Antwort nur
* verzoegern.
*/
async function withSerializationRetry<T>(run: () => Promise<T>): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await run();
} catch (error: unknown) {
const isConflict =
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === SERIALIZATION_CONFLICT_CODE;
if (!isConflict || attempt >= SERIALIZATION_MAX_ATTEMPTS) throw error;
// Auf "info" statt "error": das ist der vorgesehene Ablauf, kein
// Stoerfall. Sichtbar bleibt es trotzdem, weil eine Haeufung hier das
// erste Anzeichen dafuer waere, dass der Andrang die Datenbank an ihre
// Grenze bringt.
console.info(
`[checkout] Serialisierungskonflikt bei der Bestellanlage (Versuch ${attempt} von ${SERIALIZATION_MAX_ATTEMPTS}), wird wiederholt.`
);
await new Promise((resolve) => setTimeout(resolve, serializationBackoffMs(attempt)));
}
}
}
function generateOrderNumber(): string { function generateOrderNumber(): string {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
// Kryptografisch zufaellig statt Math.random(): nicht erratbar und // Kryptografisch zufaellig statt Math.random(): nicht erratbar und
@@ -263,13 +330,17 @@ export async function POST(request: Request) {
// Leute abbrechen. // Leute abbrechen.
const cancelToken = randomBytes(32).toString("hex"); const cancelToken = randomBytes(32).toString("hex");
let order; // Bestellanlage und Bestandsreservierung atomar: das bedingte updateMany
try { // (quantity >= bestellt) verhindert Ueberverkauf auch bei gleichzeitigen
// Bestellanlage und Bestandsreservierung atomar: das bedingte // Bestellungen — schlaegt eine Position fehl, wird die gesamte Transaktion
// updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei // (inkl. bereits reservierter Positionen) verworfen.
// gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die //
// gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen. // Als eigene Funktion, damit sie bei einem Serialisierungskonflikt
order = await prisma.$transaction( // vollstaendig wiederholt werden kann (siehe SERIALIZATION_MAX_ATTEMPTS).
// Jeder Versuch liest den Bestand frisch — eine Wiederholung kann also
// durchaus zu "ausverkauft" fuehren, und das ist dann auch richtig so.
const runOrderTransaction = () =>
prisma.$transaction(
async (tx) => { async (tx) => {
for (const item of orderItemsData) { for (const item of orderItemsData) {
const reserved = await tx.inventory.updateMany({ const reserved = await tx.inventory.updateMany({
@@ -340,8 +411,23 @@ export async function POST(request: Request) {
// gleichzeitigen Transaktionen, die beide denselben knapp begrenzten // gleichzeitigen Transaktionen, die beide denselben knapp begrenzten
// Gutscheincode einloesen wollen, und bricht eine davon zuverlaessig ab // Gutscheincode einloesen wollen, und bricht eine davon zuverlaessig ab
// (statt beide auf Basis veralteter Zaehlerstaende durchzulassen). // (statt beide auf Basis veralteter Zaehlerstaende durchzulassen).
{ isolationLevel: "Serializable" } {
isolationLevel: "Serializable",
// Prisma gibt einer Transaktion voreingestellt 2 Sekunden, um
// ueberhaupt eine Verbindung zu bekommen, und 5 Sekunden zum
// Ausfuehren. Bei starkem Andrang sind alle Verbindungen belegt und
// eine Bestellung scheitert dann nicht an der Sache, sondern am
// Warten — mitten in der Kaufentscheidung. Die Transaktion selbst
// bleibt kurz; die groesszuegigen Werte sind Puffer fuer die
// Warteschlange, keine Einladung, lange zu rechnen.
maxWait: 10_000,
timeout: 15_000,
}
); );
let order;
try {
order = await withSerializationRetry(runOrderTransaction);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof OutOfStockError) { if (error instanceof OutOfStockError) {
return NextResponse.json( return NextResponse.json(
+40 -1
View File
@@ -1,6 +1,32 @@
import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { PrismaMariaDb } from "@prisma/adapter-mariadb";
/**
* Gleichzeitig offene Datenbankverbindungen dieses Prozesses.
*
* Ohne Angabe nimmt node-postgres 10. Das reicht im Alltag mühelos, wird aber
* beim Andrang zur Obergrenze: eine Bestellung haelt ihre Verbindung ueber
* die gesamte Transaktion (Bestandsreservierung, Gutscheinpruefung,
* Bestellanlage), und Prisma gibt einer Transaktion nur begrenzt Zeit,
* ueberhaupt eine Verbindung zu bekommen (maxWait). Sind alle zehn belegt,
* scheitern weitere Checkouts nicht an der Sache, sondern am Warten.
*
* 20 ist bewusst moderat: PostgreSQL erlaubt in der Voreinstellung 100
* gleichzeitige Verbindungen, und daneben brauchen noch der Aufraeum-CronJob,
* die Sicherung und gelegentlich eine Konsole welche. Die Anwendung laeuft
* ausserdem mit einer einzigen Instanz (siehe k8s/05-app-deployment.yaml) —
* bei mehreren Instanzen multipliziert sich dieser Wert und will neu bedacht
* werden.
*/
const POOL_SIZE = 20;
/**
* Wie lange eine Anfrage auf eine freie Verbindung wartet, bevor sie
* aufgibt. Lieber ein klarer Fehler nach zehn Sekunden als eine Seite, die
* ewig laedt — und im Log ein Hinweis darauf, dass der Pool zu klein ist.
*/
const POOL_ACQUIRE_TIMEOUT_MS = 10_000;
/** /**
* Prisma 7 liest die Verbindung nicht mehr aus dem Schema, sondern erfordert * Prisma 7 liest die Verbindung nicht mehr aus dem Schema, sondern erfordert
* einen expliziten Treiber-Adapter im Client — welcher, haengt von * einen expliziten Treiber-Adapter im Client — welcher, haengt von
@@ -16,7 +42,20 @@ export function createDbAdapter(): PrismaPg | PrismaMariaDb {
if (!connectionString) throw new Error("DATABASE_URL ist nicht gesetzt."); if (!connectionString) throw new Error("DATABASE_URL ist nicht gesetzt.");
if (provider === "mysql") { if (provider === "mysql") {
// Bewusst unveraendert die Verbindungszeichenkette: der MariaDB-Adapter
// nimmt entweder sie ODER ein Einstellungsobjekt aus Host, Benutzer,
// Passwort und Datenbank — beides zugleich geht nicht, die Zeichenkette
// muesste also zerlegt werden. Der Produktivbetrieb laeuft auf
// PostgreSQL (DATABASE_PROVIDER-Vorgabe); fuer MySQL gilt weiterhin die
// Voreinstellung des Treibers.
return new PrismaMariaDb(connectionString); return new PrismaMariaDb(connectionString);
} }
return new PrismaPg({ connectionString }); return new PrismaPg({
connectionString,
max: POOL_SIZE,
// Ohne Zeitgrenze wartet eine Anfrage unbegrenzt auf eine freie
// Verbindung. Unter Andrang staut sich dann alles lautlos auf, statt dass
// irgendwo ein Fehler sichtbar wuerde.
connectionTimeoutMillis: POOL_ACQUIRE_TIMEOUT_MS,
});
} }