Экспорт связей Eloquent без N+1 при потоковой выгрузке
Потоковый экспорт через exportModel() держит память постоянной — в этом весь смысл cursor() под капотом. Но как только в выгрузку нужно добавить данные из связанной модели — имя клиента у заказа, название категории у товара, — всё внезапно начинает тормозить. На 10 000 строк выгрузка делает 10 001 запрос к базе вместо пары.
Это классический N+1, замаскированный потоковой записью. Разберём, почему он возникает именно при потоковом экспорте, почему привычный with() тут не помогает, и как выгрузить связи, не потеряв ни скорости, ни экономии памяти. Всё ниже — на avadim/fast-excel-laravel, проверено на версии 4.2.0.
Почему cursor() не дружит со связями
Метод exportModel(Order::class) внутри обходит модель через Order::cursor(). Курсор Eloquent держит в памяти ровно одну модель за раз — именно поэтому память не растёт с числом строк.
Плата за это — cursor() не поддерживает жадную загрузку связей (eager loading). Логика простая: eager loading подгружает связи для всей выборки одним запросом, а курсор видит записи по одной и «всей выборки» у него нет. В результате каждое обращение к связи внутри цикла выполняет отдельный запрос:
// Ловушка №1: N+1 под видом потокового экспорта
$excel->sheet()
->mapping(function ($order) {
return [
'id' => $order->id,
'customer' => $order->customer->name, // ← отдельный SELECT на каждую строку
'total' => $order->total,
];
})
->exportModel(Order::class);
Даже если дописать Order::with('customer') — это не спасёт: exportModel() принимает имя класса, а не готовый запрос, и внутри всё равно берёт чистый cursor(). А with('customer')->cursor() в Eloquent жадную загрузку всё равно не выполняет — связь подгрузится лениво, по запросу на строку. N+1 остаётся.
Почему нельзя просто взять get()
Очевидный обходной путь — загрузить всё сразу с жадной загрузкой:
// Ловушка №2: N+1 нет, зато вся выборка в памяти
$orders = Order::with('customer')->get();
$excel->sheet()
->mapping(function ($order) {
return [
'id' => $order->id,
'customer' => $order->customer->name,
'total' => $order->total,
];
})
->writeData($orders);
Здесь with('customer') честно уберёт N+1: связи подтянутся одним дополнительным запросом. Но get() материализует всю выборку разом — все заказы и всех их клиентов как объекты в памяти. Мы вернулись ровно к той проблеме, ради ухода от которой брали потоковую библиотеку: на больших объёмах это упрётся в memory_limit.
Кстати, mapping() здесь не для красоты. Без него модель уходит в строку целиком, через toArray(), где загруженная связь становится вложенным массивом, — и запись остановится с ошибкой Value for cell … must be scalar or callable.
Итого две ловушки тянут в разные стороны: cursor() бережёт память, но даёт N+1; get() убирает N+1, но жрёт память. Нужен способ получить оба свойства сразу.
Решение: writeData() поверх lazy()
Такой способ есть — метод lazy(). Он читает выборку чанками (по умолчанию по 1000 записей) и отдаёт их как ленивую коллекцию, по одной модели наружу. Ключевое отличие от cursor(): lazy() работает порциями, а значит внутри каждого чанка жадная загрузка связей работает — with() подтягивает связи одним запросом на чанк.
Получаем и то, и другое: в памяти одновременно живёт только текущий чанк, а число запросов — не «по запросу на строку», а «пара запросов на чанк». Скармливаем это writeData() через генератор:
$excel = \Excel::create('Orders');
$excel->sheet()
// Явные заголовки в порядке колонок — их значения уйдут в первую строку
->withHeadings(['id' => 'Заказ №', 'customer' => 'Клиент', 'email' => 'E-mail', 'total' => 'Сумма'])
->mapping(function ($order) {
return [
'id' => $order->id,
'customer' => $order->customer->name, // связь уже загружена — без лишних запросов
'email' => $order->customer->email,
'total' => $order->total,
];
})
->writeData(function () {
// with() + lazy(): eager loading внутри чанка, память ограничена одним чанком
foreach (Order::with('customer')->lazy() as $order) {
yield $order;
}
});
$excel->store('local', 'exports/orders.xlsx'); // диск local; недостающие папки Storage создаст сам
Здесь всё сходится: lazy() читает базу порциями, with('customer') убирает N+1 внутри порции, генератор отдаёт строки в writeData() по одной, а библиотека пишет их в файл потоково. Память остаётся постоянной на любом объёме.
Небольшой нюанс про заголовки: задавайте их явно, ключами маппинга, как в примере. Без аргументов withHeadings() берёт имена колонок из атрибутов самой модели (id, customer_id, total, …) — ещё до маппинга. Строка из mapping() раскладывается по этим ключам, поэтому часть колонок останется пустой, а поля, которых среди атрибутов нет (здесь email), в файл не попадут.
Проверяем цифрами
Прогоним все варианты на SQLite: 10 000 и 100 000 заказов, у каждого есть клиент. Считаем запросы к базе, прирост пика памяти и время выгрузки (Laravel 13, fast-excel-laravel 4.2.0).
| Способ | Запросов на 10 000 заказов | Время, 10 000 | Пик памяти, 10 000 | Пик памяти, 100 000 |
|---|---|---|---|---|
exportModel(), связь в маппинге |
10 001 | 1,4 с | +2 МБ | — |
with()->cursor() |
10 001 | 1,4 с | +2 МБ | — |
with()->get() |
2 | 0,4 с | +16 МБ | +155 МБ |
with()->lazy() |
21 | 0,5 с | +6 МБ | +6 МБ |
with()->lazy(200) |
101 | 0,5 с | +2 МБ | +2 МБ |
Картина ровно та, что описана выше. Курсор экономен по памяти, но платит запросом за каждую строку. get() обходится двумя запросами, зато память растёт вместе с выборкой: вдесятеро больше данных — вдесятеро больше памяти. lazy() держит ограниченным и то, и другое: два запроса на чанк (плюс один завершающий, который возвращает пустую порцию) и память на один чанк — она не изменилась, когда данных стало в десять раз больше.
Время здесь — на локальном SQLite, где у запроса нет сетевой задержки, и даже так N+1 медленнее почти втрое. С базой на другом сервере каждый из 10 000 запросов добавит ещё и сетевой раунд-трип, и разрыв только вырастет.
Связи «многие» и агрегаты
Если у заказа несколько строк (hasMany), а в отчёте нужна одна ячейка — сверните связь в маппинге, а не выгружайте её построчно:
->mapping(function ($order) {
return [
'id' => $order->id,
'customer' => $order->customer->name,
'items' => $order->items->count(), // число позиций
'total' => $order->items->sum('price'), // сумма по позициям
];
})
->writeData(function () {
foreach (Order::with(['customer', 'items'])->lazy() as $order) {
yield $order;
}
});
Такой экспорт делает три запроса на чанк: заказы, их клиенты и их позиции.
Если из связи нужно только количество, дешевле посчитать его в базе через withCount(), чем грузить сами записи:
Order::with('customer')->withCount('items')->lazy()
// внутри маппинга: 'items' => $order->items_count
Честные границы
exportModel()не умеет eager loading — это by design. Он принимает имя класса и сам вызывает на нёмcursor(); готовый запрос вродеOrder::with('customer')передать нельзя — будет ошибка. Для выгрузки со связями используйтеwriteData()с собственным запросом наlazy(), аexportModel()оставьте для простых выгрузок «модель как есть», без связей.- Запрос — ваша ответственность. Библиотека не угадывает, какие связи вам нужны. Держите список в
with()минимальным: каждая лишняя связь — это память под объекты в текущем чанке. - Глубокая вложенность стоит денег.
with('customer.company.country')на больших объёмах ощутимо утяжелит каждый чанк. Если из цепочки нужно одно поле, иногда дешевле добавить его в исходный запрос черезjoin/select, чем тянуть цепочку моделей. - Размер чанка можно настроить. По умолчанию
lazy()читает по 1000 записей; при тяжёлых связях уменьшите (lazy(200)), чтобы снизить пик памяти на чанк, — ценой большего числа запросов.
Итог
- Потоковый
exportModel()экономит память, но обращение к связям в маппинге даёт N+1:cursor()не поддерживает жадную загрузку. get()со связями убирает N+1, но материализует всю выборку в памяти — не вариант на объёме.- Золотая середина —
writeData()с генератором поверхModel::with(...)->lazy(): eager loading внутри чанка убирает N+1, а чанкинг держит память ограниченной. - Связи «многие» и счётчики сворачивайте в маппинге или считайте в базе (
withCount), а списокwith()держите минимальным.
composer require avadim/fast-excel-laravel
Репозиторий — github.com/aVadim483/fast-excel-laravel.