🔪
【Laravel】モデルをchunkしながら更新する時はchunkByIdを使おう
はじめに
Laravelでは大量のデータを処理する際にchunkやchunkByIdを使う。しかし、これらの違いを理解していないと意図しないバグが発生する。本記事では、実際のインシデントをもとに解説。
インシデントの概要
あるプロジェクトで、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')の条件から外れるデータが発生する。
インシデントの原因
以下の手順でデータが失われる
- 
where('status', 'active')で100件取得 - 
chunk内でstatusを変更し、一部のデータがactiveでなくなる - 次の
chunk取得時に、更新済みデータが除外される - 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とか言ってたけど絶対ウソだゾ、有識者いれば教えてください!
 
Discussion