บทความ

บันทึกข้อมูลทีละมาก ๆ ใน Laravel ด้วย upsert() ทำยังไง

upsert() เพิ่มแถวใหม่และอัปเดตแถวเดิมใน SQL คำสั่งเดียว นำเข้าสินค้าหลายพันแถวจบในไม่กี่ query แทนการวน updateOrCreate() ทีละแถว แลกกับการต้องมี unique index รองรับ และข้าม event กับ cast ของ model ไปทั้งหมด

เผยแพร่
เวลาอ่าน
8 นาที

ลูกค้าส่งไฟล์ Excel รายการสินค้ามาทุกสัปดาห์ หรือระบบต้องดึงราคาจาก API ของ supplier มาอัปเดตทุกคืน สินค้าที่มี SKU อยู่แล้วให้แก้ราคากับสต็อก ส่วน SKU ใหม่ให้เพิ่มเข้าไป ปกติเราก็มักวน updateOrCreate() ทีละแถว ข้อมูลไม่กี่ร้อยแถวก็ใช้ได้ดี แต่พอไฟล์มีเป็นหมื่นแถว หน้าเว็บก็หมุนนานจน timeout

Laravel มี upsert() ไว้สำหรับงานนี้ ส่งข้อมูลทั้งก้อนลงไปใน SQL คำสั่งเดียว แล้วให้ฐานข้อมูลตัดสินเองว่าแถวไหนต้องเพิ่ม แถวไหนต้องอัปเดต

วน updateOrCreate() ทีละแถว เสีย query ราวสองครั้งต่อแถว

โค้ดนำเข้าที่เราคุ้นมือหน้าตาแบบนี้

foreach ($rows as $row) {
    Product::updateOrCreate(
        ['sku' => $row['sku']],
        ['name' => $row['name'], 'price' => $row['price'], 'stock' => $row['stock']],
    );
}

ใน Laravel 13 updateOrCreate() เรียก firstOrCreate() ซึ่ง SELECT หาแถวด้วย sku ก่อน ถ้าไม่เจอก็ INSERT ถ้าเจอก็ fill() แล้ว save() ซึ่งจะ UPDATE เมื่อมีค่าเปลี่ยน (ดูได้ใน Illuminate\Database\Eloquent\Builder::updateOrCreate()) เปิด query log ตอนนำเข้าสินค้าใหม่ 3 แถว ได้ 6 query

select * from "products" where ("sku" = ?) limit 1
insert into "products" ("sku", "name", "price", "stock", "updated_at", "created_at") values (?, ?, ?, ?, ?, ?)
select * from "products" where ("sku" = ?) limit 1
insert into "products" ("sku", "name", "price", "stock", "updated_at", "created_at") values (?, ?, ?, ?, ?, ?)
select * from "products" where ("sku" = ?) limit 1
insert into "products" ("sku", "name", "price", "stock", "updated_at", "created_at") values (?, ?, ?, ?, ?, ?)

ไฟล์ 10,000 แถวก็ราว 20,000 query ทุก query ต้องวิ่งไปกลับฐานข้อมูลหนึ่งรอบ ยิ่งฐานข้อมูลอยู่คนละเครื่องกับแอปก็ยิ่งช้า ลองวัดกับสินค้า 2,000 แถวบน Mac ที่มี MySQL 8.4 กับ PostgreSQL 17 อยู่ในเครื่องเดียวกัน updateOrCreate() ใช้ราว 0.8 วินาที ส่วน upsert() ทีละ 1,000 แถวใช้ราว 0.03 วินาที

upsert() เพิ่มและอัปเดตใน query เดียว

ตาราง products ต้องมี unique index ที่ sku ก่อน

Schema::create('products', function (Blueprint $table) {
    $table->id();
    $table->string('sku')->unique();
    $table->string('name');
    $table->decimal('price', 10, 2);
    $table->integer('stock')->default(0);
    $table->timestamps();
});

แล้วส่งข้อมูลทั้งก้อนให้ upsert()

Product::upsert([
    ['sku' => 'A-001', 'name' => 'ปากกา', 'price' => 12, 'stock' => 5],
    ['sku' => 'A-002', 'name' => 'ดินสอ', 'price' => 5, 'stock' => 9],
], uniqueBy: ['sku'], update: ['name', 'price', 'stock']);

