Laravel distinctの使い方完全ガイド|重複排除の正しい書き方・paginateバグ対策・groupBy比較

基本文法・構文ガイド実装・応用テクニック

Laravelでデータベースからデータを取得する際、「重複するレコードを1件にまとめたい」「特定のカラム値のユニークなリストを取得したい」という場面は日常的に発生します。その際に直感的に使われるのが distinct() メソッドです。しかし、実務の現場では以下のような深刻なトラブルに直面するエンジニアが後を絶ちません。

  • distinct() を呼び出しているのに、なぜか重複がまったく排除されない
  • ページネーション(paginate())と併用した瞬間、総件数(total)やページ数がデタラメな数値になる
  • 特定カラムで重複排除しつつ、他の全カラムも一緒に取得したいのにエラーや全行取得になる
  • groupBy() や Collection の unique() との使い分け基準が曖昧で、メモリ枯渇やパフォーマンス低下を招く

これらのトラブルは、Laravelのクエリビルダが内部で発行するSQL(SELECT DISTINCTCOUNT(*))の動作原理と、PHPメモリ上で処理を行うコレクションとの役割分担を正しく理解していないことが原因です。

本記事では、Laravelにおける distinct() メソッドの正しい構文から、「重複が消えない」最大の原因である select() の必須ルール、実務で最大の落とし穴となる paginate() 総件数バグの完全回避策、複数カラムのユニーク取得、Window関数(ROW_NUMBER())や groupBy() との徹底比較、そしてインデックスを活用したパフォーマンスチューニングまで、実践的なコード例(Anti-Pattern vs Best-Practice)を交えて徹底的に解説します。

  1. 【目的別】Laravelでの重複排除手法(distinct / groupBy / unique)早見表
  2. 1. distinct()メソッドの基本構文と発行されるSQL
    1. 基本的な使い方(SELECT DISTINCT …)
    2. 【超頻出の罠】なぜ重複が消えないのか?select() 指定が必須である理由(主キー・タイムスタンプ混入問題)
      1. pluck() との組み合わせにおける注意点
  3. 2. 複数カラムに対するdistinct重複排除の実装パターン
    1. select(‘category_id’, ‘status’)->distinct()->get() の書き方
    2. 特定カラムだけを重複排除しつつ他の全カラムを取得したい場合の対処法(Window関数 ROW_NUMBER() や groupBy の活用)
      1. 【解決策A】Window関数 ROW_NUMBER() を使用したサブクエリ(MySQL 8.0+ / PostgreSQL 推奨・最も洗練された手法)
      2. 【解決策B】相関サブクエリ / 自己結合による最大ID取得(MySQL 5.7以前等のレガシー環境対応)
      3. 【解決策C】データ量が少ない場合の Collection unique()(小規模データ専用)
  4. 3. 【実務の落とし穴】distinct × paginate() で総件数(total)が狂うバグと完全対策
    1. なぜ総件数バグが発生するのか?(Laravelの COUNT(*) 発行仕様とDISTINCTの相性)
    2. 対策①: クエリビルダでのサブクエリを用いた正確なカウント
    3. 対策②: groupBy への書き換えと having の併用
    4. 対策③: 長大なデータでの LengthAwarePaginator 手動インスタンス化
  5. 4. 【徹底比較】distinct vs groupBy vs Collection unique() の使い分け基準
    1. distinct() を選ぶべきケース(シンプルかつ高速な単一・複数カラムのユニーク取得)
    2. groupBy() を選ぶべきケース(集計関数との併用、MySQLの ONLY_FULL_GROUP_BY 対策)
      1. MySQLの ONLY_FULL_GROUP_BY 制約とエラー回避策
    3. Collection unique() を選ぶべきケースと注意点(メモリ消費量と大量データでの危険性)
      1. 【致命的なアンチパターン】全件フェッチからの Collection unique()
  6. 5. パフォーマンス検証とインデックス最適化
    1. 大量データでの DISTINCT の実行速度・一時テーブル作成(Using temporary)の抑止
    2. 複合インデックスを活用したカバリングインデックスによる高速化
  7. 6. よくある質問(FAQ 5選)
    1. Q1: distinct() でNULL値はどのように扱われますか?(1件としてまとめられるか?)
    2. Q2: distinct()->count() と count(‘distinct column’) の違いは何ですか?
    3. Q3: Eloquentリレーション(hasMany等)経由で distinct() を使う場合の注意点は?
    4. Q4: distinct() メソッドに直接カラム名を引数として渡せますか?(例: distinct(‘email’))
    5. Q5: PostgreSQL独自の DISTINCT ON 構文はLaravelで使えますか?
  8. 7. まとめ・関連記事リンク
    1. あわせて読みたい関連記事
  9. 関連記事

