本文约 4,216 字,预计阅读 11 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:备份越来越慢,问题出在 uploads
一个跑了三年的站点,wp-content/uploads 往往有 3~8GB。其中相当一部分是”孤儿文件”——数据库里根本没有对应的附件记录,或者记录还在但没有任何文章引用它。
它的直接后果是三件事:整站备份体积和耗时翻倍;迁移时间被拉长;用 Duplicator、All-in-One WP Migration 打包时经常卡在打包阶段。WordPress 整站备份策略 里提到的 RTO 指标,很多时候就是被这些垃圾文件拖垮的。
原理分析:孤儿文件是怎么产生的
WordPress 的媒体库有”两套账”:
- 数据库账:
wp_posts里post_type = attachment的记录,加wp_postmeta里的_wp_attached_file(相对路径)和_wp_attachment_metadata(序列化数组,含各尺寸)。 - 磁盘账:
wp-content/uploads/YYYY/MM/下真实存在的文件。
两边不一致就产生孤儿。常见成因有四种:
- 用 FTP 直接删了附件记录但没删文件,或者反之在后台删了媒体但服务器上文件没清掉(很多对象存储插件就是这样);
- 主题/插件自己写文件:缓存插件生成的缩略图、SEO 插件生成的 OG 图、表单插件保存的用户上传文件,这些不进媒体库;
- 自动生成的多余尺寸:每换一次主题,
add_image_size()注册的尺寸就变一批,旧尺寸的-800x600.jpg之类就没人管了; - 导入导出残留:用 WordPress 导入器导入过内容,图片拉取失败或重复拉取,留下大量
xxx-1.jpg、xxx-2.jpg。
搞清楚成因很重要,因为不是所有”没被引用”的文件都能删——主题选项里的 logo、WooCommerce 的产品图库、SEO 插件的 OG 图,都不会出现在文章正文里。
保姆级实操:核对与清理
第一步:先备份,两条都要
# 数据库
wp db export backup-before-cleanup.sql
# 文件
tar -czf uploads-backup.tar.gz wp-content/uploads
这一步绝对不能省。清理是破坏性操作,删错了不可逆。
第二步:列出”数据库有记录但文件不存在”的附件
wp eval '
$q = new WP_Query([
"post_type" => "attachment",
"post_status" => "inherit",
"posts_per_page" => -1,
"fields" => "ids",
]);
$missing = 0;
foreach ($q->posts as $id) {
$file = get_attached_file($id);
if ($file && ! file_exists($file)) {
echo "MISSING: {$id} {$file}\n";
$missing++;
}
}
echo "TOTAL MISSING: {$missing}\n";
'
这类是”裂图”,不是孤儿,处理方式是补文件或删记录,别搞混。
第三步:列出”文件存在但数据库没记录”的孤儿
<?php
// 放到网站根目录,浏览器访问一次,用完务必删除
require_once('wp-load.php');
$basedir = wp_upload_dir()['basedir'];
$db_files = array();
$rows = $wpdb->get_results(
"SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file'"
);
foreach ($rows as $r) {
$db_files[] = basename($r->meta_value);
}
$orphans = array();
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($basedir, FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $file) {
if (! $file->isFile()) continue;
$name = $file->getFilename();
if (! in_array($name, $db_files, true)) {
$orphans[] = $file->getPathname();
}
}
file_put_contents(__DIR__ . '/orphans.txt', implode("\n", $orphans));
echo 'ORPHAN COUNT: ' . count($orphans);
注意这里只比对了 _wp_attached_file 的主文件名,没有比对各尺寸文件。_wp_attachment_metadata 里的 sizes 数组包含 thumbnail、medium 等派生文件,它们不在 postmeta 里有独立记录。所以上面的脚本会把所有 -150x150.jpg 都误判成孤儿。
修正版:把 metadata 里的尺寸文件名也加进白名单。
$meta_rows = $wpdb->get_results(
"SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attachment_metadata'"
);
foreach ($meta_rows as $r) {
$m = maybe_unserialize($r->meta_value);
if (! empty($m['sizes'])) {
foreach ($m['sizes'] as $s) {
$db_files[] = $s['file'];
}
}
}
第四步:判断哪些真的能删
拿到 orphans.txt 后,不要直接 rm。先人工看一遍规律:
wc -l orphans.txt
awk -F/ '{print $NF}' orphans.txt | sed 's/-[0-9]*x[0-9]*\././' | sort | uniq -c | sort -rn | head -30
如果某一批文件名高度相似且都带 -1、-2 后缀,基本就是重复导入残留,可以删。如果看到 logo、og-、cache 这类前缀,先去主题/插件设置里查是不是配置项在用。
第五步:先移动到隔离目录而不是直接删
mkdir -p /tmp/uploads-quarantine
while read -r f; do
mkdir -p "/tmp/uploads-quarantine/$(dirname "${f#$PWD/}")"
mv "$f" "/tmp/uploads-quarantine/${f#$PWD/}/"
done < orphans.txt
把文件移出去观察三天,前台后台都跑一遍,确认没有裂图再清空隔离目录。这是唯一能反悔的做法。
第六步:顺手清理多余尺寸
# 列出所有已注册尺寸
wp eval 'print_r(get_intermediate_image_sizes());'
# 只保留 WordPress 默认三档,其他尺寸重新生成时不再产生
wp media regenerate --yes
新主题注册的新尺寸只会影响新上传,旧图不会自动补。如果旧图也需要新尺寸,用 wp media regenerate --yes(会清掉旧尺寸文件再重建)。
常见踩坑与失败案例
- 案例一:某站按上面的初版脚本清理,删掉 4200 个”孤儿”,结果前台所有缩略图全裂——因为
-150x150.jpg全被误判。教训:必须把_wp_attachment_metadata的 sizes 加进白名单。 - 案例二:WooCommerce 站清理后商品详情页图全丢。原因是产品图库的附件挂在
post_parent上,但部分图是通过_product_image_gallery这个 meta 存的图片 ID,直接按”正文没引用”判断会误删。 - 对象存储站(OSS/COS/S3):本地可能压根没有文件,脚本扫描结果无意义。要改用插件提供的云端清单比对。
- 多站点:每个子站有自己的
uploads/sites/2/,脚本要按子站循环跑,且wp_upload_dir()要在切换站点后调用。 - 别用 SQL 直接删
wp_posts的 attachment:会留下postmeta孤儿行和磁盘文件,比不清理更乱。要删就用wp media delete <id>或后台删除。
总结
清理孤儿文件的正确姿势是:先备份 → 区分”裂图”和”孤儿” → 白名单要包含 metadata 里的所有尺寸 → 移动而非删除 → 观察期后再清。真正干净的做法其实是从源头控制:换主题前先想清楚要不要 regenerate,上传图片前先压缩,表单插件的用户上传目录单独配置并定期归档。清理是补救,不是日常。
