9. Integration in Webanwendungen
Integration in Webanwendungen
Abschnitt betitelt „Integration in Webanwendungen“Der Zugriff auf eine Datenbank über rohes SQL und Low-Level-Treiber ist bereits bekannt. In echten Webanwendungen schreiben Entwicklerinnen aber selten rohes SQL von Hand. Stattdessen nutzen sie Object-Relational Mapper (ORMs), die die Lücke zwischen der relationalen Welt von SQL und der objektorientierten Welt der Programmiersprache überbrücken.
Zwei konkrete ORMs begleiten dieses Kapitel nebeneinander:
- Prisma — ein modernes ORM für Node.js / TypeScript, die Standardwahl in Next.js-Anwendungen.
- Eloquent — das ORM des Laravel-PHP-Frameworks; das am weitesten verbreitete PHP-ORM.
Beide lösen dieselben Probleme, treffen aber unterschiedliche Entwurfsentscheidungen, besonders dabei, wie Modelle mit der Datenbank zusammenhängen. Beide im Blick zu behalten macht die zugrunde liegenden Konzepte viel klarer, als eines allein zu betrachten.
1. Was ist ein ORM?
Abschnitt betitelt „1. Was ist ein ORM?“Ein ORM (Object-Relational Mapper) ist eine Bibliothek, mit der man über die Objekte und Typen der Programmiersprache mit einer relationalen Datenbank arbeitet, ohne SQL von Hand zu schreiben.
Statt so:
SELECT * FROM users WHERE id = 42;schreibt man:
const user = await prisma.user.findUnique({ where: { id: 42 } });$user = User::find(42);Das ORM übersetzt den Code in das passende SQL, schickt es an die Datenbank und bildet die Ergebniszeilen zurück auf typisierte Objekte.
1.1 Warum ein ORM?
Abschnitt betitelt „1.1 Warum ein ORM?“| Ohne ORM | Mit ORM | |
|---|---|---|
| Abfragesprache | rohe SQL-Strings | sprachnative Methodenaufrufe — lesbar, leicht zu refaktorieren |
| Ergebniszuordnung | manuell, Spalte für Spalte | automatisch auf typisierte Objekte |
| Fehler-Feedback | SQL-Fehler erst zur Laufzeit | Typ-/Syntaxfehler früher erkannt |
| Schema-Verfolgung | manuelles ALTER TABLE | versionierte Migrationsdateien, in git eingecheckt |
| Datenbank-Portabilität | DB-spezifische Syntax je Hersteller | DB durch eine Konfigurationszeile wechseln |
| Performance-Kontrolle | voll — man schreibt genau, was läuft | teilweise — das ORM erzeugt SQL; für langsame Abfragen prüfen |
| Komplexe Abfragen | unkompliziert | erfordert manchmal den Rückgriff auf rohes SQL |
| N+1-Risiko | gering — im Code explizit | höher — in Schleifen leicht versehentlich ausgelöst |
1.2 ORM-Einrichtung
Abschnitt betitelt „1.2 ORM-Einrichtung“npm install prisma @prisma/clientnpx prisma initDas erzeugt eine Datei prisma/schema.prisma, in der die Datenmodelle in Prismas eigener Schemasprache definiert werden:
datasource db { provider = "postgresql" url = env("DATABASE_URL")}
generator client { provider = "prisma-client-js"}
model User { id Int @id @default(autoincrement()) email String @unique name String posts Post[] createdAt DateTime @default(now())}
model Post { id Int @id @default(autoincrement()) title String content String? published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId Int}Der @relation-Dekorator sagt Prisma, dass Post.authorId ein Fremdschlüssel auf User.id ist. Prisma nutzt das, um typisierte Abfragemethoden zu erzeugen und die referentielle Integrität zu erzwingen.
Laravel bringt Eloquent bereits mit. Modelle sind einfache PHP-Klassen, die Model erweitern:
<?phpclass User extends Model{ protected $fillable = ['name', 'email'];
public function posts() { return $this->hasMany(Post::class); }}<?phpclass Post extends Model{ protected $fillable = ['title', 'content', 'published', 'user_id'];
public function author() { return $this->belongsTo(User::class, 'user_id'); }}Eloquent leitet den Tabellennamen (users, posts) und den Primärschlüssel (id) per Konvention ab. Beziehungen werden als Methoden deklariert, die hasMany, belongsTo usw. zurückgeben.
1.3 Wenn das ORM nicht ausreicht
Abschnitt betitelt „1.3 Wenn das ORM nicht ausreicht“ORMs decken die Alltagsfälle gut ab — einfaches CRUD, Filtern, Sortieren, Laden verwandter Daten. Es gibt aber Situationen, in denen das vom ORM erzeugte SQL entweder zu eingeschränkt oder zu langsam ist und man SQL direkt schreiben muss.
Typische Szenarien, in denen rohes SQL die bessere Wahl ist:
- Fensterfunktionen (
RANK(),LAG(),LEAD(), laufende Summen) — die meisten ORMs haben dafür keine API. - Rekursive Abfragen (
WITH RECURSIVE) für Baumstrukturen wie Kategorien, Organigramme oder Thread-Hierarchien. - Datenbankspezifische Funktionen — PostgreSQLs Volltextsuche (
tsvector,@@), JSONB-Operatoren (@>,#>>) oderLATERAL-Joins werden von den meisten ORMs nicht bereitgestellt. - Stark optimierte Reporting-Abfragen — eine handgeschriebene Abfrage mit sorgfältig gesetzten Indizes, Teilaggregaten und bestimmter Join-Reihenfolge kann deutlich schneller sein als das, was das ORM erzeugt.
Prisma und Eloquent bieten für genau diese Fälle einen Ausweg zu rohem SQL:
$queryRaw liefert typisierte Ergebnisse; $executeRaw ist für Anweisungen ohne Rückgabezeilen (INSERT, UPDATE, DELETE).
import { prisma } from '@/lib/prisma';
// count posts per category with a window functionconst result = await prisma.$queryRaw< { category: string; postCount: bigint; rank: bigint }[]>` SELECT c.name AS category, COUNT(p.id) AS "postCount", RANK() OVER (ORDER BY COUNT(p.id) DESC) AS rank FROM "Category" c LEFT JOIN "Post" p ON p."categoryId" = c.id GROUP BY c.id, c.name ORDER BY rank;`;
// Parametrisiert — Prisma.sql verhindert SQL-Injectionimport { Prisma } from '@prisma/client';
const minPosts = 2;const popular = await prisma.$queryRaw<{ category: string }[]>` SELECT c.name AS category FROM "Category" c JOIN "Post" p ON p."categoryId" = c.id GROUP BY c.id, c.name HAVING COUNT(p.id) >= ${minPosts}`;DB::select() liefert ein Array einfacher Objekte; DB::statement() ist für DDL oder DML ohne Ergebnismenge.
use Illuminate\Support\Facades\DB;
// count posts per category with a window function$result = DB::select(" SELECT c.name AS category, COUNT(p.id) AS post_count, RANK() OVER (ORDER BY COUNT(p.id) DESC) AS rank FROM categories c LEFT JOIN posts p ON p.category_id = c.id GROUP BY c.id, c.name ORDER BY rank");
// Parametrisiert — immer Bindings verwenden, nie String-Verkettung$minPosts = 2;$popular = DB::select(" SELECT c.name AS category FROM categories c JOIN posts p ON p.category_id = c.id GROUP BY c.id, c.name HAVING COUNT(p.id) >= ?", [$minPosts]);2. Migrationen
Abschnitt betitelt „2. Migrationen“Eine Migration ist eine versionierte, schrittweise Änderung am Datenbankschema. Immer wenn ein Modell geändert wird (eine Spalte hinzufügen, eine Tabelle umbenennen, einen Index anlegen), entsteht eine Migrationsdatei, die diese Änderung festhält.
2.1 Warum Migrationen wichtig sind
Abschnitt betitelt „2.1 Warum Migrationen wichtig sind“Ohne Migrationen:
- Die lokale Datenbank ist mit den Datenbanken des Teams nicht synchron.
- Ein Deployment in die Produktion bedeutet,
ALTER TABLE-Befehle manuell auszuführen — fehleranfällig und nicht reproduzierbar. - Es gibt keine Historie darüber, was sich wann geändert hat.
Mit Migrationen:
- Jede Schemaänderung ist eine Datei in der Versionsverwaltung.
- Ein einziger Befehl wendet genau die richtigen Änderungen in der richtigen Reihenfolge auf jeder Umgebung an.
- Die Historie ist explizit, ein Rollback ist möglich.
2.2 Eine Migration erstellen und anwenden
Abschnitt betitelt „2.2 Eine Migration erstellen und anwenden“Zuerst schema.prisma bearbeiten — etwa ein Feld bio zu User hinzufügen:
model User { // ...vorhandene Felder... bio String?}Dann die Migration erzeugen und anwenden:
# development: create a migration file + apply it to the local DB immediatelynpx prisma migrate dev --name add_user_bio
# Produktion (CI/CD-Pipeline): ausstehende Migrationen anwendennpx prisma migrate deployPrisma erzeugt prisma/migrations/20240601120000_add_user_bio/migration.sql:
ALTER TABLE "User" ADD COLUMN "bio" TEXT;Eine leere Migrationsdatei erzeugen:
php artisan make:migration add_bio_to_users_table --table=usersDie erzeugte Datei in database/migrations/ bearbeiten:
public function up(): void{ Schema::table('users', function (Blueprint $table) { $table->text('bio')->nullable(); });}
public function down(): void{ Schema::table('users', function (Blueprint $table) { $table->dropColumn('bio'); });}Die Migration anwenden:
# apply all pending migrationsphp artisan migrate
# roll back the last batchphp artisan migrate:rollbackDie Migrationsdatei wird in git eingecheckt. Jede Entwicklerin und jede Deployment-Pipeline führt dieselben Migrationen in der richtigen Reihenfolge aus.
2.3 Migrations-Workflow in der Praxis
Abschnitt betitelt „2.3 Migrations-Workflow in der Praxis“1. edit schema.prisma │ ▼2. npx prisma migrate dev --name <description> │ (generates migration SQL + applies it to the local DB) ▼3. git add prisma/migrations/... && git commit │ ▼4. CI/CD runs: npx prisma migrate deploy │ (applies it to staging / production) ▼5. Done — all environments in sync1. php artisan make:migration <name> --table=<table> │ ▼2. edit up() and down() in the migration file │ ▼3. git add database/migrations/... && git commit │ ▼4. Deployment: php artisan migrate │ (applies it to staging / production) ▼5. Done — all environments in sync3. Das N+1-Problem
Abschnitt betitelt „3. Das N+1-Problem“Das N+1-Problem ist die häufigste Performance-Falle beim Einsatz eines ORM. Es entsteht, wenn man eine Liste von Datensätzen lädt und dann für jeden einzeln die verwandten Daten nachlädt — was zu weit mehr Datenbankabfragen führt als nötig.
3.1 Das Problem verstehen
Abschnitt betitelt „3.1 Das Problem verstehen“Angenommen, eine Liste von Blog-Beiträgen soll samt Namen der jeweiligen Autorin angezeigt werden.
Naiver Ansatz — kaputt:
// 1 query: load all postsconst posts = await prisma.post.findMany();
for (const post of posts) { // N queries: one per post to load the author const author = await prisma.user.findUnique({ where: { id: post.authorId } }); console.log(`${post.title} by ${author.name}`);}// 1 query: load all posts$posts = Post::all();
foreach ($posts as $post) { // N queries: Eloquent automatically fires one query per post (lazy loading) echo $post->title . ' by ' . $post->author->name;}Bei 100 Beiträgen führen beide Beispiele 101 Abfragen aus:
SELECT * FROM posts; -- 1 querySELECT * FROM users WHERE id = 1; -- for post 1SELECT * FROM users WHERE id = 2; -- for post 2... -- 98 weitereIm Produktivmaßstab zerstört das die Performance. Das Eloquent-Beispiel ist besonders gefährlich, weil die zusätzlichen Abfragen unsichtbar sind — sie werden still ausgelöst, sobald auf $post->author zugegriffen wird.
3.2 Die Lösung: Eager Loading
Abschnitt betitelt „3.2 Die Lösung: Eager Loading“Sag dem ORM, die verwandten Daten zusammen mit den Elterndatensätzen in einem JOIN zu laden:
// 1 query: posts + authors in a single JOINconst posts = await prisma.post.findMany({ include: { author: true, // users an posts joinen },});
for (const post of posts) { // author is already loaded — no extra query console.log(`${post.title} by ${post.author.name}`);}// 1 query: posts + authors in a single JOIN$posts = Post::with('author')->get();
foreach ($posts as $post) { // author is already loaded — no extra query echo $post->title . ' by ' . $post->author->name;}Erzeugtes SQL (in beiden Fällen):
SELECT posts.*, users.id, users.name, users.emailFROM postsLEFT JOIN users ON users.id = posts.user_id;Eine Abfrage, alle Daten geholt. Das nennt man Eager Loading.
4. Lazy Loading vs. Eager Loading
Abschnitt betitelt „4. Lazy Loading vs. Eager Loading“Ladestrategien bestimmen, wann verwandte Daten aus der Datenbank geholt werden.
4.1 Lazy Loading
Abschnitt betitelt „4.1 Lazy Loading“Lazy Loading heißt, dass verwandte Daten erst geladen werden, wenn tatsächlich darauf zugegriffen wird — nicht schon beim Laden des Elterndatensatzes.
Prisma unterstützt kein automatisches Lazy Loading. Verwandte Daten werden nie geladen, außer sie werden ausdrücklich mit include oder select angefordert.
const post = await prisma.post.findUnique({ where: { id: 1 } });
console.log(post.author); // undefined — not loaded, no automatic query
// a separate query must be issued manually:const author = await prisma.user.findUnique({ where: { id: post.authorId } });Das ist eine bewusste Entwurfsentscheidung: Prisma zwingt zur Klarheit und verhindert so versehentliche N+1-Probleme.
Eloquent unterstützt automatisches Lazy Loading sehr wohl. Wird auf eine Beziehungseigenschaft zugegriffen, die noch nicht geladen ist, feuert automatisch eine Datenbankabfrage:
$post = Post::find(1);// $post->author is not loaded yet — no query so far
echo $post->author->name;// Eloquent detects that 'author' is missing and fires:// SELECT * FROM users WHERE id = ?// — automatic, transparent, but dangerous in loops (N+1)Bequem für einmaligen Zugriff, aber eine ernste Performance-Falle in Schleifen — genau das N+1-Szenario aus Abschnitt 3.
Vorteile von Lazy Loading:
- Lädt nur, worauf tatsächlich zugegriffen wird.
- Hält die anfängliche Abfrage schnell und einfach.
Nachteile von Lazy Loading:
- In einer Schleife löst es still N Abfragen aus.
- Die Abfragen sind beim Lesen des Codes unsichtbar — Performance-Probleme sind im Review schwer zu erkennen.
4.2 Eager Loading
Abschnitt betitelt „4.2 Eager Loading“Eager Loading heißt, dass verwandte Daten zusammen mit dem Elterndatensatz in derselben Abfrage geladen werden — entweder als JOIN oder als zweite gebündelte Abfrage.
const post = await prisma.post.findUnique({ where: { id: 1 }, include: { author: true },});
console.log(post.author.name); // already loaded, no extra query$post = Post::with('author')->find(1);
echo $post->author->name; // already loaded, no extra queryVorteile von Eager Loading:
- Verhindert N+1-Probleme.
- Explizit — man sieht genau, was geladen wird.
- Weniger Roundtrips zur Datenbank.
Nachteile von Eager Loading:
- Lädt eventuell mehr Daten als nötig.
- Tief verschachtelte Beziehungen können große JOIN-Abfragen erzeugen.
4.3 Selektives Laden
Abschnitt betitelt „4.3 Selektives Laden“Statt ein vollständiges verwandtes Objekt zu laden, werden nur die konkreten Felder angefordert, die wirklich gebraucht werden:
const posts = await prisma.post.findMany({ select: { title: true, author: { select: { name: true }, // only the author's name }, },});// load posts with only id and the author's name$posts = Post::with('author:id,name')->get();Erzeugtes SQL (in beiden Fällen):
SELECT posts.title, users.nameFROM postsLEFT JOIN users ON users.id = posts.user_id;Das ist der effizienteste Ansatz: genau das laden, was gebraucht wird, nicht mehr.
| Strategie | Wann verwenden |
|---|---|
| Eager Loading | wenn feststeht, dass die verwandten Daten immer gebraucht werden |
| Selektives Laden | wenn nur bestimmte Felder aus verwandten Datensätzen gebraucht werden |
| Separate Abfrage | wenn die verwandten Daten selten oder optional gebraucht werden |
5. Entwurfsmuster: Active Record vs. Repository
Abschnitt betitelt „5. Entwurfsmuster: Active Record vs. Repository“Beim Strukturieren des Datenbankzugriffs in einer Anwendung dominieren zwei Muster: Active Record und Repository. Prisma und Eloquent bauen hier auf unterschiedlichen Philosophien auf — genau deshalb lohnt es sich, beide zusammen zu betrachten.
5.1 Active-Record-Muster
Abschnitt betitelt „5.1 Active-Record-Muster“Im Active-Record-Muster ist die Modellklasse zugleich Datencontainer und verantwortlich für ihre eigene Persistenz. Das Objekt weiß, wie es sich selbst speichert, aktualisiert und löscht.
Eloquent ist eine Lehrbuch-Implementierung von Active Record. Eine Modellklasse erweitert Model und erhält sofort save(), delete(), find() und einen fließenden Query Builder — kein Repository nötig.
// Erstellen$user = new User();$user->name = 'Anna';$user->save(); // INSERT INTO users ...
// find by primary key$user = User::find(42);echo $user->name;
// Aktualisieren$user->name = 'Anna Miller';$user->save(); // UPDATE users SET name = ? WHERE id = 42
// delete$user->delete(); // DELETE FROM users WHERE id = 42
// Query Builder — fluent chaining directly on the model$activeUsers = User::where('active', true) ->orderBy('name') ->get();Dieselbe Idee manuell in TypeScript umgesetzt — zur Veranschaulichung des Musters. Prisma selbst arbeitet nicht so (siehe Abschnitt 5.2).
class User { id: number; name: string; email: string;
async save() { if (this.id) { await db.query( 'UPDATE users SET name=$1, email=$2 WHERE id=$3', [this.name, this.email, this.id] ); } else { const result = await db.query( 'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id', [this.name, this.email] ); this.id = result.rows[0].id; } }
async delete() { await db.query('DELETE FROM users WHERE id=$1', [this.id]); }
static async findById(id: number): Promise<User> { const result = await db.query('SELECT * FROM users WHERE id=$1', [id]); return Object.assign(new User(), result.rows[0]); }}Merkmale:
- Das Modell „weiß”, wie es sich persistiert.
- Einfach und intuitiv — Daten und Persistenzlogik liegen zusammen.
- Das kanonische Beispiel aus der Praxis ist Laravel Eloquent (PHP).
Nachteile:
- Geschäftslogik (was ein Objekt tut) vermischt sich mit Persistenzlogik (wie es gespeichert wird).
- Schwerer zu testen — man kann ein Modellobjekt nicht ohne echte Datenbankverbindung nutzen.
5.2 Repository-Muster
Abschnitt betitelt „5.2 Repository-Muster“Im Repository-Muster wird der Datenbankzugriff in eine eigene Repository-Klasse ausgelagert. Das Modell ist ein einfaches Datenobjekt ohne Datenbankwissen, und das Repository übernimmt alle Abfragen.
Prisma ist ein Data-Mapper-ORM — Modelle sind einfache TypeScript-Objekte ohne Datenbankmethoden. Das führt natürlich zum Repository-Muster.
// simple data object — no database logicinterface User { id?: number; name: string; email: string;}
// Repository — all database access in one placeclass UserRepository { async findById(id: number): Promise<User | null> { return prisma.user.findUnique({ where: { id } }); }
async findAll(): Promise<User[]> { return prisma.user.findMany(); }
async create(data: { name: string; email: string }): Promise<User> { return prisma.user.create({ data }); }
async update(id: number, data: Partial<User>): Promise<User> { return prisma.user.update({ where: { id }, data }); }
async delete(id: number): Promise<void> { await prisma.user.delete({ where: { id } }); }}
// Verwendung:const userRepo = new UserRepository();Eine gängige Konvention ist, Repositories in einem Verzeichnis lib/repositories/ abzulegen:
lib/ repositories/ UserRepository.ts PostRepository.ts prisma.ts ← gemeinsame Prisma-Client-InstanzEloquents Standard ist Active Record, aber viele Laravel-Projekte legen für bessere Testbarkeit und Trennung eine Repository-Schicht darüber.
class UserRepository{ public function findById(int $id): ?User { return User::find($id); }
public function findAll(): Collection { return User::all(); }
public function create(array $data): User { return User::create($data); }
public function update(int $id, array $data): bool { return User::where('id', $id)->update($data); }
public function delete(int $id): bool { return User::destroy($id) > 0; }}
// usage via dependency injection in a controller:class UserController extends Controller{ public function __construct(private UserRepository $users) {}
public function index() { return response()->json($this->users->findAll()); }}Merkmale:
- Klare Trennung zwischen Datenform und Datenzugriff.
- Leicht zu testen: Für Unit-Tests ersetzt man das echte Repository durch ein Fake — keine Datenbank nötig.
- Ein Wechsel des zugrunde liegenden ORM erfordert nur eine Änderung im Repository, nicht im Rest der Anwendung.
5.3 Active Record vs. Repository auf einen Blick
Abschnitt betitelt „5.3 Active Record vs. Repository auf einen Blick“| Active Record | Repository | |
|---|---|---|
| Wo liegt die DB-Logik? | in der Modellklasse | in einer separaten Repository-Klasse |
| Weiß das Modell von der DB? | ja | nein |
| Testbarkeit | schwerer — braucht eine echte DB | leicht — Repository kann gemockt werden |
| Am besten für | kleine Apps, einfaches CRUD | größere Apps, komplexe Geschäftslogik |
| Hauptbeispiel | Eloquent (Laravel / PHP) | Prisma (Next.js / TypeScript) |
6. Datenbankverbindungen und Connection Pooling
Abschnitt betitelt „6. Datenbankverbindungen und Connection Pooling“Jede ORM-Abfrage braucht eine Datenbankverbindung — einen dauerhaften TCP-Kanal zwischen Anwendung und Datenbankserver. Wie diese Verbindungen verwaltet werden, wirkt sich direkt auf die Performance aus, besonders unter Last.
6.1 Die Kosten einer Datenbankverbindung
Abschnitt betitelt „6.1 Die Kosten einer Datenbankverbindung“Eine neue Verbindung zu öffnen ist teuer:
- Der Client führt einen TCP-Handshake mit dem Server durch.
- PostgreSQL authentifiziert den Benutzer und forkt einen eigenen Backend-Prozess für die Verbindung.
- Das dauert grob 20–100 ms und belegt serverseitig mehrere Megabyte RAM.
Datenbanken haben außerdem eine harte Grenze für die Zahl gleichzeitiger Verbindungen (in PostgreSQL standardmäßig max_connections = 100). Öffnet jede eingehende Web-Anfrage ihre eigene Verbindung, ist die Grenze schnell erreicht:
100 concurrent users → 100 open connections (already at PostgreSQL's default limit) → 2–5 MB RAM each (200–500 MB just for connections) → 50 ms connection overhead per request6.2 Wie ein Connection Pool funktioniert
Abschnitt betitelt „6.2 Wie ein Connection Pool funktioniert“Ein Connection Pool ist ein Zwischenspeicher vorab geöffneter Datenbankverbindungen, die Anfragen ausleihen und zurückgeben:
Without a pool: request 1 ──► open connection ──► query ──► close (+50 ms overhead) request 2 ──► open connection ──► query ──► close (+50 ms overhead) request 3 ──► open connection ──► query ──► close (+50 ms overhead)
With a pool (pool size = 5): ┌─────────────────────────────────────┐ │ Connection Pool │ │ conn-1 ──────────────────────────► PostgreSQL │ conn-2 ──────────────────────────► PostgreSQL │ conn-3 ──────────────────────────► PostgreSQL │ conn-4 (free, ready) │ │ conn-5 (free, ready) │ └─────────────────────────────────────┘ request 1 ──► borrow conn-1 ──► query ──► return conn-1 request 2 ──► borrow conn-2 ──► query ──► return conn-2 request 3 ──► borrow conn-1 ──► query ──► return conn-1 (reused!)Wichtiges Verhalten:
- Ist eine freie Verbindung verfügbar, wird sie sofort zugewiesen — kein Neuverbindungs-Overhead.
- Sind alle Verbindungen belegt, wartet die Anfrage bis zu einem konfigurierbaren Timeout und scheitert dann mit einem Fehler.
- Der Pool hält Verbindungen am Leben und verwendet sie über viele Anfragen hinweg wieder.
6.3 Konfiguration
Abschnitt betitelt „6.3 Konfiguration“Prisma verwaltet je PrismaClient-Instanz einen eigenen Connection Pool. Poolgröße und Timeout werden in der DATABASE_URL gesetzt:
DATABASE_URL="postgresql://user:pass@host:5432/mydb?connection_limit=10&pool_timeout=10"| Parameter | Standard | Bedeutung |
|---|---|---|
connection_limit | num_cpus * 2 + 1 | maximale offene Verbindungen im Pool |
pool_timeout | 10 (Sekunden) | wie lange auf eine freie Verbindung gewartet wird, bevor ein Fehler ausgelöst wird |
Auch deshalb ist der globale Singleton aus Abschnitt 7 wichtig. Ohne ihn wird bei jedem Hot-Reload in der Entwicklung ein neuer PrismaClient (und ein neuer Pool) erzeugt — jeder Pool öffnet eigene Verbindungen, und das Datenbanklimit ist schnell erschöpft:
// lib/prisma.ts — one pool for the entire process lifetimeimport { PrismaClient } from '@prisma/client';const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };export const prisma = globalForPrisma.prisma ?? new PrismaClient();if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;PHP-FPM betreibt eine feste Zahl von Worker-Prozessen, von denen jeder eine Anfrage zur Zeit bearbeitet und seine eigene Datenbankverbindung hält. Der „Pool” in einem klassischen Laravel-Setup ist daher der Pool der PHP-FPM-Worker.
Die Verbindung in config/database.php konfigurieren:
'pgsql' => [ 'driver' => 'pgsql', 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '5432'), 'database' => env('DB_DATABASE'), 'username' => env('DB_USERNAME'), 'password' => env('DB_PASSWORD'), 'options' => [ PDO::ATTR_PERSISTENT => true, // reuse the connection across requests ],],PDO::ATTR_PERSISTENT sagt PHP, die Verbindung offen zu halten und für die nächste Anfrage desselben Worker-Prozesses wiederzuverwenden — das spart den Neuverbindungs-Overhead.
Die PHP-FPM-Poolgröße (Zahl der Worker = maximale gleichzeitige DB-Verbindungen) wird in php-fpm.conf gesetzt:
; /etc/php/8.x/fpm/pool.d/www.confpm = dynamicpm.max_children = 20 ; max concurrent workers → max DB connectionspm.start_servers = 5pm.min_spare_servers = 5pm.max_spare_servers = 156.4 Poolgröße und Performance
Abschnitt betitelt „6.4 Poolgröße und Performance“Es hält sich der Irrglaube, ein größerer Pool sei immer besser. In der Praxis bedeutet mehr Verbindungen zu PostgreSQL mehr parallele Backend-Prozesse, die um CPU und RAM des Datenbankservers konkurrieren.
Die Faustregel für die Poolgröße:
optimal pool size ≈ (number of CPU cores on the DB server) × 2Ein Datenbankserver mit 4 Kernen bewältigt grob 8 parallele Abfragen effizient. Zusätzliche Verbindungen darüber hinaus warten auf CPU-Zeit und verbessern den Durchsatz nicht — sie erhöhen nur den Speicherverbrauch und den Scheduling-Overhead.
| Pool zu klein | Pool zu groß |
|---|---|
| Anfragen stauen sich und warten auf eine freie Verbindung | CPU und RAM des DB-Servers unter Druck |
| hohe Latenzspitzen unter Last | mehr Context Switching, langsamere Abfragen |
Fehler, wenn pool_timeout überschritten wird | kann PostgreSQLs max_connections-Grenze erreichen |
Aktive Verbindungen überwachen (PostgreSQL):
-- current connections per application and stateSELECT application_name, state, count(*)FROM pg_stat_activityWHERE datname = 'your_database'GROUP BY application_name, stateORDER BY count DESC;Ein Warnzeichen sind viele Verbindungen, die in idle in transaction hängen — ein Hinweis darauf, dass Verbindungen nach einer Transaktion nicht zügig an den Pool zurückgegeben werden.
7. Alles zusammen: ein praktisches Beispiel
Abschnitt betitelt „7. Alles zusammen: ein praktisches Beispiel“Eine vollständige Datenzugriffsschicht für eine Blog-Anwendung — Migrationen, Schema, Repository, Eager Loading und eine Seite/ein Controller. Alles ohne N+1-Problem.
model User { id Int @id @default(autoincrement()) email String @unique name String posts Post[]}
model Post { id Int @id @default(autoincrement()) title String content String? published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId Int tags Tag[] createdAt DateTime @default(now())}
model Tag { id Int @id @default(autoincrement()) name String @unique posts Post[]}Schema::create('posts', function (Blueprint $table) { $table->id(); $table->string('title'); $table->text('content')->nullable(); $table->boolean('published')->default(false); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->timestamps();});
// database/migrations/xxxx_create_tags_table.phpSchema::create('tags', function (Blueprint $table) { $table->id(); $table->string('name')->unique();});
Schema::create('post_tag', function (Blueprint $table) { $table->foreignId('post_id')->constrained()->cascadeOnDelete(); $table->foreignId('tag_id')->constrained()->cascadeOnDelete(); $table->primary(['post_id', 'tag_id']);});class Post extends Model { protected $fillable = ['title', 'content', 'published', 'user_id'];
public function author() { return $this->belongsTo(User::class, 'user_id'); } public function tags() { return $this->belongsToMany(Tag::class); }}Repository
Abschnitt betitelt „Repository“// lib/prisma.ts — shared singleton (prevents connection exhaustion on hot reload)import { PrismaClient } from '@prisma/client';const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };export const prisma = globalForPrisma.prisma ?? new PrismaClient();if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;import { prisma } from '../prisma';
export class PostRepository { async findAllPublished() { return prisma.post.findMany({ where: { published: true }, include: { author: { select: { name: true } }, // eager loading — no N+1 tags: { select: { name: true } }, }, orderBy: { createdAt: 'desc' }, }); }
async findById(id: number) { return prisma.post.findUnique({ where: { id }, include: { author: true, tags: true }, }); }
async create(data: { title: string; content: string; authorId: number; tagNames: string[] }) { return prisma.post.create({ data: { title: data.title, content: data.content, author: { connect: { id: data.authorId } }, tags: { connectOrCreate: data.tagNames.map(name => ({ where: { name }, create: { name }, })), }, }, include: { author: true, tags: true }, }); }}class PostRepository{ public function findAllPublished(): Collection { return Post::with(['author:id,name', 'tags:id,name']) // eager loading — no N+1 ->where('published', true) ->orderByDesc('created_at') ->get(); }
public function findById(int $id): ?Post { return Post::with(['author', 'tags'])->find($id); }
public function create(array $data, array $tagNames): Post { $post = Post::create($data); $tagIds = Tag::whereIn('name', $tagNames)->pluck('id'); $post->tags()->sync($tagIds); return $post->load(['author', 'tags']); }}Seite / Controller
Abschnitt betitelt „Seite / Controller“// app/blog/page.tsx (Next.js App Router)import { PostRepository } from '@/lib/repositories/PostRepository';
export default async function BlogPage() { const repo = new PostRepository(); const posts = await repo.findAllPublished();
return ( <main> {posts.map(post => ( <article key={post.id}> <h2>{post.title}</h2> <p>by {post.author.name}</p> <p>{post.tags.map(t => t.name).join(', ')}</p> </article> ))} </main> );}class BlogController extends Controller{ public function __construct(private PostRepository $posts) {}
public function index() { $posts = $this->posts->findAllPublished(); return view('blog.index', compact('posts')); }}{{-- resources/views/blog/index.blade.php --}}@foreach ($posts as $post) <article> <h2>{{ $post->title }}</h2> <p>by {{ $post->author->name }}</p> <p>{{ $post->tags->pluck('name')->join(', ') }}</p> </article>@endforeachBeide Varianten führen genau eine SQL-Abfrage aus (mit JOINs), egal wie viele Beiträge existieren — kein N+1-Problem.
Zusammenfassung
Abschnitt betitelt „Zusammenfassung“| Konzept | Kernpunkt |
|---|---|
| ORM | bildet Datenbankzeilen auf Objekte ab; erzeugt SQL aus Code (Prisma, Eloquent) |
| Migration | versionierte, eingecheckte Schemaänderungen, in Reihenfolge über Umgebungen angewendet |
| N+1-Problem | 1 Abfrage für eine Liste + N Abfragen für verwandte Daten; Lösung ist Eager Loading |
| Lazy Loading | verwandte Daten beim ersten Zugriff geladen — Eloquent kann es, Prisma nicht |
| Eager Loading | verwandte Daten vorab in einem JOIN geladen; explizit, sicher, empfohlen |
| Active Record | Modell übernimmt seine eigene Persistenz — der Eloquent-Ansatz |
| Repository | eigene Klasse für den gesamten DB-Zugriff — der natürliche Prisma-Ansatz |
Die Kombination aus Migrationen + ORM + Repository-Muster + Eager Loading ist die Standardarchitektur für produktionsreife Webanwendungen, ob mit Next.js/Prisma oder Laravel/Eloquent gebaut wird.