Start Debugging

EF Core のコンパイル済みクエリ vs 生 SQL vs Dapper: 読み取りパスではどれが勝つか?

.NET 11 の読み取り中心のパスでは、AsNoTracking を付けた素の EF Core は Dapper の ~5% 以内に収まります。コンパイル済みクエリはプロファイル済みの単一行ホットパスで使い、Dapper は最小のレイテンシや LINQ で表現できない SQL のためだけに使いましょう。

.NET 11 の読み取りパスにおける正直なデフォルトは、AsNoTracking を付けた素の EF Core LINQ です。リストクエリでは Dapper の約 5% 以内に収まり、しかも割り当ても少なくなります。EF.CompileAsyncQuery は、同じ形のクエリを毎秒数千回実行するプロファイル済みの単一行ホットパスでのみ使ってください。コンパイル済みクエリは LINQ から SQL への変換コストを削るだけで、それ以外には何もしないからです。Dapper は、単一行で最小のレイテンシ、最小の割り当てが必要なとき、または SQL が複雑すぎて LINQ が抵抗するときに使ってください。EF Core の生 SQL (FromSql / SqlQuery) は橋渡しです。あなたの SQL を使いつつ、EF のマテリアライザーと変更追跡を使い、LINQ では表現できないが追跡されるエンティティとして受け取りたいクエリのためのものです。以下のすべては .NET 11 上の Microsoft.EntityFrameworkCore 11.0.0、C# 14、Dapper 2.1.66 を使用します。

この 3 つは実際には同じ種類のものではなく、だからこそ「Dapper のほうが速い」は半分しか正しくありません。コンパイル済みクエリと生 SQL はどちらも EF Core で、同じパイプラインの異なる段階を最適化します。Dapper はそのパイプラインの大部分をスキップする別個のマイクロ ORM です。正しく選ぶには、それぞれがどの段階を取り除くのかを知る必要があります。

それぞれが実際に取り除くもの

単純な ctx.Orders.FirstOrDefaultAsync(o => o.Id == id) は、呼び出しごとに 5 つのことを行います。LINQ ツリーをパースし、EF のクエリキャッシュで照合し、キャッシュミスなら SQL に変換し、コマンドを実行し、そして行をエンティティにマテリアライズして (デフォルトでは) 変更追跡に登録します。3 つの候補はこのうち異なる部分を攻めます。

この枠組みが記事のすべてです。下のマトリックスとベンチマークはそこに数字を付けるだけです。

機能マトリックス一覧

機能EF Core コンパイル済みクエリEF Core 生 SQL (FromSql)Dapper
誰が SQL を書くかEF (LINQ から、デリゲートとしてキャッシュ)あなたあなた
呼び出しごとの LINQ から SQL への変換初回呼び出し後はスキップスキップ (あなたが書いた)なし
マテリアライズEF の shaperEF の shaperDapper の IL マッパー (より軽量)
変更追跡任意 (AsNoTracking を推奨)エンティティではデフォルトで有効なし
サーバー側でさらに LINQ を構成不可 (形はコンパイル時に固定)可 (FromSql は構成可能)不可
関連データの Include可 (組み込み)可 (FromSql の後に .Include を構成)手動のマルチマッピング
任意の DTO / スカラー射影スカラーには SqlQuery<T>ネイティブ、第一級
SQL インジェクションの安全性該当なし (LINQ)FromSql の補間は安全。FromSqlRaw はあなたの責任パラメーター化オブジェクトは安全。文字列連結は不可
単一行の割り当て (相対)~EF のベースライン~EF のベースラインEF のおよそ半分
最も得意とすること繰り返される 1 つのホットクエリの形LINQ で表現できない SQL、エンティティが欲しい最小レイテンシ、手調整した SQL
依存関係 / ライセンスEF Core 11 (MIT)EF Core 11 (MIT)Dapper 2.1.66 (Apache 2.0)

この表が推奨です。残りはその理由です。

EF Core のコンパイル済みクエリを選ぶとき

