Ленивые коллекции: lazy() — как обработать 100 000 строк и не уронить сервер
Проблема: «get() ест память»
Классический сценарий. Нужно пройти по всем пользователям и что-то сделать — отправить письмо, пересчитать баланс, экспортировать в CSV. Пишем привычное:
User::all()->each(fn ($user) => Mail::to($user)->send(new WeeklyDigest($user)));
Код красивый. И когда пользователей 500 — работает прекрасно. А когда их 100 000, all() поднимает из базы все сто тысяч моделей сразу и держит их в памяти. Каждый User — это объект с десятками полей, плюс накладные расходы Eloquent на каждую модель. На реальных проектах это легко превращается в 500–800 МБ RAM на один-единственный запрос. Если такие скрипты запускаются по крону параллельно — сервер начинает дышать через раз.
Кто-то сразу скажет: «ну так чанками же». И будет прав, chunk() существует давно:
User::chunk(500, function ($users) {$users->each(fn ($user) => Mail::to($user)->send(new WeeklyDigest($user)));});
chunk() решает проблему памяти — данные заходят порциями по 500 строк. Но у него есть ограничения, о которых часто забывают:
- Это не коллекционный стиль.
chunk()работает только на построителе запросов. Нельзя написать$query->get()->chunk()— это разные вещи, вторая как раз упадёт по памяти. - Есть подвох с модификацией записей.
chunk()внутри идёт по ID, и если вы в туре меняетеidили порядок сортировки, записи могут пропасть или задвоиться. Для таких случаев существуетchunkById(), но об этом тоже надо помнить. - Внутри замыкания у вас порция, а не единый поток. Если вы хотели
->filter()->map()->pluck()— придётся городить это внутри каждого вызова.
И вот тут на сцену выходит lazy().
lazy() — коллекция, которая притворяется обычной
User::lazy()->each(fn ($user) => Mail::to($user)->send(new WeeklyDigest($user)));
Внешне — практически то же, что get(). Но под капотом lazy() возвращает LazyCollection — объект, который держит в памяти не все сто тысяч моделей, а лишь текущую порцию. Когда вы доходите до конца порции, Laravel тихо подгружает следующую.
Главный выигрыш — весь коллекционный API остаётся доступным:
User::lazy()->filter(fn ($user) => $user->is_active)->map(fn ($user) => $user->email)->each(fn ($email) => Mail::raw('Привет!', fn ($m) => $m->to($email)->send()));
Обратите внимание: ни одного chunk, ни одного вложенного замыкания — код выглядит ровно так же, как с обычным get(). Но памяти при этом потребляет мегабайты, а не сотни мегабайт.
Что происходит под капотом (без магии и метафор)
LazyCollection построен на генераторах PHP (yield). Если коротко: генератор — это функция, которая умеет «паузиться» и отдавать элементы по одному, а не собирать их в массив заранее.
// Очень упрощённая иллюстрация идеиfunction lazyUsers(): Generator {$page = 0;do {$users = User::orderBy('id')->skip($page * 500)->limit(500)->get();foreach ($users as $user) {yield $user; // отдаём по одному, не накапливая}$page++;} while ($users->isNotEmpty());}
Пока вы не начали перебирать lazyUsers() — ни один запрос к базе не выполнен. Как только начали — данные текут порциями ровно в темпе вашего цикла. Поэтому:
- память почти не растёт, сколько бы ни было записей;
- но и подсчитать заранее длину нельзя —
count()на ленивой коллекции либо посчитает, прогнав весь перебор, либо недоступен, в зависимости от источника данных.
Отсюда главное правило: lazy() — для перебора, а не для «выборки, которую потом надо посчитать и показать».
Практический пример: экспорт в CSV
Возьмём реальную задачу — выгрузка заказов в файл:
public function export(): StreamedResponse{return response()->streamDownload(function () {$out = fopen('php://output', 'w');fputcsv($out, ['ID', 'Дата', 'Сумма', 'Статус']);Order::with('status')->lazy()->each(function ($order) use ($out) {fputcsv($out, [$order->id,$order->created_at->format('d.m.Y H:i'),$order->total,$order->status->name,]);});fclose($out);}, 'orders.csv');}
Здесь связка идеальна: streamDownload() не ждёт, пока файл сформируется целиком — он отдаёт данные браузеру по мере генерации. А lazy() подкидывает строки порциями. Итог: выгружаем миллион заказов при лимите памяти в 128 МБ, и никто не замечает.
Заметьте with('status') — он работает и с lazy(), и это критично: без него каждый $order->status породит отдельный запрос к базе, и на миллионе строк вы получите миллион запросов. С знаменитой проблемой N+1 ленивые коллекции сталкиваются точно так же, как обычные — ленивость от неё не спасает.
Где lazy() не нужен
Чтобы не превращать метод в серебряную пулю, зафиксируем обратную сторону.
Обычный get() — когда данных заведомо немного. Список категорий каталога, двадцать пакетов услуг, текущие чаты пользователя. Разница в мегабайтах несущественна, а обычная коллекция даёт свободу: её можно count(), сортировать, передавать в компоненты Blade, сериализовать в JSON сколько угодно раз. Ленивую коллекцию перебрать дважды в общем случае нельзя — после первого прохода генератор «истощён».
cursor() — родственник lazy(), но с нюансом. У Eloquent есть ещё один ленивый метод:
Order::cursor()->each(fn ($order) => ...);
Он тоже не грузит всё в память, но работает иначе: держит открытый курсор в базе (на MySQL — unbuffered query) и читает строки по одной. Плюсы: не тратит лишние запросы. Минусы: пока курсор открыт, вы делаете только чтение — если во время перебора попытаться что-то записать в базу через то же соединение, получите сюрпризы. Плюс один запрос = один курсор, то есть lazy() безопаснее в сценариях вида «читаем и параллельно обновляем».
Сводка на пальцах:
| Ситуация | Что брать |
|---|---|
Десятки–сотни строк, нужен count, сортировка, JSON | get() |
| Тысячи–миллионы строк, чистый перебор, экспорт | lazy() |
| Массовая модерация с обновлением записей по ходу | lazy() или chunkById() |
| Только чтение огромной таблицы, обновлений нет | cursor() |
Мини-шпаргалка
User::lazy(); // LazyCollection: порции по 1000 по умолчаниюUser::lazy(500); // свой размер порцииUser::lazy()->filter(...)->map(...); // весь обычный API коллекций доступенUser::lazy()->remember(); // кэширует уже пройденные элементы — если нужно перебрать дваждыUser::lazy()->take(10)->all(); // работает, но выгода нулевая: проще взять limit() в запросе
И три вывода:
lazy()— этоget()для больших данных: тот же синтаксис, ленивое потребление памяти.- Ленивость не отменяет N+1 — по-прежнему используйте
with(). - Если результат нужно считать, сортировать или отдавать повторно — это не задача для
lazy().