【実務・中級編】wp_optionsテーブルの肥大化を特定する:autoloadデータと一時データの分離とクリーンアップ – WordPress 内部コア・データベース構造とパフォーマンス最適化解析バイブル

wp_optionsの肥大化を病理学的に解剖する:Autoloadデータと浮遊Transientの完全制圧

WordPressのパフォーマンスチューニングにおいて、大半のエンジニアが陥る罠がある。プラグインの削減、Redis/Memcachedの導入、あるいはフロントエンドのバンドルサイズ削減といった表面的なアプローチだ。

しかし、RDBMS層、特に `wp_options` テーブルの物理構造とCoreの初期化プロセス(Bootstrap)に目を向けなければ、高負荷環境下でのレイテンシスパイクを根本的に解決することはできない。

本稿では、`wp_options` テーブルの肥大化メカニズムをWordPress Coreのコードレベルで解剖し、プロダクション環境のデータベースを安全かつ決定的に正常化するための高度なメンテナンス手法と、保守性の高いプロダクションコードを提示する。

—

1. なぜ `wp_options` の肥大化がシステムを殺すのか:Coreの初期化機構

WordPressはリクエストを受け取ると、初期化フェーズ(`wp-settings.php`)において `wp_not_installed()` や `wp_load_alloptions()` を呼び出す。ここで実行される標準的なクエリがこれだ。

SELECT option_name, option_value FROM wp_options WHERE autoload = ‘yes’; — ※WordPress 6.4以降は ‘on’ も可

この動作の背後にあるアーキテクチャの課題を理解しなければならない。

1.1 `alloptions` キャッシュの爆発とPHPメモリ圧迫

`autoload = ‘yes’` に設定されたすべてのレコードは、単一のクエリで全件取得され、巨大な連想配列として `alloptions` という単一のオブジェクトキャッシュキーに格納される。

もし、不調法なプラグインが数メガバイトに及ぶJSONやシリアライズドオブジェクトを `autoload = ‘yes’` で保存していた場合、以下の破滅的な連鎖が発生する:

1. RDBMSのI/O圧迫: リクエストごとにメガバイト単位のテキストデータがネットワークを駆け巡る。
2. Redis / Memcached のネットワークボトルネック: `alloptions` のサイズが膨らむと、インメモリキャッシュからの `GET` 自体がレイテンシを発生させる。
3. `unserialize()` のCPUコスト: PHPは取得した巨大なシリアライズド文字列を復元するために、リクエストごとにCPUサイクルと巨大なヒープメモリ(`memory_limit`)を無駄に消費する。

1.2 Transientデータの欠陥構造

WordPressの Transient API は、外部オブジェクトキャッシュ(Redis等)が未導入の場合、`wp_options` テーブルへとフォールバックする。この際、1つのTransientにつき以下の2行が生成される。

  • `_transient_{$transient_name}` (値)
  • `_transient_timeout_{$transient_name}` (有効期限タイムスタンプ)

問題は、WordPress Coreには期限切れTransientを背景で自動掃除するデーモンが存在しない点だ。Coreが期限切れTransientを削除するのは、`get_transient()` が明示的に呼ばれた瞬間のみである。

つまり、二度とアクセスされない非同期APIのレスポンスキャッシュなどは、永続的に `wp_options` 内にゴミとして残存し続ける。これが何万行も溜まることで、`autoload` の有無にかかわらず、インデックスのカーディナリティを低下させ、B+Treeインデックスの探索コストを増大させる。

—

2. 物理構造の診断:SQLによる病状の特定

コードを書いて自動化する前に、まずは現状の病状を正確に把握する測定プロトコルを定義する。データサイズを見ずにチューニングを行うのは悪手だ。

2.1 Autoloadデータの総サイズ算定クエリ

まずは、毎リクエストでPHPメモリにロードされている物理データ量(バイト数)を計測する。

SELECT
SUM(LENGTH(option_value)) AS total_autoload_bytes,
COUNT() AS total_autoload_keys,
ROUND(SUM(LENGTH(option_value)) / 1024 / 1024, 2) AS total_autoload_mb
FROM
wp_options
WHERE
autoload IN (‘yes’, ‘on’, ‘1’);

> エンジニアとしての基準値:
> `total_autoload_mb` が 1.0 MB を超えている場合、イエローカードだ。800 KB 未満(理想は 300〜500 KB 以下)に抑える設計を徹底しなければならない。

2.2 メモリを喰い潰している悪質オプションの特定