【目的別】Laravelでの重複排除手法(distinct / groupBy / unique)早見表

Laravelで重複排除を行うアプローチには、大きく分けて「データベースエンジン(SQL)側で除外する方法」と「Webサーバー(PHPメモリ)側で除外する方法」の2つのレイヤーが存在します。要件やデータ量に応じて最適なアプローチを選択しないと、不要な全件フェッチによるメモリ枯渇(Allowed memory size exhausted)や、データベースへの過剰な負荷を引き起こします。

比較項目 distinct() groupBy() Collection unique()
実行レイヤー データベース(SQL) データベース(SQL) Webサーバー(PHPメモリ)
発行されるSQL SELECT DISTINCT col ... SELECT col ... GROUP BY col 通常のSELECT(全行取得)
主な用途 単純な重複行・ユニーク値の抽出 グループ化と集計(COUNT/SUM等) 取得済みコレクションの加工・外部APIデータ
複数カラムの重複排除 ◯(指定カラムの組み合わせ) ◯(複数キーでグループ化) ◯(クロージャまたは複数キー)
集計関数との併用 ✕(集計は不可) ◎(COUNT, MAX, AVG等が可能) ◯(PHP側で集計)
メモリ負荷(PHP) 極小(重複排除後の結果のみ転送) 極小(集計後の結果のみ転送) (全行をメモリ展開するため危険)
paginate()との親和性 要注意(総件数ズレの罠あり) ◯(自動サブクエリ化される) ✕(DBページネーション不可)
大量データ適性 ◎(インデックス適用で超高速) ◎(インデックス適用で高速) ✕(1万件超はメモリ枯渇リスク)

