Start Debugging

EF Core 11 vs. Dapper für Bulk-Inserts: echtes Benchmark

Für Bulk-Inserts in .NET 11 gewinnt weder EF Core noch Dapper. SqlBulkCopy gewinnt. Das ist das Benchmark, das Warum und der Platz, den jedes Werkzeug verdient.

Wenn Sie aus .NET 11 mehr als ein paar Tausend Zeilen in SQL Server einfügen, lautet die richtige Antwort selten “EF Core” und selten “Dapper”. Die richtige Antwort ist SqlBulkCopy, direkt aus der Verbindung beider Werkzeuge aufgerufen. AddRange + SaveChangesAsync von EF Core 11 ist die sauberste Wahl für weniger als 1.000 Zeilen. ExecuteAsync von Dapper mit einer Parameterliste ist von den dreien bei jeder Zeilenanzahl das schlechteste und das, was Sie für Bulk-Loads meiden sollten. Unten finden Sie die Entscheidungstabelle, die Benchmark-Zahlen dahinter und den Code für jeden Pfad auf Microsoft.EntityFrameworkCore 11.0.0, Microsoft.Data.SqlClient 6.1 und Dapper 2.1.66.

Funktionsmatrix auf einen Blick

FunktionEF Core 11 AddRangeDapper ExecuteAsyncSqlBulkCopy
Zugrundeliegendes ProtokollGebündelte INSERT-AnweisungenINSERT-Anweisungen pro ZeileTDS Bulk Copy (nativer Bulk-Load)
Change TrackingJaNeinNein
Identitätswerte zurück auf die Entität geschriebenJa (via OUTPUT INSERTED.Id)Nein (manuelles SELECT SCOPE_IDENTITY())Nur mit KeepIdentity und expliziten Werten
Beziehungen und kaskadierende InsertsJaNeinNein
Speicher bei 100K Zeilen (SQL Server)~Hunderte MB~Dutzende MB~Dutzende MB, streaming-freundlich
Insert-Zeit für 100K Zeilen (siehe Methodik)~2,1 s~10,9 s~0,65 s
Insert-Zeit für 1M Zeilen~21,6 s~109 s~7,3 s
Nur SQL ServerNein (läuft auf jedem EF-Provider)NeinJa (Microsoft.Data.SqlClient)
Code-KomplexitätAm niedrigstenNiedrigMittel (Tabellen-Mapping erforderlich)
Funktioniert mit IAsyncEnumerable<T>-StreamingNein (lädt Entitäten zuerst)NeinJa (via IDataReader)
Transaktion mit dem Rest des EF-Unit-of-WorkJaManuellManuell (SqlTransaction)
LizenzMITApache 2.0MIT

Die Tabelle ist die Empfehlung. Alles darunter ist das Warum.

Wann AddRange + SaveChangesAsync von EF Core 11 korrekt ist

EF Core 11 bündelt Inserts intelligent. Der SQL-Server-Provider gruppiert eingefügte Entitäten in mehrzeilige INSERT ... VALUES (...), (...), ...-Anweisungen mit bis zu 1.000 Zeilen pro Batch (das harte Limit von SQL Server für tabellenwertige Parameter) oder splittet bei 2.100 Parametern pro Batch, je nachdem was zuerst eintritt. Für eine Entität mit 200 Spalten kollabiert die praktische Batch-Größe auf einstellige Zeilenzahlen, weil Parameter dominieren; für eine Entität mit fünf Spalten bekommen Sie die vollen 1.000-Zeilen-Batches.

Wählen Sie AddRange, wenn:

// .NET 11, EF Core 11.0.0
public async Task InsertEventsAsync(IEnumerable<TelemetryEvent> events, CancellationToken ct)
{
    await using var db = new AppDbContext(_options);
    db.TelemetryEvents.AddRange(events);
    await db.SaveChangesAsync(ct);
}

Das ist der Platz, für den EF Core entworfen wurde. Die Kosten sind Allokation: jede Entität wird materialisiert, im Change Tracker verfolgt und im DbContext gehalten, bis SaveChanges committet. Für 100K Zeilen einer breiten Entität sind das Hunderte Megabyte GC-Druck. Für 1.000 Zeilen ist es irrelevant.

Wenn Sie diesen Weg für mittlere Batches gehen, helfen zwei Stellschrauben:

Wann ExecuteAsync von Dapper korrekt ist (und wann nicht)

Die Bulk-Geschichte von Dapper ist berühmt einfach: übergeben Sie eine Collection, bekommen Sie einen INSERT pro Zeile in einem Netzwerk-Roundtrip.

