Laravelのデータベースのchunk()の罠

目次

クエリビルダのchunk()は便利だが罠があるのでchunkById()を使おう

基本的にMySQLでの話です。とはいえ、ほかのRDBMSでも大きくは変わらないかと。

chunk()とはなにか

Laravelでデータベースにある大量の行を処理する場合、すべての行を一度に取得しようとすると大量のメモリが必要になります。場合によってはメモリ不足で処理自体できないかもしれません。

そのようなことを防ぐために、Laravelにクエリビルダには一定の行数ごとに取得するchunk()というメソッドがあります。たとえば10万行あるデータを取得する際に、1000行ごとに100回取得・処理するという感じです。

chunk()は便利なメソッドですが、いくつかの場合に罠があるため、代わりにchunkById()を使おう、というのがこの記事の主旨です。

なお、データベースのカーソル機能を使うcursor()1というメソッドもありますが、この記事では取り扱いません。

どういう罠があるのか

具体的にどういう罠があるかですが、まず公式ドキュメントにも書かれているように、取得しつつ更新する場合に、想定した行を処理できない場合があります。

データベース:クエリビルダ 9.x Laravel

もう一つ、処理対象をロックせずに処理している場合に、別のトランザクションからの更新等によって、一つ目の罠同様に想定した行を処理できなかったり、さらには同じ行を複数回処理してしまうという場合があります。

ロックすれば防げるのですが、chunk()を使いたいほどの大量の行の処理では、ロックしてしまうとかなり長い時間テーブルにまったく触れなくなってしまうため、意図的にロックせずに処理したいという場合もあると思います。

この2つの罠は、chunk()ではなくchunkById()を使えばほぼ回避できます。

なぜchunkById()なら大丈夫なのか

chunk()では駄目でchunkById()であれば大丈夫なのはなぜでしょうか。まず、そもそもどうして上記のような罠が起きるかを見ていきます。

chunk()は一定行数ごとに取得してくれるのですが、その取得方法が、クエリビルダで渡したソート方法でソートした状態で、単純にOFFSET, LIMITで分けて取得する、となっています。

User::chunk(1000, function () {  })

Eloquentでこのように実行すると、SQLは以下のようになります。

select * from `users` order by `users`.`id` asc limit 1000 offset 0

この場合、処理の途中で取得対象となる行が変わってしまうと、ずれが生じます。

chunkById()の場合、OFFSETを使わずid, あるいは指定したカラムを基準に次のチャンクを取得するため、ロックする場合は取得しつつ処理してもずれなくなり、ロックしない場合も、複数回処理してしまうような大きな問題は防げます。

罠1: 取得しつつ更新する場合

id12345
取得対象か××

上記のようなデータがある場合に、取得対象のものを2つずつ処理するとします。順当に行けば最初に1, 3を、次に5が処理されて完了となります。

id12345
何回目に取得されるか1-1-2

しかし、1, 3を取得した後に、これらが取得対象とならなくなるような変更を行った場合はどうなるでしょうか。

id12345
取得対象か××××
何回目に取得されるか----1(実際は取得されない)

2回目の処理では、OFFSET 2, LIMIT 2として処理されるため、id = 5は取得されないまま処理全体が終了してしまいます。

chunkById()を使った場合、OFFSETで2回目の処理対象の先頭を判断する代わりに、

  1. idで昇順ソートしつつつ、
  2. 1回目の処理の最後のid(つまり3)を覚えておいて、それより大きいid(4以上)の行だけ選択することにより、

1, 3が取得対象とならなくなっても、id > 3の行のうち、取得対象のものを2つ(今回は5のみのため、実際には1つしか取得しないが)取得されるため、chunk()の場合に起きていた問題は起きません。

User::chunkById(1000, function () {  })

上記のようにchunkById()を使った場合のクエリは、以下のようになります。

-- 1回目
select * from `users` order by `id` asc limit 1000;

-- 2回目
select * from `users` where `id` > 1000 order by `id` asc limit 1000;

罠2: ロックせず処理する場合

lockForUpdate()等で排他ロックした上でchunk()を実行する場合、罠1のように自身のトランザクションで取得対象が変わるような変更を行なわない限り、最初のSELECT時点で取得対象となるすべての行を適切に取得・処理できます。

ただし、その処理が完了するまでの間は当然INSERT, UPDATE, DELETEはまったくできなくなります。chunk()を使いたいほど多くのデータを処理する場合、その時間は許容できないほど長くなる可能性もあります。

排他ロックされる時間が許容できない場合、ロックせずに処理するしかないですが、これが2つ目の罠となります。

