TL;DR: CakePHP 5.4.3 で、入れ子の深い WHERE 条件を SQL に組み立てる処理が指数関数的に遅くなる問題が直りました。MySQL/MariaDB は 5.4.0〜5.4.2 での退行(quoteIdentifiers を有効にしているなら 5.3 以前から)、PostgreSQL・SQLite は 5.3 以前から遅かったので、どの DB でも条件を入れ子で積み上げているアプリなら 5.4.3 に上げるだけで速くなるかもしれません。

Avoid repeated query expression visits #19637


何が直ったのか

2026年10月3日にリリースされた CakePHP 5.4.3 のリリースノートに、こんな一文があります。

Fixed a performance regression related to duplicate query expression visitor traversals.

さらっと書かれていますが、元になった Issue #19636 を読むと結構な話です。報告者の本番環境では、AND でつないだ 11 個の検索条件(それぞれが 2〜4 個の比較の OR)が 19 段の入れ子になっていて、

  • 5.3.6 では 1 回のクエリ組み立てが 2ms
  • 5.4 では同じクエリの組み立てが 9秒
  • 件数取得・本体取得・belongsToMany の subquery 戦略での eager load で 4 回組み立てるので、ページ表示に 35秒

だったそうです。DB に投げる前、PHP 側で SQL 文字列を作るだけでこれ。スゲー遅い。

なぜ遅くなっていたのか

同じ式を何度も辿っていた

CakePHP のクエリビルダーは、WHERE 条件などを QueryExpression のツリーとして持っています。SQL を組み立てる直前に、ドライバーが Query::traverseExpressions() でツリー全体を走査して、DB ごとの方言への書き換え(expression translator)を適用します。

問題はこの走査でした。QueryExpression::traverse() は自分で子孫まで再帰するのに、Query::_expressionsVisitor() 側のコールバックも子の式に入ってさらに再帰していました。

// 5.4.2 の Query::_expressionsVisitor()(抜粋)
if ($expression instanceof ExpressionInterface) {
    // traverse() 自体が子孫まで再帰する上に、コールバックでも再帰している
    $expression->traverse(fn($exp) => $this->_expressionsVisitor($exp, $callback));
    // (後略)
}

その結果、入れ子 1 段ごとに訪問回数が倍になります。式の数は 100 個に満たないのに、訪問回数は数百万回、という状態です。

5.4.3 では WeakMap で訪問済みの式を覚えておき、1 回の走査で同じ式を 2 回訪れないようになりました。

MySQL だけ 5.4 から遅くなった理由

この重複走査のコード自体は 5.3 にもあります。ではなぜ「5.4 からの退行」なのかというと、走査が走るかどうかがドライバーの設定と実装で決まるからです。

Driver::transformQuery() から traverseExpressions() に入る経路は 2 つあります。

// Driver::transformQuery()(抜粋)
if ($this->isAutoQuotingEnabled()) {
    // 経路 1: IdentifierQuoter::quote() の中で traverseExpressions() を呼ぶ
    $query = $this->quoter()->quote($query);
}

// (中略)

// 経路 2: expression translator が 1 つも無ければ走査しない
$translators = $this->_expressionTranslators();
if (!$translators) {
    return $query;
}

