Zenn
🔪

【Laravel】モデルをchunkしながら更新する時はchunkByIdを使おう

2025/03/21に公開
1

はじめに

Laravelでは大量のデータを処理する際にchunkchunkByIdを使う。しかし、これらの違いを理解していないと意図しないバグが発生する。本記事では、実際のインシデントをもとに解説。

インシデントの概要

あるプロジェクトで、where条件で絞り込みながらchunkを使い、データを処理する実装があった。さらに、各chunk内でwhereの条件に影響を与えるデータの更新も行っていた。

期待される処理は、対象1000件のデータをすべて処理すること。しかし、実際には600件しか処理されなかった。

インシデントが発生したコード

  • whereで条件を指定
  • chunkは指定した件数ごとにデータを取得し、クロージャ内で処理を実行
  • さらにクロージャ内でwhereの条件部分を更新
User::query()
    ->where('status', 'active')
    ->chunk(100, function ($users) {
        foreach ($users as $user) {
            $user->status = 'processed';
            $user->save();
        }
    });

このコードでは、100件ずつデータを取得し、各ユーザーのstatusを変更している。しかし、更新によってwhere('status', 'active')の条件から外れるデータが発生する。

インシデントの原因

以下の手順でデータが失われる

  1. where('status', 'active')で100件取得
  2. chunk内でstatusを変更し、一部のデータがactiveでなくなる
  3. 次のchunk取得時に、更新済みデータが除外される
  4. 1000件のはずが600件しか処理されない

chunkを使った場合のデータの流れ

-- 1回目の取得: 正常に100件取得
SELECT * FROM users WHERE status = 'active' LIMIT 100 OFFSET 0;
-- 取得した100件を更新
UPDATE users SET status = 'processed' WHERE id IN (1, 2, ..., 100);
-- 2回目の取得: OFFSET 100の影響で本来取得すべきデータをスキップ
SELECT * FROM users WHERE status = 'active' LIMIT 100 OFFSET 100;
-- 取得したデータを更新(しかし、本来処理すべきデータが既にスキップされている)
UPDATE users SET status = 'processed' WHERE id IN (201, 202, ..., 300);

このようにページングしながらクエリが繰り返し実行されるが、UPDATE によって status が変更されるため、次のSELECT時にはすでに一部のデータが除外される。

ID status 取得対象 更新後のstatus 次回の取得対象
1 active processed ×
2 active processed ×
... ... ... ... ...
100 active processed ×
101 active processed ×
... ... ... ... ...

解決策

chunkではなくchunkByIdを使用する。

修正後のコード

  • IDを基準にchunkを行うため、whereの条件を更新しても問題なく全件対象になる
User::query()
    ->where('status', 'active')
    ->chunkById(100, function ($users) {
        foreach ($users as $user) {
            $user->status = 'processed';
            $user->save();
        }
    });

chunkByIdを使った場合のデータの流れ

-- 1回目の取得: 正常に100件取得
SELECT * FROM users WHERE status = 'active' ORDER BY id ASC LIMIT 100;
UPDATE users SET status = 'processed' WHERE id IN (1, 2, ..., 100);
-- 2回目の取得: 前回の最大IDより大きいIDを対象に取得
SELECT * FROM users WHERE status = 'active' AND id > LAST_ID ORDER BY id ASC LIMIT 100;
-- 取得した100件を更新
UPDATE users SET status = 'processed' WHERE id IN (101, 102, ..., 200);

この方法ではidを条件に追加するため、statusが変更されても影響を受けずに全件処理できる。

ID status 取得対象 更新後のstatus 次回の取得対象
1 active processed ×
2 active processed ×
... ... ... ... ...
100 active processed ×
101 active processed ○(ID昇順のため影響なし)
... ... ... ... ...

まとめ

  • chunkしながら更新する場合はchunkByIdを使おう!

補足

  • こういうインシデントはあるあるだと思うが、一般的な名称ってないのか...?AIはPagination Driftとか言ってたけど絶対ウソだゾ、有識者いれば教えてください!
1

Discussion

ログインするとコメントできます