5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

EF Core の AsSplitQuery() を理解する 〜Cartesian Explosion(デカルト積爆発)問題とその解決策〜

Posted at

はじめに

業務で 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. 複数の1対多 Include がある → AsSplitQuery() を検討
  2. 子レコードが多い → AsSplitQuery() が有効
  3. データ一貫性が重要 → Single Query または設計の見直し
  4. 迷ったら → 実測して判断

参考文献

本記事は以下の情報源を参考に、筆者の実務経験を加えて執筆しました。

公式ドキュメント

GitHub Issues

技術ブログ


最後まで読んでいただきありがとうございました!
質問やフィードバックがあれば、コメントでお知らせください。

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?