データベース肥大化:WordPressにおけるTransientデータの管理
WordPressサイトを2年間問題なく運用してきたとします。ところがある日、数週間のうちに管理画面のページ読み込みに4秒かかるようになります。WooCommerceのチェックアウトが重くなります。REST APIまで以前より遅く感じます。サーバーを確認しても、CPU、メモリ、ディスクI/Oはすべて正常。
そこでデータベースを調べてみると――
wp_optionsテーブルのレコード数が900,000行、サイズは120MB。そしてその約60%が、WordPressがクリーンアップしなかった期限切れのtransientです。
これは特殊なケースではありません。本番環境のWordPressインストールにおける最も一般的で、かつ最も見落とされるパフォーマンス問題の一つです。wp_optionsテーブルは重要なボトルネックです。WordPressはすべてのリクエストでこのテーブルのデータを参照し(オブジェクトキャッシュがない場合はデータベースから直接読み取られます)、古いデータで溢れると、アプリケーション全体が遅くなります。
この記事では、transientとは何か、なぜ蓄積するのか、レート制限やトークン検証といったセキュリティ機能とどう関係するのか、そしてパフォーマンスに実害が出る前に問題を診断して修正する方法を説明します。
問題:成長が止まらないテーブル
wp_optionsの実際の仕組み
wp_optionsテーブルはWordPressのキーバリューストアです。サイトURL、有効なプラグイン、テーマ設定、ウィジェット設定、cronスケジュールなど、サイトの設定情報を保持します。また、transient――有効期限付きの一時的なキャッシュデータ――も保存します。
パフォーマンスにとって重要なのは次の点です。WordPressはすべてのページリクエストで、autoloadに指定されたデータを初期ロード時にメモリに読み込みます。具体的には、ブートストラップ処理の初期段階で次のクエリを実行します:
SELECT option_name, option_value FROM wp_options WHERE autoload = 'yes';
autoload = 'yes'が設定されたすべての行が、ページのレンダリングが始まる前にPHPメモリに読み込まれます。クリーンなWordPressインストールでは、数百行、合計500KB程度です。しかし、1〜2年プラグインをメンテナンスなしで使用してきたサイトでは、このクエリが数万行を返し、リクエストあたり数メガバイトのメモリを消費する場合があります。
wp_optionsテーブルは設定ストアとして設計されました。高スループットのデータテーブルとして使うことは想定されていません。しかし、セキュリティ、キャッシュ、フォーム処理に関する多くのプラグインが、まさにそのような使い方をしてしまっています。
Transientとは何か(そしてなぜ存在するのか)
transientは、有効期限が定義されたデータベースに保存される一時的な値です。WordPressはシンプルなAPIを提供しています:
// 1時間で期限切れになる値を保存する
set_transient( 'my_cache_key', $data, HOUR_IN_SECONDS );
// 取得する
$data = get_transient( 'my_cache_key' );
// 手動で削除する
delete_transient( 'my_cache_key' );
内部的には、set_transient()はwp_optionsテーブルに2つの行を作成します:
| option_name | option_value | autoload |
|---|---|---|
_transient_my_cache_key |
(シリアライズされたデータ) | no |
_transient_timeout_my_cache_key |
1708099200 |
no |
最初の行はデータを保持します。2番目は有効期限のUnixタイムスタンプを保持します。get_transient()を呼び出すと、WordPressはタイムアウト行をチェックします。現在の時刻がタイムスタンプを過ぎていれば、両方の行を削除してfalseを返します。
ここが重要です:WordPressは、期限切れのtransientを誰かがリクエストしたときにのみ、その場でクリーンアップします。 誰もget_transient( 'my_cache_key' )を再度呼び出さなければ、この遅延削除は発動しません。
期限切れのTransientが蓄積する理由
WordPressの自動クリーンアップは存在するが、信頼できるとは限らない。
WordPress Coreは、期限切れtransientを削除するための仕組みを持っています。delete_expired_transients()関数が存在し、wp_cronイベント(delete_expired_transients)として1日1回実行されるようスケジュールされています。
しかし、この仕組みには実運用上の重大な制約があります:
wp_cronはアクセス依存である。 WordPressの疑似cronはページリクエストをトリガーとして動作します。アクセスの少ないサイトでは、cronイベントが予定時刻に実行されない場合があります。DISABLE_WP_CRONを設定してシステムcronに切り替えている場合でも、cronの設定が不完全であればイベントは実行されません。- 有効期限なしのtransientは削除対象外である。
set_transient()の第3引数(有効期限)を0または省略した場合、タイムアウト行が作成されず、delete_expired_transients()の削除対象になりません。これらは永久に残り続けます。 - プラグインの実装によっては、クリーンアップが追いつかない。 1日1回の削除は、静的なキーで数十個のtransientを管理するケースでは十分です。しかし、IPハッシュやセッションIDを含む動的キーで1日に数百〜数千のtransientを生成するプラグインに対しては、生成速度がクリーンアップ速度を上回ります。
- クリーンアップの対象は「期限切れ」のtransientのみである。 タイムアウト行が存在しないtransient(有効期限なし)、データ行だけが孤児として残ったtransient、プラグイン独自の形式で保存されたデータは対象外です。
結果として、WordPressのビルトインクリーンアップは理論上は存在するが、実務では不完全です。特に、動的キーを大量に生成するプラグインが導入されている環境では、蓄積を防ぐには不十分です。
transientキーが再利用される場合、設計は機能します。キャッシュプラグインが_transient_popular_postsを保存すれば、次の生成サイクルで上書きします。キーが安定しているため、行数は一定に保たれます。
しかし、多くのプラグインは動的なtransientキー――タイムスタンプ、ユーザーID、IPハッシュ、セッショントークンを含むキー――を生成します。これらは一度使われるだけで、二度とリクエストされません。遅延削除は発動せず、1日1回のcronクリーンアップでは生成速度に追いつきません。それぞれが2つの孤児行を残します。
transient肥大化の一般的な原因:
- IPごとのカウンターを保存するレート制限プラグイン:
_transient_rate_limit_192_168_1_47 - ユーザーごとのログイン失敗回数やロックアウト状態をキャッシュするセキュリティプラグイン
- 個々のフォームレンダリングに紐づくワンタイムCSRFトークンやhoneypot検証トークンを生成するフォームスパム対策
- クエリ文字列に依存するキーを持つREST APIキャッシュ
- 投稿ごと・ネットワークごとにシェア数をキャッシュするソーシャルシェアプラグイン
- WordPress.org APIからのレスポンスをキャッシュするプラグイン更新チェック
ブロックしたIPアドレスごとにtransientを作成するセキュリティプラグインは、大量のボットトラフィックを受けるサイトで数千のtransientペアを生成します。transientの有効期限が切れた後、遅延削除は発動せず、1日1回のcronでは削除が追いつかず、行はただ蓄積していくだけです。
技術詳解:被害の診断
wp_optionsテーブルの測定
基本から始めましょう。MySQLデータベースに接続して、テーブルサイズを確認します:
SELECT
table_name AS 'Table',
ROUND(data_length / 1024 / 1024, 2) AS 'Data (MB)',
ROUND(index_length / 1024 / 1024, 2) AS 'Index (MB)',
table_rows AS 'Rows'
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'wp_options';
多くの一般的なサイトでは、5MB未満・5,000行未満に収まるケースが多いですが、これを大きく超えている場合は肥大化の兆候と考えるべきです。
期限切れTransientのカウント
このクエリは、データベースに現在残っている期限切れtransientの数を返します:
SELECT COUNT(*) AS expired_transients
FROM wp_options
WHERE option_name LIKE '\_transient\_timeout\_%' ESCAPE '\'
AND option_value REGEXP '^[0-9]+$'
AND CAST(option_value AS UNSIGNED) < UNIX_TIMESTAMP();
この数が数千であれば、原因が判明したことになります。
すべてのtransient(期限切れおよびアクティブ)が占める合計スペースを確認するには:
SELECT
COUNT(*) AS total_transient_rows,
ROUND(SUM(LENGTH(option_value)) / 1024 / 1024, 2) AS total_size_mb
FROM wp_options
WHERE option_name LIKE '\_transient\_%' ESCAPE '\'
OR option_name LIKE '\_site\_transient\_%' ESCAPE '\';
最大の原因を特定する
すべてのtransientが同じではありません。数千の小さなtransientを作成するプラグインもあれば、数メガバイトに及ぶシリアライズされた配列を含む巨大なtransientを少数作成するプラグインもあります。このクエリで最大のtransient値を見つけます:
SELECT
option_name,
LENGTH(option_value) AS value_bytes,
ROUND(LENGTH(option_value) / 1024, 2) AS value_kb
FROM wp_options
WHERE option_name LIKE '\_transient\_%' ESCAPE '\'
ORDER BY LENGTH(option_value) DESC
LIMIT 20;
そして、このクエリでtransientをプレフィックスパターンでグループ化し、最も多くの行を作成しているプラグインを特定します:
SELECT
SUBSTRING_INDEX(
REPLACE(
REPLACE(option_name, '_transient_timeout_', ''),
'_transient_', ''
), '_', 2
) AS transient_prefix,
COUNT(*) AS row_count,
ROUND(SUM(LENGTH(option_value)) / 1024, 2) AS total_kb
FROM wp_options
WHERE option_name LIKE '\_transient\_%' ESCAPE '\'
GROUP BY transient_prefix
ORDER BY row_count DESC
LIMIT 20;
これで肥大化の原因が正確にわかります。特定のプラグインに紐づくプレフィックスが見つかれば、そのプラグインを調査するか、置き換えるかの判断ができます。
Autoloadの問題
WordPressはデフォルトでtransientをautoload = 'no'に設定しますが、すべてのプラグインがルールに従っているわけではありません。一部のプラグインはtransientに明示的にautoload = 'yes'を設定するか、set_transient()の代わりにupdate_option()を使用してautoloadをオンのままにしています。
どれだけのデータがautoloadされているか確認しましょう:
SELECT
COUNT(*) AS autoloaded_rows,
ROUND(SUM(LENGTH(option_value)) / 1024 / 1024, 2) AS autoloaded_mb
FROM wp_options
WHERE autoload = 'yes';
これが1MBを超えている場合、サイトはすべてのリクエストで1メガバイト以上のoptionsデータをメモリに読み込んでいます――ページコンテンツが生成される前に。これは大きなパフォーマンス税です。
最大のautoload値を見つけるには:
SELECT
option_name,
LENGTH(option_value) AS bytes,
ROUND(LENGTH(option_value) / 1024, 2) AS kb
FROM wp_options
WHERE autoload = 'yes'
ORDER BY LENGTH(option_value) DESC
LIMIT 25;
セキュリティの側面:スパム対策とレート制限におけるTransient
セキュリティプラグインがTransientを使用する理由
Transientは、短命のセキュリティ状態を保存するための便利なメカニズムです。ユースケースには以下が含まれます:
レート制限。 IPごとのフォーム送信を1時間あたり5回に制限するプラグインは、カウンターをどこかに保存する必要があります。Transientが最も簡単な選択肢です:
function check_rate_limit( $ip ) {
$key = 'rate_limit_' . md5( $ip );
$count = (int) get_transient( $key );
if ( $count >= 5 ) {
return false; // レート制限
}
set_transient( $key, $count + 1, HOUR_IN_SECONDS );
return true;
}
これはフォームを送信するすべてのIPに対して固有のtransientペアを作成します。1日あたり500のユニークなボットIPを受けるサイトでは、1日あたり1,000の新しい行が追加されます。ボットが再訪しなければ、遅延削除は発動しません。1日1回のcronクリーンアップは有効期限切れのものを削除しますが、生成速度が速い場合は蓄積が進みます。
CSRF/nonceライクなトークン。 一部のフォーム保護プラグインは、フォームのレンダリングごとにサーバーサイドのユニークトークンを生成し、transientとして保存し、送信時に検証します。送信されなかったフォームの表示ごとに、孤児transientが残ります。
ロックアウト状態。 繰り返しの失敗アクションの後にIPをロックアウトするセキュリティプラグインは、15分または1時間の有効期限でロックアウトレコードをtransientとして保存します。ブルートフォース攻撃時には、数時間で数千のtransientペアが生成される可能性があります。
トレードオフ
セキュリティ状態にtransientを使用すること自体は間違いではありません。シンプルで、あらゆるホスティング環境で動作し、RedisやMemcachedのような外部依存を必要としません。
しかし、義務が伴います:自分で後片付けをしなければなりません。 動的で大量のtransientを作成しながら、独自のガベージコレクションを実装しないプラグインは、データベースに時限爆弾を作り出しています。WordPress Coreの1日1回のクリーンアップに頼るだけでは、高頻度の動的キー生成に対して不十分です。
よく設計されたプラグインが採用するより良いアプローチは、以下のいずれかです:
- 高頻度データには
wp_optionsの代わりに専用のカスタムテーブルを使用する。 適切なインデックスとスケジュールされたクリーンアップcronジョブを持つ専用テーブルは、optionsテーブルの汚染を完全に回避します。 - サーバーサイドのストレージを一切必要としないステートレス検証を使用する。 例えば、HMACベースのトークンはデータベースルックアップなしで暗号的に検証できます。
- 期限切れのエントリを事前に削除する独自の
wp_cronクリーンアップルーチンを登録する。 WordPress Coreの汎用クリーンアップに頼る代わりに、自プラグインが生成したtransientを確実に削除する専用処理です。
解決策:クリーンアップと将来の肥大化防止
ステップ1:期限切れのTransientを削除する
これが即効性のある修正です。
DELETEクエリを実行する前に、必ず同条件のSELECTで対象データを確認してください。
まず、削除対象を確認します:
-- 削除対象の確認(通常transient)
SELECT a.option_name AS data_row, b.option_name AS timeout_row, b.option_value AS expires_at
FROM wp_options a
INNER JOIN wp_options b
ON b.option_name = CONCAT('_transient_timeout_',
SUBSTRING(a.option_name, CHAR_LENGTH('_transient_') + 1))
WHERE a.option_name LIKE '\_transient\_%' ESCAPE '\'
AND a.option_name NOT LIKE '\_transient\_timeout\_%' ESCAPE '\'
AND b.option_value REGEXP '^[0-9]+$'
AND CAST(b.option_value AS UNSIGNED) < UNIX_TIMESTAMP()
LIMIT 100;
-- 削除対象の確認(サイトtransient・マルチサイト)
SELECT a.option_name AS data_row, b.option_name AS timeout_row, b.option_value AS expires_at
FROM wp_options a
INNER JOIN wp_options b
ON b.option_name = CONCAT('_site_transient_timeout_',
SUBSTRING(a.option_name, CHAR_LENGTH('_site_transient_') + 1))
WHERE a.option_name LIKE '\_site\_transient\_%' ESCAPE '\'
AND a.option_name NOT LIKE '\_site\_transient\_timeout\_%' ESCAPE '\'
AND b.option_value REGEXP '^[0-9]+$'
AND CAST(b.option_value AS UNSIGNED) < UNIX_TIMESTAMP()
LIMIT 100;
確認して問題なければ、以下のDELETEクエリを実行します:
-- 期限切れのtransientデータ行を削除する
DELETE a, b FROM wp_options a
INNER JOIN wp_options b
ON b.option_name = CONCAT('_transient_timeout_',
SUBSTRING(a.option_name, CHAR_LENGTH('_transient_') + 1))
WHERE a.option_name LIKE '\_transient\_%' ESCAPE '\'
AND a.option_name NOT LIKE '\_transient\_timeout\_%' ESCAPE '\'
AND b.option_value REGEXP '^[0-9]+$'
AND CAST(b.option_value AS UNSIGNED) < UNIX_TIMESTAMP();
-- サイトtransient(マルチサイト)も同様に
DELETE a, b FROM wp_options a
INNER JOIN wp_options b
ON b.option_name = CONCAT('_site_transient_timeout_',
SUBSTRING(a.option_name, CHAR_LENGTH('_site_transient_') + 1))
WHERE a.option_name LIKE '\_site\_transient\_%' ESCAPE '\'
AND a.option_name NOT LIKE '\_site\_transient\_timeout\_%' ESCAPE '\'
AND b.option_value REGEXP '^[0-9]+$'
AND CAST(b.option_value AS UNSIGNED) < UNIX_TIMESTAMP();
先にデータベースをバックアップしてください。 これらのクエリは原則的に安全ですが、本番データベースに対する直接的なSQL操作は、事前にmysqldumpで行うべきです。
ステップ2:孤児となったTransientタイムアウト行をクリーンアップする
データ行が削除されてタイムアウト行が残る場合、またはその逆の場合があります。まず確認クエリを実行します:
-- 確認:対応するデータ行のない孤児タイムアウト行
SELECT t.option_name, t.option_value
FROM wp_options t
LEFT JOIN wp_options d
ON REPLACE(t.option_name, '_transient_timeout_', '_transient_') = d.option_name
WHERE t.option_name LIKE '\_transient\_timeout\_%' ESCAPE '\'
AND d.option_name IS NULL
LIMIT 100;
確認できたら削除を実行します:
-- 対応するデータ行のないタイムアウト行を削除
DELETE t
FROM wp_options t
LEFT JOIN wp_options d
ON REPLACE(t.option_name, '_transient_timeout_', '_transient_') = d.option_name
WHERE t.option_name LIKE '\_transient\_timeout\_%' ESCAPE '\'
AND d.option_name IS NULL;
ステップ3:テーブルを最適化する
行を削除した後、MySQLは自動的にディスクスペースを回収しません。テーブルファイルは膨張したサイズのままです。回収しましょう:
OPTIMIZE TABLE wp_options;
注意: InnoDB(最新のMySQL/MariaDBでのデフォルトエンジン)では、OPTIMIZE TABLEは内部的にテーブルの再構築(ALTER TABLE ... FORCE相当)を実行します。MySQL 5.6以降のオンラインDDLにより、再構築中もテーブルへの読み書きは可能ですが、操作の準備フェーズと完了フェーズでメタデータロック(MDL)が短時間発生します。低トラフィックのサイトでは影響は軽微ですが、高トラフィック環境では長時間実行されるクエリやトランザクションとMDLが競合し、一時的なクエリ待ちが発生する可能性があります。オフピーク時間帯の実行を推奨します。大規模な本番環境でダウンタイムリスクを最小化する必要がある場合は、Percona Toolkitのpt-online-schema-changeのような高度なツールの使用も選択肢になります。
ステップ4:継続的なクリーンアップを自動化する
WordPress Coreのdelete_expired_transients cronイベントは存在しますが、前述の通り、動的キーを大量生成するプラグインがある環境では不十分です。より積極的なクリーンアップをスケジュールしましょう。以下のコードは通常transientとサイトtransientの両方を処理します:
// テーマのfunctions.phpまたはカスタムmu-pluginに記述
if ( ! wp_next_scheduled( 'cleanup_expired_transients_custom' ) ) {
wp_schedule_event( time(), 'daily', 'cleanup_expired_transients_custom' );
}
add_action( 'cleanup_expired_transients_custom', function() {
global $wpdb;
// 通常transient
$expired = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options}
WHERE option_name LIKE %s
AND option_value REGEXP '^[0-9]+$'
AND CAST(option_value AS UNSIGNED) < %d",
$wpdb->esc_like( '_transient_timeout_' ) . '%',
time()
)
);
foreach ( $expired as $transient_timeout ) {
$transient_key = str_replace( '_transient_timeout_', '', $transient_timeout );
delete_transient( $transient_key );
}
// サイトtransient(マルチサイト環境)
$expired_site = $wpdb->get_col(
$wpdb->prepare(
"SELECT option_name FROM {$wpdb->options}
WHERE option_name LIKE %s
AND option_value REGEXP '^[0-9]+$'
AND CAST(option_value AS UNSIGNED) < %d",
$wpdb->esc_like( '_site_transient_timeout_' ) . '%',
time()
)
);
foreach ( $expired_site as $transient_timeout ) {
$transient_key = str_replace( '_site_transient_timeout_', '', $transient_timeout );
delete_site_transient( $transient_key );
}
});
これはWordPress自身のdelete_transient()およびdelete_site_transient()関数を使用しており、データ行とタイムアウト行の両方をクリーンに処理します。
ステップ5:オブジェクトキャッシュを使用する(ホスティングがサポートしている場合)
サーバーでRedisまたはMemcachedが利用可能な場合、オブジェクトキャッシュドロップイン(object-cache.php)をインストールすると、transientのストレージレイヤーが完全に変わります。永続的なオブジェクトキャッシュが有効な状態では、set_transient()はデータベースではなくメモリにデータを保存します。期限切れのエントリはキャッシュデーモンによって自動的に削除されます。wp_optionsテーブルには一切書き込まれません。
これは高トラフィックサイトのtransient肥大化に対する最も効果的な修正です。WP Engine、Kinsta、CloudwaysなどのマネージドWordPressホストはRedisを標準提供しています。自前のサーバーを管理している場合、セットアップは簡単です:
# Ubuntu/Debian
apt install redis-server php-redis
# その後、以下のようなドロップインをインストール
# https://github.com/rhubarbgroup/redis-cache
wp plugin install redis-cache --activate
wp redis enable
Redisがtransientを処理すれば、wp_optionsテーブルは本来の姿に戻ります:小さく安定した設定ストアです。
データベースを大切にするプラグインを選ぶ
肥大化の問題は、突き詰めればプラグインの品質の問題です。設計の悪いプラグインはwp_optionsをゴミ捨て場のように扱います。設計の良いプラグインは、自分で後片付けをするか、高頻度データには専用テーブルを使用するか、サーバーサイドのストレージを完全に避けます。
セキュリティおよびスパム対策プラグインを評価する際には、以下の質問を確認してください:
- フォーム送信、ページビュー、ブロックされたリクエストごとにtransientを作成するか?
- 期限切れデータのcronジョブまたはクリーンアップルーチンを登録しているか?
- レート制限とログ記録にカスタムデータベーステーブルを使用しているか?
- サーバーサイドデータの保存の代わりに、ステートレスに(HMACトークンや暗号的な検証を使用して)動作できるか?
これがSamurai Honeypot for Forms の設計原則の一つです。そのトークン検証はHMACベースのステートレス検証を使用しています。サーバーはデータベースに書き込むことなく、暗号的にトークンを生成・検証します。Transientなし。カスタムテーブルなし。蓄積される行なし。フォームがどれだけのボットトラフィックを受けても、wp_optionsテーブルはクリーンなままです。
すでに他のプラグインによる肥大化に悩んでいるサイト運営者にとって、このアーキテクチャの決定は重要です。すでに過負荷のwp_optionsテーブルに行を追加するスパム保護レイヤーは、パフォーマンス問題を改善するのではなく、悪化させています。
監視:被害が出る前に肥大化を検知する
サイトが遅くなるまで待たないでください。wp_optionsテーブルが閾値を超えたときにアラートを出すシンプルな監視チェックを設定しましょう:
// mu-plugin: monitor-options-table.php
add_action( 'admin_init', function() {
if ( ! current_user_can( 'manage_options' ) ) return;
global $wpdb;
$row_count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like( '_transient_' ) . '%'
)
);
if ( (int) $row_count > 5000 ) {
add_action( 'admin_notices', function() use ( $row_count ) {
echo '<div class="notice notice-warning"><p>';
echo '<strong>Database maintenance needed:</strong> ';
echo esc_html( number_format( $row_count ) );
echo ' transient rows detected in wp_options. ';
echo 'Consider running a cleanup.';
echo '</p></div>';
});
}
});
インフラレベルの監視には、Nagios、Datadog、またはカスタム監視スタックにMySQLクエリを追加して、wp_optionsの行数と合計データサイズを定期的にチェックしてください。
まとめ
wp_optionsテーブルはWordPressインストールにおける最もパフォーマンスに敏感なコンポーネントの一つであり、汎用ストレージとして扱うプラグインによって日常的に悪用されています。WordPress Coreには期限切れtransientの自動クリーンアップ機構が存在しますが、wp_cronへの依存、有効期限なしtransientの非対象、動的キーの大量生成に対する速度不足により、実運用ではこれだけに頼ることはできません。Transientの肥大化はゆっくりと進行する問題です――サイトがすでに劣化するまで気づきません。
修正は、クリーンアップ、予防、そしてより良いツールの組み合わせです。期限切れのtransientを定期的に削除する。クリーンアップを自動化する。可能であればtransientストレージをRedisに移行する。そしてプラグインを選ぶ際――特にセキュリティやスパム対策のような高頻度のもの――は、データベースを汚さない設計のものを選びましょう。
データベースはゴミ捨て場ではありません。プラグインにそう扱わせるのはやめましょう。