どのキーがメモリを圧迫しているか、トップ10を洗い出す。

SELECT
option_name,
LENGTH(option_value) AS value_bytes,
ROUND(LENGTH(option_value) / 1024, 2) AS value_kb,
autoload
FROM
wp_options
ORDER BY
LENGTH(option_value) DESC
LIMIT 10;

2.3 孤立・期限切れTransientのカウント

データベースに残留している「ゴミTransient」の件数を割り出す。

SELECT
COUNT() AS expired_transients_count
FROM
wp_options a
INNER JOIN
wp_options b ON a.option_name = CONCAT(‘_transient_timeout_’, SUBSTRING(b.option_name, 12))
WHERE
b.option_name LIKE ‘\_transient\_%’
AND b.option_name NOT LIKE ‘\_transient\_timeout\_%’
AND a.option_value < UNIX_TIMESTAMP(); ---

3. 堅牢なメンテナンス・アーキテクチャの設計方針

無邪気に `DELETE FROM wp_options WHERE …` を本番環境で叩くような真似をしてはならない。数万件のDELETEクエリはテーブルロック(または行ロックの大量確保によるInnoDBのオーバーヘッド)を引き起こし、Webサーバーのリクエストを失効(504 Gateway Timeout)させる。

堅牢なクリーンアップ・スクリプトを構築するためには、以下の要件を満たす必要がある:

1. チャンク処理(Batching): 1000件程度の単位で分割削除し、トランザクションの肥大化を防ぐ。
2. WP-CLIの活用: Webリクエストのタイムアウト制限(`max_execution_time`)を回避するため、CLIコンテキストで実行可能にする。
3. オブジェクトキャッシュとの整合性: データベースを直接更新・削除した後は、必ず `wp_cache_delete(‘alloptions’, ‘options’)` 等を呼び出し、メモリキャッシュとDBの不整合(Stale Cache)を防ぐ。
4. 安全弁(Safeguard): Coreが使用する重要なオプション(`siteurl`, `home`, `active_plugins` 等)を誤って変更・削除しないホワイトリスト保護。

—

4. プロダクションコード実装

以下のPHPクラスは、WP-CLIコマンドとして機能し、定期メンテナンスのCron(例: システムのCrontab経由)に組み込み可能なプロダクションレベルの実装例である。