upsert() รับสามอย่าง

  1. $values ข้อมูลที่จะบันทึก เป็น array ของแถว
  2. $uniqueBy คอลัมน์ที่บอกว่าแถวไหนคือแถวเดิม
  3. $update คอลัมน์ที่จะเขียนทับเมื่อเจอแถวเดิม คอลัมน์อื่นในแถวเดิมคงค่าไว้เหมือนเดิม ถ้าไม่ส่งมา Laravel จะอัปเดตทุกคอลัมน์ที่มีในแถวแรก รวม sku เองด้วย

ถ้าไม่มี model ก็เรียกจาก query builder ได้ด้วย DB::table('products')->upsert($values, ['sku'], ['name']) ต่างกันตรงที่ query builder ไม่เติม timestamps ให้

$update ใส่เป็น คอลัมน์ => ค่า ได้ด้วย เช่นอยากบวกสต็อกที่ส่งมาเพิ่มจากของเดิมแทนเขียนทับ ก็ส่ง DB::raw() ไป แต่ SQL ที่ต้องเขียนต่างกันตามฐานข้อมูล

DB::table('products')->upsert($rows, ['sku'], [
    'stock' => DB::raw('products.stock + excluded.stock'),
]);

Laravel สร้าง SQL คนละแบบตามฐานข้อมูล

ตัวอย่างสองแถวข้างบน Laravel 13 สร้าง SQL ออกมาแบบนี้ (จัดบรรทัดใหม่ให้อ่านง่าย ค่าจริงส่งไปเป็น ?)

insert into `products` (`created_at`, `name`, `price`, `sku`, `stock`, `updated_at`)
values (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)
on duplicate key update
    `name` = values(`name`),
    `price` = values(`price`),
    `stock` = values(`stock`),
    `updated_at` = values(`updated_at`)
  • MySQL และ MariaDB ใช้ INSERT … ON DUPLICATE KEY UPDATE ถ้าแถวไหนชน unique index ก็เปลี่ยนเป็นอัปเดตแถวเดิม values(name) คือค่าที่เราพยายาม insert
  • PostgreSQL และ SQLite ใช้ ON CONFLICT (…) DO UPDATE ตาราง excluded คือแถวที่เราพยายาม insert แต่ชนกับแถวเดิม
  • SQL Server ใช้ MERGE ซึ่งไม่ได้ลองในโพสต์นี้

ชื่อคอลัมน์เรียงตามตัวอักษร เพราะ Laravel ksort() ทุกแถวก่อนสร้าง SQL เรื่องนี้มีผลกับหัวข้อ "ทุกแถวต้องมีคอลัมน์ชุดเดียวกัน" ข้างล่าง

created_at กับ updated_at Laravel เติมให้

upsert() ของ Eloquent เติม created_at และ updated_at เป็นเวลาปัจจุบันให้ทุกแถว (ยกเว้นแถวที่ส่งค่ามาเอง) แล้วเพิ่ม updated_at เข้าไปใน $update แต่ไม่เพิ่ม created_at แถวเดิมจึงเก็บวันที่สร้างไว้เหมือนเดิม และได้ updated_at ใหม่ ลองรันซ้ำห่างกันหนึ่งวินาที ได้แบบนี้

sku    created_at           updated_at
A-001  2026-10-11 18:12:59  2026-10-11 18:13:00   ← แถวเดิม
A-003  2026-10-11 18:13:00  2026-10-11 18:13:00   ← แถวใหม่

ถ้า model ใช้ HasUuids หรือ HasUlids Laravel ก็สร้าง id ให้แถวที่ยังไม่มีด้วย ทั้งหมดนี้อยู่ใน addTimestampsToUpsertValues() และ addUniqueIdsToUpsertValues() ของ Illuminate\Database\Eloquent\Builder ส่วน DB::table()->upsert() ไม่เติมอะไรให้เลย ต้องใส่ now() เอง

upsert() ไม่สร้าง model ระหว่างทาง

