Laravelで不要なクエリ発行を抑制する方法
DBのアクセス時に不要なリレーションロードを防ぐ仕組み、存在チェックの効率化、安全な一括処理、および予期せぬN+1の検知といった対策が必要になります。
ここでは具体的にどのようなメソッドを実装していくべきか紹介します。
目次
N+1問題の検知
例えば次のような予約一覧を取得する処理があったとします。
public function list(
ListReservesService $listReservesService,
Request $request,
$condition = null
) {
// 検索条件の指定
$conditions = $listReservesService->conditionQueryToArray($condition);
// 予約情報の取得
$paginator = $listReservesService->paginate($conditions, 10);
予約一覧側で次のように会員情報を表示した場合
@foreach ($paginator as $reserve)
<tr class="odd">
<td>
{{ $reserve->reserve_no }}
</td>
<td>
{{ $reserve->member->name }}
</td>
<!--中略-->
</tr>
@endforeach
ページネーションのループごとにmembersテーブルを検索し会員名を検索するクエリが発行されてしまいます。これがN+1問題で、一覧を取得するクエリ+ループ分(この場合は合計11回)クエリが発行されます。
これに対応するため、with()を利用してeager loadingを指定する必要があるのですが、見逃してしまっても動作が遅くなるだけで見た目上は不具合があるとは分かりません。
そのため一覧やページネーションを取得する処理の中で次のメソッドを実行するように改修します。
public function preventLazyLoading(bool $prevent = true): void
{
(static::$model)::preventLazyLoading($prevent);
}
※app()->isLocalなどと併用することを推奨します。
preventLazyLoadingを指定することで、N+1問題が発生した場合に例外が発生するため検知が容易になります。
存在チェックの効率化
レコードの存在の有無だけチェックする場合、不要なカラムを読み込む必要はありません。そのために次のような専用メソッドを作成します。
/**
* 指定条件に該当するデータが存在するかを返す(高速な存在チェック)
*
* @param array $conditions
* @return bool
*/
public function exists(array $conditions = []): bool
{
$query = (static::$model)::query();
$this->buildJoinClauseByConditions($query, $conditions);
$this->buildWhereClauseByConditions($query, $conditions);
if (isset($conditions['only_trashed'])) {
$query->onlyTrashed();
} elseif (isset($conditions['with_trashed'])) {
$query->withTrashed();
}
return $query->exists();
消費メモリの抑制
get()メソッドなどを使用して大量のレコードを一度に取得するとメモリ不足になり処理がタイムアウトしたり500エラーになったりします。
lazyByIdを使用すると遅延評価でループ処理ごとにデータを取得するため消費メモリを抑制できます。
/**
* 指定条件に該当するモデルをジェネレータ(遅延評価)で順次取得する
* 大量データのバッチ処理やエクスポートに利用する
*
* @param array $conditions
* @param int $chunkSize
* @return \Illuminate\Support\LazyCollection
*/
public function lazyList(array $conditions = [], int $chunkSize = 1000, bool $use_eager = true): \Illuminate\Support\LazyCollection
{
$query = (static::$model)::query();
if (0 < count(static::$eagerLoadings) && $use_eager) {
$query->with(static::$eagerLoadings);
}
$this->buildSelectClauseByConditions($query, $conditions);
$this->buildJoinClauseByConditions($query, $conditions);
$this->buildWhereClauseByConditions($query, $conditions);
$this->buildOrderbyClauseByConditions($query, $conditions);
if (isset($conditions['only_trashed'])) {
$query->onlyTrashed();
} elseif (isset($conditions['with_trashed'])) {
$query->withTrashed();
}
// メモリを圧迫しないよう cursor / lazy を利用
return $query->lazyById($chunkSize);
}
不要カラム読み込みの抑制
一覧画面の表示時のように必要なカラムのみ取得できるように対応すると処理が軽量になります。
以下の例では必要なカラムを配列で渡すパターン(select_columns)、および複数テーブルをJOINした際にカラムを指定するためのパターン(raw_select_columns)に対応しています。
/**
* 指定条件を元にクエリビルダにSELECT用カラムをセットする
*
* @param Builder $queryBuilder
* @param array $conditions
* @return void
*/
protected function buildSelectClauseByConditions(Builder &$queryBuilder, array $conditions = [])
{
$table = $queryBuilder->getModel()->getTable();
if (array_key_exists('select_columns', $conditions) && is_array($conditions['select_columns'])) {
// 例: ['id', 'title', 'created_at'] のように指定された場合
$queryBuilder->select($conditions['select_columns']);
} elseif (array_key_exists('raw_select_columns', $conditions)) {
$raw_select_columns = null === $conditions['raw_select_columns'] || '' === $conditions['raw_select_columns'] ? null : (string) $conditions['raw_select_columns'];
if (null !== $raw_select_columns) {
$queryBuilder->selectRaw($raw_select_columns);
}
} else {
// デフォルトはテーブル全体のカラムを選択
$queryBuilder->select("{$table}.*");
}
}
まとめ
4パターンの例をあげましたが、これ以外にもパフォーマンスチューニング出来ることは色々あります。
ただ「動けば良い」では良いシステムとはいえません。
- なるべく早く
- なるべく軽く
- なるべく少ないクエリを実行
- 必要最低限のカラム取得
などを意識したシステムを開発していきましょう。



















