cd ~/blog/count-async-vs-any-async

2026-06-15

CountAsync() は本当に必要だったのか — 検索上限チェックの別解

.NETC#EntityFrameworkSQLパフォーマンス

3年ほど前、.NET の Web API 開発をしていた頃の話です。

検索結果が多すぎる場合にエラーを返す処理を実装していました。当時は「普通の実装」として書いていましたが、今改めて振り返ると過剰な処理をしていたかもしれません。

当時の実装

こんなコードを書いていました。

C#
var query = CreateQuery();

var count = await query.CountAsync();
if (count > MaxCount)
{
    return Error("検索条件を絞ってください");
}

var items = await query
    .Skip(offset)
    .Take(pageSize)
    .Select(x => new ItemDto(x))
    .ToListAsync();

やっていることは単純で、

  1. CountAsync() で総件数を取得
  2. 上限を超えていたらエラー
  3. 上限以下ならページングしてデータ取得

SQL は2回発行されます。特に問題意識もなく、チームでもよく見る実装だったので疑いませんでした。

本当に欲しかったのは何か

今になって気づくのは、欲しかったのは「件数」ではなく「上限を超えているかどうか」 だったという点です。

CountAsync() は全件数を数えますが、チェックに使うのは count > MaxCount の真偽値だけです。1000件を超えているかどうかを知りたいのに、10万件あれば10万件分数えてしまいます。

別解を整理する

① Take(MaxCount + 1) で取得件数で判断する

C#
const int MaxCount = 1000;

var items = await query
    .Take(MaxCount + 1)
    .Select(x => new ItemDto(x))
    .ToListAsync();

if (items.Count > MaxCount)
{
    return Error("検索条件を絞ってください");
}

CountAsync() を使わず、1001件取得してみて件数で判断します。SQL は1回で済みます。

メリット

  • SQL が1回
  • CountAsync() 不要

デメリット

  • 最大 MaxCount 件の DTO 生成まで行われる
  • 上限が大きいと重い

上限が数十件程度なら十分実用的です。

② Skip(MaxCount).AnyAsync() で1件でも超えているか確認する

C#
var overLimit = await query
    .Skip(MaxCount)
    .AnyAsync();

if (overLimit)
{
    return Error("検索条件を絞ってください");
}

var items = await query
    .Skip(offset)
    .Take(pageSize)
    .Select(x => new ItemDto(x))
    .ToListAsync();

AnyAsync() が発行する SQL はこのような形です。

SQL
SELECT TOP(1) 1
FROM (
    SELECT ...
    FROM ...
    WHERE ...
    ORDER BY ...
    OFFSET 1000 ROWS
) AS t

1001件目が存在するかだけを確認するため、全件数を数える COUNT(*) より安くなることがあります。SQL は2回ですが、1回目が軽い。

メリット

  • 上限超過の確認コストが低い
  • DTO 生成が走らない

デメリット

  • SQL は2回(ただし1回目は非常に軽い)
  • Skip + AnyAsync という組み合わせが直感的でない

③ 表示件数 + 1 で「次ページあり」を判定する

上限チェックとは少し違いますが、ページングの文脈では似た発想です。

C#
const int PageSize = 50;

var items = await query
    .Skip(offset)
    .Take(PageSize + 1)
    .Select(x => new ItemDto(x))
    .ToListAsync();

var hasNextPage = items.Count > PageSize;
if (hasNextPage)
{
    items = items.Take(PageSize).ToList();
}

51件取得して51件あれば「次ページあり」、50件以下なら最終ページです。総件数を求めないため CountAsync() が不要です。Google 検索や X(Twitter)のようなカーソルベースページングでよく使われるパターンです。

どれを選ぶか

方法SQL 回数DTO 生成向いている場面
CountAsync → ToList2回上限以下のみ総件数を別途表示したい
Take(N+1)1回N+1件分上限が小さい、シンプルに済ませたい
Skip(N).AnyAsync2回(軽い)取得分のみ上限が大きく Count が重い
Take(pageSize+1)1回表示件数+1件総件数不要、カーソル型ページング

まとめ

当時の実装が間違いだったわけではなく CountAsync() → ToList は読みやすく、チームに説明しやすい実装です。

ただ、要件を整理すると「総件数が欲しかった」わけではなく「上限を超えているかだけ知りたかった」という場面は多いはずです。その場合、CountAsync() は要件に対して過剰になっています。

AnyAsync()Take(N+1) はそれぞれトレードオフがあるため、上限の大きさや総件数表示の有無によって使い分けるのが良いと思います。

「Count は本当に必要か」と一度問い直すだけで、実装の選択肢が広がりますね。