Урок 03

Оптимизация Eloquent: select(), chunk(), cursor()

Проблемы больших выборок

Что плохо:

  • Model::all() — вся таблица в память.
  • SELECT * — лишние колонки.
  • Жадно — миллион моделей.
  • N+1 — каждый чих.

Правило: для больших данных — специальные методы.

select()

Плохо:

$users = User::all();   // все колонки

Хорошо:

$users = User::select('id', 'name', 'email')->get();

Что даёт:

  • Меньше трафика.
  • Меньше памяти.
  • Быстрее hydration.

Правило: select — всегда с явными колонками.

select с моделью

$users = User::query()->select(['id', 'name'])->get();

⚠️ Если не указать id — связи сломаются.

Правило: primary key — обязателен.

addSelect()

$users = User::select('id', 'name')
    ->addSelect(['post_count' => Post::selectRaw('count(*)')
        ->whereColumn('user_id', 'users.id')])
    ->get();

Что даёт: подзапрос как колонка.

selectRaw()

$users = User::select('id', 'name')
    ->selectRaw('LENGTH(name) as name_length')
    ->get();

Что даёт: вычисляемые колонки.

Правило: selectRaw — осторожно с injection.

Pluck

Когда нужна одна колонка:

$emails = User::pluck('email');
$names = User::pluck('name', 'id');   // id => name

Что даёт:

  • Не гидратирует модели.
  • Быстрее в разы.
  • Мало памяти.

Правило: одна колонка — всегда pluck.

value()

$name = User::where('id', 1)->value('name');

Что даёт: одно значение без гидратации.

exists() vs count()

Плохо:

if (User::where('active', true)->count() > 0) { /* ... */ }

Хорошо:

if (User::where('active', true)->exists()) { /* ... */ }

Что даёт: SELECT EXISTS вместо SELECT COUNT.

Правило: проверка существования — exists.

chunk()

Итерация частями:

User::chunk(1000, function ($users) {
    foreach ($users as $user) {
        // обработать
    }
});

Что даёт: по 1000 в память.

Что важно:

  • Порядок по PK.
  • Изменения в таблице во время — могут пропуститься.

Правило: chunk — с orderBy PK.

chunkById()

User::chunkById(1000, function ($users) {
    foreach ($users as $user) {
        $user->update(['processed' => true]);
    }
});

Что даёт: курсор по ID.

Что важно: не пропускает при изменении.

Правило: если меняешь данные — chunkById.

chunkById с колонкой

Order::chunkById(1000, function ($orders) {
    //
}, column: 'uuid', alias: 'uuid');

Что даёт: если PK не id.

lazy()

Генератор по чанкам:

foreach (User::lazy(1000) as $user) {
    // обработать
}

Что даёт:

  • Синтаксис foreach.
  • По 1000 в память.

Правило: lazy — чище чем chunk.

lazyById()

foreach (User::lazyById(1000) as $user) {
    $user->update(['processed' => true]);
}

Что даёт: ленивый + по ID.

Правило: lazyById — безопасный при изменениях.

cursor()

Генератор по одной модели:

foreach (User::cursor() as $user) {
    // обработать
}

Что даёт:

  • Одна модель в память.
  • Минимум ресурсов.

Что важно: нельзя прервать без закрытия.

Правило: cursor — для огромных выборок.

cursor vs lazy

cursorlazy
По одной✅❌
Чанки❌✅
Eager loadingОграниченРаботает
СкоростьМедленнееБыстрее
ПамятьМинимумЧанк

Правило:

  • Миллионы — cursor.
  • Тысячи — lazy.

Eager и cursor

⚠️ cursor не работает с with на некоторых связях.

Что делать:

  • Разделить на два запроса.
  • Или lazy.

Правило: сложные связи — lazy, не cursor.

lazyById и update

Идеально для фоновых задач:

User::lazyById(500)->each(function (User $user) {
    $user->recalculate();
});

Что даёт: безопасно при изменении.

Правило: миграции данных — lazyById.

only()

Только указанные колонки:

$posts = Post::query()->get()->only(['id', 'title']);

⚠️ Работает после выборки, экономии нет.

Правило: select, не only.

LazyCollection

Что даёт:

  • PHP-генератор под коллекцию.
  • Все методы коллекций.
  • Малая память.
User::lazy()->filter(fn ($u) => $u->active)->take(100);

Правило: lazy + коллекции — мощь.

Курсор и замыкания

⚠️ cursor внутри транзакции держит соединение.

Что делать: короткие транзакции.

Правило: cursor не в долгой транзакции.

Оптимизация больших выборок

Чек-лист:

  • select — только нужные колонки.
  • pluck — если одна колонка.
  • chunk / lazy — вместо get.
  • cursor — если миллионы.
  • with — только нужное.
  • Индексы на where / orderBy.
  • limit — если можно.

Правило: все 7 пунктов — обязательны.

Пример: экспорт

Плохо:

$users = User::all();   // 1M в память
foreach ($users as $user) {
    fputcsv($file, $user->toArray());
}

Хорошо:

User::query()
    ->select('id', 'name', 'email')
    ->lazyById(1000)
    ->each(function (User $user) use ($file) {
        fputcsv($file, [$user->id, $user->name, $user->email]);
    });

Что даёт: память константна.

Пример: пересчёт

Плохо:

foreach (Post::all() as $post) {
    $post->comments_count = $post->comments()->count();   // N+1 + память
    $post->save();
}

Хорошо:

Post::query()
    ->withCount('comments')
    ->lazyById(500)
    ->each(function ($post) {
        $post->update(['comments_count' => $post->comments_count]);
    });

Что даёт: 2 запроса на чанк вместо N+1.

Массовое обновление

Post::where('status', 'draft')->update(['status' => 'published']);

Что даёт: один SQL.

⚠️ Не сработают события модели.

Правило: если нужны события — foreach c save.

upsert()

User::upsert([
    ['id' => 1, 'name' => 'Alice'],
    ['id' => 2, 'name' => 'Bob'],
], ['id'], ['name']);

Что даёт: массовый insert-or-update.

Правило: импорт — только upsert.

insert() массово

Post::insert([
    ['title' => 'A', 'created_at' => now()],
    ['title' => 'B', 'created_at' => now()],
]);

Что даёт: один SQL.

⚠️ Без событий, без timestamps авто.

cursor и memoization

Не забывай: cursor — один проход.

Нельзя два раза foreach.

Правило: если нужно дважды — get.

Профилирование памяти

$before = memory_get_usage();

// код

$after = memory_get_usage();
echo 'Memory: ' . round(($after - $before) / 1024 / 1024, 2) . ' MB';

Что даёт: видно эффект от chunk/cursor.

Правило: для больших — всегда замерять.

Практика

1. Замените all() на select+get.

2. Одноколоночные — на pluck.

3. Экспорт — на lazyById.

4. Проверку наличия — на exists.

5. Массовый импорт — на upsert.

6. Замерьте память до/после.

7. Настройте memory_limit и проверьте.

8. Оформите фон-задачи через lazyById.

9. Проверьте N+1 в chunk.

10. Прогоните тесты.

Частые ошибки

1. all() на большой таблице:

OOM

Решение: chunk / lazy.

2. select без id:

Связи пустые

Решение: id всегда.

3. chunk + update:

Пропуск строк

Решение: chunkById.

4. cursor + eager:

Ошибка

Решение: lazy.

5. count() вместо exists:

Лишний скан

Решение: exists.

6. insert без timestamps:

NULL в created_at

Решение: добавить now().

7. Курсор в транзакции:

Блокировки

Решение: без транзакции.

8. chunk без orderBy:

Непредсказуемо

Решение: orderBy('id').

Итог

  • select — только нужные колонки.
  • pluck — одна колонка.
  • exists — вместо count.
  • chunk / chunkById — по частям.
  • lazy / lazyById — foreach.
  • cursor — по одной.
  • upsert — массовый импорт.
  • События — не работают с update/insert.
  • Замерять память.
  • id — обязателен в select.

Дальше — очереди и Horizon.