Оптимизация Eloquent: select(), chunk(), cursor()
Проблемы больших выборок
Что плохо:
Model::all()— вся таблица в память.SELECT *— лишние колонки.- Жадно — миллион моделей.
- N+1 — каждый чих.
Правило: для больших данных — специальные методы.
select()
Плохо:
$users = User::all(); // все колонки
Хорошо:
$users = User::select('id', 'name', 'email')->get();
Что даёт:
- Меньше трафика.
- Меньше памяти.
- Быстрее hydration.
Правило: select — всегда с явными колонками.
select с моделью
$users = User::query()->select(['id', 'name'])->get();
⚠️ Если не указать id — связи сломаются.
Правило: primary key — обязателен.
addSelect()
$users = User::select('id', 'name')
->addSelect(['post_count' => Post::selectRaw('count(*)')
->whereColumn('user_id', 'users.id')])
->get();
Что даёт: подзапрос как колонка.
selectRaw()
$users = User::select('id', 'name')
->selectRaw('LENGTH(name) as name_length')
->get();
Что даёт: вычисляемые колонки.
Правило: selectRaw — осторожно с injection.
Pluck
Когда нужна одна колонка:
$emails = User::pluck('email');
$names = User::pluck('name', 'id'); // id => name
Что даёт:
- Не гидратирует модели.
- Быстрее в разы.
- Мало памяти.
Правило: одна колонка — всегда pluck.
value()
$name = User::where('id', 1)->value('name');
Что даёт: одно значение без гидратации.
exists() vs count()
Плохо:
if (User::where('active', true)->count() > 0) { /* ... */ }
Хорошо:
if (User::where('active', true)->exists()) { /* ... */ }
Что даёт: SELECT EXISTS вместо SELECT COUNT.
Правило: проверка существования — exists.
chunk()
Итерация частями:
User::chunk(1000, function ($users) {
foreach ($users as $user) {
// обработать
}
});
Что даёт: по 1000 в память.
Что важно:
- Порядок по PK.
- Изменения в таблице во время — могут пропуститься.
Правило: chunk — с orderBy PK.
chunkById()
User::chunkById(1000, function ($users) {
foreach ($users as $user) {
$user->update(['processed' => true]);
}
});
Что даёт: курсор по ID.
Что важно: не пропускает при изменении.
Правило: если меняешь данные — chunkById.
chunkById с колонкой
Order::chunkById(1000, function ($orders) {
//
}, column: 'uuid', alias: 'uuid');
Что даёт: если PK не id.
lazy()
Генератор по чанкам:
foreach (User::lazy(1000) as $user) {
// обработать
}
Что даёт:
- Синтаксис foreach.
- По 1000 в память.
Правило: lazy — чище чем chunk.
lazyById()
foreach (User::lazyById(1000) as $user) {
$user->update(['processed' => true]);
}
Что даёт: ленивый + по ID.
Правило: lazyById — безопасный при изменениях.
cursor()
Генератор по одной модели:
foreach (User::cursor() as $user) {
// обработать
}
Что даёт:
- Одна модель в память.
- Минимум ресурсов.
Что важно: нельзя прервать без закрытия.
Правило: cursor — для огромных выборок.
cursor vs lazy
| cursor | lazy | |
|---|---|---|
| По одной | ✅ | ❌ |
| Чанки | ❌ | ✅ |
| Eager loading | Ограничен | Работает |
| Скорость | Медленнее | Быстрее |
| Память | Минимум | Чанк |
Правило:
- Миллионы —
cursor. - Тысячи —
lazy.
Eager и cursor
⚠️ cursor не работает с with на некоторых связях.
Что делать:
- Разделить на два запроса.
- Или
lazy.
Правило: сложные связи — lazy, не cursor.
lazyById и update
Идеально для фоновых задач:
User::lazyById(500)->each(function (User $user) {
$user->recalculate();
});
Что даёт: безопасно при изменении.
Правило: миграции данных — lazyById.
only()
Только указанные колонки:
$posts = Post::query()->get()->only(['id', 'title']);
⚠️ Работает после выборки, экономии нет.
Правило: select, не only.
LazyCollection
Что даёт:
- PHP-генератор под коллекцию.
- Все методы коллекций.
- Малая память.
User::lazy()->filter(fn ($u) => $u->active)->take(100);
Правило: lazy + коллекции — мощь.
Курсор и замыкания
⚠️ cursor внутри транзакции держит соединение.
Что делать: короткие транзакции.
Правило: cursor не в долгой транзакции.
Оптимизация больших выборок
Чек-лист:
-
select— только нужные колонки. -
pluck— если одна колонка. -
chunk/lazy— вместоget. -
cursor— если миллионы. -
with— только нужное. - Индексы на
where/orderBy. -
limit— если можно.
Правило: все 7 пунктов — обязательны.
Пример: экспорт
Плохо:
$users = User::all(); // 1M в память
foreach ($users as $user) {
fputcsv($file, $user->toArray());
}
Хорошо:
User::query()
->select('id', 'name', 'email')
->lazyById(1000)
->each(function (User $user) use ($file) {
fputcsv($file, [$user->id, $user->name, $user->email]);
});
Что даёт: память константна.
Пример: пересчёт
Плохо:
foreach (Post::all() as $post) {
$post->comments_count = $post->comments()->count(); // N+1 + память
$post->save();
}
Хорошо:
Post::query()
->withCount('comments')
->lazyById(500)
->each(function ($post) {
$post->update(['comments_count' => $post->comments_count]);
});
Что даёт: 2 запроса на чанк вместо N+1.
Массовое обновление
Post::where('status', 'draft')->update(['status' => 'published']);
Что даёт: один SQL.
⚠️ Не сработают события модели.
Правило: если нужны события — foreach c save.
upsert()
User::upsert([
['id' => 1, 'name' => 'Alice'],
['id' => 2, 'name' => 'Bob'],
], ['id'], ['name']);
Что даёт: массовый insert-or-update.
Правило: импорт — только upsert.
insert() массово
Post::insert([
['title' => 'A', 'created_at' => now()],
['title' => 'B', 'created_at' => now()],
]);
Что даёт: один SQL.
⚠️ Без событий, без timestamps авто.
cursor и memoization
Не забывай: cursor — один проход.
Нельзя два раза foreach.
Правило: если нужно дважды — get.
Профилирование памяти
$before = memory_get_usage();
// код
$after = memory_get_usage();
echo 'Memory: ' . round(($after - $before) / 1024 / 1024, 2) . ' MB';
Что даёт: видно эффект от chunk/cursor.
Правило: для больших — всегда замерять.
Практика
1. Замените all() на select+get.
2. Одноколоночные — на pluck.
3. Экспорт — на lazyById.
4. Проверку наличия — на exists.
5. Массовый импорт — на upsert.
6. Замерьте память до/после.
7. Настройте memory_limit и проверьте.
8. Оформите фон-задачи через lazyById.
9. Проверьте N+1 в chunk.
10. Прогоните тесты.
Частые ошибки
1. all() на большой таблице:
OOM
Решение: chunk / lazy.
2. select без id:
Связи пустые
Решение: id всегда.
3. chunk + update:
Пропуск строк
Решение: chunkById.
4. cursor + eager:
Ошибка
Решение: lazy.
5. count() вместо exists:
Лишний скан
Решение: exists.
6. insert без timestamps:
NULL в created_at
Решение: добавить now().
7. Курсор в транзакции:
Блокировки
Решение: без транзакции.
8. chunk без orderBy:
Непредсказуемо
Решение: orderBy('id').
Итог
select— только нужные колонки.pluck— одна колонка.exists— вместоcount.chunk/chunkById— по частям.lazy/lazyById— foreach.cursor— по одной.upsert— массовый импорт.- События — не работают с
update/insert. - Замерять память.
id— обязателен в select.
Дальше — очереди и Horizon.