Start Debugging

EF Core 11 vs Dapper の一括挿入: リアルなベンチマーク

.NET 11 での一括挿入では、EF Core も Dapper も勝ちません。SqlBulkCopy が勝ちます。これはそのベンチマークと理由、そして各ツールにふさわしい場所です。

.NET 11 から SQL Server に数千行を超えるデータを挿入する場合、正解が “EF Core” であることはまれですし、“Dapper” であることもまれです。正解は SqlBulkCopy で、どちらのツールの接続からでも直接呼び出せます。EF Core 11 の AddRange + SaveChangesAsync は 1,000 行未満の最もきれいな選択肢です。Dapper のパラメーターリストを使った ExecuteAsync は、どの行数でも 3 つの中で最悪であり、一括ロードでは避けるべきものです。以下は判定表、その裏にあるベンチマーク数値、そして Microsoft.EntityFrameworkCore 11.0.0、Microsoft.Data.SqlClient 6.1、Dapper 2.1.66 における各パスのコードです。

機能マトリックスの一覧

機能EF Core 11 AddRangeDapper ExecuteAsyncSqlBulkCopy
基盤プロトコルバッチ化された INSERT行ごとの INSERTTDS bulk copy (ネイティブな一括ロード)
変更追跡ありなしなし
identity 値がエンティティに書き戻されるあり (OUTPUT INSERTED.Id 経由)なし (手動で SELECT SCOPE_IDENTITY())KeepIdentity と明示的な値の場合のみ
リレーションシップとカスケード挿入ありなしなし
100K 行時のメモリ (SQL Server)数百 MB数十 MB数十 MB、ストリーミング向き
100K 行の挿入時間 (下記の方法論を参照)約 2.1 秒約 10.9 秒約 0.65 秒
1M 行の挿入時間約 21.6 秒約 109 秒約 7.3 秒
SQL Server 専用いいえ (すべての EF プロバイダーで動作)いいえはい (Microsoft.Data.SqlClient)
コードの複雑さ最小低い中 (テーブルマッピングが必要)
IAsyncEnumerable<T> ストリーミングと併用可能いいえ (先にエンティティを読み込む)いいえはい (IDataReader 経由)
EF の他のユニットオブワークとのトランザクションあり手動手動 (SqlTransaction)
ライセンスMITApache 2.0MIT

この表が推奨です。以下はすべて 理由 です。

EF Core 11 の AddRange + SaveChangesAsync が正しい場面

EF Core 11 は挿入を賢くバッチ化します。SQL Server プロバイダーは挿入対象のエンティティを複数行の INSERT ... VALUES (...), (...), ... 文にグループ化し、1 バッチあたり最大 1,000 行 (SQL Server のテーブル値パラメーターのハード上限) まで、または 1 バッチあたり 2,100 個のパラメーターで分割します (どちらか先に来た方)。200 列のエンティティでは、パラメーター数が支配的になるため、実効的なバッチサイズは 1 桁の行数に縮みます。5 列のエンティティであれば、完全な 1,000 行のバッチが得られます。

AddRange を選ぶのは次の場合です:

// .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);
}

これは EF Core が設計された場所です。コストはアロケーションです: すべてのエンティティがマテリアライズされ、変更が追跡され、SaveChanges がコミットするまで DbContext に保持されます。広いエンティティの 100K 行では、これは数百メガバイトの GC プレッシャーです。1,000 行では、それは無関係です。

中規模バッチでこの道を進む場合、2 つのつまみが役立ちます:

Dapper の ExecuteAsync が正しい場面 (とそうでない場面)

Dapper の一括ストーリーは有名なほどシンプルです: コレクションを渡せば、1 回のネットワークラウンドトリップで 1 行ごとに 1 つの INSERT が得られます。

// .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);

書くのは快適です。スケール時には遅いです。Dapper はコレクションの要素ごとにパラメーター化された文を 1 つ送信し、1 回のネットワークラウンドトリップにバッチ化します。SQL Server は依然として各 INSERT を個別に解析し、計画し、実行します。EF Core のような行のバッチ化はなく、ネイティブな一括プロトコルもなく、文レベルの並列化もありません。

Dapper の ExecuteAsync を挿入に選ぶのは次の場合です:

1,000 行の一括挿入には Dapper を選ば ない でください。行ごとのコストは現実的で、ネットワークの節約は小さく、1 つの namespace 先によりよいツールがあります。Dapper から “高速な” 挿入に手を伸ばしているなら、ほぼ確実に Dapper.Plus (商用) が欲しいか、より正直に言えば、Dapper が既に所有している同じ SqlConnection から呼び出せる SqlBulkCopy が欲しいのです。

SqlBulkCopy が正しい場面 (“一括” にはほぼ常に)

Microsoft.Data.SqlClient.SqlBulkCopybcpBULK INSERT と同じ TDS 一括ロードプロトコルを使います。サーバーはパーサー、オプティマイザー、行ごとのログ出力をスキップして、ストリーミングされたバイナリ形式を採用します。~10,000 行を超える行数では、マネージドの世界で SQL Server 上で同じリーグにいるものはありません。

