Một người đang cưa cành trên cây cao
PHP

Eloquent chunk() và sai lầm “chết chóc”

Một người đang cưa cành trên cây cao
Cưa cái cành mình đang ngồi. Photo by Dmytro Glazunov on Unsplash

Mấy năm trước tôi có viết một artisan command để đánh dấu đơn hàng cũ là “đã đẩy sang ERP” sent to ERP. Bảng orders lúc đó tầm 2m rows. Tôi dùng chunk() như 1 thói quen, chạy qua đêm, sáng dậy log báo xử lý được… hơn 1 triệu. Còn gần 1m orders nữa không được process, chưa ai đụng tới.

Không exception. Không error log. Exit code 0.

Đây là kiểu bug tôi ghét nhất: nó không chết, nó chỉ làm sai một nửa rồi report là Done.

chunk() thật ra làm gì behind the scene?

Code của tôi hồi đó, rút gọn lại:

Order::where('exported', false)
    ->chunk(500, function ($orders) {
        foreach ($orders as $order) {
            $this->erp->push($order);
            $order->update(['exported' => true]);
        }
    });

Mở source Laravel ra xem (trait Illuminate\Database\Concerns\BuildsQueries, hiện tại là bản 13.x nhưng logic này cũ hơn nhiều), chunk() chỉ là một vòng do … while. Page 1 chạy OFFSET 0 LIMIT 500, page 2 chạy OFFSET 500 LIMIT 500, cứ thế cho tới khi một page trả về ít hơn 500 row. Mỗi vòng là một query mới, chạy lại nguyên cái where của bạn.

Ví dụ:

  • Page 1 lấy 500 đơn có exported = false, xử lý xong thì 500 đơn đó thành exported = true.
  • Page 2 chạy query với OFFSET 500… trên một tập kết quả vừa ngót đi 500 row. Nghĩa là nó nhảy qua 500 đơn kế tiếp mà chưa hề đụng tới.

Mỗi chunk xử lý bỏ sót đúng một chunk. Làm một nửa, quên một nửa…

Docs Laravel có nói vụ này. Một đoạn warning ngay dưới ví dụ chunk(), bảo nếu bạn filter theo column mà bạn sẽ update trong lúc lặp thì dùng chunkById(). Đảm bảo tôi đã đọc lướt qua đoạn đó ít nhất ba lần trước khi bị cắn 🙂

chunkById() khác gì mà sửa được?

chunkById() không dùng OFFSET. Nó nhớ id cuối cùng của chunk trước, rồi query kế tiếp là WHERE id > :lastId ORDER BY id ASC LIMIT 500 (function forPageAfterId() trong query builder, nếu bạn muốn tự kiểm tra). Row bị update hay bị xoá không ảnh hưởng gì, vì điểm neo là id, không dính gì tới vị trí trong tập kết quả.

Order::where('exported', false)
    ->chunkById(500, function ($orders) {
        foreach ($orders as $order) {
            $this->erp->push($order);
            $order->update(['exported' => true]);
        }
    });

Đổi đúng một cái tên function. Hai triệu đơn chạy sạch.

Có điều chunkById() cũng có vài chỗ dễ vấp mà official docs nói hơi ngắn.

  • Thứ nhất, orWhere phải bọc trong closure. Vì chunkById() tự nhét thêm where id > ? vào cuối query, nên viết where(a)->orWhere(b)->chunkById() thì SQL sinh ra là a OR b AND id > ?. Mấy row khớp a sẽ quay lại ở mọi chunk, tệ nhất là infinity loop. Bọc where(function ($q) { … }) là xong.
  • Thứ hai, có join thì phải chỉ rõ column và alias: chunkById(500, $callback, column: 'orders.id', alias: 'id'). Không thì hên xui: MySQL báo ambiguous column, Laravel ném RuntimeException bảo column không có trong result, hoặc tệ nhất là chạy êm ru nhưng neo nhầm id của joined table(s).
  • Thứ ba, đừng orderBy thứ khác trước khi chunkById(). Nó chỉ gỡ order theo cột id của bạn rồi thêm ORDER BY id ASC vào sau, nên orderBy('created_at')->chunkById() cho ra ORDER BY created_at, id kèm WHERE id > ?. Sort theo một cột, neo theo cột khác… kết quả là bỏ sót row mà chẳng ai báo.