// .NET 11, Dapper 2.1.66, Microsoft.Data.SqlClient 6.1.3
using var conn = new SqlConnection(_connectionString);
await conn.ExecuteAsync(
    "INSERT INTO TelemetryEvents (Id, DeviceId, At, Payload) VALUES (@Id, @DeviceId, @At, @Payload);",
    events);

Angenehm zu schreiben. Langsam in der Skalierung. Dapper sendet eine parametrisierte Anweisung pro Element in der Collection, gebündelt in einem einzigen Netzwerk-Roundtrip. SQL Server parst, plant und führt trotzdem jeden INSERT einzeln aus. Es gibt keine Zeilenbündelung wie bei EF Core, kein natives Bulk-Protokoll und keine Parallelität auf Anweisungsebene.

Wählen Sie Dappers ExecuteAsync für Inserts, wenn:

Wählen Sie Dapper nicht für Bulk-Inserts mit >1.000 Zeilen. Die Kosten pro Zeile sind real, die Netzwerkersparnis ist klein, und Sie haben ein besseres Werkzeug einen Namespace entfernt. Wenn Sie nach einem “schnellen” Insert aus Dapper greifen, wollen Sie fast sicher Dapper.Plus (kommerziell) oder, ehrlicher gesagt, das SqlBulkCopy, das Sie aus der gleichen SqlConnection aufrufen können, die Dapper bereits besitzt.

Wann SqlBulkCopy korrekt ist (fast immer für “Bulk”)

Microsoft.Data.SqlClient.SqlBulkCopy verwendet das gleiche TDS-Bulk-Load-Protokoll wie bcp und BULK INSERT. Der Server überspringt Parser, Optimizer und Logging pro Zeile zugunsten eines gestreamten Binärformats. Bei Zeilenzahlen über ~10.000 ist nichts in der verwalteten Welt auf SQL Server in der gleichen Liga.

// .NET 11, Microsoft.Data.SqlClient 6.1.3
public async Task BulkInsertAsync(IEnumerable<TelemetryEvent> events, CancellationToken ct)
{
    await using var conn = new SqlConnection(_connectionString);
    await conn.OpenAsync(ct);

    using var bulk = new SqlBulkCopy(conn, SqlBulkCopyOptions.TableLock, externalTransaction: null)
    {
        DestinationTableName = "dbo.TelemetryEvents",
        BatchSize = 5_000,
        BulkCopyTimeout = 120,
        EnableStreaming = true,
    };

    bulk.ColumnMappings.Add(nameof(TelemetryEvent.Id), "Id");
    bulk.ColumnMappings.Add(nameof(TelemetryEvent.DeviceId), "DeviceId");
    bulk.ColumnMappings.Add(nameof(TelemetryEvent.At), "At");
    bulk.ColumnMappings.Add(nameof(TelemetryEvent.Payload), "Payload");

    using var reader = new ObjectDataReader<TelemetryEvent>(events);
    await bulk.WriteToServerAsync(reader, ct);
}

Die IDataReader-Überladung ist die, die Sie verwenden sollten. Die DataTable-Überladung funktioniert und ist einfacher zu demonstrieren, aber sie materialisiert jede Zeile in eine DataTable, bevor das erste Byte auf die Leitung geht. Die IDataReader-Überladung streamt: Zeilen werden eine nach der anderen aus Ihrem Enumerable gezogen und an den Server gepusht, sobald der Batch sich füllt, was das Working Set selbst bei Millionen von Zeilen flach hält.

ObjectDataReader<T> umfasst etwa 80 Zeilen (der verlinkte Beitrag von Milan Jovanović enthält eine vollständige Version) und konvertiert ein IEnumerable<T> über gecachte PropertyInfo-Lookups in die IDataReader-Schnittstelle. ObjectReader.Create(events) von FastMember ist das Off-the-Shelf-Äquivalent, falls Sie es nicht selbst schreiben möchten.

Drei Optionen, die Sie bei jeder Bulk-Copy setzen sollten:

Für PostgreSQL ist das Äquivalent NpgsqlBinaryImporter (COPY ... FROM STDIN BINARY). Für MySQL MySqlBulkCopy. Für Oracle OracleBulkCopy. Die Form ist identisch: Zeilen aus einem Reader in ein Binärprotokoll streamen, das den SQL-Parser umgeht.

Das Benchmark

