本文约 6,645 字,预计阅读 17 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:wp_options 悄悄长到了 800MB
站点跑了一年多,数据库备份从 12MB 涨到 800MB,后台操作明显发涩,PHP 内存偶尔告警。查下来 wp_options 表占了 90% 的体积,里面有二十多万行以 _transient_ 开头的记录,绝大多数是几个月前就过期的数据。
这类数据的可怕之处在于它不会自己消失,或者说它消失的条件很苛刻:WordPress 只在「有人访问这个 transient」时才顺手删掉过期的它。如果插件用 transient 缓存的是「某个好久没人看的商品的推荐列表」,那这条记录可能永远躺着。
原理分析:transient 存在哪、怎么失效
Transient(瞬态)是 WordPress 的「带过期时间的 option」,三个函数:set_transient( $key, $value, $expiration )、get_transient( $key )、delete_transient( $key )。
存储位置取决于有没有外部对象缓存:
| 场景 | 存储位置 | 过期行为 |
|---|---|---|
| 无持久对象缓存 | wp_options 表,两行:_transient_{key}(值)与 _transient_timeout_{key}(到期时间戳) |
只在被 get_transient 访问时才删除 |
| 有 Redis/Memcached | 对象缓存(不写数据库) | 靠缓存 TTL 自然失效 |
| 多站点 | 站点级同上;set_site_transient 存 _site_transient_* |
同上 |
失效的三个条件(缺一都不会被删):
- 有过期时间(
set_transient的第三个参数大于 0)。传0表示永不过期,此时 WordPress 根本不写 timeout 行,这条记录会永久留着,而且autoload为yes——每个请求启动时都被加载进内存。 - 有人访问它。
get_transient()里发现timeout < time()时才会delete_option删掉两行。 - 定时任务跑过。WordPress 4.9 起,
delete_expired_transients()挂在每日事件wp_scheduled_delete上,会清掉数据库里的过期瞬态——但 WP-Cron 依赖站点访问触发,低流量站点可能好几天不跑;如果wp-config.php里设了DISABLE_WP_CRON而系统 cron 又没配,就一次都不跑。
所以「装了对象缓存反而更要小心」:装了 Redis 之后 transient 不再写数据库,一旦某天 Redis 挂了或配置丢失,插件会重新往 wp_options 写,堆积速度吓人。
保姆级实操
第一步:量化问题有多少
-- 总行数与瞬态占比
SELECT COUNT(*) AS total_rows,
SUM( option_name LIKE '\_transient\_%' ) AS transient_rows,
SUM( option_name LIKE '\_transient\_timeout\_%' ) AS timeout_rows,
SUM( option_name LIKE '\_site\_transient\_%' ) AS site_rows
FROM wp_options;
-- 体积最大的 20 条(看是哪个插件在写)
SELECT option_name, autoload, ROUND( LENGTH(option_value)/1024, 1 ) AS kb
FROM wp_options
WHERE option_name LIKE '\_transient\_%'
ORDER BY LENGTH(option_value) DESC
LIMIT 20;
-- 已过期但还没被删的
SELECT COUNT(*) AS expired_pending
FROM wp_options
WHERE option_name LIKE '\_transient\_timeout\_%'
AND option_value < UNIX_TIMESTAMP();
-- 永不过期的(没有对应 timeout 行,且通常 autoload=yes,最该处理)
SELECT o.option_name, o.autoload, ROUND( LENGTH(o.option_value)/1024, 1 ) AS kb
FROM wp_options o
LEFT JOIN wp_options t
ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 13))
WHERE o.option_name LIKE '\_transient\_%'
AND o.option_name NOT LIKE '\_transient\_timeout\_%'
AND t.option_id IS NULL
ORDER BY LENGTH(o.option_value) DESC
LIMIT 30;
注意 SQL 里 LIKE '\_transient\_%' 的反斜杠:下划线在 LIKE 里是单字符通配符,必须转义。SUBSTRING(o.option_name, 13) 是因为 _transient_ 正好 12 个字符。
第二步:先确认再删除
-- 1) 备份 options 表(这一步不能省)
-- mysqldump -u root -p dbname wp_options > wp_options_$(date +%F).sql
-- 2) 删除已过期的:value 行与 timeout 行一起删
DELETE a, b
FROM wp_options a
JOIN wp_options b
ON b.option_name = REPLACE( a.option_name, '_transient_timeout_', '_transient_' )
WHERE a.option_name LIKE '\_transient\_timeout\_%'
AND a.option_value < UNIX_TIMESTAMP();
这是 MySQL 的多表删除语法:以 timeout 行为驱动表 a,通过 REPLACE 拼出对应的 value 行 b,一次删两行。删完检查:
-- 还剩下多少孤儿 value 行(有值没 timeout 的,就是永不过期的那批)
SELECT COUNT(*) FROM wp_options
WHERE option_name LIKE '\_transient\_%'
AND option_name NOT LIKE '\_transient\_timeout\_%';
第三步:用 WP-CLI 更省事
# 列出当前瞬态(需要能连上数据库)
wp transient list --fields=name,expiration,size | head -30
# 删除全部过期瞬态
wp transient delete --expired
# 删除全部瞬态(含未过期,慎用,会强制插件重新拉取数据)
wp transient delete --all
# 只想删某一个插件的:按前缀精确清理
wp transient delete --all --network 2>/dev/null # 多站点场景
wp db query "DELETE FROM wp_options WHERE option_name LIKE '\_transient\_mmf\_api\_%'"
# 看 autoload 总量(超过 800KB 就该清理了)
wp eval '$t=0; foreach($GLOBALS["wpdb"]->get_results("SELECT LENGTH(option_value) l FROM {$GLOBALS["wpdb"]->options} WHERE autoload=\"yes\"") as $r){$t+=$r->l;} echo round($t/1024), " KB autoloaded\n";'
# 手动触发一次官方清理
wp eval 'delete_expired_transients( true ); echo "done\n";'
wp transient delete --expired 只删过期的,比 --all 安全得多;--all 会清掉正在生效的缓存,流量大的站点执行后源站压力会短暂上升,建议在低峰做。
第四步:把清理变成常态
WP-Cron 不可靠,用系统 cron:
# /etc/cron.daily/wp-transients
#!/usr/bin/env bash
set -euo pipefail
WP="wp --path=/var/www/html --skip-plugins --skip-themes"
$WP transient delete --expired >/dev/null 2>&1 || true
$WP db query "DELETE a,b FROM wp_options a JOIN wp_options b ON b.option_name=REPLACE(a.option_name,'_transient_timeout_','_transient_') WHERE a.option_name LIKE '\_transient\_timeout\_%' AND a.option_value < UNIX_TIMESTAMP();" || true
再加一层 WP-Cron 兜底(系统 cron 没配时至少还有它):
<?php
// wp-content/mu-plugins/transient-keeper.php
add_action( 'init', function () {
if ( ! wp_next_scheduled( 'mmf_purge_transients' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'mmf_purge_transients' );
}
} );
add_action( 'mmf_purge_transients', function () {
if ( function_exists( 'delete_expired_transients' ) ) {
delete_expired_transients( true ); // true = 强制连库清理,含多站点
}
} );
// 拦住"超大瞬态":单个值超过 512KB 的一律不写库,改由调用方降级
add_filter( 'pre_set_transient_mmf_remote_cache', function ( $value, $expiration, $transient ) {
if ( is_string( $value ) && strlen( $value ) > 512 * 1024 ) {
error_log( 'transient 过大,已拒绝写入: ' . $transient );
return false; // 返回 false 会让 set_transient 直接返回 false
}
return $value;
}, 10, 3 );
第五步:从源头约束插件行为
// 检查插件是否写了永不过期的瞬态(autoload 会被拖慢每个请求)
add_action( 'added_option', function ( $option ) {
if ( str_starts_with( $option, '_transient_' ) && ! str_starts_with( $option, '_transient_timeout_' ) ) {
$timeout = get_option( '_transient_timeout_' . substr( $option, 12 ) );
if ( false === $timeout ) {
error_log( '检测到永不过期的瞬态: ' . $option );
}
}
} );
常见踩坑
-
直接
wp transient delete --all当日常维护:清掉的是正在生效的缓存,插件会集体回源,API 类插件可能瞬间触发限流。 -
案例: 某站清完 transient 后,一个天气插件的 API Key 调用次数在 10 分钟内涨到平时的 40 倍,被服务商临时封禁。原因是清掉的瞬态里包含它缓存的 API 响应,而该插件没有并发保护,所有并发请求同时回源。教训:清理前先确认插件是否有请求合并(locking)机制,没有的话分批清、低峰清。
-
忽略
autoload才是真正的性能杀手:10 万行瞬态里,只有autoload=yes的那些会被每个请求加载进内存。永不过期的瞬态 autoload 默认是yes,危害远大于过期未清理的。 -
装了对象缓存就以为万事大吉:Redis 里的数据掉电即失,恢复期间插件会大量回写数据库;且「对象缓存里没有、数据库里有旧 transient」这种双源状态会造成数据不一致。
-
误删正在进行的更新锁:
core_updater.lock是 option 不是 transient,但有些插件会把「任务进行中」标记写成瞬态,清理时正好撞上,导致任务被重复执行。清理前先看一眼 key 名。 -
DISABLE_WP_CRON但没配系统 cron:这是瞬态堆积最常见的根因,也是定时任务全线失效的根因。用wp cron event list检查wp_scheduled_delete的next_run是不是早就过期了。 -
只删 value 行不删 timeout 行:留在表里的 timeout 行同样占用行数,
wp_options的option_name索引也会变大。
总结
判断标准:wp_options 行数异常、体积超过 50MB、autoload 总量超过 800KB,就该查瞬态了。
行动清单:先用 SQL 量化(总数、过期未清数、永不过期数、体积 Top20);备份 wp_options 表;用多表 DELETE 或 wp transient delete --expired 清理过期项;重点处理「永不过期 + autoload=yes」的那批;配系统 cron 每日清理,WP-Cron 只做兜底;给大瞬态加写入体积上限;装了对象缓存的站点也要定期查数据库,别只看 Redis。WordPress 数据库性能优化
