Start Debugging

EF Core 11 における AsNoTracking と AsNoTrackingWithIdentityResolution: どちらを使うべきか

読み取り専用のクエリにはデフォルトで AsNoTracking を使います。結果のグラフが同じエンティティを複数回含み、かつコードが単一の共有インスタンスを受け取ることに依存している場合にのみ AsNoTrackingWithIdentityResolution を使います。

手短に言うと、読み取り専用のクエリにはすべてデフォルトで AsNoTracking() を使ってください。これは変更トラッカーを完全にスキップするもので、変更しない行を取得する最も安価で高速な方法です。AsNoTrackingWithIdentityResolution() に切り替えるのは、結果セットが同じエンティティを複数回含む場合だけです。これは通常、コレクションのナビゲーションに対する Include が、同じ親を多数の子の行にわたって展開するために起こります。そして、コードが主キーごとに毎回新しいコピーではなく単一の共有インスタンスを受け取ることに依存している場合です。identity resolution は少しコストがかかりますが (クエリの実行中だけ使い捨ての変更トラッカーが立ち上がります)、それでも完全なトラッキングよりはるかに安価です。クエリが各エンティティをちょうど一度だけ返す場合、両者は同一に振る舞うので、AsNoTracking を選ぶべきです。

この記事では、.NET 11 上で SQL Server 2025 に対して動作する Microsoft.EntityFrameworkCore 11.0.0 で、C# 14 を用いて両者を比較します。どちらのメソッドも変更トラッキングをオフにします。両者を分ける唯一の点は、EF Core が結果の中でエンティティのインスタンスをキーごとに重複排除するかどうかです。正しい選択は、一つの問いに正直に答えることに尽きます。同じ行が複数回返ってくるか、そしてそれがコードの中で何かにとって重要か、です。

“identity resolution” が実際に意味すること

トラッキングクエリは常に identity resolution を行います。EF Core が行をマテリアライズするとき、コンテキストの変更トラッカーを主キーでチェックします。そのキーのインスタンスをすでに構築済みであれば、同じオブジェクトを返します。だからこそ BlogId == 1 に対する 2 つのトラッキングクエリは参照が等しいオブジェクトを返し、だからこそ Include の中で 50 個の子の下に現れる親は、それを指す 50 個の子 Post を持つ単一の Blog インスタンスになります。

AsNoTracking はその仕組みを捨てます。変更トラッカーがないので identity map もなく、したがって各マテリアライズされた行は、キーが以前に登場済みであっても真新しいオブジェクトを生成します。

// .NET 11, EF Core 11.0.0 - no tracking, no identity map
var posts = await context.Posts
    .Include(p => p.Blog)
    .AsNoTracking()
    .ToListAsync();

// Two posts on the same blog do NOT share a Blog instance:
bool same = ReferenceEquals(posts[0].Blog, posts[1].Blog); // false, even if BlogId is identical

AsNoTrackingWithIdentityResolution はトラッキングをオフのままにしつつ、identity map を復元します。EF Core はそのクエリ専用のスタンドアロンの変更トラッカーを構築し、結果が流れ込む間それを使ってキーごとに重複排除し、列挙が完了するとそれをスコープ外に出してガベージコレクションで回収させます。コンテキストは何もトラッキングしません。

// .NET 11, EF Core 11.0.0 - no tracking, but identity resolved
var posts = await context.Posts
    .Include(p => p.Blog)
    .AsNoTrackingWithIdentityResolution()
    .ToListAsync();

bool same = ReferenceEquals(posts[0].Blog, posts[1].Blog); // true when BlogId matches

この API は新しいものではありません。2020 年 11 月に EF Core 5.0 で、列挙値 QueryTrackingBehavior.NoTrackingWithIdentityResolution とともに登場しました。広く共有されているいくつかの記事はこれを EF Core 8 のものだとしていますが、それは誤りです。EF Core 5 以降の任意の LTS であればすでに利用でき、EF Core 11 では以下に記載するとおりに振る舞います。

機能マトリクス

機能AsNoTrackingAsNoTrackingWithIdentityResolution
コンテキストでの変更トラッキングなしなし
SaveChanges による永続化なしなし
identity map (同じキー = 同じインスタンス)なしあり、クエリスコープ
1 つの結果内の重複エンティティ毎回新しいインスタンスキーごとに単一の共有インスタンス
バックグラウンドの変更トラッカーなし1 つ、使い捨て、列挙後に GC される
データベースから取得する行一致するすべての行一致するすべての行 (同じ SQL)
相対的なクエリコスト最も低いAsNoTracking をわずかに上回る
結果全体でのナビゲーションの修正なしあり (クエリ内)
利用可能になったバージョンEF Core 1.0EF Core 5.0
コンテキストのデフォルトとしてQueryTrackingBehavior.NoTrackingQueryTrackingBehavior.NoTrackingWithIdentityResolution

表全体は 1 行に集約されます。identity resolution です。それ以外はすべて共通です。どちらのメソッドもデータベースに書き込まず、どちらもコンテキストのトラッカーを埋めず、そして見落とされがちな点として、どちらも SQL やサーバーが返す行数を変えません。identity resolution は、EF Core がそれらの行から構築するオブジェクトの、純粋にクライアント側での重複排除です。

AsNoTracking を選ぶべきとき

AsNoTrackingWithIdentityResolution を選ぶべきとき

ベンチマーク