Vậy cursor() thì sao? Có cứu được RAM không?

Thấy chunk phiền, nhiều bạn nhảy sang cursor(). Docs nói cursor chỉ giữ một model trong RAM tại một thời điểm. Đúng. Nhưng kéo xuống thêm vài dòng nữa có một cái note khác, và cái này ít người đọc tới.

cursor() chạy đúng một query, rồi dùng generator (yield) để hydrate từng row thành model. Model thì đúng là chỉ có một. Nhưng raw result set thì PDO đã kéo hết về PHP ngay lúc execute, vì PDO MySQL mặc định chạy buffered query. Hai triệu row raw vẫn nằm trong RAM của process, chỉ là nằm dưới dạng array thay vì object Eloquent.

Nên cursor() chết chậm hơn get(). Chậm hơn thôi. Vẫn chết.

Có một cách “hack” là tắt buffer đi: thêm PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false trong config/database.php. Lúc đó cursor mới thật sự stream từng row từ MySQL. Nhưng, bạn trả giá ở chỗ: khi một unbuffered query chưa fetch hết, connection đó không được chạy bất kỳ query nào khác -> locked. Cái $order->update() bên trong foreach sẽ throw “Cannot execute queries while other unbuffered queries are active”. Đúng cái loop ở trên, chết ngay row đầu tiên.

Tôi từng thấy một team set option này global cho cả app. Cả tuần sau vẫn còn lượm bug ở những chỗ chẳng liên quan gì tới cái command ban đầu. Đừng làm vậy.

Thêm một hạn chế nữa: cursor() không eager load. Order::with('items')->cursor() thì cái with() coi như không có tác dụng, và bạn có N+1 ngay trong cái loop mình tưởng là tối ưu nhất project.

Vậy cuối cùng dùng cái gì?

Câu trả lời: lazyById().

Order::where('exported', false)
    ->with('items')
    ->lazyById(500, column: 'id')
    ->each(function (Order $order) {
        $this->erp->push($order);
        $order->update(['exported' => true]);
    });

Bên trong nó vẫn là chunkById, tức là query theo id, update trong loop thoải mái. Nhưng bạn nhận về một LazyCollection, viết foreach hay chain filter(), map() như collection bình thường thay vì nhét hết logic vào callback. Eager load chạy được vì mỗi chunk là một Collection thật. RAM tối đa 500 model một lúc, và raw buffer của PDO cũng chỉ 500 row, vì mỗi chunk là một query riêng.

Cách tôi chọn bây giờ, sau nhiều lần bị thốn:

  • Dưới vài chục nghìn row, đọc xong không update: get() cho lẹ, đừng làm màu.
  • Đọc nhiều, chỉ đọc, cần nhanh và không cần relation: cursor(), chấp nhận buffer.
  • Có update hay delete row trong loop: chunkById() hoặc lazyById(). Không có lựa chọn thứ ba.
  • Cần eager load: lazyById().

Còn chunk() thường thì vẫn ổn khi where không đụng tới column bạn sẽ update. Ví dụ filter theo created_at rồi update exported, chunk chạy đúng. Nhưng ai dám chắc sáu tháng nữa không có ông nào thêm where('exported', false) vào cho “tối ưu”?

Nếu bạn từng bị chunk() cắn theo kiểu khác, kể tôi nghe dưới comment. Tôi nghi là còn vài biến thể nữa mà mình chưa gặp.

Peace!

Ảnh: Dmytro Glazunov trên Unsplash (Unsplash License) — https://unsplash.com/photos/person-pruning-branches-of-a-large-tree-KBiETehYWdU

Dang Nguyen Hai (Mark) is a senior fullstack engineer in Ho Chi Minh City with 13+ years building e-commerce backends, payment systems and the AI features that sit on top of them. He works with Shopware and Laravel, and writes here about PHP internals, e-commerce architecture and getting AI features to behave in production.

Reply