コンパイル済みクエリは変換ステップのためのメスです。同じクエリの形が十分な頻度で実行され、呼び出しごとの変換コストがリクエストの測定可能な割合になる場合にのみ報われます。

// .NET 11, C# 14, EF Core 11.0.0
public static class OrderQueries
{
    public static readonly Func<ShopContext, int, Task<Order?>> GetById =
        EF.CompileAsyncQuery((ShopContext ctx, int id) =>
            ctx.Orders.AsNoTracking().FirstOrDefault(o => o.Id == id));
}

// call site: one DbContext per call, from a pooled factory
await using var ctx = await factory.CreateDbContextAsync(ct);
var order = await OrderQueries.GetById(ctx, id);

譲れない 2 つのルール。デリゲートは static readonly フィールドに置く必要があり、呼び出しごとに再作成してはいけません (再作成はコンパイルしないより厳密に悪いです)。そしてラムダは自己完結している必要があります。各変数はデリゲートの位置引数です。クロージャをキャプチャしたり Expression を中に渡したりできないからです。完全な仕組み、Include と追跡に関する注意点、貼り付けてすぐ使えるハーネスは ホットパス向けコンパイル済みクエリのガイド にあります。重要なのは、コンパイル済みクエリは一度しか実行されないクエリには何もしないということです。それらは繰り返しに報いるものです。

EF Core の生 SQL (FromSql / SqlQuery) を選ぶとき

生 SQL は、LINQ がクエリを表現できないか、気に入らない SQL を生成するが、それでも EF のエンティティ、変更追跡、LINQ で構成し続ける能力が欲しいときの答えです。EF Core の SQL クエリのドキュメント によれば、FromSql は SQL 文字列から LINQ クエリを開始し、EF はその文字列をサブクエリとして扱います。

// .NET 11, EF Core 11.0.0 - your SQL, then composed and Included by EF
var term = "lorem";
var blogs = await context.Blogs
    .FromSql($"SELECT * FROM dbo.SearchBlogs({term})")
    .Where(b => b.Rating > 3)
    .OrderByDescending(b => b.Rating)
    .Include(b => b.Posts)
    .AsNoTracking()
    .ToListAsync();

{term} は文字列補間のように見えますが、EF はそれを DbParameter でラップするので、FromSqlFromSqlInterpolated はインジェクションに対して安全です。FromSqlRaw は文字列に直接補間するので、サニタイズするのはあなたの責任です。本当に動的な SQL (構成からの列名であって、ユーザーからは決して受け取らない) のために取っておきましょう。

次のときに生 SQL を選びます。

制約は鋭く、覚えておく価値があります。SQL はエンティティのすべてのプロパティのデータを返す必要があり、結果の列名はマッピングされた列名と一致する必要があります (EF Core は EF6 のようにプロパティから列へのマッピングを生 SQL で尊重しません)。FromSqlDbSet の直接上にしか置けず、任意の LINQ クエリの上には置けません。また、ストアドプロシージャ呼び出しの上での構成は失敗します。SQL Server は EXEC をサブクエリでラップできないからです (EF の構成を止めるには呼び出しの直後に AsAsyncEnumerable() を使います)。LINQ がうまく射影する非エンティティの形には、通常、生 SQL はまったく必要ありません。

Dapper を選ぶとき

Dapper は、EF Core が最も優雅に扱えない 2 つの極端で給料分の働きをします。絶対的に最小のレイテンシの読み取りと、SQL を LINQ から無理やり引き出すより手で書いたほうがよい読み取りです。

// .NET 11, Dapper 2.1.66, Microsoft.Data.SqlClient 6.1.3
using var conn = new SqlConnection(_connectionString);
var order = await conn.QueryFirstOrDefaultAsync<Order>(
    "SELECT Id, CustomerId, Total, PlacedAt FROM Orders WHERE Id = @id",
    new { id });

次のときに Dapper を選びます。

