Entity Framework Core

Ein Werkzeug, keine Regel

"AsNoTracking ist schneller" ist die Art von Ratschlag, die überlebt, weil sie meistens stimmt. Sie erzählt auch nur die halbe Geschichte: AsNoTracking schaltet nicht nur das Change Tracking ab, sondern auch die Identitätsauflösung. Wir haben alle drei Tracking-Modi gegen eine SQLite-In-Memory-Datenbank gebencht, um zu sehen, wo das tatsächlich etwas kostet.

Tracking (default)
Volles Change Tracking. Dieselbe Entität, dieselbe Instanz, jedes Mal, auf Kosten eines Snapshots pro Zeile.
AsNoTracking
Kein Change Tracker, aber auch keine Identity Map: Jede Zeile baut eine neue Instanz, selbst für einen Blog, den sich 500 Posts teilen.
AsNoTrackingWithIdentityResolution
Kein Change Tracker, aber die Identity Map ist zurück: geteilte Entitäten sind wieder eine einzige Instanz.
Behauptung

"Einfach AsNoTracking dazu, das ist schneller"

Meistens ist das ein guter Ratschlag. AsNoTracking sagt EF Core, das Snapshotting zu überspringen: kein ChangeTracker-Eintrag, keine Kopie der Originalwerte, kein Proxy, der das Objekt auf Änderungen überwacht. Für einen reinen Lese-Endpunkt, der Daten nur in eine Response projiziert, ist das fast geschenkte Performance, und jede Performance-Checkliste für EF Core empfiehlt es.

Was dieser Ratschlag meist auslässt: Der Change Tracker ist auch der Ort, an dem die Identitätsauflösung lebt. Schaltet man ihn ab, schaltet man die auch ab. EF Core sagt das direkt im eigenen Quellcode: Die Dokumentation von AsNoTracking hält fest, dass keine Identitätsauflösung stattfindet, und dass eine Entität mit demselben Schlüssel bei mehrfachem Auftreten im Ergebnis jedes Mal eine andere Instanz ist.

Identität

Ein Blog, fünfhundert Posts, eine Zeile Code

Nehmen wir einen Blog mit 500 Posts und fragen die Posts mit Include(p => p.Blog) ab. Mit aktivem Tracking erkennt EF Core, dass derselbe Blog-Schlüssel 500 Mal zurückkommt, und gibt 500 Referenzen auf ein einziges Objekt zurück. Mit AsNoTracking erkennt es das nicht mehr: Jede Zeile baut einen neuen Blog, obwohl die zugrunde liegenden Daten identisch sind. Nichts stürzt ab, aber ReferenceEquals(posts[0].Blog, posts[1].Blog) kippt lautlos von true auf false, und jeder Code, der sich darauf verlässt (Caching über Referenzen, Mutieren eines geteilten In-Memory-Graphen, Deduplizieren für eine UI), bricht still und leise.

Genau für diese Lücke gibt es AsNoTrackingWithIdentityResolution: keine ChangeTracker-Einträge (also auch kein SaveChanges, genau wie bei AsNoTracking), aber die Identity Map ist zurück, sodass wiederkehrende Schlüssel wieder auf eine gemeinsame Instanz auflösen. Wir haben dieselbe Abfrage gegen eine SQLite-In-Memory-Datenbank laufen lassen und die tatsächlichen Objektinstanzen gezählt:

Dieselbe Abfrage, drei Arten
// 40 Blogs, 500 Posts each - every Blog is referenced by 500 rows.
var tracked = context.Posts.Include(p => p.Blog)
    .ToList();

var noTracking = context.Posts.Include(p => p.Blog)
    .AsNoTracking()
    .ToList();

var withIdentityResolution = context.Posts.Include(p => p.Blog)
    .AsNoTrackingWithIdentityResolution()
    .ToList();
40

Tracking (default) - 20'000 Zeilen rein, 40 unterschiedliche Blog-Instanzen raus

20'000

AsNoTracking - 20'000 Zeilen rein, 20'000 unterschiedliche Blog-Instanzen raus

40

AsNoTrackingWithIdentityResolution - 20'000 Zeilen rein, 40 unterschiedliche Blog-Instanzen raus

Benchmark

Also gewinnt AsNoTrackingWithIdentityResolution einfach, oder?