テーマの `functions.php` ではなく、カスタムプラグインまたはWP-CLI拡張モジュールとして配置すること。

  • Plugin Name: Production Database Optimizer
  • Description: WP-CLIコマンドによるwp_optionsの高速・安全なクリーンアップクラス
  • Author: Core Engineering Lead
  • Version: 1.0.0
  • /

    if (!defined(‘ABSPATH’)) {
    exit;
    }

    if (defined(‘WP_CLI’) && WP_CLI) {

    class Advanced_Options_Optimizer_Command {

    /

    • ホワイトリスト: 絶対にautoload=’no’に変更してはならないCore必須オプション

    /
    private const PROTECTED_AUTOLOAD_KEYS = [
    ‘siteurl’,
    ‘home’,
    ‘blogname’,
    ‘blogdescription’,
    ‘users_can_register’,
    ‘admin_email’,
    ‘start_of_week’,
    ‘use_balance_tags’,
    ‘use_smilies’,
    ‘require_name_email’,
    ‘comments_notify’,
    ‘posts_per_page’,
    ‘what_to_show’,
    ‘default_category’,
    ‘active_plugins’,
    ‘template’,
    ‘stylesheet’,
    ‘rewrite_rules’,
    ];

    /

    • 期限切れTransientのバッチ削除
    • OPTIONS

    • [–batch-size=]
    • : 1回のループで削除する件数
    • —
    • default: 1000
    • —
    • EXAMPLES

    • wp db-optimize clean-transients –batch-size=500
    • @subcommand clean-transients

    /
    public function clean_transients($args, $assoc_args) {
    global $wpdb;

    $batch_size = (int) ($assoc_args[‘batch-size’] ?? 1000);
    $deleted_total = 0;

    WP_CLI::log(‘期限切れTransientのクリーンアップを開始します…’);

    do {
    // 期限切れのtimeoutキーとそれに対応するtransientキーを取得
    $now = time();
    $sql = $wpdb->prepare(
    “SELECT
    timeout_opt.option_name AS timeout_key,
    transient_opt.option_name AS transient_key
    FROM {$wpdb->options} AS timeout_opt
    JOIN {$wpdb->options} AS transient_opt
    ON transient_opt.option_name = REPLACE(timeout_opt.option_name, ‘_timeout_’, ‘_’)
    WHERE timeout_opt.option_name LIKE %s
    AND timeout_opt.option_value < %d LIMIT %d", $wpdb->esc_like(‘_transient_timeout_’) . ‘%’,
    $now,
    $batch_size
    );

    $results = $wpdb->get_results($sql);
    $count = count($results);

    if ($count === 0) {
    break;
    }

    $keys_to_delete = [];
    foreach ($results as $row) {
    $keys_to_delete[] = $row->timeout_key;
    $keys_to_delete[] = $row->transient_key;
    }

    // IN句用のプレースホルダーを動的生成して一括削除(SQLインジェクション対策)
    $format = implode(‘,’, array_fill(0, count($keys_to_delete), ‘%s’));
    $delete_sql = $wpdb->prepare(
    “DELETE FROM {$wpdb->options} WHERE option_name IN ($format)”,
    …$keys_to_delete
    );

    $deleted_rows = $wpdb->query($delete_sql);

    if ($deleted_rows === false) {
    WP_CLI::error(‘クエリ実行中にDBエラーが発生しました: ‘ . $wpdb->last_error);
    return;
    }

    $deleted_total += ($deleted_rows / 2); // timeout と value で2件=1transient
    WP_CLI::log(sprintf(‘バッチ処理完了: %d 件の期限切れTransientを削除…’, $deleted_rows / 2));

    // DB負荷軽減のための短い休止
    usleep(50000); // 50ms

    } while ($count === $batch_size);

    // 全オプションキャッシュを破棄し不整合を防止
    wp_cache_delete(‘alloptions’, ‘options’);

    WP_CLI::success(sprintf(‘処理完了。合計 %d 件の期限切れTransientを完全に除去しました。’, $deleted_total));
    }

    /

    • 巨大なAutoloadオプションを抽出し、危険性の低いものを autoload=’no’ に最適化する
    • OPTIONS

    • [–threshold-kb=]
    • : autoload=’no’ の検討対象とするサイズしきい値(KB)
    • —
    • default: 50
    • —
    • [–dry-run]
    • : DBを変更せず、対象のフラグ付けのみ行う
    • EXAMPLES

    • wp db-optimize optimize-autoload –threshold-kb=100 –dry-run
    • @subcommand optimize-autoload

    /
    public function optimize_autoload($args, $assoc_args) {
    global $wpdb;

    $threshold_kb = (int) ($assoc_args[‘threshold-kb’] ?? 50);
    $threshold_bytes = $threshold_kb 1024;
    $dry_run = isset($assoc_args[‘dry-run’]);

    WP_CLI::log(sprintf(‘Autoloadサイズが %d KB 以上のオプションを分析中…’, $threshold_kb));

    $sql = $wpdb->prepare(
    “SELECT option_name, LENGTH(option_value) AS bytes
    FROM {$wpdb->options}
    WHERE autoload IN (‘yes’, ‘on’, ‘1’)
    AND LENGTH(option_value) >= %d
    ORDER BY bytes DESC”,
    $threshold_bytes
    );

    $offenders = $wpdb->get_results($sql);

    if (empty($offenders)) {
    WP_CLI::success(‘しきい値を超える肥大化したAutoloadオプションは見つかりませんでした。’);
    return;
    }

    $updated_count = 0;

    foreach ($offenders as $opt) {
    $name = $opt->option_name;
    $size_kb = round($opt->bytes / 1024, 2);

    if (in_array($name, self::PROTECTED_AUTOLOAD_KEYS, true)) {
    WP_CLI::warning(sprintf(‘スキップ(Core保護対象): %s (%s KB)’, $name, $size_kb));
    continue;
    }

    // 独自ルール: TransientはそもそもAutoloadされるべきではない
    if (strpos($name, ‘_transient_’) === 0) {
    WP_CLI::log(sprintf(‘TransientのAutoload検出: %s (%s KB)’, $name, $size_kb));
    }

    if ($dry_run) {
    WP_CLI::log(sprintf(‘[Dry Run] autoload=\’no\’ に変更予定: %s (%s KB)’, $name, $size_kb));
    } else {
    $updated = $wpdb->update(
    $wpdb->options,
    [‘autoload’ => ‘no’],
    [‘option_name’ => $name],
    [‘%s’],
    [‘%s’]
    );

    if ($updated !== false) {
    WP_CLI::log(sprintf(‘最適化完了 (autoload=\’no\’): %s (%s KB)’, $name, $size_kb));
    $updated_count++;
    }
    }
    }

    if (!$dry_run && $updated_count > 0) {
    // キャッシュのパージ
    wp_cache_delete(‘alloptions’, ‘options’);
    WP_CLI::success(sprintf(‘%d 個のオプションを autoload=\’no\’ にフラグ更新しました。’, $updated_count));
    } elseif ($dry_run) {
    WP_CLI::success(‘Dry run が完了しました。DBへの書き込みは行われていません。’);
    }
    }
    }

    WP_CLI::add_command(‘db-optimize’, ‘Advanced_Options_Optimizer_Command’);
    }

    —

    5. コードレビューと設計の深掘り

    このスクリプトがなぜプロダクション耐性を持つのか、技術的根拠を提示する。

    ① `delete_option()` をループで回さない理由

    初心者エンジニアは `delete_option()` 関数をループで回しがちだ。しかし、`delete_option()` は1回の呼び出しごとに `BEFORE` / `AFTER` フック(`delete_option_{$option}` や `deleted_option`)を発火させ、さらに個別に `alloptions` キャッシュの更新を行う。

    何万件ものレコードに対してこれを行うと、フックのオーバーヘッドとキャッシュ破棄の連鎖により処理時間が倍増し、RDBMSとPHPメモリの両方が破綻する。

    したがって、上記コードのように CLI コンテキストで安全にバッチ処理用の可変SQL(`IN (…)`)を組み、最後に一括してオブジェクトキャッシュを無効化(`wp_cache_delete`)する設計が解となる。

    ② あえて `usleep(50000)` を挟む理由

    高速化を追求するあまり、ループを最高速度でぶん回すと、レプリケーション遅延(Replication Lag)を引き起こし、リードレプリカが存在する構成においてDB参照の不整合が発生する。50ミリ秒のウェイト(`usleep`)を入れることで、マスターDBのCPU使用率スパイクを防ぎ、I/Oを平滑化させるのが大人のシステム設計だ。

    ③ Autoloadを ‘no’ に変更する際の注意点

    何でもかんでも `autoload = ‘no’` にすれば良いわけではない。

    もし毎リクエストで確実に呼ばれるプラグインの設定値を `autoload = ‘no’` に変更してしまうと、今度は `alloptions` による1回のクエリで済んでいたものが、ページロード中に `SELECT FROM wp_options WHERE option_name = ‘…’` という個別のSQLが何十回も追加発行される(N+1問題の類似現象)。

    • `autoload = ‘yes’` にすべきデータ: サイズが小さく(数KB以下)、ほぼすべてのページ描画で参照される設定値。
    • `autoload = ‘no’` にすべきデータ: サイズが巨大なデータ、あるいは特定のアドミンスクリーンや非同期APIエンドポイントでしか参照されない設定値。

    —

    6. テクニカルリードとしての開発現場への指針

    チーム内のエンジニアが新規機能やプラグインを実装する際、コードレビューで以下の設計指針を徹底させること。

    1. Transient使用時の原則:

    // BAD: デフォルトで wp_options に永続化され、設定によっては autoload 候補になる
    set_transient(‘my_large_remote_api_cache’, $data, HOUR_IN_SECONDS);

    // GOOD: 外部オブジェクトキャッシュ(Redis等)が存在する前提の設計を行うか、
    // データサイズが巨大(>100KB)なら独自テーブルかカスタムポストタイプ、
    // あるいは wp-content/uploads へのファイルキャッシュを検討させる。

    2. `add_option` の第3引数を明示する:
    `add_option($option, $value, ”, $deprecated)` の第3引数(`$autoload`)を省略すると、WordPressは歴史的経緯から `’yes’`(または `’on’`)をデフォルト値として扱う(※バージョンによって動的挙動あり)。

    大規模データをオプショナルのテーブルに保存するコードを書いているエンジニアには、必ず以下のように書かせよ。

    // オプション追加時は明示的に ‘no’ を指定させる
    add_option(‘my_plugin_heavy_json_data’, $json_string, ”, ‘no’);

    結語

    `wp_options` テーブルの健全性は、WordPressサイトのレスポンスタイムの「床」を決定づける。

    表層的なキャッシュプラグインに頼るのではなく、データベースの物理構造とCoreのロードメカニズムを正しく把握し、本稿で示したようなバッチクリーンアップ構造をCI/CDや定期メンテナンスジョブ(Cron)に組み込むこと。それこそが、堅牢なWebアプリケーションを運用するエンジニアの責務である。

    タイトルとURLをコピーしました