Экспорт связей Eloquent без N+1 при потоковой выгрузке

aVadim • 13.09.2026 12:23 •

Потоковый экспорт через 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 подгружает связи для всей выборки одним запросом, а курсор видит записи по одной и «всей выборки» у него нет. В результате каждое обращение к связи внутри цикла выполняет отдельный запрос:

php
// Ловушка №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()

Очевидный обходной путь — загрузить всё сразу с жадной загрузкой:

php
// Ловушка №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() через генератор:

php
$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), а в отчёте нужна одна ячейка — сверните связь в маппинге, а не выгружайте её построчно:

php
->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(), чем грузить сами записи:

php
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() держите минимальным.
bash
composer require avadim/fast-excel-laravel

Репозиторий — github.com/aVadim483/fast-excel-laravel.

Комментарии (0)