Diese Zahlen stammen aus Milan Jovanovićs SQL Server Bulk Insert Benchmark, ausgeführt auf .NET 9 gegen eine lokale SQL Server 2022 Instanz mit einer fünfspaltigen Customer-Tabelle. Ich habe die Form auf einem .NET 11.0.0 + Microsoft.Data.SqlClient 6.1.3 + EF Core 11.0.0 Setup nachverifiziert (Einzelmessungen, AMD Ryzen 9 7900X, SQL Server 2022 Developer in Docker auf der gleichen Maschine, BenchmarkDotNet 0.14.0). Die relative Reihenfolge ist identisch. Die absoluten Zahlen verschieben sich um wenige Prozent je nach Hardware und SQL-Server-Konfiguration, aber keine Methode wechselt den Platz.

Methode100 Zeilen1.000 Zeilen10.000 Zeilen100.000 Zeilen1.000.000 Zeilen
EF Core 11 AddRange2,04 ms17,86 ms204,03 ms2.111,11 ms21.605,67 ms
Dapper ExecuteAsync10,65 ms113,14 ms1.027,98 ms10.916,63 ms109.064,82 ms
EFCore.BulkExtensions 8.01,92 ms7,94 ms76,41 ms742,33 ms8.333,95 ms
SqlBulkCopy1,72 ms7,38 ms68,36 ms646,22 ms7.339,30 ms

Methodik: BenchmarkDotNet 0.14.0, [MemoryDiagnoser] auf jeder Methode, SQL Server 2022 in Docker auf dem gleichen Host, Tabelle zwischen Läufen geleert, nur auf Id indiziert. Die Dapper-Zahl verwendet das naive Muster “Liste an ExecuteAsync übergeben”; ein handgeschriebenes INSERT ... VALUES mit 1.000 Tupeln pro Anweisung schließt einen Teil der Lücke, holt SqlBulkCopy aber nicht ein.

Drei Lesarten der Tabelle:

  1. Bei 100 Zeilen ist jede Methode schnell. Wählen Sie, was in den Code passt. EF Core gewinnt bei der Ergonomie, Dapper gewinnt, wenn Sie bereits dort sind, SqlBulkCopy gewinnt um ein Haar, das kein Benutzer je bemerken wird.
  2. Bei 10.000 Zeilen ist SqlBulkCopy 3x schneller als EF Core und 15x schneller als Dapper. Hier beginnt die Entscheidung für die benutzerseitige Latenz zu zählen.
  3. Bei 1.000.000 Zeilen ist SqlBulkCopy 3x schneller als EF Core und 15x schneller als Dapper, und die Unterschiede sind Minuten statt Sekunden. Hier hört es auf, für die Benutzer-Latenz zu zählen, und beginnt für ETL-Fensterbudgets zu zählen.

EFCore.BulkExtensions liegt innerhalb von 15 Prozent von rohem SqlBulkCopy, weil es unter der Haube SqlBulkCopy ist, eingewickelt in eine EF-Core-geschmackliche API, die Ihre Mapping-Konfiguration liest. Wenn Sie SqlBulkCopy-Geschwindigkeit ohne das Spalten-Mapping-Boilerplate wollen und EF Core bereits im Projekt haben, ist diese Bibliothek der Platz. Wenn Sie die Abhängigkeit nicht eingehen können (oder PostgreSQL mit seinem anderen Bulk-Pfad unterstützen wollen), wickeln Sie Ihren eigenen Helper um SqlBulkCopy und NpgsqlBinaryImporter.

Für eine PostgreSQL-Sicht des gleichen Trade-offs zeigt das Bulk-Operations-Benchmark in EF Core 10 auf .NET 10 + PostgreSQL 17, dass EFCore.BulkExtensions.BulkInsert für 100K Zeilen 8x schneller ist als AddRange, mit 77 Prozent weniger Speicher. Rohes COPY via Npgsql ist noch schneller.

Die Gotchas, die für Sie entscheiden

Einige Einschränkungen erzwingen die Entscheidung unabhängig von der Präferenz.

Die meinungsstarke Empfehlung, wiederholt

Standardmäßig AddRange + SaveChangesAsync von EF Core 11 für alles unter 1.000 Zeilen. Wechseln Sie zu SqlBulkCopy (oder EFCore.BulkExtensions, wenn Sie das EF-Mapping behalten wollen) für alles über 10.000. Das Mittelfeld gehört der Seite der Grenze, auf der Ihr Code bereits lebt. Verwenden Sie Dapper für das, worin es wirklich am besten ist (präzise Lesevorgänge und kleine Befehle), nicht für Bulk-Inserts.

Zwei Korollare, die als Hausregeln behandelt werden sollten:

Verwandt

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück