Урок 04

Массовые операции и upsert

Проблема

Создание 10 000 записей через модель:

foreach ($data as $row) {
    Post::create($row);   // 10 000 INSERT-запросов
}

Проблемы:

  • 10 000 запросов к БД — медленно.
  • 10 000 объектов в памяти.
  • События модели × 10 000.
  • Timestamps × 10 000.

Решение: массовые операции.

insert

Массовая вставка без событий:

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

Что НЕ работает:

  • События (creating, created) — не запускаются.
  • Timestamps — не проставляются автоматически. Добавляй вручную.
  • Casts — не применяются.
  • Возвращаемое значение — bool, не модели.
  • ID — не возвращаются.

Когда использовать: логи, импорт, аналитика — где события не нужны.

insertOrIgnore

Вставить, игнорируя дубликаты:

Post::insertOrIgnore([
    ['slug' => 'a', 'title' => 'A'],
    ['slug' => 'b', 'title' => 'B'],
    ['slug' => 'a', 'title' => 'C'],   // проигнорируется (уникальный slug)
]);

Не падает при нарушении уникальности. Полезно при импорте.

insertGetId

Вставить одну запись и получить ID:

$id = Post::insertGetId([
    'title' => 'X',
    'body' => 'Text',
    'created_at' => now(),
    'updated_at' => now(),
]);

Только одна запись. Возвращает ID.

upsert

INSERT ... ON CONFLICT UPDATE — создать или обновить в одном запросе.

Post::upsert([
    ['id' => 1, 'title' => 'A', 'slug' => 'a'],
    ['id' => 2, 'title' => 'B', 'slug' => 'b'],
    ['id' => 3, 'title' => 'C', 'slug' => 'c'],
], ['id'], ['title', 'slug']);

Аргументы:

  1. Данные — массив массивов.
  2. Уникальные колонки — по ним определяется конфликт.
  3. Обновляемые колонки — что менять при совпадении.

Один запрос вместо N. Работает быстро.

Поддерживается: MySQL 5.7+, PostgreSQL 9.5+, SQLite 3.24+.

Пример — синхронизация данных:

$posts = [
    ['id' => 1, 'title' => 'Обновлён', 'slug' => 'post-1'],
    ['id' => 2, 'title' => 'Новый', 'slug' => 'post-2'],
];

Post::upsert($posts, ['id'], ['title', 'slug']);

Что происходит:

  • ID 1 есть — обновит title и slug.
  • ID 2 нет — создаст.

updateOrCreate в цикле

Для небольших данных:

foreach ($data as $row) {
    Post::updateOrCreate(
        ['slug' => $row['slug']],
        ['title' => $row['title'], 'body' => $row['body']],
    );
}

Плюсы:

  • События работают.
  • Casts применяются.
  • Timestamps автоматически.

Минусы:

  • 2 запроса на строку (SELECT + INSERT/UPDATE).
  • Медленно для больших объёмов.

Chunk для больших массивов

Разбить на части:

$rows = [...];   // 100 000 записей

foreach (array_chunk($rows, 1000) as $chunk) {
    Post::insert($chunk);
}

100 запросов вместо 100 000.

Для очень больших — LazyCollection:

LazyCollection::make($rows)
    ->chunk(1000)
    ->each(fn ($chunk) => Post::insert($chunk->all()));

Не грузит всё в память.

Обновление массово

Без событий:

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

С событиями — по одной:

Post::where('status', 'draft')->chunk(500, function ($posts) {
    foreach ($posts as $post) {
        $post->update(['status' => 'archived']);
    }
});

Правило: массовые операции — без событий, по одной — с событиями.

increment массово

Post::where('user_id', 1)->increment('views');

Один UPDATE. Быстро.

С дополнительными полями:

Post::where('user_id', 1)->increment('views', 1, [
    'last_viewed_at' => now(),
]);

Транзакции

Массовые операции оборачивай в транзакцию:

DB::transaction(function () use ($rows) {
    foreach (array_chunk($rows, 1000) as $chunk) {
        Post::insert($chunk);
    }
});

Если что-то упадёт — всё откатится.

Практика: импорт CSV

use Illuminate\Support\Facades\DB;

function importCsv(string $path): int
{
    $handle = fopen($path, 'r');
    $header = fgetcsv($handle);
    $count = 0;

    DB::transaction(function () use ($handle, $header, &$count) {
        $batch = [];

        while (($row = fgetcsv($handle)) !== false) {
            $batch[] = array_combine($header, $row);

            if (count($batch) >= 1000) {
                Post::insert($batch);
                $count += count($batch);
                $batch = [];
            }
        }

        if (!empty($batch)) {
            Post::insert($batch);
            $count += count($batch);
        }
    });

    fclose($handle);

    return $count;
}

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

1. insert без timestamps:

Post::insert(['title' => 'X']);   // created_at = NULL

2. upsert без уникальных полей в данных:

Post::upsert([['title' => 'X']], ['id'], ['title']);
// Warning: нет id — не сможет определить конфликт

3. insert в цикле без chunk:

foreach ($rows as $row) {
    Post::insert($row);   // медленно
}

4. Забыть про транзакцию:

Post::insert($batch1);
Post::insert($batch2);   // упадёт — batch1 уже сохранён

Итог

  • insert — быстро, но без событий и timestamps.
  • insertOrIgnore — не падает на дублях.
  • upsert — создать или обновить одним запросом.
  • updateOrCreate — для небольших данных.
  • chunk — разбить на части.
  • increment — атомарное обновление.
  • Транзакция — обязательна для батчей.

Дальше — Soft Deletes.