0
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?

N+1をテストで防ぐ

0
Posted at

LaravelのN+1を「件数を増やしても問い合わせ回数が変わらない」テストで防ぐ

個人開発のサービス( https://butsuyokusenki.com )で、N+1をテストで防いだ話です。
この問題はAI開発でよく起きるので参考になるかもしれません。
そこでやったことを3つ紹介します。

  1. 件数を変えて問い合わせの回数が同じか比べるテスト
  2. 開発とテストでlazy loadingを禁止する
  3. PostgreSQLの外部キーにインデックスを張る(部分インデックス)

環境:Laravel 13、PHP 8.3、PostgreSQL 16、Pest

1. 問い合わせの回数を数えるテスト

まず問い合わせの回数を数えるヘルパーを用意します。

// tests/Pest.php
function queryCount(callable $request): int
{
    DB::flushQueryLog();
    DB::enableQueryLog();
    $request();
    $count = count(DB::getQueryLog());
    DB::disableQueryLog();

    return $count;
}

テストでは、データの件数を変えて2回測り回数が同じことを確かめます。
回数そのもの(「12回以下」など)を決め打ちしないので実装を直してもテストを書き換えずに済みます。

it('通知の一覧は通知の件数で問い合わせが増えない', function (): void {
    $owner = User::factory()->create();
    $make = fn (int $n) => /* 通知を $n 件ずつ作る */;
    $measure = fn (): int => queryCount(
        fn () => $this->actingAs($owner)->getJson('/api/v1/notifications')->assertOk()
    );

    $make(1);
    $few = $measure();
    $make(4);
    $many = $measure();

    expect($many)->toBe($few);
});

APIだけでなく、定期処理(Artisanコマンド)も同じやり方で測れます。

$few = queryCount(fn () => $this->artisan('notifications:check-plans')->assertSuccessful());

実際にこれで1時間ごとに全員のプランを確かめる処理が利用者1人につき2回ずつ購読を読んでいたことが分かりました。

2. 開発とテストでlazy loadingを禁止する

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}

複数の行をまとめて読んだあとに関連を 1 件ずつ読むと、LazyLoadingViolationException になります。
1件だけ読んだモデル関連は対象外(Laravelの仕様)なので詳細画面などで誤検知することはほとんどありません。

既存のテスト310件で有効にしてみたところ、引っかかったのはテストのコードの1か所だけでした。

3. まとめて読む(preload)

ループの中で1人ずつ問い合わせていた処理は200人分のIDでまとめて読み、ループの中ではその結果を引くように直しました。

User::query()->chunkById(200, function (Collection $users) use ($center): void {
    Plan::preload($users->modelKeys()); // 購読を whereIn でまとめて読む
    try {
        foreach ($users as $user) {
            $center->checkPlan($user); // 中のPlan::of() はpreload済みを使う
        }
    } finally {
        Plan::forgetPreloaded();
    }
});

「まとめて読んだ結果」と「1 人ずつ求めた結果」が同じになることもテストで確かめています。

4. PostgreSQLは外部キーにインデックスを作らない

MySQL(InnoDB)は外部キーに自動でインデックスを作りますが、PostgreSQLは作りません。
foreignId()->constrained() だけでは次のような処理で全件を読むことになります。

  • 親を消したときのON DELETE CASCADE / SET NULL
  • 子の表を親のIDで引くJOIN・withCount

通知の表のように種類によってNULLの列が多い表には、値のある行だけの部分インデックスが向いています。

DB::statement('CREATE INDEX user_notifications_announcement_user_index
    ON user_notifications (announcement_id, user_id) WHERE announcement_id IS NOT NULL');

// 未読の件数(ベルの丸)と「いちばん新しい未読」用。既読が増えても小さいまま
DB::statement('CREATE INDEX user_notifications_unread_index
    ON user_notifications (user_id, id) WHERE read_at IS NULL');

インデックスがあることもテストで確かめています(消してしまったときに気づけるように)。

$names = collect(DB::select('SELECT indexname FROM pg_indexes WHERE schemaname = current_schema()'))
    ->pluck('indexname')->all();
expect($names)->toContain('user_notifications_unread_index');

まとめ

  • 回数を決め打ちせず件数を変えて同じ回数かを比べる
  • preventLazyLoadingで、新しいN+1を作った時点で気づけるようにする
  • PostgreSQLでは、外部キーのインデックスを自分で張る

この記事のサービスは物欲に勝った日・負けた日を記録して家族と見せ合うアプリです。よければ触ってみてください。
https://butsuyokusenki.com

0
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
0
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?