// .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);
}

使うべきは IDataReader オーバーロードです。DataTable オーバーロードも動作し、デモするのはより簡単ですが、最初のバイトが回線に乗る前にすべての行を DataTable にマテリアライズします。IDataReader オーバーロードはストリーミングします: 行はあなたの enumerable から 1 つずつ取り出され、バッチがいっぱいになるとサーバーにプッシュされるため、数百万行でもワーキングセットを平坦に保ちます。

ObjectDataReader<T> はおおよそ 80 行 (リンク先の Milan Jovanović の記事に完全版があります) で、キャッシュされた PropertyInfo ルックアップ経由で IEnumerable<T>IDataReader インターフェースに変換します。自分で書きたくない場合は、FastMemberObjectReader.Create(events) が既製の同等品です。

すべての一括コピーで設定する価値のある 3 つのオプション:

PostgreSQL の場合、同等のものは NpgsqlBinaryImporter (COPY ... FROM STDIN BINARY) です。MySQL の場合は MySqlBulkCopy。Oracle の場合は OracleBulkCopy。形は同じです: SQL パーサーを迂回するバイナリプロトコルにリーダーから行をストリーミングします。

ベンチマーク

これらの数値は Milan Jovanović の SQL Server 一括挿入ベンチマーク からで、5 列の Customer テーブルを持つローカル SQL Server 2022 インスタンスに対して .NET 9 で実行されました。.NET 11.0.0 + Microsoft.Data.SqlClient 6.1.3 + EF Core 11.0.0 のセットアップで形を再検証しました (単一実行のタイミング、AMD Ryzen 9 7900X、同じマシン上の Docker 内の SQL Server 2022 Developer、BenchmarkDotNet 0.14.0)。相対的な順序は同じです。絶対的な数値はハードウェアと SQL Server の設定によって数パーセント変動しますが、どの方法も順位は変わりません。

方法100 行1,000 行10,000 行100,000 行1,000,000 行
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

方法論: BenchmarkDotNet 0.14.0、各メソッドに [MemoryDiagnoser]、同じホスト上の Docker 内 SQL Server 2022、実行間でテーブルを truncate、Id のみインデックス。Dapper の数値は “リストを ExecuteAsync に渡す” という素朴なパターンを使用しています。文ごとに 1,000 タプルの手書きの INSERT ... VALUES はギャップの一部を埋めますが、SqlBulkCopy には追いつきません。

表の 3 つの読み方:

  1. 100 行では、すべての方法が高速です。 コードに合うものを選んでください。EF Core はエルゴノミクスで勝ち、Dapper は既にそこにいるなら勝ち、SqlBulkCopy はユーザーが決して気づかない程度の差で勝ちます。
  2. 10,000 行では、SqlBulkCopy は EF Core より 3 倍速く、Dapper より 15 倍速いです。 ここがユーザー向けのレイテンシで判断が重要になり始めるところです。
  3. 1,000,000 行では、SqlBulkCopy は EF Core より 3 倍速く、Dapper より 15 倍速く、差は秒ではなく分です。 ここはユーザー向けのレイテンシでは重要でなくなり、ETL ウィンドウ予算で重要になり始めるところです。

EFCore.BulkExtensions は生の SqlBulkCopy の 15 パーセント以内です。なぜなら、内部では SqlBulkCopy である からで、マッピング構成を読む EF Core 風 API にラップされているからです。SqlBulkCopy の速度を、列マッピングのボイラープレートを書かずに欲しく、プロジェクトに既に EF Core がある場合、そのライブラリが居場所です。依存性を取れない場合 (あるいは異なる一括パスを持つ PostgreSQL をサポートしたい場合)、SqlBulkCopyNpgsqlBinaryImporter の周りに自分のヘルパーをラップしてください。

同じトレードオフの PostgreSQL ビューについては、.NET 10 + PostgreSQL 17 上の EF Core 10 一括操作ベンチマーク は、100K 行で EFCore.BulkExtensions.BulkInsertAddRange より 8 倍速く、77 パーセント少ないメモリで動作することを示しています。Npgsql 経由の生の COPY はさらに速いです。

あなたに代わって判定する落とし穴

いくつかの制約は、好みに関係なく決定を強制します。

意見ある推奨、再述

1,000 行未満には EF Core 11 の AddRange + SaveChangesAsync をデフォルトに。10,000 行を超えるものには SqlBulkCopy (または EF マッピングを維持したい場合は EFCore.BulkExtensions) に切り替えます。中間は、コードが既にいる境界のどちらか側に属します。Dapper を、本当に最も得意とすること (正確な読み取りと小さなコマンド) に使ってください。一括挿入ではありません。

家のルールとして扱う価値のある 2 つの帰結:

関連

ソース

Comments

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

< 戻る