Eloquent ส่งข้อมูลต่อให้ query builder ตรง ๆ ($this->toBase()->upsert(...)) ไม่มี model เกิดขึ้นสักตัว ลองทดสอบกับ model ที่มี saving listener, mutator ที่แปลงชื่อเป็นตัวพิมพ์ใหญ่ และ $fillable ที่ไม่มี barcode ได้ผลแบบนี้

  • ไม่มี model event saving, created, updated ไม่ทำงาน observer ก็ไม่ทำงาน ถ้าเคยล้าง cache หรือเขียน log ไว้ใน model event ต้องย้ายมาทำหลัง upsert() เอง
  • ไม่ผ่าน mutator และ cast ชื่อที่ส่งไปเป็น lowercase name ก็บันทึกเป็นตัวพิมพ์เล็กตามเดิม ส่วนคอลัมน์ที่ cast เป็น array ถ้าส่ง array ไปตรง ๆ จะได้ Array to string conversion ต้อง json_encode() เอง Carbon กับ backed enum ไม่ต้องห่วง query builder แปลงให้
  • ไม่เช็ค $fillable คอลัมน์ไหนอยู่ในแถวก็บันทึกลงไปหมด แถวที่มาจากไฟล์ของคนอื่นจึงควรเลือกเฉพาะคอลัมน์ที่ต้องการก่อนส่ง

คอลัมน์ใน uniqueBy ต้องมี unique index

เอกสารของ Laravel บอกว่าทุกฐานข้อมูล ยกเว้น SQL Server ต้องมี primary key หรือ unique index ตรงกับคอลัมน์ใน uniqueBy บน PostgreSQL ถ้าไม่มีจะได้ error นี้

ERROR:  there is no unique or exclusion constraint matching the ON CONFLICT specification

MySQL ไม่ดู uniqueBy เลย

เอกสารเดียวกันบอกว่า MySQL กับ MariaDB ไม่สนคอลัมน์ใน uniqueBy แถวที่ชน primary key หรือ unique index ตัวไหนของตารางก็กลายเป็นอัปเดตทั้งหมด เพราะ SQL ที่ Laravel สร้างไม่มีชื่อคอลัมน์ให้ MySQL ดูตั้งแต่แรก ลองกับตาราง products ที่มี unique ทั้ง sku และ barcode

Product::upsert([
    ['sku' => 'A-001', 'barcode' => '999999', 'name' => 'ชนที่ sku'],
], uniqueBy: ['barcode'], update: ['name']);

barcode 999999 ยังไม่มีในตาราง แต่ sku A-001 มีแล้ว

  • MySQL อัปเดตชื่อของ A-001 ทั้งที่เราบอกให้ดู barcode
  • PostgreSQL ดูแค่ barcode ตามที่บอก พอ insert ไปชน sku ก็โยน UniqueConstraintViolationException

คู่มือของ MySQL เองก็แนะนำให้เลี่ยง ON DUPLICATE KEY UPDATE กับตารางที่มี unique index หลายตัว เพราะถ้าแถวที่ส่งไปชนหลายแถวพร้อมกัน MySQL อัปเดตแค่แถวเดียว ก่อนใช้ upsert() บน MySQL จึงควรเปิดดูว่าตารางมี unique index อะไรบ้าง

partial unique index ใช้กับ upsert() ตรง ๆ ไม่ได้

ตารางที่ใช้ soft delete แล้วทำ unique เฉพาะแถวที่ยังไม่ลบบน PostgreSQL จะเจอ error ข้างบนเหมือนกัน เพราะ ON CONFLICT ต้องมีเงื่อนไขของ index ต่อท้ายด้วย แต่ Laravel ไม่มีที่ให้ใส่ วิธีเขียน SQL เองอยู่ในโพสต์ Partial Unique Index ใน PostgreSQL

ทุกแถวต้องมีคอลัมน์ชุดเดียวกัน

Laravel เอาชื่อคอลัมน์จากแถวแรกมาสร้าง insert into (…) แล้วเรียงค่าของแถวอื่นตามลำดับ key ที่ ksort() แล้ว ถ้าแถวมีจำนวนคอลัมน์ไม่เท่ากัน ฐานข้อมูลจะไม่ยอมรัน

MySQL:       Column count doesn't match value count at row 2
PostgreSQL:  VALUES lists must all be the same length

ที่อันตรายกว่าคือแถวที่มีจำนวนคอลัมน์เท่ากันแต่คนละชุด

DB::table('items')->upsert([
    ['sku' => 'D-001', 'name' => 'สมุด', 'barcode' => '8850001'],
    ['sku' => 'D-002', 'name' => 'แฟ้ม', 'unit' => 'เล่ม'],
], ['sku'], ['name']);

