はじめに
業務で Entity Framework Core を使っていて、複数の Include を使ったクエリが異常に遅くなる現象に遭遇しました。調べてみると「Cartesian Explosion(デカルト積爆発)」という問題があり、AsSplitQuery() で解決できることがわかりました。
ただ、日本語の情報が少なかったので、学んだことを整理して記事にまとめます。
この記事で学べること
- Cartesian Explosion(デカルト積爆発)問題とは何か
-
AsSplitQuery()の仕組みと使い方 - Single Query と Split Query の使い分け
- 実務での判断基準
対象読者
- EF Core 5.0 以降を使用している方
-
Includeを複数使ったクエリのパフォーマンス問題に悩んでいる方
⚠️ 本記事は EF Core 5.0 以降 を対象としています。
AsSplitQuery()は EF Core 5.0 で導入された機能です。
サンプルのデータモデル
本記事では、飲食店の予約システムを例に説明します。
Customer(顧客)
├── Reservations(予約履歴) 1対多
├── Orders(注文履歴) 1対多
└── CouponUsages(クーポン利用) 1対多
エンティティ定義
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string PhoneNumber { get; set; }
public DateTime RegisteredAt { get; set; }
public List<Reservation> Reservations { get; set; }
public List<Order> Orders { get; set; }
public List<CouponUsage> CouponUsages { get; set; }
}
public class Reservation
{
public int Id { get; set; }
public int CustomerId { get; set; }
public DateTime ReservedAt { get; set; }
public int NumberOfGuests { get; set; }
public string Status { get; set; } // Confirmed, Cancelled, Completed
public Customer Customer { get; set; }
}
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public DateTime OrderedAt { get; set; }
public decimal TotalAmount { get; set; }
public Customer Customer { get; set; }
}
public class CouponUsage
{
public int Id { get; set; }
public int CustomerId { get; set; }
public string CouponCode { get; set; }
public decimal DiscountAmount { get; set; }
public DateTime UsedAt { get; set; }
public Customer Customer { get; set; }
}
Cartesian Explosion(デカルト積爆発)問題とは
問題が発生するクエリ
顧客情報と、その顧客の予約・注文・クーポン利用履歴を一度に取得したい場合、こんなクエリを書きます。
var customers = await context.Customers
.Include(c => c.Reservations)
.Include(c => c.Orders)
.Include(c => c.CouponUsages)
.Where(c => c.RegisteredAt >= new DateTime(2025, 1, 1))
.ToListAsync();
一見問題なさそうですが、EF Core はこれを 1つの巨大なSQL に変換します。
発行されるSQL(Single Query)
SELECT c.Id, c.Name, c.PhoneNumber, c.RegisteredAt,
r.Id, r.ReservedAt, r.NumberOfGuests, r.Status,
o.Id, o.OrderedAt, o.TotalAmount,
cu.Id, cu.CouponCode, cu.DiscountAmount, cu.UsedAt
FROM Customers c
LEFT JOIN Reservations r ON c.Id = r.CustomerId
LEFT JOIN Orders o ON c.Id = o.CustomerId
LEFT JOIN CouponUsages cu ON c.Id = cu.CustomerId
WHERE c.RegisteredAt >= '2025-01-01'
ORDER BY c.Id, r.Id, o.Id, cu.Id
行数が爆発する
複数の1対多リレーションを JOIN すると、デカルト積(直積) が発生します。
【顧客1人あたりのデータ】
├── Reservations(予約): 10件
├── Orders(注文): 8件
└── CouponUsages(クーポン): 5件
【Single Query の結果】
10 × 8 × 5 = 400行(たった1人の顧客で!)
100人の顧客を取得すると → 40,000行 がDBから返される可能性があります。
なぜ爆発するのか?
LEFT JOIN は各テーブルの行を「掛け算」で組み合わせます。
顧客: 田中さん
├── 予約: A, B(2件)
├── 注文: X, Y, Z(3件)
└── クーポン: ①, ②(2件)
【結果セット】2 × 3 × 2 = 12行
| 顧客 | 予約 | 注文 | クーポン |
|--------|------|------|----------|
| 田中 | A | X | ① |
| 田中 | A | X | ② |
| 田中 | A | Y | ① |
| 田中 | A | Y | ② |
| 田中 | A | Z | ① |
| 田中 | A | Z | ② |
| 田中 | B | X | ① |
| 田中 | B | X | ② |
| 田中 | B | Y | ① |
| 田中 | B | Y | ② |
| 田中 | B | Z | ① |
| 田中 | B | Z | ② |
同じデータが何度も重複して転送されるため、ネットワーク帯域、メモリ、CPU すべてに負荷がかかります。
AsSplitQuery() による解決
使い方
クエリに .AsSplitQuery() を追加するだけです。
var customers = await context.Customers
.Include(c => c.Reservations)
.Include(c => c.Orders)
.Include(c => c.CouponUsages)
.Where(c => c.RegisteredAt >= new DateTime(2025, 1, 1))
.AsSplitQuery() // ← これを追加!
.ToListAsync();
発行されるSQL(Split Query)
EF Core は複数の独立したクエリに分割します。
-- クエリ1: Customers
SELECT c.Id, c.Name, c.PhoneNumber, c.RegisteredAt
FROM Customers c
WHERE c.RegisteredAt >= '2025-01-01'
ORDER BY c.Id
-- クエリ2: Reservations
SELECT r.Id, r.CustomerId, r.ReservedAt, r.NumberOfGuests, r.Status
FROM Reservations r
INNER JOIN Customers c ON r.CustomerId = c.Id
WHERE c.RegisteredAt >= '2025-01-01'
ORDER BY r.CustomerId
-- クエリ3: Orders
SELECT o.Id, o.CustomerId, o.OrderedAt, o.TotalAmount
FROM Orders o
INNER JOIN Customers c ON o.CustomerId = c.Id
WHERE c.RegisteredAt >= '2025-01-01'
ORDER BY o.CustomerId
-- クエリ4: CouponUsages
SELECT cu.Id, cu.CustomerId, cu.CouponCode, cu.DiscountAmount, cu.UsedAt
FROM CouponUsages cu
INNER JOIN Customers c ON cu.CustomerId = c.Id
WHERE c.RegisteredAt >= '2025-01-01'
ORDER BY cu.CustomerId
EF Core は取得した結果を内部でマージし、正しいオブジェクトグラフを構築します。
行数の比較
| 方式 | 行数(顧客100人の場合) |
|---|---|
| Single Query | 最大 40,000行(爆発!) |
| Split Query | 100 + 1,000 + 800 + 500 = 2,300行 |
データ転送量がかなり削減されます。
グローバル設定
プロジェクト全体で Split Query をデフォルトにすることもできます。
DbContext での設定
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(
connectionString,
o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
);
}
個別に Single Query に戻す
グローバルで Split Query を設定した場合でも、個別のクエリで Single Query に戻せます。
var customers = await context.Customers
.Include(c => c.Reservations)
.AsSingleQuery() // 明示的に Single Query を指定
.ToListAsync();
メリット・デメリット
メリット
| メリット | 説明 |
|---|---|
| データ転送量の削減 | Cartesian Explosion を回避し、重複データが減少 |
| クエリ実行の安定性 | 巨大な結果セットによるタイムアウトリスクを軽減 |
| DBサーバーの負荷軽減 | 複雑な JOIN の処理コストを分散 |
デメリット
| デメリット | 説明 |
|---|---|
| ラウンドトリップ増加 | 複数回のDB通信が発生(ネットワークレイテンシの影響を受けやすい) |
| データ一貫性のリスク | 複数クエリ間でデータが変更される可能性 |
| バッファリングによるメモリ増加 | MARS が無効な場合、最後のクエリ以外の結果がメモリにバッファされる |
メモリ使用量について: Split Query のメモリ効率は状況によって異なります。
- Cartesian Explosion が深刻な場合: Split Query の方がメモリ効率が良いことが多い(重複データの削減効果)
- MARS が無効な場合: 複数クエリの結果がバッファリングされるため、メモリ使用量が増加する可能性
実際のベンチマークでは、Cartesian Explosion が発生するケースで Single Query が 46.93 MB、Split Query が 8.35 MB という結果も報告されています(Code Maze)。
データ一貫性の問題
Split Query では複数のクエリが順番に実行されるため、その間にデータが変更される可能性があります。
時間軸 →
[Query 1: Customers 取得]
↓
[別のプロセスが Reservation を INSERT] ← ここで変更!
↓
[Query 2: Reservations 取得]
↓
[Query 3: Orders 取得]
→ データの不整合が起きる可能性
トランザクションでの緩和
using var transaction = await context.Database
.BeginTransactionAsync(IsolationLevel.Serializable);
var customers = await context.Customers
.Include(c => c.Reservations)
.Include(c => c.Orders)
.AsSplitQuery()
.ToListAsync();
await transaction.CommitAsync();
ただし、トランザクションを使うと 別のパフォーマンス問題 を引き起こす可能性があります。
- ロックの競合が増える
- デッドロックのリスク
- 接続保持時間が長くなる
- スループットの低下
つまり、トランザクションは「一貫性 vs 性能」のトレードオフを解消するものではなく、問題を別の場所に移動させるだけです。
使用すべき場面 / 避けるべき場面
AsSplitQuery() を使うべき場面
| 場面 | 理由 |
|---|---|
| 複数の1対多リレーションを同時に Include | Cartesian Explosion の影響が大きい |
| 子テーブルのレコード数が多い | 例: 顧客1人に対して注文が100件以上 |
| 結果セットが大きくなる可能性 | 数千〜数万行になるクエリ |
| メモリ制約がある環境 | サーバーレス、コンテナ環境など |
| 読み取り専用のレポート・分析 | 一貫性より効率を優先できる |
AsSplitQuery() を使わないほうが良い場面
| 場面 | 理由 |
|---|---|
| 1対1 または 1対少数のリレーション | リファレンスナビゲーションは Split Query でも常に JOIN として含まれるため効果がない |
| Include が1つだけ(コレクション) | 分割する対象が1つしかないため効果が薄い |
| ネットワークレイテンシが高い環境 | ラウンドトリップのコストが大きい |
| データの厳密な一貫性が必要 | リアルタイム在庫、金融取引など |
| 小規模データセット | オーバーヘッドの方が大きくなる |
📌 1対1リレーションについて: 公式ドキュメントおよび GitHub Issue #29182 によると、「リファレンスナビゲーション(1対1)を含む Split Query では、各分割クエリにリファレンスナビゲーションへの JOIN が含まれる」ため、1対1のみの場合は Split Query の効果がありません。
コード例
// ✅ 良い例:複数の1対多(コレクションナビゲーション)+ 大量データ
var customers = await context.Customers
.Include(c => c.Reservations) // コレクション
.Include(c => c.Orders) // コレクション
.Include(c => c.CouponUsages) // コレクション
.AsSplitQuery()
.ToListAsync();
// ❌ 不要な例:1対1 のリレーション(リファレンスナビゲーション)のみ
var customers = await context.Customers
.Include(c => c.Profile) // リファレンス(1対1)
.Include(c => c.MembershipCard) // リファレンス(1対1)
// .AsSplitQuery() ← 効果なし!リファレンスは常にJOINされる
.ToListAsync();
// ❌ 不要な例:コレクションナビゲーションが1つだけ
var customers = await context.Customers
.Include(c => c.Reservations) // コレクション1つだけ
// .AsSplitQuery() ← 分割対象が1つしかないため効果薄い
.ToListAsync();
判断フローチャート
実務での目安(経験則)
以下は公式ドキュメントの基準ではなく、筆者の経験に基づく目安です。実際の効果は環境・データ量・クエリ構造によって大きく異なるため、必ずベンチマークで確認してください。
| 条件 | 推奨 |
|---|---|
| Include数 × 平均子レコード数 < 100 | Single Query を検討 |
| Include数 × 平均子レコード数 > 1,000 | Split Query を検討 |
| 100〜1,000 の間 | 実測して判断 |
📌 公式の推奨: Microsoft の公式ドキュメントでは、具体的な閾値は示されておらず、「benchmark on your platform before making any decisions(自分のプラットフォームでベンチマークしてから判断する)」ことを推奨しています。
注意点・Tips
1. Skip/Take 使用時の ORDER BY に注意(EF Core 10 より前)
EF Core 10 より前のバージョンでは、AsSplitQuery() と Skip/Take を併用する場合、ORDER BY を完全にユニークにする必要があります。
// ❌ 危険:RegisteredAt だけでは重複する可能性
var customers = await context.Customers
.Include(c => c.Reservations)
.OrderBy(c => c.RegisteredAt) // 同じ日時が複数ある場合に問題
.Skip(10)
.Take(10)
.AsSplitQuery()
.ToListAsync();
// ✅ 安全:Id を追加してユニークに
var customers = await context.Customers
.Include(c => c.Reservations)
.OrderBy(c => c.RegisteredAt)
.ThenBy(c => c.Id) // ← ユニークなキーを追加
.Skip(10)
.Take(10)
.AsSplitQuery()
.ToListAsync();
2. 警告メッセージへの対応
EF Core は複数の Collection Include を使用すると以下の警告を出します。
warn: Microsoft.EntityFrameworkCore.Query[20504]
Compiling a query which loads related collections for more than one
collection navigation, either via 'Include' or through projection,
but no 'QuerySplittingBehavior' has been configured.
この警告が出たら、AsSplitQuery() の使用を検討しましょう。
3. パフォーマンス計測
実際に計測して判断することをおすすめします。
var stopwatch = Stopwatch.StartNew();
var resultSingle = await context.Customers
.Include(c => c.Reservations)
.Include(c => c.Orders)
.AsSingleQuery()
.ToListAsync();
Console.WriteLine($"Single Query: {stopwatch.ElapsedMilliseconds}ms");
stopwatch.Restart();
var resultSplit = await context.Customers
.Include(c => c.Reservations)
.Include(c => c.Orders)
.AsSplitQuery()
.ToListAsync();
Console.WriteLine($"Split Query: {stopwatch.ElapsedMilliseconds}ms");
4. SQL Server での MARS の活用
SQL Server を使用している場合、MARS(Multiple Active Result Sets)を有効にすると、Split Query のパフォーマンスが向上する場合があります。
// 接続文字列に MARS を有効化
"Server=...;Database=...;MultipleActiveResultSets=true;"
MARS の効果:
- MARS 無効(デフォルト): Split Query では、最後のクエリ以外の結果がすべてメモリにバッファされる
- MARS 有効: 複数のクエリ結果を同時にストリーミングできるため、バッファリングを回避
⚠️ 注意: MARS を有効にすると、接続プーリングの効率が低下する場合があります。特に高負荷環境では、有効/無効両方でベンチマークを行うことをおすすめします。
まとめ
| 観点 | Single Query | Split Query |
|---|---|---|
| クエリ数 | 1 | N(コレクションナビゲーション数 + 1)※ |
| データ転送量 | 多い(重複あり) | 少ない |
| ラウンドトリップ | 1回 | N回 |
| データ一貫性 | ✅ 保証 | ⚠️ 要注意 |
| 小規模データ | ✅ 有利 | ❌ オーバーヘッド |
| 大規模データ | ❌ 爆発リスク | ✅ 有利 |
※ クエリ数について: Split Query は「コレクションナビゲーション(1対多)」ごとにクエリを分割します。リファレンスナビゲーション(1対1)は常に JOIN として各クエリに含まれるため、別クエリには分離されません。
AsSplitQuery() は万能ではありませんが、複数の1対多リレーションを Include する場合には非常に効果的です。
実務での使い分けのポイント:
- 複数の1対多 Include がある →
AsSplitQuery()を検討 - 子レコードが多い →
AsSplitQuery()が有効 - データ一貫性が重要 →
Single Queryまたは設計の見直し - 迷ったら → 実測して判断
参考文献
本記事は以下の情報源を参考に、筆者の実務経験を加えて執筆しました。
公式ドキュメント
GitHub Issues
- Remove reference joins in split queries · Issue #29182(リファレンスナビゲーションの挙動)
技術ブログ
- How To Improve Performance With EF Core Query Splitting - Milan Jovanović
- Single and Split Queries in Entity Framework Core - Code Maze(ベンチマーク結果)
最後まで読んでいただきありがとうございました!
質問やフィードバックがあれば、コメントでお知らせください。