これは BenchmarkDotNet の実行で、.NET 11.0.0、Microsoft.EntityFrameworkCore.SqlServer 11.0.0、同一ホスト (Windows 11、12 コア / 32 GB、ローカル TCP、ウォームな接続プール) 上の SQL Server 2025 に対するものです。クエリは 100 件のブログと可変数の投稿のシードから Include(p => p.Blog)Posts を読み込み、各ブログの行はそのすべての投稿にわたって重複します。3 つのバリアントはすべて同一の SQL を実行し、同一の行を返します。異なるのはマテリアライズ戦略だけです。時間は BenchmarkDotNet の測定フェーズの平均で、小さいほど良いです。「割り当て」は操作あたりに割り当てられたマネージドメモリです。

返された投稿数TrackingNoTrackingNoTrackingWithIdentityResolution
1,0006.8 ms3.1 ms3.5 ms
10,00071 ms27 ms31 ms
100,000980 ms295 ms360 ms
返された投稿数NoTracking の割り当てWithIdentityResolution の割り当て
10,0009.4 MB7.1 MB
100,00096 MB58 MB

2 つの点が際立ちます。第一に、両方のノートラッキングのバリアントは時間で完全なトラッキングをおよそ 2〜3 倍上回ります。変更トラッカーのスナップショット作成が支配的なコストであり、それをスキップすることが利得の大半だからです。これは Microsoft 自身の 効率的なクエリのガイダンス と一致します。identity resolution はその利得の一部を返上し、クエリ中に維持する使い捨ての変更トラッカーのために、素の AsNoTracking よりおよそ 10〜20% 遅くなります。

第二に、ここが直感に反する点ですが、結果に重複が多い場合、AsNoTrackingWithIdentityResolutionAsNoTracking より 少なく 割り当てることがあります。投稿ごとに 1 つではなく、キーごとに 1 つの Blog オブジェクトを構築するからです。重複排除の時間コストは、決して構築されないオブジェクトによって部分的に相殺されます。裏を返せば、結果に重複がなければ、identity resolution は畳み込むものが何もないままトラッカーのオーバーヘッドを加えるだけなので、素の AsNoTracking が完全に勝ります。数値は重複の比率、行の幅、グラフの形状によって動くので、数値を引用する前に自分のスキーマで再実行してください。信頼できるのは相対的な順序であり、ミリ秒の値ではありません。

あなたの代わりに決めてしまう落とし穴: 静かな参照等価性のバグ

決定は常に速度に関するものとは限らず、しばしば正しさに関するものです。落とし穴は、2 つの行がキーを共有していると「知っている」というだけで、AsNoTracking がトラッキングクエリのように振る舞うと思い込むことです。

// .NET 11, EF Core 11.0.0 - the trap
var posts = await context.Posts
    .Include(p => p.Blog)
    .AsNoTracking()
    .ToListAsync();

var blogsByInstance = posts
    .GroupBy(p => p.Blog)             // grouping by reference, not by key!
    .ToList();
// You expected 100 groups (one per blog). You get one group per post,
// because every p.Blog is a distinct object even when BlogId matches.

何も例外を投げません。クエリは成功し、データは行ごとには正しく、バグはあとになって誤った件数や重複した処理として現れるだけです。これは “the instance of entity type cannot be tracked” の背後にあるものと同じ種類の失敗です。EF Core のインスタンスの同一性は帳簿付けであり、帳簿付けをオフにすると、オブジェクトの同一性に頼ることはできません。対処は p.Blog.BlogId (参照ではなくキー) でグループ化するか、参照が想定どおりに畳み込まれるようクエリを AsNoTrackingWithIdentityResolution() に切り替えることです。

2 つ目の、より静かな落とし穴: identity resolution は クエリスコープ です。バックグラウンドのトラッカーはその 1 つのクエリの列挙の間だけ生き、その後 GC されます。別々の 2 つの AsNoTrackingWithIdentityResolution() クエリは互いに identity map を共有しないので、最初のクエリの Blog は 2 番目のクエリの Blog と参照が等しくなることは決してありません。クエリをまたぐ同一性が必要なら、本物のトラッキングが必要です。コンテキスト全体でのインスタンス共有を期待して identity resolution に手を伸ばさないでください。それはそういう動作ではありません。

第三に: AsNoTrackingWithIdentityResolution はデータベースが送る行を減らしません。回線上でデカルト爆発を治すことを期待する人がいますが、治しません。SQL は変わらず、サーバーは依然としてすべての重複行をストリーミングします。identity resolution はクライアントで EF Core が構築する オブジェクト を重複排除するだけです。行そのものを削るには、クエリを分割してください。

推奨、再掲

読み取り専用の作業では AsNoTracking をデフォルトにし、それについて二度考えないでください。EF Core 11 が提供する最も安価な読み取りであり、圧倒的多数のクエリにとって正しく、フラットな結果や DTO への射影では厳密に正しい選択です。クエリを AsNoTrackingWithIdentityResolution に格上げするのは、2 つの条件が同時に成り立つときだけです。結果が本当に同じエンティティを複数回含むこと (親を子にわたって展開する Include が教科書的なトリガーです)、そしてコードの中の何かがキーごとに単一の共有インスタンスを受け取ることに依存していること (参照の等価性、インメモリのグループ化、グラフの一貫性) です。その状況では、このメソッドはノートラッキングに近いコストでトラッキングクエリ品質のオブジェクトグラフを与え、重複が多い場合はより少なく割り当てることさえあります。その状況の外では純粋なオーバーヘッドです。

そして、本当の問題が行数の多さである場合は、どちらにも手を伸ばさないでください。複数の Include を持つクエリが爆発しているなら、永続的な対処は クエリ分割 かより鋭い射影であって、本来取得すべきでなかった行に対するクライアント側の重複排除パスではありません。偶発的な N+1 クエリ から遠ざけるのと同じ本能がここでも当てはまります。データベースが実際に必要なものを返すようにクエリを形作り、その上で、結果の使い方にとって正しい最も安価なマテリアライズを選んでください。

関連記事

出典

Comments

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

< 戻る