はじめに
LINQは可読性が高く生産性を上げてくれる強力なツールですが、遅延実行・多重列挙・不適切なデータ構造などでパフォーマンス問題を招きやすいです。
この記事では、実務でよく遭遇する落とし穴を再現コードとともに示し、すぐ使える改善策を具体例で解説します。コードはそのままコピペして試せます。
よくある落とし穴 1 多重列挙による無駄な再評価
問題の例
var query = items.Where(x => x.IsActive);
int count = query.Count(); // クエリを評価
var first = query.FirstOrDefault(); // 再度クエリを評価
query は遅延実行されるため、Count() と FirstOrDefault() で同じ処理が2回走る。
対処法
- 一度だけ評価して結果を保持する(
ToList()やToArray()を使う)。
var list = items.Where(x => x.IsActive).ToList();
int count = list.Count;
var first = list.FirstOrDefault();
注意点:ToList() はメモリを消費するため、データ量に応じて使い分ける。
よくある落とし穴 2 不要な ToList の乱用
問題の例
// 毎回 ToList() を呼んでいる
var names = users.Select(u => u.Name).ToList();
var filtered = names.Where(n => n.StartsWith("A")).ToList();
ToList() を多用するとメモリ使用量が増え、GC負荷が高まる。
対処法
- 必要なタイミングでのみ即時評価する。
- パイプライン内で絞り込みや投影を先に行う。
var filtered = users
.Where(u => u.IsActive)
.Select(u => u.Name)
.Where(n => n.StartsWith("A"))
.ToList(); // 最後に一度だけ評価
よくある落とし穴 3 Where と Select の順序ミス
問題の例
// 先に Select してから Where を適用すると無駄が生じる場合がある
var result = items.Select(x => new { x.Id, x.Value })
.Where(x => x.Value > 100)
.ToList();
投影(Select)で重い処理を先に行うと、絞り込み(Where)で減らせるはずのコストを払ってしまう。
対処法
- 可能なら先に
Whereで絞る。
var result = items.Where(x => x.Value > 100)
.Select(x => new { x.Id, x.Value })
.ToList();
よくある落とし穴 4 LINQ to Objects と LINQ to Entities の違いを無視する
問題の例
// EF Core の DbSet に対してローカルメソッドを使うとクエリがクライアント評価される
var q = context.Users
.Where(u => IsValid(u.Name)) // IsValid はローカルメソッド
.ToList();
ローカルメソッドはデータベース側で評価できないため、全件取得してからクライアント側で評価される。
対処法
- DB側で評価できる式にするか、必要な列だけ取得してからローカル処理する。
// DB側で評価可能な式にする
var q = context.Users
.Where(u => u.Name.StartsWith("A"))
.ToList();
// あるいは必要列だけ取得してからローカル評価
var names = context.Users.Select(u => u.Name).ToList();
var filtered = names.Where(n => IsValid(n)).ToList();
よくある落とし穴 5 Join や GroupBy で巨大な中間結果を作る
問題の例
var joined = from o in orders
join i in items on o.Id equals i.OrderId
select new { o, i };
var list = joined.ToList(); // 中間結果が巨大になる可能性
対処法
- 必要な列だけを投影する。
- 大きな結合は DB 側で行い、ページングやフィルタを活用する。
var list = context.Orders
.Where(o => o.CreatedAt >= DateTime.Today.AddDays(-7))
.Select(o => new
{
o.Id,
o.Total,
Items = o.Items.Select(i => new { i.Id, i.Price })
})
.ToList();
よくある落とし穴 6 PLINQ の安易な導入
問題の例
var results = items.AsParallel()
.Select(x => HeavyCompute(x))
.ToList();
PLINQ は並列化のオーバーヘッドがあり、小さな処理や I/O バウンド処理では逆に遅くなる。
対処法
- CPU バウンドでかつ十分に重い処理に限定して使う。
- 並列度を制御する。
var results = items.AsParallel()
.WithDegreeOfParallelism(Environment.ProcessorCount)
.Select(x => HeavyCompute(x))
.ToList();
パフォーマンス改善テクニック集
1. 計測を最初に行う
-
StopwatchやBenchmarkDotNetでボトルネックを特定する。
var sw = Stopwatch.StartNew();
var result = query.ToList();
sw.Stop();
Console.WriteLine($"Elapsed: {sw.ElapsedMilliseconds} ms");
2. 必要な列だけ取得する
- 大きなオブジェクトを丸ごと取得せず、DTO に投影する。
var dto = context.Users
.Where(u => u.IsActive)
.Select(u => new UserDto { Id = u.Id, Name = u.Name })
.ToList();
3. ルックアップは HashSet / Dictionary を使う
-
Containsを大量に呼ぶ場合はHashSetに変換して O(1) にする。
var ids = new HashSet<int>(largeList.Select(x => x.Id));
var filtered = items.Where(x => ids.Contains(x.Id)).ToList();
4. 多重列挙を避ける
- クエリを複数回評価する場合は
ToList()で一度確定する。
5. 遅延評価を意識する
-
IEnumerable<T>とIQueryable<T>の評価タイミングを理解する。
6. 大量データはチャンク処理する
- メモリに乗り切らない場合は分割して処理する。
const int batchSize = 1000;
int skip = 0;
List<Item> batch;
do
{
batch = context.Items.OrderBy(i => i.Id).Skip(skip).Take(batchSize).ToList();
Process(batch);
skip += batchSize;
} while (batch.Count > 0);
実務チェックリスト
- 計測してから最適化している
- 多重列挙をしていない
- ToList の使用箇所を意図的に決めている
- DB クエリとローカル処理の境界を明確にしている
- 必要列だけを取得している
- HashSet/Dictionary を適切に使っている
- PLINQ は慎重に導入している
- 大きな結合はページングや分割で対処している
まとめ
- LINQ の便利さの裏には「評価タイミング」と「データ量」に関する落とし穴がある。
- まずは計測し、多重列挙を避ける、必要列だけ取得する、適切なデータ構造を使うことが最も効果的。
- 小さな改善を積み重ねることで、可読性を保ちながらパフォーマンスを大きく改善できる。
おわりに
この記事があなたのコードのボトルネック発見と改善に役立てば嬉しいです。
よければストック・フォローお願いします!