💡 手法選定のクイック判断フローチャート

  • 「特定のカラム値(または組み合わせ)の重複のないリストが欲しいだけ」
    👉 distinct()select() が最適解です。SQLレベルで絞り込まれるため最も軽量で高速です。
  • 「グループごとに件数(COUNT)や最大値(MAX)などの集計を行いたい」
    👉 groupBy() を選択します。集計関数との併用は distinct() では行えません。
  • 「すでにDBから取得済みのデータ、または外部API等の配列データをPHP側で重複排除したい」
    👉 Collection::unique() を使います。ただしDBからの初期取得時に使うとメモリを大量浪費するため厳禁です。
  • 「特定カラムで重複排除しつつ、最新の1件など全カラムのモデルインスタンスを取得したい」
    👉 Window関数(ROW_NUMBER() または相関サブクエリを検討してください。

1. distinct()メソッドの基本構文と発行されるSQL

LaravelのクエリビルダおよびEloquentが提供する distinct() メソッドは、SQLの DISTINCT 句を付与して問い合わせを実行するためのメソッドです。まずは基本構文と、裏側で実際にどのようなSQLが生成・発行されているのかを確認しましょう。

基本的な使い方(SELECT DISTINCT …)

クエリビルダ(DB::table())でもEloquentモデルでも、構文は基本的に共通です。必ず select() メソッドで対象カラムを指定した上で distinct() をチェーンします。

use Illuminate\Support\Facades\DB;
use App\Models\User;

// 【クエリビルダでの基本例】
// ユーザーテーブルから重複しない「都道府県」の一覧を取得する
$prefectures = DB::table('users')
    ->select('prefecture')
    ->distinct()
    ->get();

// 【Eloquentでの基本例】
// 重複しない「ステータス」の一覧を取得する
$statuses = User::select('status')
    ->distinct()
    ->get();

上記のクエリを実行した際、Laravelがデータベースに対して発行する実際のSQL文は以下のようになります。

-- クエリビルダで発行されるSQL
SELECT DISTINCT `prefecture` FROM `users`;

-- Eloquentで発行されるSQL
SELECT DISTINCT `status` FROM `users`;

🔍 発行SQLを素早く確認するテクニック(Laravel 10 / 11 / 12 対応)

Laravel 10.0以降では、バインドパラメータを含んだ完全な実行SQLを出力できる toRawSql() メソッドが利用可能です。デバッグ時にクエリの末尾に dd(User::select('status')->distinct()->toRawSql()) と記述することで、データベースに送られるSQLを一目で検証できます。

【超頻出の罠】なぜ重複が消えないのか?select() 指定が必須である理由(主キー・タイムスタンプ混入問題)

実務で distinct() を使い始めたエンジニアが最も頻繁に遭遇するのが、distinct() を書いたのに重複レコードがそのまま取得されてしまう」 という現象です。

結論から言うと、その原因は select() によるカラム絞り込みを行っていないこと にあります。

// ❌ 【Anti-Pattern】select() を指定せずに distinct() を呼ぶ
$users = User::distinct()->get();

// ❌ 【Anti-Pattern】select('*') を指定して distinct() を呼ぶ
$users = User::select('*')->distinct()->get();

一見すると「重複のないユーザー一覧」が取れそうに見えますが、このコードが発行するSQLは次のようになります。

-- 発行されるSQL(Anti-Pattern)
SELECT DISTINCT * FROM `users`;

SQLにおける DISTINCT は、「SELECT句で指定されたすべてのカラムの組み合わせが完全に一致する行」 のみを重複とみなして除外します。

SELECT * を指定すると、テーブル内の全カラム(主キーである id、一意な email、作成日時 created_at、更新日時 updated_at など)が評価対象に含まれます。主キー id はすべてのレコードで100%異なる値を持つため、データベースエンジンから見れば「1行たりとも一致する行は存在しない」と判断され、結果として全レコードが1件も削られず返されてしまう のです。

// ⭕ 【Best-Practice】重複排除したいカラムのみを select() で明示的に指定する
$roles = User::select('role')
    ->distinct()
    ->get();

// 発行されるSQL:
// SELECT DISTINCT `role` FROM `users`;

重複排除を機能させるためには、「重複を判定したいカラムのみ」を select() に渡すことが絶対条件 となります。

pluck() との組み合わせにおける注意点

特定カラムの一次元配列(またはコレクション)を取得する便利なメソッドとして pluck() があります。distinct()pluck() を組み合わせる場合、以下のような書き方が可能です。

// ⭕ pluck() を使う場合
$departments = User::distinct()->pluck('department');

// 発行されるSQL:
// SELECT DISTINCT `department` FROM `users`;

pluck('department') は内部的に select('department') を自動設定するため、上記コードでも正常に SELECT DISTINCT department が発行され、重複排除された配列が得られます。

ただし、クエリの可読性とチーム開発での意図の明確化という観点からは、select('department')->distinct()->pluck('department') のように明示しておくか、クエリビルダのメソッドチェーン順序を揃えて記述することが推奨されます。

2. 複数カラムに対するdistinct重複排除の実装パターン

実務では単一のカラムだけでなく、「カテゴリIDとステータスの組み合わせ」や「部署と役職のペア」など、複数のカラム値の組み合わせで重複を排除したいケースが頻繁に生じます。

select(‘category_id’, ‘status’)->distinct()->get() の書き方

複数カラムのユニークな組み合わせを取得したい場合、select() に複数のカラム名を引数または配列として渡し、distinct() をチェーンします。

// 【書き方1】引数として複数カラムを渡す
$pairs = DB::table('products')
    ->select('category_id', 'status')
    ->distinct()
    ->get();

// 【書き方2】配列として渡す(動的配列を渡す場合に最適)
$columns = ['category_id', 'status', 'supplier_id'];
$combinations = DB::table('products')
    ->select($columns)
    ->distinct()
    ->get();

発行されるSQLは以下の通りです。

SELECT DISTINCT `category_id`, `status` FROM `products`;

このSQLにより、例えば (category_id: 1, status: 'active') という全く同じ組み合わせのレコードが複数存在していた場合、1行にまとめられて返されます。どちらか一方のカラム値が異なっていれば別の行として抽出されます。

⚠️ ORDER BY を併用する際のSQL標準・各DBの厳格制約

DISTINCT を使用するクエリで orderBy() を組み合わせる場合、データベースの仕様(特に PostgreSQL や厳格設定の MySQL)に注意が必要です。

SQL標準の原則:ORDER BY に指定するカラムは、SELECT DISTINCT のカラムリストに含まれていなければならない」。もし select('category_id')->distinct()->orderBy('created_at', 'desc') のようにSELECTに含まれないカラムでソートしようとすると、PostgreSQL では ERROR: for SELECT DISTINCT, ORDER BY expressions must appear in select list という致命的エラーが発生します。ソート対象カラムも必ず select() に含めるか、後述のサブクエリパターンを採用してください。

特定カラムだけを重複排除しつつ他の全カラムを取得したい場合の対処法(Window関数 ROW_NUMBER() や groupBy の活用)

category_id ごとに重複を排除しつつ、最新の1件についてタイトルや価格、作成日時などすべてのカラム(モデルの全フィールド)を取得したい」という要件は、WEBアプリケーション開発で極めて一般的です。

しかし前述の通り、SQLの DISTINCT は「行全体の完全一致」しか扱えないため、distinct() メソッド単体でこの要件を満たすことは不可能です。

この課題を解決するための実務的な3つの実装パターンを紹介します。

【解決策A】Window関数 ROW_NUMBER() を使用したサブクエリ(MySQL 8.0+ / PostgreSQL 推奨・最も洗練された手法)

MySQL 8.0以降やPostgreSQL、SQLite 3.25以降では、Window関数 ROW_NUMBER() OVER (...) を活用することで、カテゴリごとに最新順の連番を振り、連番が1のものだけを抽出する手法が最もパフォーマンスが高く堅牢です。

use App\Models\Product;
use Illuminate\Support\Facades\DB;

// Window関数でカテゴリごとに連番(row_num)を付与するサブクエリ
$subQuery = Product::select('*')
    ->selectRaw('ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY created_at DESC, id DESC) as row_num');

// 外側のクエリで連番が 1 の行(各カテゴリの最新1件)だけを抽出
$latestProducts = Product::fromSub($subQuery, 'ranked_products')
    ->where('row_num', 1)
    ->get();

発行されるSQLは以下のようになります。

SELECT * FROM (
    SELECT *, ROW_NUMBER() OVER (
        PARTITION BY category_id 
        ORDER BY created_at DESC, id DESC
    ) as row_num
    FROM `products`
) as `ranked_products`
WHERE `row_num` = 1;

この手法の大きなメリットは、Product モデルの全属性(リレーションやアクセサ、キャスト)がそのまま利用可能な完全なEloquentコレクションとして取得できる点です。

【解決策B】相関サブクエリ / 自己結合による最大ID取得(MySQL 5.7以前等のレガシー環境対応)

Window関数が利用できない古い環境では、各カテゴリの最大ID(MAX(id))をサブクエリで取得し、そのIDリストと whereIn でマッチングさせます。

use App\Models\Product;
use Illuminate\Support\Facades\DB;

$latestProducts = Product::whereIn('id', function ($query) {
    $query->select(DB::raw('MAX(id)'))
        ->from('products')
        ->groupBy('category_id');
})->get();

発行されるSQL:

SELECT * FROM `products` 
WHERE `id` IN (
    SELECT MAX(id) FROM `products` GROUP BY `category_id`
);

【解決策C】データ量が少ない場合の Collection unique()(小規模データ専用)

対象レコードが数十〜数百件程度と十分に小さいことが保証されている場合に限り、全件を一度取得してからコレクションメソッドで重複排除するアプローチも簡潔です。

// ⚠️ レコード数が少ない場合(マスタデータ等)のみ許容される書き方
$products = Product::orderBy('created_at', 'desc')
    ->get()
    ->unique('category_id');

事前に orderBy('created_at', 'desc') でソートしておくことで、unique('category_id') が各カテゴリで最初に出現した要素(=最新のもの)を保持し、後続の重複要素を破棄します。ただし、データ量が数千件・数万件を超えるテーブルでこれを行うと深刻なメモリ不足を招くため、必ず次章以降のパフォーマンス解説を踏まえて使い分けてください。

3. 【実務の落とし穴】distinct × paginate() で総件数(total)が狂うバグと完全対策

Laravelのクエリビルダにおいて、最も開発者を悩ませるバグの1つが distinct()paginate() の併用時に発生する総件数ズレ です。

「取得されたデータの一覧には重複がないのに、画面に表示される『全 ◯◯ 件』という数字が重複を含んだ全行数になっている」「最終ページをクリックするとデータが空っぽになる」という現象に遭遇した経験はないでしょうか。

なぜ総件数バグが発生するのか?(Laravelの COUNT(*) 発行仕様とDISTINCTの相性)

このバグが発生する根本的な理由は、Laravelの paginate() メソッドが総件数をカウントする際に発行する内部SQLの仕様 にあります。

// ❌ 【典型的なバグのコード】
$users = User::select('city')
    ->distinct()
    ->paginate(10);

開発者の期待としては、「重複を排除した都市名の総数(ユニーク数)」を分母としてページネーションしてほしいところです。

しかし、Laravelの内部処理(Builder::paginate() 内の getCountForPagination())は、パフォーマンス最適化のために元のクエリから SELECT 句を削ぎ落とし、単に COUNT(*) を発行しようとします。その結果、バックグラウンドで実行される2つのSQLは以下のようになります。

-- 1. データ取得クエリ(これは正しく重複排除される)
SELECT DISTINCT `city` FROM `users` LIMIT 10 OFFSET 0;

-- 2. 総件数カウントクエリ(⚠️ ここでバグが発生!)
SELECT COUNT(*) AS aggregate FROM `users`;
-- または
SELECT COUNT(DISTINCT) AS aggregate FROM `users`; -- 構文エラーまたはカラム不整合

総件数カウントクエリにおいて、DISTINCT が消落するか、あるいは COUNT(*) として発行されてしまうため、「都市が100種類しかないのに、テーブル全体の行数である50,000件が総件数($users->total())として返される」 という致命的な不整合が発生します。結果としてページャーには何百ページも表示され、2ページ目以降を進むと突然データが存在しなくなる「幽霊ページ問題」が発生します。

対策①: クエリビルダでのサブクエリを用いた正確なカウント

このバグを防ぐ確実な対策の1つは、重複排除クエリ自体をサブクエリとしてラッピングし、外側から正確に行数をカウントする方法です。

use App\Models\User;
use Illuminate\Support\Facades\DB;

// 1. 重複排除を行うベースクエリを構築
$baseQuery = User::select('city')->distinct();

// 2. サブクエリとしてラッピングして正確な総件数を取得
$total = DB::table($baseQuery, 'unique_users')->count();

// 3. ページネーションに必要なデータを手動取得
$page = request()->get('page', 1);
$perPage = 10;
$items = $baseQuery->forPage($page, $perPage)->get();

// 4. LengthAwarePaginator を生成
$paginator = new \Illuminate\Pagination\LengthAwarePaginator(
    $items,
    $total,
    $perPage,
    $page,
    ['path' => request()->url(), 'query' => request()->query()]
);

この実装により、発行されるカウントSQLは以下の通り完全に意図通りのものになります。

-- 正確な総件数カウントSQL
SELECT COUNT(*) AS aggregate FROM (
    SELECT DISTINCT `city` FROM `users`
) AS `unique_users`;

対策②: groupBy への書き換えと having の併用

単一または複数カラムの重複排除であれば、distinct()groupBy() に書き換えることで、Laravel標準の paginate() の自動サブクエリ生成ロジックを活用できます。

// ⭕ groupBy を使うと paginate() が自動でサブクエリ化してくれる
$users = User::select('city')
    ->groupBy('city')
    ->paginate(10);

Laravelのクエリビルダは、クエリに groupByhaving が含まれている場合、「単純な COUNT(*) では集計行数が狂う」ことを認識し、自動的に元のクエリをサブクエリ化してカウントを実行します。

-- groupBy 時の自動生成カウントSQL
SELECT COUNT(*) AS aggregate FROM (
    SELECT `city` FROM `users` GROUP BY `city`
) AS aggregate_table;

余分なコードを書かずにLaravel標準の paginate() ヘルパーを生かしたい場合、groupBy() への置き換えは極めて手軽で有効な解決策となります。

対策③: 長大なデータでの LengthAwarePaginator 手動インスタンス化

実務で何百万件もの大規模データを扱う場合、正確な件数を取得する COUNT(*) 自体がデータベースにとって非常に重い処理となります。

このようなケースでは、正確な総ページ数表示を諦めて simplePaginate()cursorPaginate() に切り替えるか、あらかじめキャッシュしたユニーク件数を使って LengthAwarePaginator を手動生成するのがベストプラクティスです。

use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Cache;

$perPage = 15;
$page = (int) request()->get('page', 1);

// 1. 重複排除の総件数をキャッシュ(頻繁な重いCOUNTを防ぐ)
$total = Cache::remember('users_unique_cities_count', 3600, function () {
    return User::select('city')->distinct()->count('city');
});

// 2. 対象ページのデータのみ取得
$items = User::select('city')
    ->distinct()
    ->orderBy('city', 'asc')
    ->forPage($page, $perPage)
    ->get();

// 3. 手動インスタンス化
$paginatedCities = new LengthAwarePaginator(
    $items,
    $total,
    $perPage,
    $page,
    [
        'path' => request()->url(),
        'query' => request()->query(),
    ]
);

🚀 総件数が不要なら simplePaginate() が圧倒的に高速

もしUI上で「前へ」「次へ」のナビゲーションだけで十分(全ページ番号の表示が不要)であれば、User::select('city')->distinct()->simplePaginate(15) を採用してください。総件数の COUNT クエリの発行そのものが完全にスキップされるため、データベースの負荷を劇的に削減できます。

4. 【徹底比較】distinct vs groupBy vs Collection unique() の使い分け基準

Laravelで「データの重複を取り除く」という目的を達成するための3つの手段――distinct()groupBy()、Collection unique()。それぞれの特性と、どのようなシチュエーションでどれを選ぶべきかの判断基準を詳しく掘り下げます。

distinct() を選ぶべきケース(シンプルかつ高速な単一・複数カラムのユニーク取得)

distinct() を選択すべき最も典型的なシーンは、「集計(COUNTやSUM等)を伴わず、単純に重複のない値の一覧・組み合わせを取得したい場合」 です。

  • 検索フォームのドロップダウン選択肢(例:登録されている全「職種」の一覧)
  • フィルタリング用のユニークな「年代」や「都道府県」マスタの動的抽出
  • 複数テーブルを結合した結果、リレーションの多重結合によって発生した行の重複排除
// ⭕ 単純な選択肢リスト作成に最適な distinct()
$categories = DB::table('articles')
    ->select('category_id')
    ->where('is_published', true)
    ->distinct()
    ->pluck('category_id');

distinct() はデータベースエンジン内部でソートやハッシュテーブルを用いて重複を弾くため、適切なインデックスが貼られていれば最小のコストで高速に結果を返します。

groupBy() を選ぶべきケース(集計関数との併用、MySQLの ONLY_FULL_GROUP_BY 対策)

groupBy() を選択すべきシーンは、「各グループに対して集計関数(COUNT, SUM, AVG, MAX, MIN)を適用したい場合」 です。

// ⭕ 集計を伴う場合は groupBy() が必須
$summary = DB::table('orders')
    ->select('customer_id', DB::raw('COUNT(*) as total_orders'), DB::raw('SUM(amount) as total_spent'))
    ->groupBy('customer_id')
    ->having('total_orders', '>=', 5)
    ->get();

MySQLの ONLY_FULL_GROUP_BY 制約とエラー回避策

groupBy() を扱う上で絶対に避けて通れないのが、MySQLの厳格モード ONLY_FULL_GROUP_BY(SQL標準準拠)です。

Laravelのデフォルト設定(config/database.php'strict' => true)では、このモードが有効になっています。この設定下では、「GROUP BY句に指定されていない非集計カラムをSELECT句に直接記述する」とクエリ実行時にSQLエラー となります。

// ❌ 【ONLY_FULL_GROUP_BY エラーになる例】
// Syntax error or access violation: 1055 'users.name' isn't in GROUP BY
$users = User::select('department_id', 'name')
    ->groupBy('department_id')
    ->get();

// ⭕ 【Best-Practice 1: 集計関数で包む】
$users = User::select('department_id', DB::raw('ANY_VALUE(name) as name'))
    ->groupBy('department_id')
    ->get();

// ⭕ 【Best-Practice 2: GROUP BY に全SELECTカラムを含める】
$users = User::select('department_id', 'name')
    ->groupBy('department_id', 'name')
    ->get();

MySQL 5.7以降では、グループ内の任意の値を取得することを明示する ANY_VALUE() 関数が利用できます。ただし、どの行の name が選ばれるかは非決定的であるため、最新順など特定の行を狙いたい場合は前述のWindow関数(ROW_NUMBER())を使用してください。

Collection unique() を選ぶべきケースと注意点(メモリ消費量と大量データでの危険性)

Collection の unique() メソッドは、PHPのメモリ上にすでに展開されているコレクションインスタンスから重複要素をフィルタリングするためのメソッドです。

// ⭕ すでにメモリ上にあるコレクションに対する安全な操作
$collection = collect([
    ['id' => 1, 'role' => 'admin'],
    ['id' => 2, 'role' => 'editor'],
    ['id' => 3, 'role' => 'admin'],
]);

$uniqueRoles = $collection->unique('role');
// 結果: id 1 と 2 の要素が残る

【致命的なアンチパターン】全件フェッチからの Collection unique()

初心者が最も陥りやすい重大なミスが、「DBレベルでの重複排除の書き方が分からないから」という理由で、全件を一度PHP側に読み込んでから unique() を呼ぶことです。

// ❌ 🚨 【超危険・アンチパターン】
// テーブルの全レコードをPHPメモリ上に読み込んでから重複排除
$users = User::all()->unique('email');

このコードを実行した時の処理の流れを追ってみましょう。

  1. データベースから全件(例えば10万行)のデータをSELECTする
  2. 10万件分のEloquentモデルインスタンスが生成され、PHPメモリに格納される(数百MB〜数GBのメモリを消費)
  3. PHPのループ処理で1件ずつ重複を比較・破棄する
  4. 最終的に数十件のコレクションが残る

本番環境でユーザー数やデータ量が増加した瞬間、以下のような致命的な障害が発生します。

  • Fatal error: Allowed memory size of 134217728 bytes exhausted によるサーバー500エラー
  • 大量のデータ転送によるネットワーク帯域の圧迫とクエリ遅延(レスポンスが数秒〜十数秒に悪化)
  • WebサーバーのCPU使用率スパイク
// ⭕ 【Best-Practice】必ずDB側で重複排除を済ませてから転送する
$users = User::select('email')->distinct()->get();

User::select('email')->distinct()->get() であれば、データベース側で重複排除が完了した数十行のみがPHPに送られてくるため、メモリ消費量は数KBにとどまり、実行速度もミリ秒単位で完了します。

5. パフォーマンス検証とインデックス最適化

distinct() は非常に強力なメソッドですが、データベース内部では重複排除のために「ソート処理」や「一時テーブル(Temporary Table)の構築」が発生することがあります。大量データを安全かつ高速に処理するためのインデックス設計を理解しておきましょう。

大量データでの DISTINCT の実行速度・一時テーブル作成(Using temporary)の抑止

対象カラムにインデックスが貼られていない状態で DISTINCT を実行した場合、MySQLの実行計画(EXPLAIN)を確認すると、以下のような危険な状態が表示されることがあります。

EXPLAIN SELECT DISTINCT category_id, status FROM products;
-- Extra列: Using temporary; Using filesort
  • Using temporary: データベースが重複を判定するために、ディスクやメモリ上に一時テーブルを作成していることを意味します。データ量が大きい場合、ディスクI/Oが頻発してクエリ速度が激減します。
  • Using filesort: 重複排除のために行の並び替え(ソート)をメモリまたは一時ディスクで行っていることを示します。

データ件数が数十万件を超えると、この一時テーブル作成とファイルソートが原因で、1回のクエリに数秒以上かかる深刻なスロークエリへと発展します。

複合インデックスを活用したカバリングインデックスによる高速化

このボトルネックを根本から解消するのが、「カバリングインデックス(Covering Index)」 の設計です。

SELECT DISTINCT に指定するすべてのカラムを網羅する複合インデックスを作成すると、データベースは実データ行(テーブル本体)に一度もアクセスすることなく、B-Treeインデックスのリーフノードだけを走査して重複を排除(Index Loose Scan) できます。

// マイグレーションファイルでの複合インデックス定義例
Schema::table('products', function (Blueprint $table) {
    // category_id と status の組み合わせに対する複合インデックス
    $table->index(['category_id', 'status'], 'idx_products_category_status');
});

このインデックスが存在する状態でクエリを実行すると、EXPLAIN の結果は以下のように劇的に改善します。

EXPLAIN SELECT DISTINCT category_id, status FROM products;
-- Extra列: Using index for group-by (または Using index)

Using temporaryUsing filesort が完全に消え去り、Using index for group-by(疎なインデックススキャン:Loose Index Scan)へと変化します。インデックスツリー内のユニークキーのみを跳び飛びに読み取るため、100万件のデータが存在していても数ミリ秒以内で瞬時に結果が返るようになります。

📌 カバリングインデックス設計の鉄則

select('colA', 'colB')->distinct() を行うクエリには、['colA', 'colB'] の順序で複合インデックスを作成してください。もし where('is_active', 1) などの絞り込み条件も同時に付与する場合は、['is_active', 'colA', 'colB'] のように「等価条件(WHERE)カラム ➔ 重複排除(DISTINCT)カラム」の順でインデックスを定義するのが最も効果的です。

6. よくある質問(FAQ 5選)

Q1: distinct() でNULL値はどのように扱われますか?(1件としてまとめられるか?)

A: SQL標準に従い、複数の NULL 値は「1件の NULL」としてまとめられます。

例えば、カラム内に ['apple', NULL, 'banana', NULL, 'apple'] というデータが存在する場合、distinct() を実行した結果は ['apple', NULL, 'banana'] となり、NULL値は1つだけ返されます。NULLを除外したい場合は、whereNotNull('column_name') をチェーンして事前にNULLをフィルタリングしてください。

Q2: distinct()->count() と count(‘distinct column’) の違いは何ですか?

A: 発行されるSQLと正確性に明確な違いがあります。

// パターンA: カラム指定なしの count()
$countA = User::select('city')->distinct()->count();
// 発行SQL: SELECT COUNT(*) FROM (SELECT DISTINCT city FROM users) ... または環境により単なる SELECT COUNT(*)

// パターンB: 引数に raw expression または明確にカラムを渡す
$countB = User::distinct()->count('city');
// 発行SQL: SELECT COUNT(DISTINCT `city`) FROM `users`;

特定カラムのユニーク件数を素早く1発でカウントしたい場合は、User::distinct()->count('city') の書き方が最も安全で無駄なサブクエリも発生しません。

Q3: Eloquentリレーション(hasMany等)経由で distinct() を使う場合の注意点は?

A: 親モデルの主キーや外部キーが自動で SELECT 句に含まれていないか確認してください。

例えば $user->posts()->distinct()->get() と呼び出すと、内部的にリレーション制約(WHERE posts.user_id = ?)はかかりますが、select() を指定していないと posts.* がすべてSELECTされてしまい、主キー posts.id のせいで重複排除が効きません。リレーション経由でも必ず $user->posts()->select('category_id')->distinct()->get() のように明示的な select() を忘れないようにしてください。

Q4: distinct() メソッドに直接カラム名を引数として渡せますか?(例: distinct(‘email’))

A: 引数は無視されるか、一部のデータベースドライバを除いて基本的に引数を取りません。

Laravelのクエリビルダの実装では、public function distinct($value = true) と定義されており、引数には真偽値(boolean)を受け取って $this->distinct = true; フラグを立てる設計になっています。そのため distinct('email') と文字列を渡しても、単に true として扱われてしまい、指定したカラム名は反映されません。必ず select('email')->distinct() と記述してください。

Q5: PostgreSQL独自の DISTINCT ON 構文はLaravelで使えますか?

A: 標準メソッドにはありませんが、selectRaw() や raw 表現を用いることで利用可能です。

PostgreSQLには「特定カラム値の重複排除を行いつつ、他の全カラムを1行取得する」ための便利な SELECT DISTINCT ON (category_id) * FROM products ORDER BY category_id, created_at DESC という構文が存在します。Laravelでこれを利用したい場合は、以下のように selectRaw() を使用します。

$products = Product::selectRaw('DISTINCT ON (category_id) *')
    ->orderBy('category_id')
    ->orderBy('created_at', 'desc')
    ->get();

※ただし、このコードはPostgreSQL専用であり、MySQLやSQLite環境に移行した際には構文エラーとなる点にご注意ください。ポータビリティを考慮する場合は前述のWindow関数(ROW_NUMBER())の利用を推奨します。

7. まとめ・関連記事リンク

Laravelにおける distinct() メソッドは、文法自体はシンプルでありながら、データベースの動作原理やクエリビルダの仕様を知らないと数々の実務バグを引き起こしやすいメソッドです。

本記事で解説した重要なポイントを振り返りましょう。

📝 本記事の最重要チェックポイントまとめ

  1. 重複が消えない時は select() を確認: select('*') やカラム無指定では主キー(id)が混入し、全行がユニークと判定されてしまう。必ず重複排除したいカラムのみを select() で指定する。
  2. 複数カラムの組み合わせも対応可能: select('category_id', 'status')->distinct() でペア・トリプルのユニーク組み合わせを安全に取得できる。
  3. 全カラム取得したいなら Window関数: 特定カラムで重複排除しつつモデルの全フィールドが欲しい場合は、ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) によるサブクエリを活用する。
  4. paginate() 併用時の総件数ズレに厳戒態勢: 内部の COUNT(*) 発行仕様により総件数が全行数に狂う。サブクエリによる正確なカウント、groupBy() への置き換え、または LengthAwarePaginator 手動生成で対策する。
  5. Collection unique() の全件取得は厳禁: 10万件を User::all()->unique() するとメモリ枯渇を引き起こす。大量データは必ずデータベース側で distinct() して絞り込む。
  6. インデックスで一時テーブルを抑止: DISTINCT 対象カラムにカバリングインデックスを貼ることで、Using temporary; Using filesort を抑止し、ミリ秒単位で超高速処理させる。

重複排除の特性を深く理解し、適切なレイヤーとクエリを組み合わせて、安全で高速なLaravelアプリケーションを構築していきましょう。

あわせて読みたい関連記事

Laravelのクエリビルダやデータ操作の最適化について、さらに理解を深めたい方は以下の解説記事もぜひ参考にしてください。

レン (Wren)

こんにちは。レンです。

Laravelのコードの森に住んでいる、小さな案内役です。
ルーティングの枝やクラスの影を歩きながら、コードの流れや仕組みを眺めています。

このサイトでは、Laravelの基本から実装のコツまで、開発で役立つポイントを静かに整理しています。
難しいことを増やすのではなく、コードの見通しが少し良くなるヒントを届けるのが役目です。

「この処理はどこに書くのがいいのか」
「Laravelではどう考えると整理できるのか」

そんな疑問に、小さなメモを残すような気持ちで記事を書いています。

コードを書いている途中で迷ったとき、
このサイトが少し立ち止まって整理できる場所になればうれしいです。

レン (Wren)をフォローする

コメント