Eloquent на больших выборках: где память кончается первой
Выгрузка 200 тысяч заказов в отчёт положила воркер по памяти на 512 МБ. Разбираем, почему Eloquent жрёт RAM пропорционально выборке, и чем платит каждый способ это починить.

Артизан-команда экспорта заказов падала стабильно: Allowed memory size of 536870912 bytes exhausted на выборке около 180 тысяч строк. Лимит памяти воркера — 512 МБ, таблица orders — 40 колонок, включая пару текстовых полей. Классический Order::all() в цикле foreach превращал каждую строку в полноценную модель Eloquent, и вся коллекция жила в памяти одновременно. Ниже — почему так, четыре способа это разрулить и что каждый стоит по памяти и времени. Всё проверялось на Laravel 11.x, PHP 8.3, PostgreSQL 16.
Почему Eloquent ест память линейно
Когда вы делаете Model::all() или Model::get(), драйвер PDO вытягивает весь результат из базы, а Eloquent для каждой строки создаёт объект модели. Объект модели — это не строка данных. Внутри него живут два массива атрибутов ($attributes и $original для отслеживания изменений), ссылки на связи, флаги. Одна модель с 40 полями съедает в разы больше, чем ассоциативный массив тех же 40 значений.
Отсюда правило: память растёт пропорционально числу строк в выборке. Пока строк тысячи — всё работает и никто не замечает. На сотнях тысяч воркер упирается в лимит и умирает. Задача — не держать всю выборку в памяти сразу.
Что замеряем
Условия замеров ниже одинаковы: PostgreSQL 16 на той же машине, что и PHP-процесс, таблица orders с 200 000 строк, 40 колонок. Пиковая память снята через memory_get_peak_usage(true) — она возвращает реально запрошенные у ОС байты. Время — microtime(true) вокруг цикла. Цифры зависят от вашего железа, размера строки и версии PHP: воспроизводите на своих данных, а не переносите как абсолют.
Способ 1 — базовый, который падает
// Laravel 11.x — так делать нельзя на больших таблицах
$orders = Order::all(); // вся выборка в память сразу
foreach ($orders as $order) {
$this->writeRow($order->number, $order->total);
}
На 200 000 строк этот код на лимите 512 МБ не доживает до конца — падает с memory exhausted. Он здесь как точка отсчёта, а не как рабочий вариант.
Способ 2 — chunk и chunkById
Метод chunk разбивает выборку на порции и обрабатывает по одной, освобождая память между итерациями. Для порции всё равно создаются модели, но живёт в памяти только одна порция.
// порции по 2000 строк, память держится на уровне одной порции
Order::chunk(2000, function ($orders) {
foreach ($orders as $order) {
$this->writeRow($order->number, $order->total);
}
});
У chunk есть коварная ловушка: если внутри колбэка вы меняете строки, попадающие в условие выборки (например, снимаете флаг processed = false, по которому и фильтруете), сдвигается пагинация и часть строк пропускается. Для таких случаев есть chunkById — он листает не по OFFSET, а по возрастанию первичного ключа, и на изменение данных не реагирует.
// безопасно при изменении строк внутри колбэка
Order::where('processed', false)
->chunkById(2000, function ($orders) {
foreach ($orders as $order) {
$order->update(['processed' => true]);
}
});
Если внутри цикла вы обновляете колонку, по которой фильтруете выборку, — беритеchunkById, иначеchunkтихо пропустит половину строк, и вы узнаете об этом от бухгалтерии.
Способ 3 — lazy и cursor
Метод cursor использует PDO-курсор и отдаёт строки по одной через генератор. Модель создаётся ровно одна за раз. Память минимальная, но есть нюанс с буферизацией на стороне драйвера.
// по одной модели за итерацию, ленивая обработка
foreach (Order::where('processed', false)->cursor() as $order) {
$this->writeRow($order->number, $order->total);
}
Метод lazy (Laravel 8.34+) снаружи похож на cursor, но под капотом делает то же, что chunkById: тянет данные порциями отдельными запросами, а наружу отдаёт по одной модели. Это компромисс: память чуть выше, чем у cursor, зато нет одного долгоживущего курсора к базе.
// порционная выборка под капотом, интерфейс как у cursor
foreach (Order::where('processed', false)->lazy(2000) as $order) {
$this->writeRow($order->number, $order->total);
}
Подводный камень cursor: с драйвером MySQL по умолчанию PDO буферизует весь результат на клиенте (опция PDO::MYSQL_ATTR_USE_BUFFERED_QUERY), и выигрыш по памяти исчезает — весь результат всё равно оказывается в памяти PHP. На небуферизованных запросах падает возможность делать другие запросы к той же соединению, пока курсор открыт. С PostgreSQL поведение зависит от настроек PDO. Проверяйте на своём драйвере, а не полагайтесь на то, что «cursor всегда экономит память».
Способ 4 — уйти от моделей вовсе
Если строки вам нужны только на чтение и вы не пользуетесь ни связями, ни аксессорами, ни мутаторами — Eloquent-модель лишний расход. Query Builder возвращает объекты stdClass, а не модели: без второго массива атрибутов, без отслеживания изменений.
// DB-фасад, только нужные колонки, порционно
use Illuminate\Support\Facades\DB;
DB::table('orders')
->select('number', 'total') // не тянем 40 колонок ради двух
->where('processed', false)
->orderBy('id')
->chunk(5000, function ($rows) {
foreach ($rows as $row) {
$this->writeRow($row->number, $row->total);
}
});
Два отдельных выигрыша складываются: stdClass вместо модели и select только по нужным колонкам вместо SELECT *. На отчёте, где реально нужны две колонки из сорока, второе даёт больше, чем первое.
Цифры до и после
Замеры на 200 000 строк, лимит воркера 512 МБ, условия из раздела выше. Числа округлены и приведены как порядок величины для этой конкретной таблицы — на вашей ширине строки соотношение сохранится, абсолют изменится.
| Способ | Пиковая память | Время | Что платим |
|---|---|---|---|
Order::all() | падает по лимиту 512 МБ | — | не доживает до конца |
chunk(2000) | ~40–60 МБ | базовое | ловушка сдвига при изменении строк |
lazy(2000) | ~40–60 МБ | близко к chunk | несколько лишних запросов к базе |
cursor() | ~25–40 МБ | чуть быстрее lazy | долгий курсор, зависит от драйвера |
DB::table + select | ~15–25 МБ | заметно быстрее | теряете связи и аксессоры моделей |
Ключевой сдвиг — не между chunk и cursor, а между «модель» и «stdClass с нужными колонками». Если отчёту не нужна бизнес-логика модели, переход на Query Builder режет и память, и время сильнее, чем любая возня с размером порции.
Когда что выбирать
- Нужны связи, аксессоры, события модели —
chunkилиlazy. Модели нужны, минимизируем число живущих одновременно. - Обновляете строки, попадающие в фильтр — только
chunkByIdилиlazyById, иначе пропустите данные. - Чтение без бизнес-логики, минимум памяти —
cursor, но проверьте буферизацию драйвера. - Отчёт или экспорт по паре колонок —
DB::tableс явнымselect. Модель здесь только мешает.
Что ломается на практике
Три вещи, на которых спотыкаются чаще всего.
- Жадная загрузка внутри порции. Если в колбэке
chunkобращаетесь к связи безwith(), получаете N+1 внутри каждой порции. ПрописывайтеOrder::with('items')->chunk(...)— иначе на 200 000 строк это сотни тысяч лишних запросов. - Аксессоры и appends. Модель с тяжёлым аксессором в
$appendsсчитает его при каждой сериализации. На больших выборках это скрытая нагрузка на CPU, которую не видно в замере памяти. - OFFSET на глубоких порциях. Обычный
chunkпод капотом использует LIMIT/OFFSET, и на больших смещениях база всё равно сканирует пропущенные строки. На очень больших таблицахchunkByIdс движением по ключу быстрее, потому что использует индекс первичного ключа вместо OFFSET.
Что делать дальше
Перед тем как менять код, снимите базовую линию: оберните текущий цикл в memory_get_peak_usage(true) и microtime(true), зафиксируйте цифры на реальном объёме данных. Меняете подход — сравнивайте с этой линией, а не с ощущениями. Если у вас MySQL и вы ставите на cursor — сначала проверьте, включена ли буферизованная выборка, иначе экономии не будет. А если выборка нужна только на чтение и по нескольким полям, начните с самого простого шага: замените Model::get() на DB::table()->select() и посмотрите на пик памяти — часто этого одного уже хватает, чтобы воркер перестал падать.
Помощник по курсовой и диплому: Большая работа поэтапно: от плана до защиты
Спросить за 25 ₽Источники: Laravel 11.x — Eloquent: Chunking, Cursors, Lazy Collections, Laravel 11.x — Database: Query Builder
Частые вопросы
У меня Laravel 8, метод lazy работает?
Cursor не экономит память, всё равно падает — почему?
Chunk пропускает часть строк, это баг?
Какой размер порции ставить в chunk?
Стоит ли отказываться от моделей ради Query Builder везде?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.