---
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 и подава на closure-а колекция от 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 създава обект за всеки ред, прилага cast-ове и събития — при стотици хиляди редове това са излишни секунди и стотици мегабайти. При това събитията на моделите и observers не се извикват при масови операции.' }
published: '2026-10-03'
---
# Eloquent и големи данни: N+1, chunk, lazy и cursor без недостиг на памет

Отчетът „всички поръчки за годината в CSV“ работи на staging, където има пет хиляди поръчки, и гърми в продукция с `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. Създава **обект модел за всеки ред**: атрибути, копие на оригиналните стойности за проследяване на промените, cast-ове, релации.
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` трябва задължително да се посочи.

За да не се връща 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` и пагинацията ще се счупи. Обвивайте условията си в closure:
>
> ```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()` с closure.

---

<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`, без cast-ове и проследяване на промените. За заявка, построена върху модел, същото дава `->toBase()`.
* `select()` ограничава колоните: `SELECT *` влачи `TEXT` полета, които в CSV не са нужни.
* `streamDownload()` изпраща данните на клиента в хода на генерирането им, отговорът не се сглобява в паметта.

> [!NOTE]
> **Експорт, по-дълъг от 30 секунди — в опашка.** HTTP заявката ще опре в таймаутите на PHP-FPM, nginx или балансьора. Големият експорт е по-добре да се генерира в задача в опашка във файл на диска или в S3, а на потребителя да се изпрати линк. Подробности за таймаутите и паметта на worker-ите — в статията за [опашките в продукция](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 builder-а ще попълни `updated_at`, но **няма да извика събитията на моделите и observers**, няма да приложи мутаторите и няма да провери `$fillable`. Ако към събитието `updated` е закачена логика (изчистване на кеша, одит), тя трябва да се изпълни явно.

**Сложните заявки — с честен SQL и обвързване на параметрите.** `INSERT ... SELECT`, CTE и прозоречните функции през `DB::select()` или `selectRaw()` се четат по-добре от верига от двайсет метода на builder-а. Главното правило — стойностите само чрез placeholder-и:

```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, които събират заявките, раздуват паметта на дълго работещите worker-и.
* За заявките, които са останали бавни след поправката на 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>