Batch-Verarbeitung vs. Chatty Apps:
Tausend kleine Anfragen oder eine große — was ist schlimmer?
Die Antwort auf die Frage im Untertitel lautet: Tausend kleine. Immer. Wer das bezweifelt, hat noch nie die Extended-Events-Ausgabe einer typischen ORM-betriebenen Applikation gesehen — ein endloser Strom identischer Queries, jeder einzeln harmlos, zusammen ein Bremsblock am Datenbankserver.
Dieses Kapitel richtet sich an Entwickler, die verstehen wollen warum "es doch funktioniert" nicht dasselbe ist wie "es ist effizient". Und an Administratoren, die dem Entwickler-Team erklären müssen, warum der Server trotz moderater Query-Dauer ins Schwitzen gerät.
Wir schauen uns das klassische Chatty-App-Anti-Pattern an, lösen das N+1-Problem, optimieren Batch-Inserts und -Deletes, und sprechen über Connection Pooling. Am Ende hast du ein konkretes Diagnosewerkzeug und vier Kästen die zeigen was du sofort tun kannst.
Querverweise: Die Auswirkungen auf TempDB behandelt Kapitel 13, Blocking durch lange Transaktionen in Kapitel 14. Wie ORM-Frameworks aus harmlosen Code-Mustern Performance-Desaster machen, liest du in Kapitel 30. Stored Procedures als elegante Lösung für viele der hier gezeigten Probleme folgen in Kapitel 26.
Das Chatty-App-Anti-Pattern: Wenn die Applikation nicht aufhört zu reden
Eine Chatty App ist eine Applikation, die sehr viele kleine Datenbankaufrufe macht, statt wenige größere. "Klein" heißt dabei nicht unbedingt schnell — es heißt vor allem: unnötig oft. Das Gegenteil wäre eine Chunky App: wenige, dafür inhaltlich reichhaltigere Aufrufe.
|
Definition: Chatty vs. Chunky |
|---|
|
Chatty App: Viele kleine Datenbankaufrufe — jedes Business-Objekt wird einzeln geladen, jedes Update einzeln ausgeführt, jede Prüfung einzeln abgefragt. |
|
Chunky App: Wenige, gezielt formulierte Aufrufe — Daten werden in sinnvollen Mengen geladen, Batch-Operationen ersetzen Schleifen, Joins ersetzen Nachlade-Operationen. |
|
Die Faustregel: Queries pro Sekunde ist eine wichtigere Metrik als Millisekunden pro Query — wenn du 5.000 Queries/s mit je 0,5 ms siehst, hast du ein Problem, auch, wenn keine einzelne Query langsam ist. |
Was kostet eigentlich ein Roundtrip zur Datenbank? Mehr als die meisten Entwickler annehmen. Jeder Aufruf summiert:
Summiert: Ein simpler Roundtrip kostet selbst im LAN realistisch 0,3–2 ms. Pro Aufruf. Nicht nach einer Stunde — pro Aufruf. 1.000 Aufrufe für eine Benutzeraktion bedeuten 300 ms bis 2 Sekunden alleine durch Overhead, noch, bevor SQL Server auch nur eine Zeile verarbeitet hat.
|
Praxisbeispiel: Trendforge Digital: Der N+1-Klassiker im Industrieformat |
|---|
|
Bei Trendforge Digital GmbH (Kapitel 34) fanden wir folgendes Muster in der Kundenübersicht: Zuerst eine Abfrage für die Kundenliste — 500 Zeilen. Dann für jeden Kunden eine separate Abfrage für seine Bestellungen. Macht 501 Queries. |
|
Gemessene Latenz pro Bestellungs-Query: ca. 0,5 ms. Summiert: 500 × 0,5 ms = 250 ms allein für Roundtrip-Overhead. Eine JOIN-Variante würde 2 ms benötigen. Faktor 125 — für exakt dieselbe Datenmenge. |
|
Die Entwickler hatten das nie als Problem wahrgenommen, weil keine einzelne Query langsam war. sys.dm_exec_query_stats zeigte hingegen eine Query mit execution_count > 200.000 pro Stunde. |
Das N+1-Problem: Erkennen, Verstehen, Lösen
N+1 ist das bekannteste Chatty-App-Muster. Die Schleife sieht harmlos aus: lade eine Liste, iteriere über die Elemente, lade für jedes Element weitere Daten nach. Für kleine N funktioniert das sogar. Für N = 500 in Produktion ist es eine Katastrophe.
N+1 erkennen: Zwei Diagnosewege
Der schnellste Weg: sys.dm_exec_query_stats nach execution_count sortieren. Wenn eine Query mit minimaler Einzeldauer aber extremem execution_count auftaucht, ist N+1 der wahrscheinlichste Verdächtige. Der zweite Weg: Extended Events mit hoher Query-Frequenz — man sieht dann hunderte identischer Queries mit leicht unterschiedlichen Parameterwerten im zeitlichen Cluster.
-- N+1-Kandidaten identifizieren: viele Ausführungen, kurze Laufzeit, hohe Gesamtlast
-- Sortierung nach total_elapsed_time zeigt den "stillen Kostentreiber"
SELECT TOP 20
qs.execution_count,
qs.total_elapsed_time / qs.execution_count AS avg_elapsed_us,
qs.total_elapsed_time, -- Gesamtzeit in Mikrosekunden
qs.total_logical_reads,
SUBSTRING(
st.text,
(qs.statement_start_offset / 2) + 1,
CASE qs.statement_end_offset
WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset
END - qs.statement_start_offset / 2 + 1
) AS query_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
WHERE qs.execution_count > 10000 -- Nur häufig ausgeführte Queries
AND qs.total_elapsed_time / qs.execution_count < 5000 -- Unter 5 ms Einzeldauer
ORDER BY qs.execution_count DESC;
-- Tipp: Wenn dieselbe Query-Struktur mit vielen verschiedenen Parameterwerten auftaucht,
-- ist es fast sicher ein N+1-Problem aus der Applikationsschicht.
N+1 lösen: JOIN statt Schleife
Die fundamentale Lösung für N+1: Daten in einem einzigen Aufruf laden statt in einer Schleife. Das geht entweder als echter JOIN — also auf SQL-Ebene — oder als Batch-Load mit einer WHERE IN-Klausel, wenn die IDs aus der ersten Query bereits bekannt sind.
-- ❌ Das N+1-Anti-Pattern (Pseudocode der App-Schicht):
-- 1. SELECT KundeID, Name FROM Kunden -- 1 Query
-- 2. for each kunde:
-- SELECT * FROM Bestellungen WHERE KundeID = @id -- N Queries
-- ✅ Lösung 1: JOIN — alles in einem Aufruf
SELECT
k.KundeID,
k.Name,
k.Email,
b.BestellungID,
b.Datum,
b.Gesamtbetrag
FROM Kunden k
-- LEFT JOIN damit auch Kunden ohne Bestellungen erscheinen
LEFT JOIN Bestellungen b ON b.KundeID = k.KundeID
WHERE k.Aktiv = 1
ORDER BY k.Name, b.Datum DESC;
-- ✅ Lösung 2: Batch-Load — wenn IDs schon bekannt sind
-- Applikation lädt zuerst Kundenliste, dann in einem zweiten Call alle Bestellungen:
SELECT
b.BestellungID,
b.KundeID,
b.Datum,
b.Gesamtbetrag
FROM Bestellungen b
WHERE b.KundeID IN ( -- Alle IDs auf einmal statt N Einzelaufrufe
SELECT value FROM STRING_SPLIT(@KundeIDs, ',')
)
AND b.Datum >= DATEADD(YEAR, -1, GETDATE());
|
Tipp: ORM und N+1: Eager Loading aktivieren |
|---|
|
In Entity Framework Core ist Lazy Loading die Standardfalle für N+1. Jede Navigation Property wird beim ersten Zugriff einzeln nachgeladen — transparent für den Entwickler, tödlich für den Datenbankserver. |
|
Lösung: .Include(k => k.Bestellungen) in der Query — das erzeugt einen JOIN statt N Einzelaufrufe. Für komplexe Objektgraphen: .ThenInclude() für tiefe Hierarchien. |
|
Noch besser für Read-Only-Szenarien: .AsNoTracking() kombiniert mit gezielten .Include()-Ketten. Spart Change-Tracking-Overhead und erzwingt explizites Denken über Ladestrategien. |
|
Kapitel 30 behandelt ORM-Muster und deren Datenbankauswirkungen im Detail. |
Batch-Inserts: Tausend Zeilen, ein Roundtrip
Wer Zeilen einzeln in eine Schleife packt und per INSERT … VALUES (…) einfügt, gibt SQL Server keine Chance effizient zu arbeiten. Jede Zeile bedeutet einen separaten Roundtrip, eine separate Log-Schreiboperation, und — ohne Transaktionsklammer — einen separaten Commit. Die Verbesserungspotenziale sind enorm.
Multi-Row INSERT: Der einfache erste Schritt
SQL Server erlaubt bis zu 1.000 Zeilen in einem einzigen INSERT … VALUES-Statement. Das ist die schnellste Optimierung mit dem geringsten Aufwand: Applikation sammelt Zeilen, sendet einen Batch statt 1.000 Einzelstatements.
-- ❌ Einzeln einfügen — 1.000 Roundtrips für 1.000 Zeilen
INSERT INTO Bestellpositionen (BestellungID, ArtikelID, Menge, Preis)
VALUES (1001, 42, 3, 29.90);
-- ... 999 weitere identische Statements
-- ✅ Multi-Row INSERT — 1 Roundtrip für bis zu 1.000 Zeilen
INSERT INTO Bestellpositionen (BestellungID, ArtikelID, Menge, Preis)
VALUES
(1001, 42, 3, 29.90),
(1001, 17, 1, 149.00),
(1001, 88, 5, 9.99),
-- ... weitere Zeilen bis max. 1.000
(1001, 33, 2, 79.50);
-- Wichtig: Ab 1.001 Zeilen muss der Batch gesplittet werden (SQL Server-Limit)
Table-Valued Parameters: Der professionelle Weg
Für größere Datenmengen oder, wenn die Batch-Logik in einer Stored Procedure gekapselt sein soll, sind Table-Valued Parameters (TVPs) das Mittel der Wahl. Eine ganze Tabelle wird als Parameter übergeben — ein Roundtrip, beliebig viele Zeilen, vollständige Typsicherheit.
-- Schritt 1: Tabellentyp definieren (einmalig, DDL)
CREATE TYPE dbo.BestellpositionenTyp AS TABLE (
BestellungID INT NOT NULL,
ArtikelID INT NOT NULL,
Menge INT NOT NULL,
Preis DECIMAL(10,2) NOT NULL
);
GO
-- Schritt 2: Stored Procedure mit TVP-Parameter
CREATE OR ALTER PROCEDURE dbo.BestellpositionenEinfuegen
@Positionen dbo.BestellpositionenTyp READONLY -- READONLY ist Pflicht bei TVPs
AS
BEGIN
SET NOCOUNT ON;
-- Alle übergebenen Zeilen in einem einzigen INSERT verarbeiten
-- Kein Schleife, kein N+1, kein Roundtrip pro Zeile
INSERT INTO dbo.Bestellpositionen (BestellungID, ArtikelID, Menge, Preis)
SELECT BestellungID, ArtikelID, Menge, Preis
FROM @Positionen;
-- Rückgabe: Anzahl eingefügter Zeilen für die Applikation
SELECT @@ROWCOUNT AS EingefügteZeilen;
END;
GO
-- Aufruf aus .NET: SqlParameter mit SqlDbType.Structured und DataTable als Wert.
-- Die DataTable wird client-seitig befüllt und in einem Roundtrip übertragen.
-- Typische Performance: 10.000 Zeilen in < 100 ms inklusive Netzwerk.
BULK INSERT und SqlBulkCopy: Wenn es richtig groß wird
Für Massenladeoperationen — ab etwa 10.000 Zeilen aufwärts, oder, wenn Geschwindigkeit kritisch ist — sind BULK INSERT (aus Dateien) und SqlBulkCopy (aus .NET-Applikationen) die richtigen Werkzeuge. SqlBulkCopy erreicht realistisch 50.000–200.000 Zeilen pro Sekunde, je nach Hardware und Tabellenstruktur.
|
Warnung: BULK INSERT und das Transaction Log |
|---|
|
BULK INSERT mit minimaler Protokollierung (Datenbank im BULK_LOGGED oder SIMPLE Recovery Model) ist extrem schnell, aber: nach einem Absturz während des Ladevorgangs ist ein Point-in-Time-Recovery nicht möglich. |
|
Im FULL Recovery Model wird jede eingefügte Zeile protokolliert — der Ladevorgang ist langsamer, aber vollständig wiederherstellbar. |
|
Entscheidung: Einmalige Datenimporte → BULK_LOGGED temporär akzeptabel. Reguläre Betriebsprozesse → FULL, damit das Backup-Konzept funktioniert. Kapitel 6 erklärt Recovery Models im Detail. |
Batch-Deletes und -Updates: Der sanfte Riese
DELETE FROM Tabelle WHERE Datum < DATEADD(YEAR, -3, GETDATE()) klingt nach einem harmlosen Archivierungsjob. Wenn die Tabelle 50 Millionen Zeilen enthält und 40 Millionen davon älter als drei Jahre sind, ist das kein harmloses Statement mehr. Es ist eine Transaktion die Stunden läuft, gigabyteweise Transaction Log belegt, und dabei alle schreibenden Zugriffe auf die betroffenen Seiten blockiert.
Das Ergebnis: Blocking in der Applikation (Verweis auf Kapitel 14), möglicherweise Transaction Log-Vollläufe, und ein Rollback der tagelang läuft, wenn jemand die Verbindung abbricht. Wir haben das bei Sparfuchs & Partner (Kapitel 33) gesehen — 183 GB Transaction Log für eine 24 GB Datenbank ist kein Zufall.
Das Batch-Delete-Pattern
Die Lösung ist elegant: Statt alles auf einmal zu löschen, löschen wir in kleinen Häppchen. 1.000 Zeilen pro Batch ist ein vernünftiger Ausgangswert. Zwischen den Batches kurze Pause einbauen — der Produktionsserver soll atmen dürfen.
-- Batch-Delete Pattern: Große Löschoperationen schonend durchführen
-- 1.000 Zeilen pro Durchlauf, kurze Pause dazwischen
DECLARE @BatchGroesse INT = 1000;
DECLARE @GelöschteZeilen INT;
DECLARE @GesamtGelöscht INT = 0;
PRINT 'Starte Batch-Delete...';
WHILE 1 = 1
BEGIN
-- Batch löschen: TOP limitiert die Tranksaktion auf überschaubare Größe
DELETE TOP (@BatchGroesse)
FROM dbo.Protokoll
WHERE Datum < DATEADD(YEAR, -3, GETDATE());
SET @GelöschteZeilen = @@ROWCOUNT;
SET @GesamtGelöscht += @GelöschteZeilen;
-- Schleife beenden, wenn keine Zeilen mehr zu löschen
IF @GelöschteZeilen = 0 BREAK;
-- Fortschritt ausgeben — bei großen Tabellen ist das beruhigend
IF @GesamtGelöscht % 10000 = 0
PRINT 'Gelöscht: ' + CAST(@GesamtGelöscht AS VARCHAR(20)) + ' Zeilen';
-- Kurze Pause: andere Transaktionen kommen zu Wort
-- Im Wartungsfenster weglassen, im Produktionsbetrieb unbedingt drin lassen
WAITFOR DELAY '00:00:00.050'; -- 50 ms Pause zwischen Batches
END
PRINT 'Fertig. Gesamt gelöscht: ' + CAST(@GesamtGelöscht AS VARCHAR(20)) + ' Zeilen';
Dasselbe Prinzip gilt für große UPDATE-Operationen. UPDATE … SET … WHERE … über Millionen von Zeilen ist genauso problematisch wie DELETE über Millionen von Zeilen — das Transaction Log wächst, Locks werden gehalten, andere Transaktionen warten.
|
Szenario |
Batch-Größe |
Pause |
Begründung |
|---|---|---|---|
|
Wartungsfenster (kein Live-Betrieb) |
10.000–50.000 |
Keine |
Maximaler Durchsatz, kein Blocking-Risiko |
|
Nebenzeiten (geringer Betrieb) |
5.000 |
10 ms |
Schnell aber Concurrency-freundlich |
|
Voller Produktionsbetrieb |
500–1.000 |
50–100 ms |
Minimale Auswirkung auf laufende Transaktionen |
|
Sehr kritische Systeme (24/7) |
100–200 |
200 ms |
Kaum messbare Auswirkung, dafür sehr langsam |
Tabelle 25-1: Batch-Größen nach Betriebsszenario
Connection Pooling: Das stille Fundament
Eine neue SQL Server-Verbindung kostet zwischen 50 und 200 Millisekunden. Das klingt wenig, ist aber enorm, wenn eine Applikation für jeden Request eine neue Verbindung aufbaut und wieder schließt. Connection Pooling löst das Problem: der Verbindungs-Pool hält Verbindungen offen und gibt sie beim nächsten Request wieder heraus — für unter 1 ms.
In .NET regelt der SqlConnection-Pool das automatisch und ist standardmäßig aktiv. Maximale Pool-Größe: 100 Verbindungen per Connection String. Das klingt großzügig — bis die Applikation 200 gleichzeitige Requests bekommt und die anderen 100 auf eine freie Verbindung warten.
|
Warnung: Connection Pool Exhaustion — der unsichtbare Engpass |
|---|
|
Symptom: Die Applikation wirft "Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool" — aber der SQL Server ist kaum ausgelastet. |
|
Ursache: Zu viele parallele Anfragen, oder Verbindungen werden nicht rechtzeitig zurückgegeben (vergessenes .Close(), Ausnahme ohne using-Block). |
|
Diagnose: sys.dm_exec_sessions zeigt aktive Sessions. Wenn das viele sleep-Sessions mit offenen Transaktionen enthält, sind das Pool-Leichen. |
|
Gegenmaßnahmen: using-Blöcke in .NET erzwingen, Max Pool Size erhöhen, Verbindungen nicht länger halten als nötig, und prüfen ob wirklich so viele parallele DB-Aufrufe notwendig sind. |
-- Pool-Nutzung überwachen: aktive Sessions und deren Wartezeit
SELECT
s.session_id,
s.status, -- running, sleeping, waiting
s.login_name,
s.program_name,
s.host_name,
s.last_request_start_time,
s.last_request_end_time,
r.wait_type, -- NULL, wenn nicht aktiv wartend
r.wait_time / 1000.0 AS wait_sec,
DATEDIFF(SECOND, s.last_request_start_time, GETDATE()) AS leerlauf_sek
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
WHERE s.is_user_process = 1 -- Keine Systemsessions
AND s.session_id <> @@SPID -- Eigene Session ausblenden
ORDER BY leerlauf_sek DESC; -- Pool-Leichen oben
-- Sessions mit open_transaction_count > 0 und status = sleeping sind verdächtig:
-- Offene Transaktion, die niemand mehr bedient — Blocking-Zeitbombe.
Die goldene Regel für Verbindungsmanagement: Verbindung so spät wie möglich öffnen, so früh wie möglich schließen. Nicht die Verbindung für die Lebensdauer eines Objekts halten — das ist der häufigste Fehler in Request-basierter Entwicklung.
Transaktionen richtig dimensionieren: Der Batch als Einheit
Transaktionen haben einen doppelten Ruf: Richtig eingesetzt garantieren sie Datenkonsistenz. Falsch eingesetzt sind sie die häufigste Ursache für Blocking und Log-Wachstum. Das Problem liegt fast immer an der Granularität.
Der häufigste Fehler: Transaktion pro Schleifeniteration
Die Schleife macht BEGIN TRANSACTION, führt ein INSERT aus, macht COMMIT — für jede Zeile. Das klingt sicher. Es ist auch sicher, aber wahnsinnig langsam: Jeder COMMIT erzwingt einen synchronen Log-Flush auf die Festplatte. 10.000 Zeilen bedeuten 10.000 synchrone IO-Operationen. Auf einer SSD vielleicht 5 Sekunden. Auf einer Spindel: mehrere Minuten.
-- ❌ Anti-Pattern: COMMIT pro Zeile — garantiert langsam
DECLARE @i INT = 1;
WHILE @i <= 10000
BEGIN
BEGIN TRANSACTION;
INSERT INTO dbo.Staging (ID, Wert) VALUES (@i, NEWID());
COMMIT; -- Jeder COMMIT = 1 synchroner Log-Flush auf Disk
SET @i += 1;
END
-- Auf Spindel: ~10.000 × 5 ms = ~50 Sekunden für 10.000 Zeilen
-- ✅ Besser: Ganzen Batch in einer Transaktion
BEGIN TRANSACTION;
-- Alle 10.000 Zeilen in einer Transaktion — ein Log-Flush am Ende
INSERT INTO dbo.Staging (ID, Wert)
SELECT n, NEWID()
FROM (VALUES (1),(2),(3)) v(n) -- In Praxis: Quelltabelle oder TVP
CROSS JOIN (SELECT TOP 3333 1 AS x FROM sys.objects) sq;
COMMIT;
-- Auf Spindel: 1 × 5 ms = 5 ms. Faktor 10.000 schneller.
-- Hinweis: Bei Batch-Inserts über AUTOCOMMIT (kein explizites BEGIN TRAN)
-- batcht SQL Server intern — auch das ist deutlich besser als explizites
-- COMMIT pro Zeile.
Auf der anderen Seite gilt: Eine Transaktion über Millionen von Zeilen ist genauso problematisch. Zu groß ist genauso schlimm wie zu klein. Der Sweet Spot liegt bei Batch-Größen die einerseits IO-Flushes amortisieren, andererseits das Log nicht überlasten und Blocking begrenzen. Die Tabelle im vorherigen Abschnitt gilt sinngemäß auch hier.
|
Hinweis: AUTOCOMMIT ist dein Freund — meistens |
|---|
|
Wenn du kein explizites BEGIN TRANSACTION schreibst, läuft jedes Statement in AUTOCOMMIT — SQL Server erstellt automatisch eine Transaktion pro Statement und committed sofort. |
|
Für Einzel-INSERTs und kleine UPDATEs ist das völlig in Ordnung. Für Batch-Inserts, die du aus der Applikation schickst, musst du keine explizite Transaktion öffnen — der Treiber und SQL Server handeln das effizient. |
|
Nur, wenn du mehrere Statements atomisch zusammenfassen musst (alles oder nichts), brauchst du eine explizite Transaktion. |
Diagnose: Wenn die Applikation zu gesprächig ist
|
Warnung: Symptome: Chatty Apps und Batch-Probleme |
|---|
|
Hohe Queries/Sekunde bei kurzer Einzeldauer: sys.dm_exec_query_stats zeigt viele Queries < 1 ms, aber execution_count in Millionen pro Tag. |
|
Viele identische Queries mit leicht unterschiedlichen Parametern: Extended Events oder SQL Server Profiler (deprecated) zeigt Cluster von SELECT-Statements auf dieselbe Tabelle mit verschiedenen ID-Werten — klares N+1-Signal. |
|
Connection Pool Exhaustion: Applikation meldet Timeout-Fehler beim Verbindungsaufbau, obwohl SQL Server-CPU < 20%. sys.dm_exec_sessions zeigt sleep-Sessions mit offenen Transaktionen. |
|
Log-Wachstum durch große Einzeltransaktionen: sys.dm_tran_database_transactions zeigt Transaktionen mit database_transaction_log_bytes_used > 1 GB — ein einzelnes DELETE oder UPDATE hat das Log gefüllt. |
|
Blocking durch lange Transaktionen: sys.dm_os_waiting_tasks zeigt viele Waiter auf eine einzelne Session (Kapitel 14). Die blockierende Session hält eine große Transaktion offen. |
|
Tipp: So misst du das: Chatty-App-Diagnose mit DMVs |
|---|
|
Schritt 1 — Top-N+1-Kandidaten finden: |
|
SELECT TOP 20 execution_count, total_elapsed_time/execution_count AS avg_us, SUBSTRING(st.text, 1, 100) AS query FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st WHERE execution_count > 50000 ORDER BY execution_count DESC; |
|
|
|
Schritt 2 — Queries/Sekunde pro Applikation messen: |
|
SELECT login_name, program_name, COUNT(*) AS sessions, SUM(r.reads + r.writes) AS io FROM sys.dm_exec_sessions s LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id WHERE s.is_user_process = 1 GROUP BY login_name, program_name ORDER BY sessions DESC; |
|
|
|
Schritt 3 — Aktive Transaktionen mit Log-Nutzung: |
|
SELECT session_id, database_transaction_log_bytes_used/1024/1024 AS log_mb, database_transaction_begin_time FROM sys.dm_tran_database_transactions WHERE database_transaction_log_bytes_used > 100*1024*1024 ORDER BY log_mb DESC; |
|
Hintergrund: Typische Fehlinterpretationen |
|---|
|
"Viele schnelle Queries sind kein Problem" — Doch. Roundtrip-Overhead summiert sich linear. 10.000 Queries/s à 0,5 ms Overhead = 5 Sekunden verschwendete CPU-Zeit pro Sekunde, nur durch Protokoll-Overhead. Das ist 100% Last auf einem Core, für nichts. |
|
"Batch-Inserts sind komplizierter, also machen wir Einzel-Inserts" — Der Mehraufwand für TVPs oder Multi-Row-INSERT ist ein einmaliger Implementierungsaufwand von vielleicht 2 Stunden. Die gesparte Datenbankzeit sind täglich Stunden. |
|
"Große Transaktionen sind effizienter" — Für den Durchsatz ja. Für Blocking, Log-Management und Recovery-Risiko nein. Die optimale Transaktionsgröße ist ein Kompromiss, keine Maximierung einer einzigen Variable. |
|
"Connection Pool auf 500 erhöhen löst das Pool-Exhaustion-Problem" — Kurzzeitig ja. Eigentlich nicht. 500 gleichzeitige Verbindungen bedeuten 500 parallele SQL Server-Sessions — das kann der Server nicht besser bedienen als 100. Das eigentliche Problem ist zu viel Parallelität in der Applikation. |
|
Tipp: Erste Gegenmaßnahmen |
|---|
|
N+1 identifizieren und priorisieren: sys.dm_exec_query_stats mit execution_count > 100.000/Tag und avg_elapsed < 2 ms — das sind deine Top-Kandidaten. Drei bis fünf davon eliminieren bringt oft mehr als jede andere Optimierung. |
|
TVPs für alle Batch-Inserts einführen: Für jede Entität die in Bulk eingefügt wird, einen Tabellentyp und eine Stored Procedure erstellen. Einmalige Arbeit, dauerhafter Gewinn. |
|
Batch-Delete-Pattern für alle geplanten Löschoperationen: Das WHILE-Loop-Pattern aus Abschnitt 4 als Vorlage nehmen und für alle Archivierungs- und Bereinigungsjobs adaptieren. |
|
Connection Pool überwachen: Täglichen Alert, wenn sys.dm_exec_sessions mehr sleeping Sessions mit open_transaction_count > 0 zeigt als ein konfigurierter Schwellenwert (z.B. 20). |
|
Für ORM-Projekte: Lazy Loading deaktivieren und alle Stellen auflisten wo Navigation Properties ohne explizites Include verwendet werden. Das ist die N+1-Karte. |
Zusammenfassung
Batch-Verarbeitung vs. Chatty Apps ist kein akademisches Konzept — es ist einer der häufigsten und wirkungsvollsten Hebel in der Praxis. Die zentralen Erkenntnisse:
Die drei Fallstudien zeigen das Spektrum: Bei Trendforge Digital (Kapitel 34) war N+1 das dominante Muster — 501 Queries statt einem JOIN. Bei Sparfuchs & Partner (Kapitel 33) kamen alle Probleme zusammen: Einzeln committete Inserts, kein Connection Pooling, und gelegentliche Millionen-Zeilen-Deletes die das schon überlastete System vollends in die Knie zwangen. Musterwerk GmbH (Kapitel 32) hatte das Batch-Problem bei einem Archivierungsjob der täglich das Transaction Log füllte — nach Umstellung auf das Batch-Loop-Pattern war das vorbei.
Ausblick auf Kapitel 26 — Stored Procedures & T-SQL: Stored Procedures sind mehr als nur wiederverwendbarer Code. Sie sind das Vehikel für Batch-Logik auf Datenbankebene, vermeiden Roundtrips durch serverseitige Verarbeitung, und ermöglichen granulare Rechtevergabe ohne direkten Tabellenzugriff. Außerdem: Warum skalare User-Defined Functions die versteckte Leistungsbremse sind, die kaum jemand auf dem Radar hat — bis sys.dm_exec_function_stats die Wahrheit zeigt.

Abb. 1: Chatty App vs. Batch-Verarbeitung
Kapitel 26