$query->traverseExpressions(function ($expression) use ($translators, $query): void {
    // (後略)

経路 1 の自動クォートは、Datasource の設定で 'quoteIdentifiers' => true にしたときだけ有効です。cakephp/app の config/app.php の既定値は false なので、多くのアプリでは経路 2 だけが問題になります。

経路 2 で使うドライバーごとの translator を 5.3.7 と 5.4.2 で比べるとこうなっていました。

ドライバー 5.3.7 5.4.2
Mysql なし StringAggExpression, DistinctComparisonExpression
Postgres IdentifierExpression など 3 つ 4 つ(StringAggExpression を追加)
Sqlite FunctionExpression, TupleComparison 3 つ(StringAggExpression を追加)
Sqlserver FunctionExpression, TupleComparison 3 つ(StringAggExpression を追加)

MySQL ドライバーは 5.4 で初めて translator を持ったので、そこから走査が走るようになり、遅さが表に出てきた、というわけです。

逆に言うと、PostgreSQL・SQLite・SQL Server は 5.3 の時点で既に遅かったことになります。また経路 1 は DB の種類を問わないので、quoteIdentifiers を有効にしていれば MySQL も 5.3 の時点で遅かったことになります。

手元で測ってみた

DB に接続しなくても SQL の組み立てだけなら試せるので、cakephp/database だけを入れた使い捨てプロジェクトを 5.3.7・5.4.2・5.4.3 で用意して測ってみました。

composer require cakephp/database:5.4.2

検索条件を 1 つ追加するたびに、それまでの条件ごと and() で包み直す書き方をしています。

<?php
require 'vendor/autoload.php';

use Cake\Database\Connection;
use Cake\Database\Driver\Mysql;
use Cake\Database\Driver\Postgres;
use Cake\Database\Driver\Sqlite;

// SQL を組み立てるだけなので DB への接続は不要
foreach ([Mysql::class, Postgres::class, Sqlite::class] as $driverClass) {
    $connection = new Connection(['driver' => new $driverClass()]);

    foreach ([11, 15, 19] as $depth) {
        $query = $connection->selectQuery('id', 'articles');

        // 検索条件を 1 つずつ AND で包み直して積み上げる
        $conditions = $query->expr(['published' => true]);
        for ($i = 0; $i < $depth; $i++) {
            $conditions = $query->expr()->and([
                $conditions,
                $query->expr()->or(["title$i" => 'foo', "body$i" => 'bar']),
            ]);
        }
        $query->where($conditions);

        $visits = 0;
        $query->traverseExpressions(function () use (&$visits): void {
            $visits++;
        });

        $start = hrtime(true);
        $query->sql();
        $ms = (hrtime(true) - $start) / 1e6;

        printf("%-8s depth %2d: %8d visits, sql() %9.1f ms\n", substr(strrchr($driverClass, '\\'), 1), $depth, $visits, $ms);
    }
}

PHP 8.5(Xdebug 無効)で実行した結果の、入れ子 19 段のときの sql() の所要時間です。

ドライバー 5.3.7 5.4.2 5.4.3
Mysql 0.1 ms 2,613 ms 0.2 ms
Postgres 2,830 ms 3,047 ms 0.2 ms
Sqlite 2,638 ms 2,932 ms 0.2 ms

訪問回数は 5.4.2 が 7,340,022 回、5.4.3 が 79 回。入れ子 11 段・15 段でもそれぞれ 28,662 回 → 47 回、458,742 回 → 63 回と、5.4.3 では式の数に比例するだけになっています。

quoteIdentifiers を有効にした場合

ドライバーの生成を new $driverClass(['quoteIdentifiers' => true]) に変えて、同じく入れ子 19 段で測った結果です。

ドライバー 5.3.7 5.4.2 5.4.3
Mysql 4,557 ms 6,628 ms 0.3 ms
Postgres 6,838 ms 7,265 ms 0.4 ms
Sqlite 6,518 ms 6,804 ms 0.4 ms

MySQL も 5.3.7 の時点で 4.5 秒かかっていて、5.4.3 では 0.3ms になりました。5.4.2 では経路 1 と経路 2 の両方で走査するので、さらに遅くなっています。

quoteIdentifiers が既定の false なら、5.3.7 の MySQL ドライバーはそもそも走査しないので、5.4.3 に上げても 5.3 より速くなるわけではありません。

※ SQL Server は手元に pdo_sqlsrv が無く測れていません。translator を持っているので、コード上は PostgreSQL・SQLite と同じ経路を通ります。

自分のアプリは速くなる?

正直なところ、多くのアプリでは体感できるほどの差は出ないと思います。

where() を普通にチェーンしているだけなら、条件は同じ QueryExpression にフラットに追加されていくので、入れ子はほとんど深くなりません。

// これは入れ子にならない(AND の QueryExpression に追加されていくだけ)
$query = $this->Articles->find()
    ->where(['published' => true])
    ->where(fn(QueryExpression $exp) => $exp->or(['title LIKE' => $q, 'body LIKE' => $q]))
    ->where(['category_id' => $categoryId]);

同じ測り方で where() を 11 回チェーンした場合、5.4.2 でも sql() は 0.6ms 程度でした。

効いてくるのは、検索フォームの条件を動的に組み立てるときなどに、前の条件ごと and() / or() で包み直して積み上げているケースです。上の計測だと 11 段で約 10ms、そこから 4 段増えるごとに約 16 倍になっていきます。

自分のクエリが当てはまるかは、アップデート前のバージョンで traverseExpressions() の訪問回数を数えてみるのが手っ取り早いです。

// 5.4.2 以前で実行する。式の数に比べて桁違いに多ければ 5.4.3 で速くなる
$visits = 0;
$query->traverseExpressions(function () use (&$visits): void {
    $visits++;
});
debug($visits);

ORM の Table::find() が返す Cake\ORM\Query\SelectQuery も Cake\Database\Query\SelectQuery を継承しているので、そのまま同じ方法で調べられます。

ちなみに筆者の開発しているアプリでは、CIでのテスト実行が全体で5%程度速くなりました。わーい

プラグイン作者向けの注意

この修正で、Query::_expressionsVisitor() は traverseExpressions() の外から呼ぶと CakeException を投げるようになりました。Query を継承して _expressionsVisitor() を直接呼んでいる場合は、PR のテストにあるように、呼ぶ前に $this->visitedExpressions = new WeakMap(); をセットする必要があります。普通にアプリを書いている分には関係ありません。

まとめ

  • CakePHP 5.4.3 で、入れ子の深い条件を持つクエリの組み立てが指数関数的に遅くなる問題が直った
  • MySQL/MariaDB は 5.4.0〜5.4.2 での退行。PostgreSQL・SQLite(・SQL Server)は 5.3 以前から同じ問題を抱えていた
  • quoteIdentifiers を有効にしているなら、MySQL/MariaDB も 5.3 以前から同じ問題を抱えていた
  • where() をフラットにチェーンしているだけなら差はほぼ無い。条件を包み直して積み上げているなら劇的に速くなる

以上のような感じで、検索画面が妙に遅いな…と思っていた PostgreSQL ユーザーの方や、quoteIdentifiers を有効にしている方も、5.4.3 に上げると速くなるかもしれません。5.3 系の最新版(5.3.7)にはこの修正は入っていないので、5.4 系へのアップデートを検討してみてください。

参考リンク