🔪
【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