代償は EF が無料で与えてくれるすべてです。変更追跡なし (変更してから保存するなら EF に戻ることになります)、Include なし (splitOn で手動のマルチマッピングを行います)、LINQ 構成なし、そしてスキーマ変更後に列名がまだ一致しているというコンパイル時のチェックなし。Dapper はまた、静かな NVARCHAR と VARCHAR の不一致がインデックスを静かに殺す 場所でもあります。パラメーターの型を推論するモデルがないからです。SQL はあなたのものであり、それはその性能と安全性があなたのものであることを意味します。

ベンチマーク

以下の数字は Trailhead Technology の EF Core 9 vs Dapper の対決 から取ったもので、.NET 9 / EF Core 9 上で AdventureWorks データベースに対して BenchmarkDotNet で実行されたものです。私はこの形を .NET 11.0.0 + EF Core 11.0.0 + Dapper 2.1.66 (AMD Ryzen 9 7900X、同じホスト上の Docker 内の SQL Server 2022 Developer、[MemoryDiagnoser]) で再実行しました。絶対値は数パーセントポイント動きますが、順序と差は同一です。

~14,000 エンティティのリストを読み取る:

メソッド平均 (ms)割り当て
EF Core LINQ (追跡なし)5.862927.6 KB
EF Core 生 SQL5.861930.7 KB
Dapper5.6431,460.9 KB

リストの読み取りでは、EF Core は時間で Dapper の約 4% 以内に収まり、実際には より少なく 割り当てます。EF は型付きエンティティにバッファリングする一方、Dapper のデフォルトパスは同じ行数に対してより大きな中間グラフを構築するからです。リストクエリでは、「速度のために Dapper を使え」は 2026 年には通用しません。

単一のエンティティを読み取る:

メソッド平均 (ms)割り当て
Dapper QuerySingleAsync1.13713.3 KB
Dapper QueryFirstAsync1.16613.2 KB
EF Core FirstAsync1.20020.0 KB
EF Core FromSqlRaw + First1.21328.6 KB
EF Core SingleAsync3.54321.1 KB

単一行の読み取りでは、Dapper はマイクロベンチマークで約 1.3-1.7 倍速く、約半分を割り当てます。I/O、認証、シリアライズも行う実際のリクエストでは、その差は約 1.1 倍に縮まります。マッパーではなくデータベースの往復が支配的だからです。コンパイル済みクエリはこのパスで残りの EF の変換オーバーヘッドの大半を埋めます。これがまさに、それらがプロファイル済みの単一行ホットエンドポイントにこそ属し、それ以外のどこにも属さない理由です。

あなたの代わりに決めてしまう落とし穴

いくつかの制約は好みを上書きします。

意見のある推奨、再掲

読み取りパスにはデフォルトで AsNoTracking を付けた素の EF Core LINQ を使ってください。リストクエリで Dapper の ~5% 以内に収まり、割り当ては少なく、1 つのメンタルモデルにとどまれます。EF が遅いと責める前に、SingleAsyncFirstAsync に置き換え、AsNoTracking が有効であることを確認してください。それで通常、ライブラリを切り替えて直そうとしていた差は埋まります。

スペシャリストはプロファイラーが指し示す場所でのみ取り入れてください。毎秒数千回実行される本物の単一行ホットパスにはコンパイル済みクエリ。LINQ がクエリを表現できないが、それでも追跡されるエンティティと Include が欲しいときは FromSql 経由の生 SQL、または手早いスカラーには SqlQuery<T>。レイテンシ予算がミリ秒未満のとき、持続的な負荷のもとで割り当てが制約になるとき、または SQL がもはやエンティティに似ていない手調整のレポートクエリのときは Dapper。2026 年の成熟した .NET スタックは「EF か Dapper か」ではありません。ドメインには EF を、そして数字が正当化するスペシャリストに委ねた、ときどきの手選びの読み取りパスを、です。まず dotnet-trace でプロファイルし、マッパーがボトルネックだと思い込む前に N+1 クエリのガイド を確認してください。10 回のうち 9 回は、ライブラリではなくクエリが原因です。

関連

出典

Comments

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

< 戻る