後付けで定まる値――例えば集計結果の値を持つカラムを持つテーブルを作る時があります(正規化に反してでも速度が要求される時など)。こういったカラムを持つテーブルについて整合性を保ったテストデータを作る時、あるいは集計結果を定期的に更新する時などに大量のレコードを一度に更新する必要が出てきます。ここでは MySQL と Laravel を使った大量のレコードを一気に高速に更新する方法を紹介します。
例としていいねとコメントの総数を持つ次の投稿テーブルを考えます。
使う SQL 文は次の様な CASE 文です。
UPDATE posts SET `good_count` = CASE `post_id` WHEN 1 then ? WHEN 2 then ? WHEN 3 then ? WHEN 4 then ? WHEN 5 then ? END, `comment_count` = CASE `post_id` WHEN 1 then ? WHEN 2 then ? WHEN 3 then ? WHEN 4 then ? WHEN 5 then ? END WHERE `post_id` in (1, 2, 3, 4, 5) # ?には以下を割り当て # [post_id=1のいいね総数、post_id=2のいいね総数、...、post_id=5のいいね総数、post_id=1のコメント総数、...]
主キーである post_id についてのケース文を更新対象の各カラムについて構築します。これで一クエリ(REPLACE の様なエセでなくきっちり一トランザクションです)で一気に更新できます。
Laravel のデフォルトのクエリビルダでこれを構築する機能はありません。ここでは生 SQL を構築して投げます(テストデータ生成時なら安全性が気にならないのでこれでも十分です。そういう時でない時は都度テストして実装することになります)。
// 主キー => いいね総数を呼べるオブジェクト となる連想配列的 Collecion を構築 $goodCounts = PostGood::select(['post_id', DB::raw('count(*) as agg')]) ->groupBy('post_id') ->orderBy('post_id') ->get() ->keyBy('post_id'); // 主キー => コメント総数を呼べるオブジェクト となる連想配列的 Collecion を構築 $commentCounts = PostComment::select(['post_id', DB::raw('count(*) as agg')]) ->groupBy('post_id') ->orderBy('post_id') ->get() ->keyBy('post_id'); // 更新対象の投稿を取得 $posts = Post::withoutGlobalScopes()->get(); $chunkSize = 5000; $postId = 1; // チャンク機能で 5000 件ずつ処理 $posts->chunk($chunkSize)->each( static function ($collect) use ($commentCounts, $goodCounts, $chunkSize, &$postId) { $cases = []; // CASE の各 id についての部分を構築 $ids = []; // 更新対象の id を格納 $goodCountParams = []; // バインドするいいね総数パラメータを格納 $commentCountParams = []; // バインドするコメント総数パラメータを格納 // チャンクの数だけ回してパラメータを構築 // 主キーの欠けがない前提ならばキーを用意せずに id 相当の変数をインクリメントするだけで十分 $collect->each(static function () use (&$postId, $goodCounts, $commentCounts, &$cases, &$ids, &$goodCountParams, &$commentCountParams) { $cases[] = "WHEN {$postId} then ?"; $goodCountParams[] = $goodCounts[$postId]->agg ?? 0; $commentCountParams[] = $commentCounts[$postId]->agg ?? 0; $ids[] = $postId; ++$postId; }); // クエリの文面になる様に配列を結合 $idsStr = implode(',', $ids); $casesStr = implode(' ', $cases); // sql を構築 $sql = <<<SQL UPDATE posts SET `good_count` = CASE `post_id` {$casesStr} END, `comment_count` = CASE `post_id` {$casesStr} END WHERE `post_id` in ({$idsStr}) SQL; // 更新を実行。パラメータはスプレッド演算子(PHP7.4の機能)で一つの配列に結合 \DB::update($sql, [...$goodCountParams, ...$commentCountParams]); } );
こんな感じで CASE を使った UPDATE を使うと一レコードずつ UPDATE するより速く処理を実行できます。
一旦テーブル内の全レコードを削除するクエリを使って次の様にすることもできます。
// あらかじめ posts テーブルの中を PHP 内の $posts 変数に展開 // 外部キー制約を無効化 DB::statement('SET FOREIGN_KEY_CHECKS = 0'); // posts テーブルの中のレコードを全削除 DB::statement('truncate posts'); $posts->chunk($chunkSize)->each( static function ($collect) use ($commentCounts, $goodCounts, $chunkSize, &$bar, &$postId) { $collect = $collect->map(static function (Post $post) use (&$postId, $goodCounts, $commentCounts) { // PHP の中の posts のレコード情報を更新 $post->good_count = $goodCounts[$postId]->agg ?? 0; $post->comment_count = $commentCounts[$postId]->agg ?? 0; $post->setHidden([]); ++$postId; return $post->toArray(); })->toArray(); // バルクインサート機能でまとめてインサート Post::insert($collect); } ); // 外部キー制約を有効化 DB::statement('SET FOREIGN_KEY_CHECKS = 1');
この辺りを上手く使うと素朴に一レコードずつ更新するより格段に速くデータの更新をできます。