Урок 02

Оптимизация JOIN и подзапросов в Laravel

Когда JOIN

Что даёт:

  • Один запрос вместо N.
  • Скорость при правильных индексах.
  • Сортировка по связанной.
  • Агрегация по связанной.

Что стоит:

  • Сложнее hydrate.
  • Дубли при hasMany.
  • Не работает с Eloquent-связями.

Правило: JOIN — когда один запрос лучше нескольких.

JOIN vs with

with:

  • Eloquent-модели.
  • Отдельный запрос.
  • Нет дублей.
  • Проще.

join:

  • Query builder-результат.
  • Один запрос.
  • Дубли при 1:N.
  • Быстрее на больших.

Правило: with — по умолчанию, join — по необходимости.

Базовый JOIN

$posts = DB::table('posts')
    ->join('users', 'users.id', '=', 'posts.user_id')
    ->select('posts.id', 'posts.title', 'users.name as author_name')
    ->get();

Что даёт: одно SQL.

Правило: select — явно, не *.

leftJoin

$users = DB::table('users')
    ->leftJoin('orders', 'orders.user_id', '=', 'users.id')
    ->select('users.name', DB::raw('COUNT(orders.id) as orders_count'))
    ->groupBy('users.id', 'users.name')
    ->get();

Что даёт: включая без заказов.

whereColumn

$posts = DB::table('posts')
    ->join('users', 'users.id', '=', 'posts.user_id')
    ->whereColumn('posts.created_at', '>', 'users.created_at')
    ->get();

Что даёт: сравнение колонок.

JOIN в Eloquent

$posts = Post::query()
    ->join('users', 'users.id', '=', 'posts.user_id')
    ->select('posts.*', 'users.name as author_name')
    ->with('user')   // осторожно, может быть N+1
    ->get();

⚠️ with + join = лишний запрос.

Правило: либо with, либо join.

Дубли при hasMany

Проблема:

Post::join('comments', 'comments.post_id', '=', 'posts.id')
    ->select('posts.*')
    ->get();

Что даёт: один пост на каждый комментарий.

Решение:

Post::join('comments', 'comments.post_id', '=', 'posts.id')
    ->select('posts.*')
    ->distinct()
    ->get();

Правило: distinct или отдельный запрос.

Подзапросы

В select:

$posts = Post::query()
    ->addSelect(['comments_count' => Comment::selectRaw('COUNT(*)')
        ->whereColumn('post_id', 'posts.id')])
    ->get();

Что даёт: счётчик без JOIN.

Правило: подзапрос — когда нет дублей.

В where

$users = User::whereIn('id', function ($query) {
    $query->select('user_id')->from('orders')->where('paid', true);
})->get();

Что даёт: фильтр по связанной.

Exists

$users = User::whereExists(function ($query) {
    $query->select('id')->from('orders')
        ->whereColumn('orders.user_id', 'users.id');
})->get();

Что даёт: быстрее whereIn на больших.

Правило: exists — для больших IN.

whereHas

$users = User::whereHas('orders', function ($q) {
    $q->where('paid', true);
})->get();

Что даёт: Eloquent-связь в фильтре.

Правило: whereHas — читается лучше подзапроса.

doesntHave

$users = User::doesntHave('orders')->get();

Что даёт: без связанных.

whereHas с count

$users = User::has('orders', '>', 5)->get();

Что даёт: по количеству.

withWhereHas

$users = User::withWhereHas('orders', fn ($q) => $q->where('paid', true))->get();

Что даёт: и загрузить, и отфильтровать.

Правило: withWhereHas — комбо двух.

Оптимизация join

Что важно:

  • Индексы на FK.
  • Индексы на where.
  • Порядок JOIN.
  • select — явно.
  • limit — если можно.

Правило: JOIN без индексов — медленнее двух запросов.

Порядок JOIN

Правило: меньшая таблица первой.

Пример:

FROM small JOIN big   -- быстро
FROM big JOIN small   -- медленно

Правило: планировщик часто решает сам, но не всегда.

Explain JOIN

DB::table('posts')
    ->join('users', 'users.id', '=', 'posts.user_id')
    ->toSql();

Что даёт: SQL для EXPLAIN.

Правило: в psql/EXPLAIN ANALYZE — обязательно.

Nested loop vs hash

Nested Loop:

  • Индекс обязателен.
  • Маленькая выборка.
  • Быстро если индекс.

Hash Join:

  • Большие таблицы.
  • Нет индекса.
  • Много памяти.

Правило: PLANNER выбирает — но проверить EXPLAIN.

Materialized

Для тяжёлых:

CREATE MATERIALIZED VIEW post_stats AS
SELECT post_id, COUNT(*) AS comments
FROM comments GROUP BY post_id;

Что даёт: заранее посчитано.

Правило: материалка — когда агрегат тяжёлый.

Материалка в Laravel

Schema::create('post_stats', function (Blueprint $table) {
    $table->foreignId('post_id')->primary();
    $table->integer('comments')->default(0);
});

// Обновление через job
RefreshPostStats::dispatch();

Правило: ручная материалка — полезнее view.

Подзапрос vs JOIN

Подзапрос:

  • Нет дублей.
  • Читается лучше.
  • Один скан.

JOIN:

  • Быстрее на больших.
  • Дубли при 1:N.
  • Порядок важен.

Правило: пробовать оба, EXPLAIN решит.

Профилирование

Telescope → Queries:

  • Длительность каждого.
  • SQL и bindings.

Pulse → Slow queries:

  • Топ по времени.

pg_stat_statements:

  • Топ по total.

Правило: смотреть все три.

Практика

1. Найдите топ-5 медленных JOIN.

2. EXPLAIN каждую.

3. Проверьте индексы на FK.

4. Замените whereHas на JOIN где быстрее.

5. Замените JOIN на with где дубли.

6. Добавьте addSelect с подзапросом.

7. Замерьте до/после.

8. Проверьте через EXPLAIN.

9. Настройте slow log.

10. Сравните p95.

Частые ошибки

1. JOIN без индекса:

Seq Scan

Решение: индекс на FK.

2. select * в JOIN:

Много данных

Решение: явно.

3. JOIN + with:

N+1

Решение: одно.

4. Дубли 1:N:

Больше строк

Решение: distinct / отдельно.

5. Порядок JOIN:

Медленно

Решение: EXPLAIN.

6. Всё через JOIN:

Нет гибкости

Решение: with где можно.

7. Подзапрос в whereIn:

Медленно

Решение: EXISTS.

8. Игнорировать EXPLAIN:

Не понять

Решение: всегда.

Итог

  • JOIN — один запрос.
  • with — Eloquent.
  • Подзапрос — без дублей.
  • EXISTS — для больших.
  • Индексы на FK.
  • select — явно.
  • Дубли — distinct.
  • Материалка — для тяжёлых.
  • EXPLAIN — обязателен.
  • Пробовать оба варианта.

Дальше — пагинация: offset vs cursor.