ロックせずにチャンクの処理をすると、各チャンクの処理の間に、取得対象の行が増減する可能性があります。それにより、全処理の間に同じ行が2回取得されたり、逆にずっと取得対象だったのに一度も取得されない、ということがありえます。

id12345
取得対象か××
何回目に取得されるか1-1-2(予定)

1, 3が取得された後、2が別のトランザクションから取得対象になるような変更をされた場合、

id12345
取得対象か×
何回目に取得されるか11(実際は取得されていない)2-2

3が、1回目と2回目のどちらでも取得されることになります。

あるいは1, 3が取得された後、1が別のトランザクションから取得対象にならないような変更をされた場合、

id12345
取得対象か×××
何回目に取得されるか--1-1(実際は取得されていない)

となり、5は1回目の取得済みと判断され、2回目に取得されません。

ロックせず処理する場合、以下の4パターンの問題が起こり得ます。

  1. 途中で取得対象になった行が取得されてしまう場合がある。
  2. 途中で取得対象でなくなった行が取得されなくなってしまう場合がある。
  3. 途中で変更されていない行が、2回取得・処理されてしまう場合がある。
  4. 途中で変更されていない行が、取得・処理されない場合がある。

1, 2についてはchunkById()でも対処できませんが、これは許容できる場合も多いかと思います。

問題は3, 4で、特に4が許容できる場合はあまりないかと思います。chunkById()を使えば、OFFSETを基準にすることが原因で起こるこれら2つの問題は起きなくなります。

それでもchunk()の方が適切な場合

基本的には常にchunkById()を使うべきだと思うのですが、「ユニークなカラムで昇順」以外の順番で処理したい場合、そのときだけはchunk()を使うしかありません。

chunkById()は、カラム名は指定できますが、ユニークなカラムでなければ正しく処理できない可能性があり、また処理順は昇順で固定です。そのため、この場合はchunk()を使い、ロックした上で、更新しないように気をつけるしかありません。

関連メソッドについて

chunk()chunkById()には関連するメソッドがいくつかあります。簡単にご紹介します。

each(), eachById()

each(), eachById()は、それぞれchunk(), chunkById()内で単純にforeachでチャンクを処理するパターンのシンタックスシュガー的なメソッドです。以下の2つの処理は、ほぼ同等となります。

User::where(...)->chunkById(1000, function ($users) {
    foreach ($users as $user) {
        $user->update(...);
    }
});
User::where(...)->eachById(function (User $user) {
    $user->update(...);
});

このようなシンプルな処理の場合、eachById()を使う方があきらかにわかりやすく書けるため、おすすめです。

ただしeachById()が内部的にchunkById()を使うことを知らない人には一行ずつ処理しているように見えること、また、each()についてはコレクション(Illuminate\Support\Collection)のeach()と混同される可能性がある点には注意が必要かもしれません。

なおコレクションにもchunk()がありますが、こちらは引数がまったく違うため、あまり混同することはないと思います。

chunkMap()

chunkMap()は、chunk()内で取得したデータを元にコレクションを作るメソッドです。対応するchunkMapById()はありません。機能の用途的に取得しつつ更新することはないと思われますが、ロックしない場合はchunk()同様にずれる可能性はあるはずです。

lazy(), lazyById(), lazyByIdDesc()

私はまだ試していないのですが、Laravel 8からはlazy(), lazyById(), またlazyByIdDesc()というメソッドも追加されたようです。

これらはPHPのジェネレータを使うようになったchunk()と考えるのがよさそうです。LazyCollectionを返すので、chunk()each()よりも、さらにシンプルにコードが書けるかもしれません。

foreach (User::where(...)->lazyById() as $user) {
    $user->update(...);
}

また、chunk()系にはなかった、id降順で取得するlazyByIdDesc()も場合によっては便利そうです。

別解: バルクアップデートする

取得するだけの場合には使えませんが、取得したデータを変更等した上で保存するような使い方の場合、バルクアップデートの方が向いていて、かつ高速かもしれません。

chunk()を使うのは、アプリケーション側でデータを処理する必要があるためです。すべてをデータベース側で処理できるのであれば、必要はありません。

Laravelで気軽にバルクアップデートしたい - Qiita

この記事のライセンス

クリエイティブ・コモンズ・ライセンス

この文書はCC BY(クリエイティブ・コモンズ表示4.0国際ライセンス)で公開します。


  1. cursor()は単純にジェネレータでPDOStatement::fetch()しているだけのようで、実際には分割されておらずメモリ消費があまり減らなかったり、それを防ぐために設定を変えた場合は処理が終了するまで同じコネクションで別のクエリを実行できなかったり、癖が強いようなので私は使用していません。 参考: Laravelのcursorとchunkの違いとバッファクエリの対処法 - honeplusのメモ帳 ↩︎