Eloquent на больших выборках: разбор инцидента с OOM
Экспорт заказов уронил PHP-воркер по памяти на 400 тысячах строк. Разбираем, почему Eloquent съедает гигабайты, чем платит каждый способ обхода и что мерить в проде.

Ночной крон-экспорт заказов в CSV начал падать с Allowed memory size of 536870912 bytes exhausted. До этого джоба работала полгода без единого сбоя. Изменилось одно: таблица orders перевалила за 400 тысяч строк. Воркер поднимал в память всю выборку одним Order::where(...)->get(), и на этом объёме коллекция моделей вместе с ленивой загрузкой связей выходила за лимит в 512 МБ. Разбираем, где именно уходит память, чем отличаются chunk, cursor и lazy, и почему замена get() на chunk() не всегда решает проблему.
Всё, что ниже, проверено на Laravel 11.x с PHP 8.3 и MySQL 8. Поведение lazy() и chunkById() в целом стабильно с Laravel 8.x, но за именами методов лучше свериться с документацией вашей версии.
Куда уходит память на get()
Метод get() выполняет запрос, забирает весь результат из драйвера БД в PHP-массив, а затем гидрирует каждую строку в объект модели. И вот здесь коренная проблема: строка в CSV — это ~200 байт, а Eloquent-модель той же строки — это несколько килобайт.
На каждую модель приходятся два массива атрибутов ($attributes и $original для отслеживания изменений), служебные поля, ссылки на связи и накладные расходы zval PHP. Плюс Illuminate\Database\Eloquent\Collection поверх всего. Эмпирически одна модель среднего размера съедает 3–8 КБ. Умножьте на 400 000 — получите 1,2–3,2 ГБ только на объектах, без учёта связей.
Правило, которое экономит ночи дежурств: если количество строк в выборке не ограничено сверху бизнес-логикой, get() в этом коде — потенциальный OOM. Вопрос только в том, на каком объёме данных он выстрелит.Почему это не воспроизводилось на стейдже
На стейдже в orders было 20 тысяч строк. 20 000 × 5 КБ ≈ 100 МБ — влезает в лимит с запасом. Ошибка масштаба линейна и невидима, пока не пересечёшь порог. Именно поэтому инцидент вылез в проде через полгода, а не на код-ревью.
Три способа читать большую выборку
У Eloquent есть три инструмента, и они решают разные части проблемы. Смешивать их наугад — верный способ починить память, но убить производительность.
chunk и chunkById
chunk() разбивает выборку на порции по N строк, делая отдельный SELECT ... LIMIT ? OFFSET ? на каждую. В памяти живёт только текущая порция.
// Laravel 11.x — обработка порциями по 1000 строк
Order::where('status', 'completed')
->chunkById(1000, function ($orders) use ($csv) {
foreach ($orders as $order) {
fputcsv($csv, [$order->id, $order->total, $order->created_at]);
}
});Важная деталь: используйте chunkById(), а не chunk(). Обычный chunk() ходит по OFFSET, и если во время обхода строки удаляются или добавляются, порции сдвигаются — часть строк пропускается или дублируется. chunkById() держит курсор по первичному ключу (WHERE id > ?), поэтому устойчив к изменениям и вдобавок не деградирует на больших OFFSET.
cursor
cursor() делает один запрос и тянет строки из БД по одной через PDO в небуферизованном режиме. Возвращает LazyCollection. Память под сами данные почти константна.
foreach (Order::where('status', 'completed')->cursor() as $order) {
fputcsv($csv, [$order->id, $order->total, $order->created_at]);
}Подвох: небуферизованный запрос держит соединение занятым до конца обхода. Пока вы читаете курсор, на этом же соединении нельзя выполнить другой запрос к MySQL — получите Commands out of sync. Если внутри цикла вам нужен новый запрос (например, дозагрузить что-то), cursor() не подойдёт.
lazy
lazy() (Laravel 8.x и новее) — компромисс: внутри работает через chunkById, но наружу отдаёт единый LazyCollection, по которому можно итерироваться как по обычному потоку. Соединение не блокируется на всё время обхода.
Order::where('status', 'completed')
->lazyById(1000)
->each(function ($order) use ($csv) {
fputcsv($csv, [$order->id, $order->total, $order->created_at]);
});Чем платит каждый вариант
| Способ | Память | Запросов к БД | Блокирует соединение | Устойчив к изменениям данных |
|---|---|---|---|---|
get() | Линейна от числа строк | 1 | Нет | — |
chunkById() | Порция × размер модели | N / размер порции | Нет | Да |
cursor() | Почти константна | 1 | Да | Снимок на момент запроса |
lazyById() | Порция × размер модели | N / размер порции | Нет | Да |
Что мы упустили в первом фиксе
Первая правка была очевидной: заменили get() на chunkById(1000). Память упала до 90 МБ, джоба перестала падать. Но время выполнения выросло с 4 до 40 минут. Причина — ленивая загрузка связей внутри цикла.
В теле обработчика вызывался $order->customer->email. При get() связи можно было подгрузить одним with('customer'). А внутри chunkById мы забыли про with(), и на каждую из 400 000 строк уходил отдельный SELECT к таблице customers. Классический N+1*, только растянутый по порциям.
Фикс — жадная загрузка внутри порции:
Order::where('status', 'completed')
->with('customer:id,email') // подгружаем связь одним запросом на порцию
->lazyById(1000)
->each(function ($order) use ($csv) {
fputcsv($csv, [$order->id, $order->total, $order->customer->email]);
});С with() Eloquent делает по два запроса на порцию (заказы + все их клиенты одним WHERE id IN (...)) вместо тысячи. Время вернулось к 6 минутам, память осталась в пределах 90 МБ.
* N+1 — антипаттерн, когда вместо одного запроса к связанной таблице выполняется отдельный запрос на каждую запись основной выборки. На 400 тысячах строк это 400 тысяч лишних запросов.
Где ломается даже правильный вариант
- Тяжёлые связи. Если у заказа десятки позиций и вы грузите
with('items'), порция в 1000 заказов может раздуться в памяти в разы. Уменьшайте размер порции при больших связях — не 1000, а 100–200. - Аксессоры и касты. Кастомные аксессоры, JSON-касты, шифрованные поля выполняются на гидрации каждой модели. На большом объёме это заметная доля CPU. Если данные нужны только для выгрузки — берите сырые значения.
- Обход внутри транзакции. Долгий обход
chunkByIdвнутриDB::transactionдержит транзакцию открытой десятки минут. Это раздувает undo-log и мешает вакууму. Читайте порциями вне транзакции, а изменения делайте короткими транзакциями внутри цикла.
Когда чтение вообще не нужно
Самая частая ошибка — гидрировать модели там, где нужны только цифры. Если задача — посчитать сумму, максимум или количество, не тяните строки в PHP:
// Плохо: 400k моделей ради одной суммы
$total = Order::where('status', 'completed')->get()->sum('total');
// Хорошо: агрегация на стороне БД, ноль моделей
$total = Order::where('status', 'completed')->sum('total');Для массовых обновлений — update() вместо цикла по моделям. Для выгрузки без бизнес-логики над строками — toBase() или прямой DB::table(), который возвращает stdClass вместо моделей и экономит гидрацию.
Что мерить в проде
Не полагайтесь на «работает на стейдже». Джобы, читающие таблицы без жёсткого верхнего лимита строк, — кандидаты на OOM по мере роста данных. Что настроить:
- Логируйте
memory_get_peak_usage(true)в конце каждой джобы и стройте по нему график. Рост пиковой памяти во времени — ранний сигнал. - Включите лог медленных запросов MySQL и следите за всплеском числа запросов после релизов — так ловится N+1.
- Проверьте
memory_limitдля CLI-воркеров отдельно: у веб-FPM и крона он часто разный, и джоба может падать только ночью.
Инвалидация кеша выборок и стриминг CSV прямо в HTTP-ответ — отдельные темы, здесь мы разбирали только чтение большой выборки из БД.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Laravel 11.x — Eloquent: Chunking, cursor и lazy, Laravel 11.x — Eloquent Relationships: Eager Loading
Частые вопросы
Что выбрать по умолчанию — cursor, chunk или lazy?
У меня Laravel 8, метод lazyById есть?
Я заменил get() на chunk(), но джоба стала пропускать строки. Почему?
Память починилась, но джоба стала работать в разы дольше. Что не так?
Грозит ли OOM на проде, если сейчас всё работает?
Нужны только сумма или количество — тоже читать порциями?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.