Nicht ganz, oder besser: Es kommt darauf an. Korrektheit ist nicht dieselbe Frage wie Performance, und die Antwort auf "was ist schneller" hängt komplett von der Form der Abfrage ab: wie viele Zeilen zurückkommen und wie viele davon sich denselben Schlüssel teilen. Wir haben drei Formen mit BenchmarkDotNet 0.15.8 gegen eine Microsoft.Data.Sqlite-In-Memory-Datenbank gemessen, eine einzige offen gehaltene Verbindung hinter DataSource=:memory:, damit keine externe Datenbank die Zahlen verzerrt, auf einem Apple M2 Pro mit .NET 10.0.11.

Szenario 1 und 2 nutzen dasselbe kleine Blog/Post-Paar von oben: 40 Blogs, je 500 Posts, 20'000 Posts insgesamt. Szenario 3 tauscht das gegen eine deutlich schwerere geteilte Entität und einen deutlich höheren Fan-out, um zu sehen, ob sich die Antwort dadurch ändert.

SQLite-In-Memory-Setup
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open(); // kept open for the run's lifetime - closing it drops the data

var options = new DbContextOptionsBuilder<BlogContext>()
    .UseSqlite(connection)
    .Options;

Szenario 1: geteilte Referenzen (Include mit Fan-out)

context.Posts.Include(p => p.Blog).ToList() - jede der 20'000 Zeilen referenziert eine von nur 40 Blog-Entitäten.

Methode Mittelwert Allokiert Zeit-Verhältnis Alloc-Verhältnis
Tracking (default) 51.36 ms 28.37 MB 1.00x 1.00x
AsNoTracking 33.51 ms 22.78 MB 0.65x 0.80x
AsNoTrackingWithIdentityResolution 52.77 ms 35.39 MB 1.03x 1.25x

Szenario 2: keine geteilten Referenzen (eine flache Abfrage)

context.Posts.ToList() - dieselben 20'000 Zeilen, aber ohne Include und ohne irgendetwas zu deduplizieren. Jeder Post ist ohnehin schon einzigartig.

Methode Mittelwert Allokiert Zeit-Verhältnis Alloc-Verhältnis
Tracking (default) 29.68 ms 23.96 MB 1.00x 1.00x
AsNoTracking 17.13 ms 11.52 MB 0.58x 0.48x
AsNoTrackingWithIdentityResolution 29.28 ms 23.96 MB 0.99x 1.00x

Szenario 3: eine schwere geteilte Entität, hoher Fan-out

context.Purchases.Include(o => o.Customer).ToList() - ein Customer mit 14 Spalten, referenziert von je 2'000 Purchases: 50 Customers, 100'000 Zeilen insgesamt.

Methode Mittelwert Allokiert Zeit-Verhältnis Alloc-Verhältnis
Tracking (default) 229.4 ms 110.21 MB 1.00x 1.00x
AsNoTracking 334.5 ms 175.00 MB 1.46x 1.59x
AsNoTrackingWithIdentityResolution 257.3 ms 145.31 MB 1.12x 1.32x

Hier dreht sich die Reihenfolge um. AsNoTrackingWithIdentityResolution schlägt AsNoTracking um rund 23% bei der Zeit und 17% beim Speicher. An volles Tracking kommt es immer noch nicht heran, aber zwischen den beiden No-Tracking-Optionen gewinnt genau die, von der alle annehmen, sie sei "immer langsamer", weil sie den breiten Customer nur einmal statt 2'000 Mal aufbauen muss.

Gemessen auf: Apple M2 Pro · .NET 10.0.11 · BenchmarkDotNet v0.15.8 · MemoryDiagnoser

Keiner der drei Modi gewinnt durchgehend, und genau das ist der Punkt. In Szenario 1 und 2, wo die geteilte Entität winzig ist oder es nichts Geteiltes gibt, ist AsNoTracking bei jeder Kennzahl die günstigste Option, und AsNoTrackingWithIdentityResolution holt nie wieder rein, was es für seinen eigenen State-Manager ausgibt. In Szenario 3, wo die geteilte Entität breit ist und 2'000-fach wiederverwendet wird, zahlt sich das aus: AsNoTrackingWithIdentityResolution schlägt AsNoTracking bei Zeit und Speicher, weil es den Customer nicht zehntausendfach neu materialisieren muss. Dieselben zwei Optionen, umgekehrter Sieger, nur die Datenform hat sich geändert.

Fazit

Identitätsauflösung ist kein kostenloses HashSet