แถวแรกเรียงเป็น barcode, name, sku ส่วนแถวที่สองเรียงเป็น name, sku, unit ค่าของแถวที่สองจึงไปลงผิดช่องหมด ทั้ง MySQL และ PostgreSQL บันทึกผ่านโดยไม่มี error

sku    name   barcode
D-001  สมุด    8850001
เล่ม    D-002  แฟ้ม

สร้างทุกแถวจาก map() ตัวเดียวกันที่ระบุคอลัมน์ครบทุกตัว แถวไหนไม่มีค่าก็ใส่ null หรือค่าเริ่มต้นไว้

key ซ้ำในก้อนเดียว PostgreSQL ไม่ยอม

ไฟล์ Excel มักมี SKU เดียวกันโผล่สองบรรทัด MySQL กับ SQLite ยอมรันและเก็บค่าของบรรทัดหลัง แต่ PostgreSQL ไม่ยอมให้คำสั่งเดียวแก้แถวเดิมสองครั้ง (ON CONFLICT DO UPDATE ต้องได้ผลแน่นอน)

ERROR:  ON CONFLICT DO UPDATE command cannot affect row a second time
HINT:  Ensure that no rows proposed for insertion within the same command have duplicate constrained values.

ตัดแถวซ้ำก่อนส่งด้วย keyBy('sku') ซึ่งเก็บแถวหลังสุดไว้ ได้ผลเหมือนกันทุกฐานข้อมูล

แบ่งส่งทีละ 1,000 แถว

คำสั่งเดียวรับค่าได้ไม่จำกัด เพราะ Laravel ส่งค่าทุกช่องไปเป็น placeholder (?) และทั้ง PostgreSQL กับ MySQL รับ placeholder ได้ไม่เกิน 65,535 ตัวต่อคำสั่ง ลองส่งแถวละ 2 คอลัมน์

ตาราง products ข้างบนมี 6 คอลัมน์รวม timestamps จึงส่งได้ไม่เกินราว 10,900 แถวต่อคำสั่ง นอกจากนี้ขนาดของคำสั่งทั้งก้อนก็ต้องไม่เกิน max_allowed_packet ของ MySQL ซึ่งค่าเริ่มต้นใน 8.4 คือ 64 MB แบ่งทีละ 500 ถึง 1,000 แถวจึงปลอดภัยและยังเร็วอยู่ ลองส่ง 40,000 แถวทีละ 1,000 ใช้ไม่ถึงครึ่งวินาที

รวมเป็นโค้ดนำเข้าสินค้าจาก CSV

อ่านไฟล์ด้วย LazyCollection ทีละบรรทัด หน่วยความจำจึงไม่โตตามขนาดไฟล์ แปลงทุกแถวให้มีคอลัมน์ชุดเดียวกัน แบ่งทีละ 1,000 ตัดแถวซ้ำ แล้วค่อย upsert()

<?php

namespace App\Actions;

use App\Models\Product;
use Illuminate\Support\LazyCollection;

class ImportProducts
{
    public function handle(string $path): int
    {
        $imported = 0;

        LazyCollection::make(function () use ($path) {
            $handle = fopen($path, 'r');
            $header = fgetcsv($handle, escape: '');

            while (($line = fgetcsv($handle, escape: '')) !== false) {
                yield array_combine($header, $line);
            }

            fclose($handle);
        })
            ->map(fn (array $row) => [
                'sku' => trim($row['sku']),
                'name' => trim($row['name']),
                'price' => (float) $row['price'],
                'stock' => (int) $row['stock'],
            ])
            ->chunk(1000)
            ->each(function (LazyCollection $chunk) use (&$imported) {
                $rows = $chunk->collect()->keyBy('sku')->values()->all();

                Product::upsert($rows, uniqueBy: ['sku'], update: ['name', 'price', 'stock']);

                $imported += count($rows);
            });

        return $imported;
    }
}

$chunk->collect() แปลงก้อนนั้นเป็น Collection ธรรมดาก่อน keyBy() เพราะ keyBy() ของ LazyCollection ยังปล่อย key ซ้ำออกมาได้ ถ้าลืมบรรทัดนี้ PostgreSQL จะเจอ error เรื่อง key ซ้ำข้างบน ส่วนไฟล์ .xlsx ใช้ spatie/simple-excel ได้ getRows() คืน LazyCollection เหมือนกัน โค้ดหลังจากนั้นใช้ชุดเดิม

