SARGability:
Search ARGument Able — wenn dein WHERE-Klausel den Index findet
SARGability ist das Wort, das kein Entwickler kennt, aber jedes Mal bereut, wenn er es ignoriert. Du baust einen schönen Index auf die Spalte "Bestelldatum", überzeugst deinen DBA, führst ihn ein, freust dich — und deine Abfrage läuft trotzdem mit einem Full Table Scan. 10 Millionen Zeilen. Jede einzelne. Sequentiell. Weil dein WHERE-Ausdruck nicht SARGable ist.
Das ist kein Edge Case. Das ist Alltag. Und der Grund ist meistens erschreckend simpel: eine Funktion auf der falschen Seite des Gleichheitszeichens, eine implizite Konvertierung, ein führendes Wildcard-Zeichen. Der Index existiert, ist gepflegt, hat die richtige Schlüsselspalte — und SQL Server dreht ihm trotzdem den Rücken zu.
Dieses Kapitel erklärt, warum das passiert, wie du es erkennst und — am wichtigsten — wie du es vermeidest. Wenn du die Konzepte hier verinnerlicht hast, wirst du WHERE-Klauseln nie wieder gleich schreiben. Versprochen.
Was ist SARGability — und warum sollte mich das kümmern?
|
Definition: SARGability |
|---|
|
SARG steht für Search ARGument. Eine Bedingung ist SARGable, wenn SQL Server einen Index-Seek statt eines Index Scans oder Table Scans verwenden kann, um die Treffer zu finden. |
|
Nicht-SARGable bedeutet: SQL Server muss jede einzelne Zeile der Tabelle prüfen — auch, wenn ein passender Index vorhanden ist. Der Index ist dann wirkungslos. |
Der Unterschied zwischen einem Index Seek und einem Index Scan klingt akademisch, ist aber in der Praxis dramatisch. Ein Seek springt direkt zur ersten Trefferseite im B-Tree und liest nur die relevanten Einträge. Ein Scan geht die gesamte Indexstruktur durch — oder schlimmer: bei einem Table Scan die gesamte Tabelle, Seite für Seite.
Wie das konkret aussieht, haben wir in Kapitel 15 ausführlich besprochen — dort haben wir Ausführungspläne gelesen und gesehen, was ein Seek-Symbol von einem Scan-Symbol unterscheidet. Hier interessiert uns, warum SQL Server überhaupt auf einen Scan zurückfällt, obwohl ein Index vorhanden ist.
|
Praxisbeispiel: Das Millionen-Zeilen-Problem |
|---|
|
Tabelle "Bestellungen" mit 10 Millionen Zeilen, Index auf Spalte "Bestelldatum" (datetime2). |
|
Abfrage: WHERE YEAR(Bestelldatum) = 2023 |
|
Ergebnis: Full Table Scan, 10 Millionen Zeilen gelesen, Logical Reads > 100.000. |
|
Abfrage geändert auf: WHERE Bestelldatum >= '2023-01-01' AND Bestelldatum < '2024-01-01' |
|
Ergebnis: Index Seek, ~800.000 Zeilen gelesen, Logical Reads < 9.000. |
|
Laufzeitunterschied: 14 Sekunden vs. 0,8 Sekunden. Gleiche Daten, gleicher Index, anderer WHERE-Ausdruck. |
Der Grund: Wenn du YEAR() auf eine Spalte anwendest, muss SQL Server erst den Wert berechnen und kann dann nicht mehr im B-Tree suchen. Der B-Tree ist nach den Rohwerten von "Bestelldatum" sortiert — nicht nach den Ergebnissen von YEAR(Bestelldatum). Es gibt also keine Möglichkeit, direkt auf den richtigen Ast des Baums zu springen. SQL Server muss deshalb jede Zeile einzeln durch die Funktion jagen.
Die SARGability-Killer — vollständige Liste
Es gibt eine überschaubare Menge an Mustern, die eine Bedingung nicht-SARGable machen. Wenn du diese kennst, kannst du sie aktiv vermeiden — und im Code Review erkennen, bevor sie in Produktion gehen.
Killer 1: Funktionen auf indizierten Spalten
Die häufigste Ursache. Jede Funktion, die auf eine indizierte Spalte angewendet wird, macht die Bedingung nicht-SARGable. SQL Server kann den Index nicht nutzen, weil der Index die Rohwerte enthält, nicht die Funktionsergebnisse.
-- NICHT SARGable: Funktion auf der Spalte
SELECT * FROM Bestellungen WHERE YEAR(Bestelldatum) = 2023;
SELECT * FROM Bestellungen WHERE MONTH(Bestelldatum) = 6;
SELECT * FROM Kunden WHERE LEFT(Nachname, 3) = 'Mue';
SELECT * FROM Produkte WHERE ISNULL(Preis, 0) > 100;
SELECT * FROM Mitarbeiter WHERE UPPER(Abteilung) = 'VERTRIEB';
-- SARGable: Berechnung auf die Parameter-Seite verlagern
SELECT * FROM Bestellungen
WHERE Bestelldatum >= '2023-01-01'
AND Bestelldatum < '2024-01-01'; -- Bereich statt Funktion
SELECT * FROM Kunden
WHERE Nachname >= 'Mue'
AND Nachname < 'Muf'; -- Prefix-Suche ohne LEFT()
-- Faustregel: Was auch immer berechnet werden muss, muss auf
-- der rechten Seite des Operators stehen — nie auf der Spaltenseite.
Das Prinzip ist immer dasselbe: Verlagere die Berechnung auf die Seite des Parameters, nicht auf die Seite der Spalte. Was auch immer du ausrechnen musst, tue es einmal für den Suchparameter — nicht hunderttausendmal für jede Zeile in der Tabelle.
Killer 2: Implizite und explizite Konvertierungen
Dieser Killer ist besonders heimtückisch, weil er unsichtbar ist. Im T-SQL-Code steht nichts von einer Konvertierung — und trotzdem macht sie den Index wirkungslos. Das passiert, wenn Spaltentyp und Parametertyp nicht übereinstimmen.
-- Spalte "Kundennummer" ist VARCHAR(20), Parameter ist INT
-- SQL Server konvertiert JEDE Zeile der Spalte zu INT → nicht SARGable
SELECT * FROM Kunden WHERE Kundennummer = 12345;
-- Spalte "Beschreibung" ist NVARCHAR(500), Parameter ist VARCHAR
-- SQL Server muss die Spalte zu NVARCHAR konvertieren → nicht SARGable
DECLARE @suche VARCHAR(100) = 'Laptop';
SELECT * FROM Produkte WHERE Beschreibung = @suche;
-- Richtig: Parametertyp mit Spaltentyp abstimmen
DECLARE @suche NVARCHAR(100) = N'Laptop'; -- N-Präfix für NVARCHAR-Literal
SELECT * FROM Produkte WHERE Beschreibung = @suche;
-- Oder explizite Konvertierung, aber auf der Parameterseite:
SELECT * FROM Kunden WHERE Kundennummer = CAST(12345 AS VARCHAR(20));
Implicit Conversions sind in Kapitel 20 (Datentypen) ausführlich behandelt. An dieser Stelle der Hinweis: Der Ausführungsplan zeigt Implicit Conversion Warnings unter dem Reiter "Warnings" an — und das ist eines der ersten Dinge, die du nach einem neuen Deployment prüfen solltest. Eine einzige VARCHAR/NVARCHAR-Verwechslung kann einen gut genutzten Index vollständig lahmlegen.
|
Warnung: NVARCHAR vs. VARCHAR — ein teures Missverständnis |
|---|
|
Bei NVARCHAR-Spalten (und das sind in modernen Anwendungen die meisten String-Spalten) MUSS der Literal-Parameter das N-Präfix tragen: N'Wert' statt 'Wert'. |
|
Ohne N-Präfix: VARCHAR-Parameter, SQL Server konvertiert die NVARCHAR-Spalte → Full Scan. |
|
Mit N-Präfix: NVARCHAR-Parameter, kein Konvertierungsbedarf → Index Seek. |
|
ORMs generieren das meistens korrekt — aber handgeschriebenes T-SQL nicht immer. |
Killer 3: LIKE mit führendem Wildcard
LIKE ist nicht automatisch nicht-SARGable. Der entscheidende Unterschied liegt im Wildcard-Zeichen und seiner Position.
-- SARGable: Wildcard am Ende — SQL Server kann im B-Tree suchen
SELECT * FROM Kunden WHERE Nachname LIKE 'Schmidt%';
-- → Index Seek: direkt zum ersten "Schmidt" springen, alle folgenden lesen
-- NICHT SARGable: Wildcard am Anfang — kein definierter Startpunkt im B-Tree
SELECT * FROM Kunden WHERE Nachname LIKE '%schmidt';
-- NICHT SARGable: Wildcard auf beiden Seiten — entspricht einer Suche überall
SELECT * FROM Kunden WHERE Nachname LIKE '%schmid%';
-- Für echte Volltextsuche: Full-Text Search nutzen statt LIKE '%...%'
-- Full-Text Search hat eigene invertierte Indizes und ist für diesen
-- Anwendungsfall um Größenordnungen schneller
Ein führendes Wildcard bedeutet: SQL Server weiß nicht, wo er im B-Tree anfangen soll. Der Index ist nach dem Anfang der Strings sortiert, nicht nach dem Ende oder der Mitte. Also muss er von vorn bis hinten alles prüfen. "Schmidt%" funktioniert, weil der B-Tree direkt zum Schlüssel "S-c-h-m-i-d-t" navigieren kann.
Killer 4: Arithmetik auf Spalten
Genauso wie Funktionen: Rechenoperationen auf Spalten machen einen Index wirkungslos. Auch hier lautet die Lösung: Verlagere die Rechnung auf die Parameterseite.
-- NICHT SARGable: Rechnung auf der Spalte
SELECT * FROM Positionen WHERE Preis * 1.19 > 100;
SELECT * FROM Lager WHERE Bestand - Reserviert < 10;
-- SARGable: Rechnung umstellen, Spalte allein lassen
SELECT * FROM Positionen WHERE Preis > 100.0 / 1.19; -- ~84,03
SELECT * FROM Lager WHERE Bestand < 10 + Reserviert;
-- Achtung: Die zweite Variante bleibt nicht SARGable, wenn Reserviert
-- selbst eine Spalte ist. Dann braucht man eine Computed Column (s.u.).
Killer 5: NOT-Operatoren und schlechte Selektivität
NOT-Operatoren sind technisch gesehen SARGable — SQL Server kann theoretisch einen Index für "Status <> 'Aktiv'" nutzen. Das Problem ist die Selektivität: Wenn 90% der Zeilen den Status "Aktiv" haben, dann liefert "<> Aktiv" 10% der Tabelle zurück. Ab etwa 20-30% Selektivität wechselt der Query Optimizer auf einen Scan, weil ein Scan bei vielen Treffern günstiger ist als viele einzelne Seeks.
-- Kann SARGable sein, wird aber oft zum Scan wegen schlechter Selektivität:
SELECT * FROM Aufgaben WHERE Status <> 'Erledigt';
-- Klassische Falle: NOT IN mit NULL-Werten
-- Wenn SubQuery irgendeinen NULL-Wert enthält, gibt NOT IN KEIN Ergebnis zurück
SELECT * FROM A WHERE A.ID NOT IN (SELECT B.Ref FROM B WHERE B.Ref IS NULL);
-- → immer leer! Weil NULL-Vergleiche in NOT IN immer UNKNOWN ergeben
-- Sicher und oft schneller: NOT EXISTS
SELECT * FROM A WHERE NOT EXISTS (
SELECT 1 FROM B WHERE B.Ref = A.ID
);
SARGable vs. Nicht-SARGable — die Referenztabelle
Die folgende Tabelle ist deine Checkliste für Code Reviews. Jedes Muster in der linken Spalte ist ein Warnsignal — jedes Muster in der mittleren Spalte ist die SARGable-Alternative.
|
Nicht-SARGable (Problem) |
SARGable (Lösung) |
Grund |
|---|---|---|
|
WHERE YEAR(Datum) = 2023 |
WHERE Datum >= '2023-01-01' AND Datum < '2024-01-01' |
Funktion auf Spalte |
|
WHERE MONTH(Datum) = 6 |
WHERE Datum >= '2023-06-01' AND Datum < '2023-07-01' |
Funktion auf Spalte |
|
WHERE LEFT(Name, 3) = 'Mue' |
WHERE Name >= 'Mue' AND Name < 'Muf' |
Funktion auf Spalte |
|
WHERE ISNULL(Preis, 0) > 100 |
WHERE Preis > 100 (NULL separat behandeln) |
Funktion auf Spalte |
|
WHERE Preis * 1.19 > 100 |
WHERE Preis > 84.03 |
Arithmetik auf Spalte |
|
WHERE VarcharSpalte = 42 |
WHERE VarcharSpalte = '42' |
Implizite Konvertierung |
|
WHERE NVarchar = @varcharParam |
WHERE NVarchar = @nvarcharParam |
Typ-Mismatch VARCHAR/NVARCHAR |
|
WHERE Name LIKE '%Schmidt' |
WHERE Name LIKE 'Schmidt%' |
Führendes Wildcard |
|
WHERE Name LIKE '%Schmidt%' |
Full-Text Search verwenden |
Wildcard beidseitig |
|
WHERE dbo.fn_Kategorie(ID) = 'X' |
Berechnung in die Abfrage inlinen |
Scalar UDF auf Spalte |
|
WHERE UPPER(Code) = 'ABC' |
Case-insensitive Collation oder Index, oder WHERE Code = 'abc' |
Funktion auf Spalte |
|
WHERE CAST(Datum AS DATE) = '2023-05-15' |
Computed Column + Index, oder Bereich-Abfrage |
Explizite Konvertierung |
Tabelle 21-1: SARGability-Killer und ihre Lösungen
Computed Columns: Wenn eine Funktion unvermeidlich ist
Manchmal gibt es legitime Gründe, eine Funktion auf einer Spalte zu brauchen. Vielleicht, weil du eine Drittanwendung nicht ändern kannst, oder, weil das Schema fest vorgegeben ist. In diesen Fällen gibt es einen Ausweg: Computed Columns mit Index.
-- Computed Column hinzufügen: Wert wird bei INSERT/UPDATE berechnet und gespeichert
ALTER TABLE Bestellungen ADD
Bestelldatum_Datum AS CAST(Bestelldatum AS DATE) PERSISTED;
-- PERSISTED bedeutet: Wert wird physisch gespeichert, nicht bei jeder Abfrage neu berechnet
-- Index auf die Computed Column erstellen
CREATE INDEX IX_Bestellungen_Datum ON Bestellungen (Bestelldatum_Datum);
-- Jetzt kann SQL Server den Index nutzen, auch, wenn du CAST() schreibst:
SELECT * FROM Bestellungen WHERE CAST(Bestelldatum AS DATE) = '2023-05-15';
-- → SQL Server erkennt, dass CAST(Bestelldatum AS DATE) der Computed Column entspricht
-- → Index Seek auf IX_Bestellungen_Datum
-- Weitere nützliche Computed Columns:
ALTER TABLE Kunden ADD
Nachname_Upper AS UPPER(Nachname) PERSISTED;
CREATE INDEX IX_Kunden_Nachname_Upper ON Kunden (Nachname_Upper);
-- Jetzt ist auch UPPER()-Suche SARGable:
SELECT * FROM Kunden WHERE UPPER(Nachname) = 'MUELLER';
Computed Columns sind ein elegantes Werkzeug, aber sie haben ihren Preis: jede INSERT/UPDATE-Operation muss den berechneten Wert schreiben. Bei PERSISTED-Columns bedeutet das mehr Schreibarbeit und mehr Speicherbedarf. Der Austausch ist meistens sinnvoll — aber prüfe ihn mit deiner konkreten Workload, bevor du überall Computed Columns hinzufügst.
|
Tipp: SQL Server 2022 — Automatic Parameterization |
|---|
|
Ab SQL Server 2022 gibt es verbesserte Mechanismen zur automatischen Plan-Optimierung. |
|
Aber verlasse dich nicht darauf: Saubere SARGability ist immer besser als auf automatische Korrektur zu hoffen. |
|
Der Optimizer ist klug, aber er kann keine nicht-SARGable Abfrage in eine SARGable umschreiben. |
|
Was er nicht kann: einen Full Scan mit dem Wissen umgehen, dass ein besserer Ausdruck möglich wäre. |
Scalar UDFs: Der SARGability-Killer mit Multiplikatoreffekt
Scalar User-Defined Functions (Scalar UDFs) in WHERE-Klauseln sind das vielleicht schwerste Performance-Antipattern überhaupt — nicht nur, weil sie nicht SARGable sind, sondern, weil sie zusätzlich sequentiell, ohne Parallelismus, und mit hohem Context-Switch-Overhead ausgeführt werden.
-- Diese Abfrage sieht harmlos aus:
SELECT * FROM Bestellpositionen
WHERE dbo.fn_GetKategorie(ProduktID) = 'Elektronik';
-- Was SQL Server tatsächlich tut:
-- 1. Full Table Scan — kein Index nutzbar (Funktion auf Zeilenebene)
-- 2. Für JEDE der z.B. 5 Millionen Zeilen: fn_GetKategorie() aufrufen
-- 3. Jeder Aufruf: Context-Switch in die Funktion, dort ggf. weitere Abfrage
-- 4. Kein Parallelismus: Scalar UDFs erzwingen seriellen Ausführungsplan
-- Wenn fn_GetKategorie() selbst eine Tabellen-Abfrage enthält:
-- 5 Mio. Aufrufe × je 1 Abfrage = 5 Mio. zusätzliche Lesezugriffe
|
Praxisbeispiel: Trendforge Digital: 360.000 Funktionsaufrufe pro Query |
|---|
|
Instanz TFSQL01 — ein klassisches Beispiel dafür, was passiert, wenn Entwickler keine Ahnung von SARGability haben. |
|
Eine Funktion dbo.fn_GetSteuersatz(ProduktID) wird in der WHERE-Klausel und im SELECT verwendet. |
|
Pro Query: 360.000 Aufrufe der Funktion. Die Funktion selbst macht einen JOIN über drei Tabellen. |
|
Ergebnis: 360.000 × 3-Tabellen-JOIN = effektiv 1,08 Millionen Operationen für eine einzige Abfrage. |
|
Laufzeit: 47 Sekunden. Erwartete Laufzeit mit korrektem Design: 0,3 Sekunden. |
|
Wie wir in der Trendforge-Fallstudie in Kapitel 34 sehen werden, war das einer der drei teuersten Befunde — und einer der einfachsten zu lösen. |
Die Lösung für Scalar UDFs hat mehrere Stufen, je, nachdem was möglich ist:
-- Scalar UDF (problematisch):
CREATE FUNCTION dbo.fn_GetKategorie(@ProduktID INT)
RETURNS VARCHAR(50)
AS BEGIN
DECLARE @Kat VARCHAR(50);
SELECT @Kat = Kategorie FROM Produkte WHERE ID = @ProduktID;
RETURN @Kat;
END;
-- Inline TVF (SARGable, Parallelismus möglich):
CREATE FUNCTION dbo.fn_GetKategorie_inline(@ProduktID INT)
RETURNS TABLE
AS RETURN (
-- Eine einzige SELECT-Anweisung, kein BEGIN/END, keine Variablen
SELECT Kategorie FROM Produkte WHERE ID = @ProduktID
);
-- Noch besser: Funktion ganz raus, direkt joinen:
SELECT bp.*
FROM Bestellpositionen bp
JOIN Produkte pr ON pr.ID = bp.ProduktID
WHERE pr.Kategorie = 'Elektronik';
-- → Join, Index Seek auf Produkte.Kategorie, Parallelismus möglich
-- → Laufzeit: Faktor 50-100 schneller als Scalar UDF in WHERE
Scalar UDFs und Stored Procedures hängen eng zusammen — beide können durch schlechtes Design massive CPU-Last erzeugen. In Kapitel 26 (Stored Procedures & T-SQL) gehen wir tiefer auf die verschiedenen Funktionstypen und ihre Performance-Implikationen ein.
OR-Bedingungen und Index-Nutzung
OR-Bedingungen sind nicht grundsätzlich nicht-SARGable, aber sie erschweren dem Optimizer die Arbeit erheblich. SQL Server kann theoretisch für "WHERE Spalte1 = 'A' OR Spalte2 = 'B'" zwei separate Index-Seeks durchführen und die Ergebnisse mit einem OR-Operator zusammenführen (den sogenannten "Index Union"-Plan). In der Praxis wählt der Optimizer aber oft einen Scan, weil die Schätzung für die Ergebnismenge unzuverlässig wird.
-- OR über zwei verschiedene Spalten — Optimizer-Herausforderung
SELECT * FROM Kunden
WHERE Nachname = 'Müller'
OR Vorname = 'Hans';
-- Kann zwei Index Seeks + OR zusammenführen, wenn Indizes vorhanden
-- Oft aber: Table Scan, weil OR-Kardinalität schlecht geschätzt wird
-- Besser: UNION ALL (wenn Überschneidungen unkritisch sind)
SELECT * FROM Kunden WHERE Nachname = 'Müller'
UNION ALL
SELECT * FROM Kunden WHERE Vorname = 'Hans'
AND Nachname <> 'Müller'; -- Duplikate vermeiden
-- Oder: UNION (entfernt Duplikate, aber teurer wegen DISTINCT)
SELECT * FROM Kunden WHERE Nachname = 'Müller'
UNION
SELECT * FROM Kunden WHERE Vorname = 'Hans';
-- Beide UNION-Varianten können je einen eigenen Index Seek verwenden
-- und parallele Ausführung ermöglichen
Parameterisierung und SARGability
SARGability und Parameterisierung hängen enger zusammen als es auf den ersten Blick scheint. Eine SARGable Abfrage nützt wenig, wenn sie nicht parameterisiert ist — denn dann wird für jeden Ausführungswert ein neuer Plan kompiliert, der Plan Cache läuft voll mit Einmal-Plänen, und die CPU-Last durch Compilationen steigt.
-- Schlecht: String-Konkatenation statt Parameterisierung
-- Für jeden anderen @KundenName wird ein neuer Plan erstellt
DECLARE @sql NVARCHAR(500) = 'SELECT * FROM Kunden WHERE Nachname = ''' + @KundenName + '''';
EXEC (@sql);
-- Gut: sp_executesql mit Parametern — SARGable UND wiederverwendbarer Plan
EXEC sp_executesql
N'SELECT * FROM Kunden WHERE Nachname = @Name',
N'@Name NVARCHAR(100)',
@Name = @KundenName;
-- Noch besser: Stored Procedure mit typisiertem Parameter
CREATE PROCEDURE dbo.GetKundeByNachname
@Name NVARCHAR(100)
AS
SELECT * FROM Kunden WHERE Nachname = @Name;
-- → Parametertyp entspricht Spaltentyp → keine implizite Konvertierung
-- → SARGable → Plan wird wiederverwendet
Die Verbindung zwischen Parameterisierung, Plan-Cache und Parameter Sniffing haben wir in Kapitel 18 ausführlich besprochen. Wenn du dort nachschlagen willst: Parameter Sniffing und SARGability sind zwei unabhängige Probleme, die aber beide im selben WHERE-Ausdruck auftreten können — und sich gegenseitig verstärken.
SARGability prüfen — praktische Werkzeuge
Drei Werkzeuge decken 95% aller SARGability-Probleme auf. Du brauchst keine speziellen Tools — alles davon ist direkt in SSMS verfügbar.
Werkzeug 1: Ausführungsplan (Actual Execution Plan)
Strg+M in SSMS aktiviert den Actual Execution Plan. Was du suchst:
Werkzeug 2: SET STATISTICS IO
STATISTICS IO zeigt die Logical Reads — also wie viele 8-KB-Seiten SQL Server lesen musste. Das ist die direkteste Messgröße für die Effizienz einer Abfrage. Wie wir in Kapitel 9 (Wait Statistics) gesehen haben, ist PAGEIOLATCH_SH der Wait Type, der entsteht, wenn diese Seiten nicht im Buffer Pool sind und von Disk gelesen werden müssen.
-- STATISTICS IO einschalten
SET STATISTICS IO ON;
GO
-- Nicht-SARGable Variante
SELECT COUNT(*) FROM Bestellungen WHERE YEAR(Bestelldatum) = 2023;
-- Output: "Table 'Bestellungen'. Scan count 1, logical reads 89.432"
-- SARGable Variante
SELECT COUNT(*) FROM Bestellungen
WHERE Bestelldatum >= '2023-01-01' AND Bestelldatum < '2024-01-01';
-- Output: "Table 'Bestellungen'. Scan count 1, logical reads 7.841"
-- Faktor 11,4× weniger Lesezugriffe — und das bei einer einfachen Umformulierung
SET STATISTICS IO OFF;
Werkzeug 3: Extended Events für hohe Logical Reads
Um systematisch nicht-SARGable Abfragen im laufenden Betrieb zu finden, ist eine Extended Events Session sinnvoll. Kapitel 8 hat die Grundlagen zu Extended Events erklärt — hier die konkrete Session für SARGability-Diagnose:
-- Extended Events Session: Abfragen mit hohen Logical Reads finden
CREATE EVENT SESSION [Hohe_Logical_Reads] ON SERVER
ADD EVENT sqlserver.sql_statement_completed (
ACTION(sqlserver.sql_text, sqlserver.database_name)
WHERE logical_reads > 10000 -- Schwellenwert anpassen
AND database_name = N'Produktion'
)
ADD TARGET package0.ring_buffer
WITH (MAX_DISPATCH_LATENCY = 30 SECONDS);
ALTER EVENT SESSION [Hohe_Logical_Reads] ON SERVER STATE = START;
-- Ergebnisse abfragen:
SELECT
xdr.value('(action[@name="sql_text"]/value)[1]', 'NVARCHAR(MAX)') AS SQL_Text,
xdr.value('(data[@name="logical_reads"]/value)[1]', 'BIGINT') AS Logical_Reads,
xdr.value('(data[@name="duration"]/value)[1]', 'BIGINT') / 1000 AS Dauer_ms
FROM (
SELECT CAST(target_data AS XML) AS TargetData
FROM sys.dm_xe_session_targets t
JOIN sys.dm_xe_sessions s ON s.address = t.event_session_address
WHERE s.name = 'Hohe_Logical_Reads'
) AS Data
CROSS APPLY TargetData.nodes('//RingBufferTarget/event') AS XEventData(xdr)
ORDER BY Logical_Reads DESC;
Diagnose: SARGability-Probleme erkennen und beheben
|
Hinweis: Symptome |
|---|
|
Index vorhanden, trotzdem langsame Abfragen — "Ich habe doch einen Index auf die Spalte!" |
|
Im Ausführungsplan: Table Scan oder Clustered Index Scan statt Index Seek. |
|
SET STATISTICS IO zeigt unverhältnismäßig hohe Logical Reads (>10× mehr als erwartet). |
|
Implicit Conversion Warning im Ausführungsplan (gelbes Ausrufezeichen, Tab "Warnings"). |
|
WHERE-Klauseln mit Funktionsaufrufen auf Tabellenspalten: YEAR(), MONTH(), LEFT(), ISNULL(), UPPER(), CAST() usw. |
|
Scalar UDFs in WHERE-Klauseln oder SELECT — sichtbar im Plan als "User Defined Function"-Knoten. |
|
Abfragen die sich im Laufe des Tages ohne Datenmengenwachstum verlangsamen — Hinweis auf Scans die mit wachsender Tabelle teurer werden. |
|
Tipp: So misst du das |
|---|
|
Schritt 1: Ausführungsplan aktivieren (Strg+M in SSMS) und Abfrage ausführen. |
|
Schritt 2: Suche nach Table Scan / Clustered Index Scan statt Index Seek. |
|
Schritt 3: Klicke auf den Scan-Knoten — "Predicate" zeigt die WHERE-Bedingung. Wenn dort Funktionen stehen: nicht SARGable. |
|
Schritt 4: SET STATISTICS IO ON — vergleiche Logical Reads der nicht-SARGable vs. der SARGable Variante. |
|
Schritt 5: Warnungen im Plan prüfen — "Type conversion in expression may affect CardinalityEstimate" ist der direkteste Implicit-Conversion-Hinweis. |
|
Schritt 6: Für systematische Suche die Extended Events Session "Hohe_Logical_Reads" einsetzen (s. Code-Beispiel oben). |
|
Schritt 7: sys.dm_exec_query_stats abfragen für historische Logical Reads — Abfragen mit top_logical_reads sind häufig nicht SARGable. |
|
Warnung: Typische Fehlinterpretationen |
|---|
|
"Ich habe einen Index auf die Spalte, also wird er genutzt." — Falsch. Index + nicht-SARGable WHERE = Full Scan. Der Index existiert, aber SQL Server kann ihn nicht navigieren. |
|
"UPPER() in der WHERE-Klausel schadet nicht viel." — Falsch. Bei 10 Millionen Zeilen bedeutet das 10 Millionen Funktionsaufrufe. Das schadet sehr viel. |
|
"LIKE '%wert%' ist ok, wenn die Tabelle klein ist." — Heute klein, morgen groß. Eine Tabelle mit 10.000 Zeilen wird mit der Zeit zu 10 Millionen. Dann ist das Problem da. |
|
"Der Plan zeigt einen Index Scan — das ist doch ein Index." — Index Scan ≠ Index Seek. Ein Index Scan liest alle Blätter des Index sequentiell — fast so teuer wie ein Table Scan. |
|
"Computed Columns sind teuer." — Sie kosten bei Writes etwas mehr, aber sie ermöglichen SARGable Abfragen. In Read-heavy Workloads (und die meisten OLTP-Systeme sind das) ist der Kompromiss fast immer sinnvoll. |
|
"NOT IN ist genauso gut wie NOT EXISTS." — Nicht bei NULL-Werten. NOT IN mit einer Subquery die NULLs liefern kann, gibt immer eine leere Ergebnismenge zurück. Immer. Ohne Fehlermeldung. |
|
Tipp: Erste Gegenmaßnahmen |
|---|
|
Sofort: Alle WHERE-Klauseln in gespeicherten Prozeduren und Inline-SQL auf Funktionsaufrufe auf Spalten prüfen. Jeder Fund ist ein direkter Kandidat für eine Umformulierung. |
|
Sofort: Implicit Conversion Warnings im Ausführungsplan eliminieren. Parametertypen in gespeicherten Prozeduren mit Spaltentypen abgleichen — das ist oft eine 5-Minuten-Aufgabe. |
|
Kurzfristig: LIKE-Abfragen mit führendem Wildcard identifizieren. Wenn echte Volltextsuche benötigt wird: Full-Text Search einführen. |
|
Mittelfristig: Scalar UDFs in WHERE-Klauseln durch JOINs oder Inline-TVFs ersetzen. Das ist manchmal aufwändiger, aber der Laufzeitgewinn ist dramatisch. |
|
Mittelfristig: Für unvermeidbare Funktionsaufrufe Computed Columns mit Indizes einführen. |
|
Baseline: Logical Reads der wichtigsten 20 Abfragen dokumentieren. Nach Optimierungen vergleichen. Kapitel 9 erklärt, wie du eine vernünftige Baseline aufbaust. |
Zusammenfassung
SARGability ist das stille Performance-Problem. Kein Error, kein Warning, keine offensichtliche Ursache — nur eine Abfrage, die trotz vorhandenem Index langsam ist. Dabei lassen sich die Ursachen auf eine überschaubare Liste reduzieren: Funktionen auf Spalten, implizite Konvertierungen, führende Wildcards, Arithmetik auf Spalten, und Scalar UDFs.
Die Lösung ist in den meisten Fällen einfach: Verlagere Berechnungen auf die Parameterseite, nicht auf die Spaltenseite. Stimme Parametertypen mit Spaltentypen ab. Vermeide Scalar UDFs in WHERE-Klauseln — oder ersetze sie durch Inline-TVFs. Und prüfe jeden neuen WHERE-Ausdruck mit STATISTICS IO, bevor er in Produktion geht.
Die drei wichtigsten Metriken nach jeder Optimierung: Logical Reads vorher vs. nachher, Laufzeit vorher vs. nachher, und der Ausführungsplan — Seek statt Scan. Diese drei Werte zeigen dir in 30 Sekunden, ob die Änderung etwas gebracht hat.
|
Hinweis: Querverweise |
|---|
|
Kapitel 15: Ausführungspläne lesen — Index Seek vs. Index Scan visuell erkennen. |
|
Kapitel 16: Statistiken und Cardinality Estimator — warum der Optimizer manchmal trotz SARGable Abfrage einen Scan wählt. |
|
Kapitel 17: Index-Strategie — welche Spalten indizieren, wann Covering-Indizes, wann Composite-Indizes. |
|
Kapitel 20: Datentypen — Implicit Conversion im Detail, welche Typkombinationen problematisch sind. |
|
Kapitel 26: Stored Procedures & T-SQL — Scalar UDFs, iTVFs, Scalar UDF Inlining. |
|
Kapitel 34: Trendforge-Fallstudie — fn_GetSteuersatz als dokumentierter Praxisfall mit Messwerten. |
Kapitel 22 setzt direkt hier an — aber aus einer anderen Perspektive: Nicht "wie schreibe ich meine WHERE-Klausel SARGable?", sondern "welche Indizes brauche ich, damit meine Abfragen überhaupt SARGable sein können?" Index-Strategie aus Entwicklersicht — wann brauche ich einen Covering Index, wann einen Composite Index, und wann ist kein Index die richtige Antwort?

Abb. 1: SARGability-Killer: Funktionen auf Index-Spalten
Kapitel 22