Der EF-Core-Quellcode erklärt die Zahl. QueryContext.InitializeStateManager nimmt ein standAlone-Flag entgegen: Ist es true, baut EF Core einen eigenständigen IStateManager rein zum Zweck der Identitätsauflösung auf, statt den des DbContext wiederzuverwenden. AsNoTrackingWithIdentityResolution schraubt keine schlanke Lookup-Tabelle an AsNoTracking: Für jede materialisierte Entität baut es dieselben InternalEntityEntry- und ISnapshot-Objekte auf, die eine voll getrackte Abfrage auch erzeugt, nur innerhalb dieses Wegwerf-StateManagers, der nie an den DbContext angehängt wird und nie an SaveChanges teilnimmt.

Diese Maschinerie kostet immer gleich viel, egal ob sie je gebraucht wird, deshalb landen die Allokationen in Szenario 2 exakt auf der Zahl von vollem Tracking, obwohl es nichts zu deduplizieren gab. Aber sie kauft auch etwas Konkretes: EF Core überspringt die volle Re-Materialisierung für einen Schlüssel, zu dem schon ein Entry existiert, liest und alloziert die Spalten dieser Entität also kein zweites Mal. Ist die Entität klein und selten wiederholt, wie in Szenario 1 und 2, gibt es nichts zu überspringen, und man bezahlt nur für den State-Manager. Ist die Entität breit und wird oft wiederverwendet, wie in Szenario 3, überwiegt die Ersparnis durch das Überspringen zehntausender überflüssiger Materialisierungen diesen fixen Overhead, und AsNoTrackingWithIdentityResolution zieht an AsNoTracking vorbei.

QueryContext.cs, dotnet/efcore (vereinfacht)
public void InitializeStateManager(bool standAlone = false)
    // standAlone: true for AsNoTrackingWithIdentityResolution.
    // A throwaway StateManager is built just for this query's identity map;
    // it is never attached to the DbContext and never sees SaveChanges.
    => _stateManager ??= standAlone
        ? new StateManager(Dependencies.StateManager.Dependencies)
        : Dependencies.StateManager;
Wahl

Keiner der drei ist die Standardantwort

Der richtige Modus hängt davon ab, was die Abfrage mit dem Ergebnis tatsächlich macht, und wie gross und wie oft wiederholt die Daten sind, nicht davon, welcher Modus isoliert am besten benchmarkt.

Tracking (default)

Man will die Entitäten verändern und SaveChanges aufrufen. Das ist der einzige der drei Modi, der das überhaupt unterstützt, Punkt.

AsNoTracking

Nur lesend, und was auch immer sich wiederholt, ist klein: eine flache Liste, eine einzelne Tabelle, oder eine schmale geteilte Entität wie unser Blog. Genau hier stimmt die gängige Faustregel: am günstigsten bei Zeit und Speicher, solange es nicht viel zu duplizieren gibt.

AsNoTrackingWithIdentityResolution

Nur lesend, und entweder hängt der eigene Code von Referenzgleichheit ab (eine geteilte Navigationseigenschaft, die mit == verglichen wird, ein Graph, der pro eindeutiger Instanz einmal serialisiert wird), oder die geteilten Entitäten sind breit und werden stark wiederverwendet. Der erste Fall ist ein Korrektheits-Trade-off, den man bewusst eingeht. Der zweite ist ein echter Performance-Gewinn: Unser Benchmark mit der schweren Entität zeigt, wie es AsNoTracking um 23% bei der Zeit und 17% beim Speicher schlägt, einfach indem es dieselbe breite Zeile nicht tausendfach neu aufbaut.

Das Wichtigste

AsNoTracking, AsNoTrackingWithIdentityResolution und volles Tracking kaufen jeweils eine andere Mischung aus Change Tracking und Identitätsauflösung, und keines davon ist kostenlos oder immer das Beste. Über unsere drei Benchmarks hinweg hat AsNoTracking klar gewonnen, wenn die geteilten Daten klein oder nicht vorhanden waren, aber AsNoTrackingWithIdentityResolution hat es um 23% bei der Zeit und 17% beim Speicher geschlagen, sobald die geteilte Entität breit und stark wiederverwendet wurde. Ob sich die Identitätsauflösung lohnt, hängt komplett von der Form der eigenen Daten ab, nicht davon, was man letztes Mal genommen hat. Das ist keine Regel zum Auswendiglernen, das ist ein Werkzeug, das man auf die eigene Workload richten und messen muss.

Kontakt

Lassen Sie uns gemeinsam etwas Grossartiges aufbauen

Haben Sie eine Frage oder möchten Sie ein Projekt besprechen? Wir freuen uns, von Ihnen zu hören. Füllen Sie das untenstehende Formular aus und wir melden uns so schnell wie möglich bei Ihnen.

0 / 2000
oder

Sie können uns auch direkt erreichen unter [email protected]