Concrete CMS 8.5.21:Express 大量データ環境でのランダムソート更新と UI 障害の原因・解決策

2026/02/15
Concrete CMS 8.5.21 の環境で、Express エントリーを扱うカスタムジョブ実行後に「クリップボードが空になる」「コンテンツ追加パネルが表示されない」といった UI 障害が発生した事例の技術メモです。原因はコード上の不具合ではなく、MySQL における 2 万件規模の全件 UPDATE によるロックが、ページ編集モードの内部 SELECT をブロックしていたことでした。本記事では、発生した現象、原因の切り分け、そして UI に影響を与えない安全なバッチ処理方式への改善方法を詳しく解説します。Concrete CMS で大量データを扱うサイト運用者にとって有用な知見となるはずです。

はじめに

Concrete CMS 8.5 系で Express エントリーを大量(2 万件以上)に扱う環境で、
ランダムソート用の random_value を更新するジョブを実行すると、編集モードの UI(クリップボード)が壊れる
という現象に遭遇しました。

  • クリップボードが空になる
  • ブロック追加パネルが開かない
  • エラーは出ないが UI が機能しない

最初は Concrete CMS 側の不具合を疑いましたが、原因は MySQL のロックUPDATE の仕様 にありました。

本記事では、発生した現象、原因、そして最終的に安定して動作した解決策をまとめます。


発生した問題

現象

以下のような「全件 UPDATE」を行うジョブを実行すると UI が壊れました。

UPDATE IllustRandomValue
SET random_value = FLOOR(1 + RAND() * 10000000)

INSERT のみでは問題は起きず、
UPDATE を追加した途端に UI が壊れる という状況でした。


原因①:MySQL の「全件 UPDATE」によるロックが UI を巻き込んでいた

Concrete CMS の編集モードは、
クリップボードやブロック追加 UI を表示するために
大量の SELECT を即時実行します。

そこに 2 万件の全件 UPDATE が走ると:

  • InnoDB が長時間ロックを保持
  • 編集モードの SELECT がロック待ちに入る
  • UI が「空」と誤認して表示されない

という流れになります。

INSERT だけでは問題が出なかったのは:

  • INSERT はロックが軽い
  • UPDATE は全件ロックで重い

という違いによるものです。


原因②:LIMIT UPDATE は「同じ行ばかり更新される」仕様がある

ロックを避けるために LIMIT を使ったバッチ処理を試すと、
次の問題が発生しました。

UPDATE IllustRandomValue
SET random_value = ...
ORDER BY exEntryID
LIMIT 2000

これは 毎回同じ 2000 行だけが更新される という MySQL の仕様があり、
全件更新ができません。

そのため、random_value が「順送りのように見える」現象も発生しました。


原因③:RAND() のシードをいじると「ランダムに見えない」ことがある

RAND(seed) は

  • 同じ seed → 同じ乱数
  • seed が規則的に増える → 乱数も規則的に増える

という性質があるため、
ソート用途では RAND(seed) は不向き です。

RAND(exEntryID + 時間) のような工夫も、
実際には「ランダムに見えない」ことがあります。


最終的に最も安定した解決策:

???? exEntryID の範囲で分割し、RAND() を素直に使うバッチ方式

最終的に最も安定し、UI に影響を与えず、
random_value も十分にバラけたのは次の方式です。

✔ ポイント

  • LIMIT を使わない(同じ行ばかり更新される問題を回避)
  • exEntryID の範囲で分割する
  • RAND() を素直に使う(行ごとに独立して評価される)
  • 1 回の UPDATE は 2000 行程度に抑える(ロック時間を最小化)

実際に採用したジョブ(最終版)

<?php
namespace Application\Job;

use Concrete\Core\Job\Job;
use Concrete\Core\Support\Facade\Database;

class UpdateRandomValueJoinsort extends Job
{
    public function getJobName()
    {
        return t("日次ランダム値更新(範囲バッチ方式)");
    }

    public function getJobDescription()
    {
        return t("IllustRandomValue テーブルの random_value を、安全にバラけさせて更新します。");
    }

    public function run()
    {
        $db = Database::connection();

        // ① 新規エントリーの追加(random_value は後でまとめて更新)
        $db->executeQuery("
            INSERT INTO IllustRandomValue (exEntryID, random_value)
            SELECT e.exEntryID, 0
            FROM ExpressEntityEntries e
            LEFT JOIN IllustRandomValue rv ON rv.exEntryID = e.exEntryID
            WHERE rv.exEntryID IS NULL
              AND e.exEntryEntityID = (SELECT id FROM ExpressEntities WHERE handle = 'illust')
        ");

        // ② exEntryID の最小・最大を取得
        $minId = (int) $db->fetchColumn("SELECT MIN(exEntryID) FROM IllustRandomValue");
        $maxId = (int) $db->fetchColumn("SELECT MAX(exEntryID) FROM IllustRandomValue");

        if ($minId === 0 && $maxId === 0) {
            return t("IllustRandomValue に対象データがありません。");
        }

        // ③ exEntryID の範囲でバッチ更新(RAND() を素直に使う)
        $step = 2000;

        for ($start = $minId; $start <= $maxId; $start += $step) {
            $end = $start + $step - 1;

            $db->executeQuery("
                UPDATE IllustRandomValue
                SET random_value = FLOOR(1 + RAND() * 10000000)
                WHERE exEntryID BETWEEN ? AND ?
            ", [$start, $end]);
        }

        return t("ランダム値更新完了(範囲バッチ方式)");
    }
}

この方式のメリット

  • UI が壊れない(ロック時間が短い)
  • 全件確実に更新される(LIMIT の罠を回避)
  • RAND() が行ごとに独立して評価されるため、見た目が十分バラける
  • Express の JOIN ソートとも相性が良い
  • データ量が増えてもスケールする

まとめ

今回の問題は、
「大量データ × Concrete CMS × Express × MySQL のロック × UPDATE の仕様」
という複数の要素が絡んだ、現場でしか気づけないタイプの罠でした。

最終的に安定して動いたのは:

  • LIMIT を使わず
  • exEntryID の範囲で分割し
  • RAND() を素直に使う

というシンプルな方式でした。

この知見は、Concrete CMS で大量データを扱う現場にとって非常に有用だと思います。