---
title: 'Eloquent и большие данные: chunk, lazy и cursor | DevSense'
description: 'Как обрабатывать и выгружать сотни тысяч строк в Laravel без нехватки памяти: N+1 и preventLazyLoading, chunk против chunkById, lazy и cursor, буферизация PDO, стриминг CSV, Query Builder и сырой SQL для отчётов.'
faq:
    - { question: 'Чем chunk() отличается от cursor() в Laravel?', answer: 'chunk() выполняет много запросов с LIMIT и OFFSET и передаёт в замыкание коллекцию из N моделей, поэтому поддерживает eager loading. cursor() выполняет один запрос и создаёт модели по одной через генератор, но не умеет eager loading, а драйвер PDO по умолчанию всё равно держит весь сырой результат запроса в памяти.' }
    - { question: 'Почему chunk() пропускает записи, если обновлять их внутри цикла?', answer: 'chunk() листает результат через OFFSET. Если цикл меняет колонку, по которой фильтруется запрос (например, processed = false → true), обработанные строки выпадают из выборки, и следующий OFFSET перескакивает через необработанные. chunkById() листает по первичному ключу (WHERE id > последний) и этой проблемы не имеет.' }
    - { question: 'Почему cursor() всё равно приводит к нехватке памяти?', answer: 'Модели создаются по одной, но PDO по умолчанию получает весь результат запроса от сервера и хранит его в буфере клиента — так работают и pdo_mysql (буферизованные запросы), и pdo_pgsql. На миллионах строк этот буфер не помещается в memory_limit. Для таких объёмов используйте lazyById() или серверный курсор базы данных.' }
    - { question: 'Когда стоит отказаться от Eloquent в пользу Query Builder или сырого SQL?', answer: 'Когда модели не нужны: агрегаты и отчёты (GROUP BY, оконные функции), массовые UPDATE и INSERT ... SELECT, экспорт плоских данных. Eloquent создаёт объект на каждую строку, применяет касты и события — на сотнях тысяч строк это лишние секунды и сотни мегабайт. При этом события моделей и observers при массовых операциях не вызываются.' }
published: '2026-10-03'
---
# Eloquent и большие данные: N+1, chunk, lazy и cursor без нехватки памяти

Отчёт «все заказы за год в CSV» работает на стейджинге, где пять тысяч заказов, и падает в продакшене с `Allowed memory size of 536870912 bytes exhausted`. Ночная команда пересчёта бонусов обрабатывает половину клиентов и молча завершается — а на следующий день выясняется, что каждый второй клиент пропущен. Страница со списком из пятидесяти статей делает двести запросов к базе.

Все три истории — про один и тот же навык: понимать, что Eloquent делает с базой и памятью, и выбирать правильный инструмент под объём данных. В этой статье — N+1, разница между `chunk`, `chunkById`, `lazy` и `cursor`, почему `cursor` не спасает от нехватки памяти, как стримить экспорт на миллион строк и когда честнее написать SQL.

**Связанные материалы:** [Оптимизация запросов](database-query-optimization) · [Индексы баз данных](database-indexes-deep-dive) · [PostgreSQL для дашбордов](postgresql-for-dashboards) · [Очереди Laravel в продакшене](laravel-queues-production)

## Содержание

