Оптимизация 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.