แต่ละก้อนเป็นคำสั่งแยกกัน ถ้าก้อนที่ห้าพัง สี่ก้อนแรกบันทึกไปแล้ว ถ้าต้องการให้ทั้งไฟล์สำเร็จหรือไม่บันทึกเลย ให้ครอบด้วย DB::transaction()

ค่าที่ upsert() คืนมา อย่าเอาไปนับแถว

upsert() คืนจำนวน "แถวที่ได้รับผล" ตามที่ฐานข้อมูลบอก ซึ่งแต่ละฐานข้อมูลนับไม่เหมือนกัน

สถานการณ์ MySQL 8.4 PostgreSQL 17 SQLite
Eloquent: แก้ราคา 1 แถว, ค่าเดิม 1 แถว, แถวใหม่ 1 แถว 5 3 3
DB::table(): ส่งค่าเดิมทั้ง 2 แถว 0 2 2

MySQL นับแถวที่ insert เป็น 1 แถวที่อัปเดตเป็น 2 และแถวที่ค่าไม่เปลี่ยนเป็น 0 ฝั่ง Eloquent ทุกแถวเดิมได้ updated_at ใหม่เสมอ จึงนับเป็น 2 ทุกแถว ส่วน PostgreSQL นับแถวที่เพิ่มหรืออัปเดตแถวละ 1 ถ้าอยากรายงานว่านำเข้ากี่แถว นับจากข้อมูลที่ส่งไปเองอย่างในโค้ดข้างบนจะตรงกว่า

id ข้ามเลขหลัง upsert()

เพิ่มสินค้า A, B, C ได้ id 1 ถึง 3 แล้ว upsert() สามตัวเดิมซ้ำอีกรอบ จากนั้นเพิ่มสินค้า D ตัวเดียว D ได้ id 7 ทั้งบน MySQL 8.4 และ PostgreSQL 17 เพราะฐานข้อมูลจองเลขไว้ตั้งแต่ตอนพยายาม insert ก่อนจะรู้ว่าแถวนั้นชน (MySQL เรียกว่า mixed-mode insert ส่วน PostgreSQL ยกตัวอย่าง ON CONFLICT ไว้ตรง ๆ) id ที่เป็น bigint ไม่มีทางหมด แต่อย่าใช้ id เป็นเลขที่เอกสารที่ต้องเรียงติดกัน

upsert(), insertOrIgnore() หรือ updateOrCreate() เลือกตามงาน

เมธอด ทำอะไร query event
updateOrCreate() หาแถว แล้วเพิ่มหรือแก้ทีละ model ราว 2 ต่อแถว มี
updateOrInsert() แบบเดียวกันบน query builder เช็ค exists() แล้ว insert หรือ update 2 ต่อแถว ไม่มี
upsert() เพิ่มหรือแก้ทั้งก้อน 1 ต่อก้อน ไม่มี
insertOrIgnore() เพิ่มเฉพาะแถวที่ไม่ชน แถวที่ชนข้ามไปเฉย ๆ 1 ต่อก้อน ไม่มี

insertOrIgnore() เหมาะกับข้อมูลที่ไม่ต้องแก้ทีหลัง เช่น log หรือรายการที่ซิงก์มาครั้งเดียว บน MySQL Laravel ใช้ INSERT IGNORE ซึ่งข้าม error อื่นนอกจากค่าซ้ำไปด้วย เช่นค่าที่ยาวเกินคอลัมน์ MySQL ก็ตัดทิ้งแล้วบันทึกต่อ บน PostgreSQL ใช้ ON CONFLICT DO NOTHING

ข้อมูลไม่กี่สิบแถวต่อครั้ง หรือต้องให้ observer ทำงานทุกแถว updateOrCreate() ยังเป็นตัวเลือกที่ง่ายกว่า พอเป็นไฟล์หลักพันแถวขึ้นไป หรือ job ที่ซิงก์ทุกคืน ค่อยเปลี่ยนมาใช้ upsert() พร้อมเตรียมข้อมูลให้เรียบร้อยก่อนส่ง เทคนิค Eloquent อื่น ๆ อ่านต่อได้ใน20 เทคนิค Laravel Eloquent

อ่านต่อ

ล่าสุด

ดูทั้งหมด →