* [Откуда берётся нехватка памяти](#why-memory)
* [N+1: сотни запросов вместо двух](#n-plus-one)
* [Карта методов для больших выборок](#methods)
* [chunk() и ловушка OFFSET](#chunk)
* [lazy() и lazyById(): поток вместо пачек](#lazy)
* [cursor() и буферизация PDO](#cursor)
* [Экспорт CSV на миллион строк](#export)
* [Когда нужен Query Builder или сырой SQL](#query-builder)
* [Как измерять](#measure)
* [Частые ошибки](#common-mistakes)
* [Чеклист](#checklist)
* [Квиз для самопроверки](#self-test-quiz)

---

<a id="why-memory"></a>
## Откуда берётся нехватка памяти

`Order::where('year', 2026)->get()` делает три вещи:

1. Выполняет запрос и получает **все строки** результата в память PHP.
2. Создаёт **объект модели на каждую строку**: атрибуты, копия исходных значений для отслеживания изменений, касты, связи.
3. Складывает модели в **коллекцию**, которая живёт, пока жива переменная.

Модель Eloquent занимает в памяти в разы больше, чем сама строка таблицы. Десять тысяч моделей — обычно не проблема, миллион — гарантированный выход за `memory_limit`. Поэтому для больших объёмов цель одна: **никогда не держать в памяти весь результат сразу** — ни моделей, ни сырых строк.

---

<a id="n-plus-one"></a>
## N+1: сотни запросов вместо двух

Классика: список статей с именами авторов.

```php
$articles = Article::latest()->take(50)->get();

foreach ($articles as $article) {
    echo $article->author->name; // one extra query per article
}
```

Один запрос за статьями плюс по запросу на автора каждой статьи — 51 запрос. Добавьте теги и количество комментариев, и страница делает двести обращений к базе. Решение — **eager loading**:

```php
$articles = Article::latest()
    ->with(['author:id,name', 'tags'])
    ->withCount('comments')
    ->take(50)
    ->get();
```

Теперь запросов четыре: статьи, авторы, теги (с промежуточной таблицей) и подсчёт комментариев подзапросом. `author:id,name` загружает только нужные колонки — первичный ключ `id` указывать обязательно, иначе Eloquent не сопоставит авторов со статьями.

Чтобы N+1 не возвращался незаметно, запретите ленивую загрузку вне продакшена:

```php
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}
```

В разработке и тестах обращение к незагруженной связи бросит исключение, а в продакшене код продолжит работать. В свежих версиях Laravel есть и обратный подход — `Model::automaticallyEagerLoadRelationships()`: при первом обращении к связи она догружается сразу для всех моделей коллекции. Это удобная страховка, но явный `with()` остаётся понятнее: из кода видно, какие данные нужны странице.

---

<a id="methods"></a>
## Карта методов для больших выборок

| Метод | Запросов | В памяти одновременно | Eager loading | Безопасен при изменении фильтра |
|-------|----------|-----------------------|---------------|---------------------------------|
| `get()` | 1 | Все модели | Да | — |
| `chunk(N)` | Много (`LIMIT/OFFSET`) | N моделей | Да | **Нет** |
| `chunkById(N)` | Много (`WHERE id > ?`) | N моделей | Да | Да |
| `lazy(N)` | Много (`LIMIT/OFFSET`) | N моделей, отдаются по одной | Да | **Нет** |
| `lazyById(N)` | Много (`WHERE id > ?`) | N моделей, отдаются по одной | Да | Да |
| `cursor()` | 1 | 1 модель + весь сырой результат в буфере PDO | **Нет** | Да |

Правило по умолчанию для фоновых задач и команд: **`lazyById()`** или **`chunkById()`**. Они ограничивают память, поддерживают `with()` и не пропускают записи.

---

<a id="chunk"></a>
## chunk() и ловушка OFFSET

`chunk()` разбивает выборку на страницы через `LIMIT` и `OFFSET`:

```php
Customer::where('bonus_recalculated', false)
    ->chunk(1000, function (Collection $customers) {
        foreach ($customers as $customer) {
            $customer->recalculateBonus(); // sets bonus_recalculated = true
        }
    });
```

Этот код обработает примерно половину клиентов. Первая пачка — строки 1–1000, после обработки они перестают подходить под `bonus_recalculated = false`. Второй запрос просит `OFFSET 1000` — но выборка уже сдвинулась на тысячу строк, и клиенты 1001–2000 пропущены. Ошибки нет, команда завершается успешно.

`chunkById()` листает по первичному ключу: каждый следующий запрос — `WHERE id > :last_id ORDER BY id LIMIT 1000`. Сдвиг выборки ему не страшен:

```php
Customer::where('bonus_recalculated', false)
    ->chunkById(1000, function (Collection $customers) {
        foreach ($customers as $customer) {
            $customer->recalculateBonus();
        }
    });
```

У `OFFSET` есть и вторая проблема — производительность. Чтобы отдать `OFFSET 900000 LIMIT 1000`, база всё равно читает и отбрасывает 900 000 строк. Последние пачки выполняются в разы дольше первых. Пагинация по ключу (keyset) использует индекс первичного ключа и стоит одинаково на любой глубине.

> [!WARNING]
> **Группируйте условия с `orWhere`.** `chunkById()` и `lazyById()` добавляют своё условие `id > ?`. Если ваш запрос содержит `orWhere`, без скобок получится `a = 1 OR b = 2 AND id > 100`, и пагинация сломается. Оборачивайте свои условия в замыкание:
>
> ```php
> Customer::where(function ($query) {
>     $query->where('tier', 'gold')->orWhere('lifetime_cents', '>', 1_000_000);
> })->chunkById(1000, fn (Collection $customers) => /* ... */);
> ```

---

<a id="lazy"></a>
## lazy() и lazyById(): поток вместо пачек

`lazy()` внутри делает то же, что `chunk()`, но возвращает `LazyCollection` — поток моделей, по которому можно идти обычным `foreach` и применять методы коллекций:

```php
Customer::where('newsletter', true)
    ->with('subscription')
    ->lazyById(1000)
    ->filter(fn (Customer $customer) => $customer->subscription?->isActive())
    ->each(fn (Customer $customer) => SendDigest::dispatch($customer->id));
```

Методы `LazyCollection` выполняются лениво: `filter` и `each` обрабатывают модели по мере поступления, а в памяти находится только текущая пачка из 1000 штук. `with()` работает — связи догружаются для каждой пачки отдельным запросом. Для обхода в обратном порядке есть `lazyByIdDesc()`.

Код с `lazyById()` читается как обычный цикл, поэтому в новых задачах его удобнее использовать, чем `chunkById()` с замыканием.

---

<a id="cursor"></a>
## cursor() и буферизация PDO

`cursor()` выполняет **один** запрос и через генератор создаёт модели по одной:

```php
foreach (Order::where('status', 'paid')->cursor() as $order) {
    // only one Order model is hydrated at a time
}
```

Выглядит идеально, но есть два ограничения.

**Нет eager loading.** В памяти только одна модель, поэтому `with()` не применяется. Обращение к `$order->customer` внутри цикла — это снова N+1, причём на всю выборку.

**Весь сырой результат всё равно в памяти.** PDO по умолчанию забирает результат запроса от сервера целиком и хранит его в буфере клиента:

* **pdo_mysql** работает в режиме буферизованных запросов (`PDO::MYSQL_ATTR_USE_BUFFERED_QUERY = true`);
* **pdo_pgsql** получает от libpq весь результат запроса сразу.

Модели создаются по одной, но массив сырых строк на миллион записей лежит в памяти процесса — и рано или поздно упирается в `memory_limit`. Документация Laravel прямо рекомендует для очень больших объёмов `lazy()` вместо `cursor()`.

Если нужен именно один проход без пагинации, есть два честных способа стриминга.

**MySQL: небуферизованный запрос на отдельном соединении.**

```php
// config/database.php — a dedicated connection for exports
'mysql_unbuffered' => array_merge(config('database.connections.mysql'), [
    'options' => [PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false],
]),
```

Пока результат не дочитан, соединение занято: выполнить другой запрос через него нельзя. Поэтому такое соединение используют только для чтения потока, а записи делают через основное.

**PostgreSQL: серверный курсор.**

```php
DB::transaction(function () {
    DB::statement(
        'DECLARE export_cursor NO SCROLL CURSOR FOR
         SELECT id, customer_id, total_cents, created_at FROM orders WHERE created_at >= ?',
        [now()->startOfYear()],
    );

    while ($rows = DB::select('FETCH 5000 FROM export_cursor')) {
        foreach ($rows as $row) {
            // stream $row somewhere
        }
    }
});
```

Курсор живёт на сервере, а PHP получает данные порциями по 5000 строк. Курсор без `WITH HOLD` существует только внутри транзакции. Держать её открытой часами не стоит: долгая транзакция мешает VACUUM очищать старые версии строк.

На практике `lazyById()` закрывает 95% задач, а серверный курсор нужен, когда сортировка не по первичному ключу или запрос сложный и дорого выполнять его заново для каждой пачки.

---

<a id="export"></a>
## Экспорт CSV на миллион строк

Типичный экспорт без нехватки памяти — это три приёма: выбирать только нужные колонки, не создавать модели и писать результат в поток, а не в строку.

```php
// app/Http/Controllers/OrderExportController.php
use Symfony\Component\HttpFoundation\StreamedResponse;

public function __invoke(Request $request): StreamedResponse
{
    $year = (int) $request->validate(['year' => ['required', 'integer', 'min:2020']])['year'];

    return response()->streamDownload(function () use ($year) {
        $out = fopen('php://output', 'w');
        fputcsv($out, ['id', 'customer_id', 'total', 'created_at']);

        DB::table('orders')
            ->select(['id', 'customer_id', 'total_cents', 'created_at'])
            ->whereYear('created_at', $year)
            ->lazyById(5000)
            ->each(function (object $row) use ($out) {
                fputcsv($out, [
                    $row->id,
                    $row->customer_id,
                    number_format($row->total_cents / 100, 2, '.', ''),
                    $row->created_at,
                ]);
            });

        fclose($out);
    }, "orders-{$year}.csv", ['Content-Type' => 'text/csv']);
}
```

* `DB::table()` вместо модели — на выходе лёгкие объекты `stdClass`, без кастов и отслеживания изменений. Для запроса, построенного на модели, то же даёт `->toBase()`.
* `select()` ограничивает колонки: `SELECT *` тянет `TEXT`-поля, которые в CSV не нужны.
* `streamDownload()` отправляет данные клиенту по мере генерации, ответ не собирается в памяти.

> [!NOTE]
> **Экспорт дольше 30 секунд — в очередь.** HTTP-запрос упрётся в таймауты PHP-FPM, nginx или балансировщика. Большой экспорт лучше генерировать в задаче очереди в файл на диске или в S3 и присылать пользователю ссылку. Подробности о таймаутах и памяти воркеров — в статье про [очереди в продакшене](laravel-queues-production#heavy-jobs).

Обратите внимание на `whereYear()`: функция над колонкой мешает использовать индекс по `created_at`. Для больших таблиц надёжнее диапазон — `whereBetween('created_at', [$from, $to])` (подробнее в статье про [индексы](database-indexes-deep-dive)).

---

<a id="query-builder"></a>
## Когда нужен Query Builder или сырой SQL

Eloquent удобен, когда нужны модели: бизнес-логика, связи, события. Для работы с данными «пачкой» он часто лишний.

**Агрегаты считайте в базе, а не в PHP.**

```php
// Bad: loads every order into PHP to sum one column
$total = Order::where('status', 'paid')->get()->sum('total_cents');

// Good: the database returns one number
$total = Order::where('status', 'paid')->sum('total_cents');

// Reports: grouping and window functions belong in SQL
$daily = DB::table('orders')
    ->selectRaw('date(created_at) as day, count(*) as orders, sum(total_cents) as revenue')
    ->where('created_at', '>=', now()->subDays(30))
    ->groupByRaw('date(created_at)')
    ->orderBy('day')
    ->get();
```

**Массовые изменения — одним запросом.**

```php
// One UPDATE instead of loading and saving 200 000 models
Order::where('status', 'pending')
    ->where('created_at', '<', now()->subDays(30))
    ->update(['status' => 'expired']);

// Batch upsert for imports: one statement per batch of rows
DB::table('product_prices')->upsert(
    $rows,                       // array of ['sku' => ..., 'price_cents' => ..., 'updated_at' => ...]
    uniqueBy: ['sku'],
    update: ['price_cents', 'updated_at'],
);
```

Массовый `update()` через Eloquent-построитель проставит `updated_at`, но **не вызовет события моделей и observers**, не применит мутаторы и не проверит `$fillable`. Если на событии `updated` висит логика (очистка кэша, аудит), её нужно выполнить явно.

**Сложные запросы — честным SQL с привязкой параметров.** `INSERT ... SELECT`, CTE и оконные функции через `DB::select()` или `selectRaw()` читаются лучше, чем цепочка из двадцати методов построителя. Главное правило — значения только через плейсхолдеры:

```php
// Safe: values are bound, never concatenated into SQL
$rows = DB::select(
    'SELECT customer_id, sum(total_cents) AS spent
     FROM orders WHERE created_at >= ? GROUP BY customer_id HAVING sum(total_cents) > ?',
    [$from, 100_000],
);
```

Подстановка пользовательского ввода в строку SQL — прямой путь к SQL-инъекции, даже в «внутреннем» отчёте (подробнее — в статье о [веб-атаках](web-attacks-and-prevention)).

---

<a id="measure"></a>
## Как измерять

Не гадайте — меряйте на реалистичном объёме данных:

```php
$start = hrtime(true);
DB::enableQueryLog();

// ... code under test ...

logger()->info('export stats', [
    'queries' => count(DB::getQueryLog()),
    'peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 1),
    'ms' => (int) ((hrtime(true) - $start) / 1_000_000),
]);
```

* `memory_get_peak_usage(true)` показывает пик, а не текущее значение — именно пик упирается в `memory_limit`.
* Лог запросов включайте только на время замера. В долгих командах он сам становится утечкой: каждый запрос сохраняется в массив. По той же причине Telescope и Debugbar, собирающие запросы, раздувают память долгих воркеров.
* Для запросов, которые остались медленными после исправления N+1, смотрите план выполнения — `EXPLAIN ANALYZE` (подробно в [мастер-классе по оптимизации запросов](database-query-optimization)).

---

<a id="common-mistakes"></a>
## Частые ошибки

**1. `chunk()` с изменением колонки из условия.**
Половина записей молча пропускается. Используйте `chunkById()` или `lazyById()`.

**2. `cursor()` как лекарство от нехватки памяти.**
Модели создаются по одной, но сырой результат буферизуется PDO. На миллионах строк — тот же OOM, только позже.

**3. Обращение к связям внутри `cursor()`.**
Eager loading не работает, получается N+1 на всю выборку.

**4. `get()->sum()`, `get()->count()`, `all()->filter()`.**
Агрегаты и фильтры, посчитанные в PHP, тянут в память всю таблицу. Считайте в базе.

**5. `orWhere` без группировки вместе с `chunkById()`.**
Условие `id > ?` склеивается с вашим `OR`, и пагинация ломается.

**6. Массовый `update()` там, где нужны события модели.**
Observers и события не вызываются — кэш не сбрасывается, аудит не пишется.

**7. Включённый лог запросов в долгой команде.**
Каждый запрос оседает в памяти. Включайте лог только для замеров.

---

<a id="checklist"></a>
## Чеклист

1. Списки с связями загружаются через `with()` и `withCount()`; `preventLazyLoading()` включён вне продакшена.
2. Выборки больше нескольких тысяч строк обрабатываются через `lazyById()` или `chunkById()`, а не `get()`.
3. `chunk()` и `lazy()` не используются, если цикл меняет колонки из условия запроса.
4. `cursor()` применяется осознанно: без связей и с пониманием буферизации PDO.
5. Экспорты выбирают только нужные колонки, работают без моделей и пишут в поток.
6. Долгие экспорты и импорты выполняются в очереди, а не в HTTP-запросе.
7. Агрегаты и массовые изменения выполняются в базе одним запросом.
8. Сырой SQL использует только привязку параметров.
9. Пиковая память и число запросов проверены на реалистичном объёме данных.

---

## Итог

Eloquent не медленный — он делает ровно то, что попросили: загружает всё, что вернул запрос, и создаёт объект на каждую строку. Большие данные требуют другого вопроса: «сколько этого окажется в памяти одновременно?» Для обработки — `lazyById()`, для отчётов — агрегаты в базе, для экспорта — поток без моделей, для массовых изменений — один запрос. А `chunk()` с изменяемым условием и `cursor()` на миллионах строк пусть остаются в списке частых ошибок, а не в вашем продакшене.

---

<a id="self-test-quiz"></a>
## Квиз для самопроверки

### Вопрос 1: Команда обновляет `processed = true` у записей, выбранных по условию `processed = false`, через `chunk(500)`. Что произойдёт?
- А) Все записи будут обработаны, но медленнее, чем с `chunkById()`.
- Б) Примерно половина записей будет пропущена: выборка сдвигается, а `OFFSET` растёт.
- В) Laravel выбросит исключение о конкурентном изменении данных.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
После обработки первой пачки эти строки перестают подходить под условие, и следующая страница с `OFFSET 500` перескакивает через ещё не обработанные записи. `chunkById()` листает по `id > последний` и не зависит от сдвига выборки.
</details>

### Вопрос 2: Почему `cursor()` может исчерпать память на выборке из пяти миллионов строк, хотя модели создаются по одной?
- А) Генераторы PHP хранят все ранее выданные значения.
- Б) Драйвер PDO по умолчанию получает весь результат запроса и хранит его в клиентском буфере.
- В) `cursor()` автоматически загружает все связи моделей.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
И pdo_mysql в буферизованном режиме, и pdo_pgsql забирают результат запроса целиком. Для очень больших объёмов используют `lazyById()`, небуферизованное соединение MySQL или серверный курсор PostgreSQL.
</details>

### Вопрос 3: Нужно перевести 300 000 просроченных заказов в статус `expired`. На событии `updated` модели `Order` висит сброс кэша. Какой подход корректен?
- А) Один запрос `Order::where(...)->update(['status' => 'expired'])` — события сработают автоматически.
- Б) Один массовый `update()` и явный сброс кэша после него, либо `lazyById()` с сохранением моделей, если на каждую запись нужна логика события.
- В) `Order::where(...)->get()->each->update(...)` — это самый быстрый вариант.

<details>
<summary>Показать правильный ответ</summary>

**Правильный ответ: Б**
Массовый `update()` не вызывает события моделей и observers. Если их логика нужна, её выполняют явно после запроса или обрабатывают модели потоком через `lazyById()`. Вариант В загружает все 300 000 моделей в память.
</details>