Ein Contains(), vier EF-Core-Versionen: gleicher Code, anderes SQL
Eine Zeile LINQ, ids.Contains(p.Id), gehört zu den häufigsten Filtern in EF-Anwendungen. Welches SQL daraus wird, hat sich von EF Core 7 bis EF Core 10 zweimal grundlegend geändert, ohne dass jemand den Anwendungscode anfasst.
Im Lab haben wir dieselbe Abfrage mit EF Core 7, 8, 9 und 10 gegen SQL Server 2022 und 2025 laufen lassen. Ergebnis: Jede Version hat ihre Stärke und ihren blinden Fleck. EF Core 7 füllt den Plan Cache, EF Core 8 und 9 entlasten ihn, wählen aber bei schief verteilten Daten einen Plan, der im Lab mehr als doppelt so viel CPU und dreißigmal so viele Lesezugriffe kostete wie nötig. EF Core 10 liegt dazwischen und lässt dafür den ersten Aufruf über den Plan entscheiden. Welche Variante für Ihre Abfrage passt, hängt von den Daten ab, nicht von der Versionsnummer.
Drei Übersetzungen derselben Zeile
var produkte = await db.Products
.Where(p => ids.Contains(p.Id))
.ToListAsync();So sieht die Bedingung [p].[Id] … in der WHERE-Klausel aus, gekürzt aus den Labprotokollen:
| EF Core | SQL für drei Werte | Parameter |
|---|---|---|
| 7 | IN (1300871, 877705, 1518750) | keine, die Werte stehen im Text |
| 8 und 9 | IN (SELECT … FROM OPENJSON(@__ids_0) …) | einer, die Liste als JSON-Text |
| 10 | IN (@ids1, @ids2, @ids3) | je Wert einer |
Ausgeschrieben lautet die Form von EF Core 8 und 9:
WHERE [p].[Id] IN (
SELECT [i].[value]
FROM OPENJSON(@__ids_0) WITH ([value] int '$') AS [i]
)Ein paar Details aus den Messungen, die im Alltag zählen:
- EF Core 7 schreibt die Werte in den SQL-Text. Jede andere Liste ergibt einen anderen Text. Bei 3.000 Werten war der Befehl gut 25.000 Zeichen lang.
- EF Core 8 und 9 schicken immer denselben Text. Der Parameter ist bis zu einer gewissen Länge
nvarchar(4000), bei langen Listennvarchar(max); im Lab lag der Wechsel zwischen 100 und 1.000 Werten. Auch eine leere Liste geht überOPENJSON. - EF Core 10 nimmt je Wert einen eigenen Parameter und füllt die Liste auf feste Größen auf: Für 1 bis 50 Werte entstanden nur zehn verschiedene Texte (1, 2, 3, 4, 5, 10, 20, 30, 40 und 50 Parameter). Die aufgefüllten Plätze wiederholen den letzten Wert. Ab 2.100 Werten, der Grenze von SQL Server für Parameter je Befehl, wechselt EF Core 10 auf
OPENJSON. Die Parameter heißen außerdem kürzer (@ids1statt@__ids_0).
Alle Varianten lieferten in allen 392 geprüften Fällen dieselben Zeilen. Es geht hier also nicht um Richtigkeit, sondern um Kosten.
Plan Cache: 500 Texte, einer oder zehn
Im ersten Versuch ruft die Anwendung die Abfrage 500-mal auf, mit zufälligen Listen von 1 bis 50 Werten, wie es etwa eine Merkliste oder eine Mehrfachauswahl im Filter tut. Gemessen auf SQL Server 2022, Median aus fünf Wiederholungen:
| EF Core | Texte im Plan Cache | Speicher im Plan Cache | Kompilierungen | SQL-CPU je Aufruf | Antwortzeit je Aufruf (Median) |
|---|---|---|---|---|---|
| 7 (Konstanten) | 500 | 39,6 MB | rund 500 | 4,5 ms | 3,5 ms |
| 8 und 9 (JSON-Parameter) | 1 | 0,2 MB | praktisch keine | 0,86 ms | 1,9 ms |
| 10 (Parameterliste) | 10 | 1,4 MB | rund 10 | 1,0 ms | 2,3 ms |
Mit Konstanten kompiliert SQL Server fast jeden Aufruf neu. Das kostet CPU und füllt den Plan Cache mit Einmal-Plänen, die andere, wichtigere Pläne verdrängen können. Microsoft nennt das in der Dokumentation zu EF Core 10 ausdrücklich als Grund für die Änderung. Bis hierhin sieht EF Core 8 wie der klare Sieger aus.
Schätzung und Plan bei schiefen Daten
Der zweite Versuch filtert nicht nach dem Primärschlüssel, sondern nach einer Kategorie:
var preise = await db.Products
.Where(p => kategorien.Contains(p.CategoryId))
.Select(p => new { p.Id, p.Price })
.ToListAsync();Die Tabelle hat 2 Millionen Zeilen. Eine Kategorie ist groß (800.000 Zeilen), 50.000 Kategorien sind klein (je 20 Zeilen). Solche Verteilungen sind in echten Daten normal: ein Hauptmandant, ein Standardstatus, ein Großkunde. Die Anwendung fragt einmal fünf kleine Kategorien ab (100 Zeilen) und einmal eine Liste, die die große enthält (800.080 Zeilen).
Für 100 Zeilen ist ein Index Seek mit Lookup richtig, für 800.000 Zeilen ein Scan der Tabelle. Was SQL Server wählt, hängt davon ab, was er über die Werte weiß (CPU je Ausführung, Median der wiederholten Ausführungen):
| EF Core | Was SQL Server sieht | Plan für 100 Zeilen | Plan für 800.080 Zeilen |
|---|---|---|---|
| 7 (Konstanten) | die konkreten Werte | Seek, 0,7 ms CPU, 333 Reads | Scan, 0,9 s CPU, 81.380 Reads |
| 8 und 9 (JSON-Parameter) | einen Text, dessen Inhalt er nicht kennt | Seek, 0,8 ms CPU | Seek, 2,0 s CPU, 2,55 Mio. Reads |
| 10, erster Aufruf mit kleinen Kategorien | die Werte des ersten Aufrufs | Seek, 0,8 ms CPU | Seek, 2,0 s CPU, 2,55 Mio. Reads |
| 10, erster Aufruf mit der großen Kategorie | die Werte des ersten Aufrufs | Scan, 385 ms CPU, 81.380 Reads | Scan, 0,9 s CPU |
Was dahintersteckt:
- Mit dem JSON-Parameter schätzt SQL Server blind. Er weiß nicht, welche Werte im JSON stehen, und rechnet mit einer festen Annahme: im Lab 282,7 Zeilen, egal welche Liste kommt. Das passt für die kleinen Kategorien und ist für die große um den Faktor 2.800 daneben. Der Plan bleibt immer derselbe: viele Einzelzugriffe über den Index, für 800.000 Zeilen 2,55 Millionen gelesene Seiten statt 81.380.
- Mit der Parameterliste von EF Core 10 schaut SQL Server beim ersten Aufruf auf die Werte und optimiert für genau diese. Das ist klassisches Parameter Sniffing: Kam zuerst die kleine Liste, bekommt die große einen zu kleinen Plan. Kam zuerst die große, scannt die kleine Liste die ganze Tabelle, 385 ms CPU statt 0,7 bis 0,8 ms, mehr als das 400-Fache. Nach einem Neustart oder einer Statistikaktualisierung kann es jeweils anders herum sein. Das typische Bild „mal schnell, mal langsam“ beschreibt der Performance-Ratgeber.
- Mit Konstanten bekommt jede Liste ihren passenden Plan, zum Preis der Kompilierungen aus dem ersten Versuch.
Für den Primärschlüssel aus dem ersten Versuch spielt das keine Rolle: Jeder Wert trifft genau eine Zeile, jede Schätzung passt. Die Frage „welche Übersetzung ist besser“ hat deshalb keine allgemeine Antwort. Sie hängt an der Spalte.
Wenn der Compatibility Level nicht passt
OPENJSON setzt einen Compatibility Level der Datenbank von mindestens 130 voraus. Datenbanken, die nach einem Server-Upgrade auf einem alten Level stehen geblieben sind, gibt es häufiger, als man denkt. Im Lab mit Level 120:
- EF Core 8 scheiterte bei jeder Liste, auch der leeren, mit Fehler 102 (
Incorrect syntax near '$'.). Das passiert nicht beim Upgrade, sondern beim ersten Aufruf in Produktion. - EF Core 10 lief mit Level 120 zunächst problemlos, weil es Parameter statt JSON verwendet. Erst ab 2.100 Werten wechselt es auf
OPENJSONund scheitert dann mit demselben Fehler. Ein Test mit kleinen Listen bemerkt das nicht. UseCompatibilityLevel(120)in der Provider-Konfiguration bringt EF dazu, wieder Konstanten zu schreiben. Es ändert aber nicht den Compatibility Level der Datenbank, sondern nur, welches SQL EF erzeugt. Die Datenbank bleibt, wie sie ist.
Prüfen Sie deshalb vor dem Update den tatsächlichen Level: SELECT name, compatibility_level FROM sys.databases;
Steuern statt hinnehmen
Seit EF Core 8.0.2 und vollständig ab EF Core 9 lässt sich die Übersetzung steuern, global oder für eine einzelne Abfrage. Im Lab haben alle Varianten gebaut, liefen fehlerfrei und erzeugten die erwartete Form:
- Für eine einzelne Abfrage (EF Core 9 und 10):
EF.Constant(ids)schreibt Konstanten,EF.Parameter(ids)schickt einen JSON-Parameter. EF Core 10 kennt zusätzlichEF.MultipleParameters(ids)für die Parameterliste. - Als Standard für alle Abfragen in EF Core 9: in der Provider-Konfiguration die Methoden der Familie
TranslateParameterizedCollections…, also…ToConstants()bzw.…ToParameters(). - Als Standard für alle Abfragen in EF Core 10:
UseParameterizedCollectionMode(…)mitParameterTranslationMode.Constant,.Parameteroder.MultipleParameters.
Eine Faustregel aus den Messungen, keine Garantie für Ihre Daten:
- Gleichmäßig verteilte Spalten (Primärschlüssel, eindeutige Nummern): JSON-Parameter oder Parameterliste. Ein Plan für alle passt, der Plan Cache bleibt schlank.
- Schief verteilte Spalten (Status, Mandant, Kategorie mit Ausreißern):
EF.Constantfür genau diese Abfrage, wenn sie nicht hundertmal je Sekunde läuft. Die Kompilierung kostet hier weniger als ein falscher Plan. - Häufige Abfrage auf schiefer Spalte: Das ist kein EF-Thema mehr. Dann helfen getrennte Abfragen für die großen Werte, ein passender Index oder eine Planführung auf SQL-Server-Seite.
Mit EF.Constant gab es im Lab eine Überraschung: Unter EF Core 8 brauchte derselbe Aufruf im Client fast doppelt so lange wie mit EF Core 7 (6,6 statt 3,5 ms), obwohl das SQL identisch war. Unter EF Core 9 und 10 war der Unterschied verschwunden. Wir haben die Ursache nicht weiter untersucht.
Ein Upgrade prüfen
Am 10. November 2026 endet der Support für EF Core 8 und 9. Wer jetzt auf EF Core 10 umstellt, ändert das SQL vieler Contains()-Abfragen, und mit den neuen Parameternamen auch den Text jeder anderen Abfrage mit Parametern. Nach dem Update kompiliert SQL Server diese Anweisungen deshalb neu. Ein kurzer Prüfplan:
- Compatibility Level der Datenbanken prüfen, bevor EF Core 10 Listen mit mehr als 2.100 Werten über
OPENJSONschickt. - Die wichtigsten Abfragen mit Listen finden. Im Code nach
.Contains(auf Listen suchen, im Query Store nachOPENJSON(@oder langen Parameterlisten. Die Abfrage auf der Übersichtsseite zeigt beides. - Für jede dieser Abfragen klären, ob die Spalte schief verteilt ist. Eine einfache Gruppierung nach der Spalte mit
COUNT(*)reicht. - Vor und nach dem Update mit realistischen Daten messen, und zwar mit großen und kleinen Listen in beiden Reihenfolgen. Ein einzelner Testlauf zeigt nur den Plan, der zufällig zuerst kompiliert wurde.
- Erst EF, dann den Compatibility Level ändern, nicht beides auf einmal. Sonst lässt sich nicht sagen, was eine Veränderung ausgelöst hat.
Weitere EF-Themen: Übersicht · Include, Projektion und Split Query
Quellen
- Microsoft: EF Core 8: Queries with primitive collections
- Microsoft: EF Core 8: Contains kann auf älteren SQL-Server-Versionen scheitern und mögliche Performance-Regressionen
- Microsoft: EF Core 9: Parameterized primitive collections
- Microsoft: EF Core 10: Improved translation for parameterized collection und Breaking Changes
- Microsoft: Compatibility Level im SQL-Server-Provider
- Microsoft: OPENJSON