Ленивые коллекции: 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 строк. Но у него есть ограничения, о которых часто забывают:

  1. Это не коллекционный стиль. chunk() работает только на построителе запросов. Нельзя написать $query->get()->chunk() — это разные вещи, вторая как раз упадёт по памяти.
  2. Есть подвох с модификацией записей. chunk() внутри идёт по ID, и если вы в туре меняете id или порядок сортировки, записи могут пропасть или задвоиться. Для таких случаев существует chunkById(), но об этом тоже надо помнить.
  3. Внутри замыкания у вас порция, а не единый поток. Если вы хотели ->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, сортировка, JSONget()
Тысячи–миллионы строк, чистый перебор, экспорт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() в запросе


И три вывода:

  1. lazy() — это get() для больших данных: тот же синтаксис, ленивое потребление памяти.
  2. Ленивость не отменяет N+1 — по-прежнему используйте with().
  3. Если результат нужно считать, сортировать или отдавать повторно — это не задача для lazy().