本文约 5,951 字,预计阅读 15 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:改完了,全世界都没看见
改完主题的 CSS,自己电脑上刷新是新样式,客户电脑上还是旧的;清了插件缓存、清了浏览器缓存、甚至重启了 Nginx,客户那边依然没变。最后发现是 CDN 边缘节点上还躺着一份 TTL 为 4 小时的旧文件——它根本没被通知到。
CDN 缓存和插件缓存是两套完全独立的系统,插件清自己的缓存不会通知 CDN。反过来,CDN 缓存的是「URL 到内容」的映射,只要 URL 不变,它就没有理由去回源。这就是为什么「版本化资源」比「清除缓存」更可靠:改了内容就换一个新 URL,CDN 自然会当成全新对象回源取一次。
本文把两件事讲透:静态资源用版本化,让失效自动发生;HTML 文档用清除联动,在内容变更时主动通知 CDN。
原理分析:两层缓存与两种失效策略
一次典型的 WordPress 请求要穿过的缓存层:
| 层级 | 位置 | 失效方式 |
|---|---|---|
| 浏览器缓存 | 用户设备 | Cache-Control / ETag + URL 变化 |
| CDN 边缘缓存 | 服务商节点 | URL 变化、TTL 到期、API 主动清除 |
| 服务器层缓存 | Nginx / LiteSpeed | 插件或配置规则清除 |
| 插件页面缓存 | wp-content/cache |
插件后台或钩子清除 |
| 对象缓存 | Redis / Memcached | wp_cache_flush() |
失效策略只有两种:
版本化(Versioning):资源 URL 带上内容指纹,如 main.css?ver=1728547200 或 app.a1b2c3.js。内容一变 URL 就变,所有层级自动失效,无需任何通知。缺点是构建/部署环节要保证指纹与内容严格对应。
清除(Purge):调 API 告诉 CDN「这个 URL 作废」。适合 URL 不能变的场景(HTML 文章页的 URL 是永久链接,不能加版本号)。缺点是依赖代码主动调用,漏调就出事故,且 API 有频率限制。
正确的分工是:CSS/JS/图片/字体一律版本化,HTML 文档一律走清除联动。 混用会两头不讨好。
保姆级实操
第一步:给主题与插件资源做内容指纹
最常见的错误是把版本号写死成 '1.0',那样版本号就失去了意义。用文件的修改时间最省事:
<?php
// 子主题 functions.php
add_action( 'wp_enqueue_scripts', function () {
$base_uri = get_stylesheet_directory_uri();
$base_dir = get_stylesheet_directory();
$css = $base_dir . '/assets/css/main.css';
$js = $base_dir . '/assets/js/app.js';
wp_enqueue_style(
'mmf-main',
$base_uri . '/assets/css/main.css',
[],
file_exists( $css ) ? (string) filemtime( $css ) : null
);
wp_enqueue_script(
'mmf-app',
$base_uri . '/assets/js/app.js',
[],
file_exists( $js ) ? (string) filemtime( $js ) : null,
true
);
}, 20 );
如果是 Vite、Webpack 这类构建产物,直接读 manifest 里的内容哈希文件(dist/assets/app-a1b2c3d4.js),连 query string 都不需要,CDN 和浏览器对「文件名变了」的处理最一致。
对第三方插件吐出来的资源,可以用 style_loader_src / script_loader_src 统一加版本号:
add_filter( 'style_loader_src', 'mmf_bump_asset_ver', 10, 2 );
add_filter( 'script_loader_src', 'mmf_bump_asset_ver', 10, 2 );
function mmf_bump_asset_ver( $src, $handle ) {
// 只对本站资源生效,外链 CDN 资源不要动
if ( str_starts_with( $src, home_url( '/' ) ) ) {
$src = add_query_arg( 'ver', get_option( 'mmf_asset_release', '1' ), $src );
}
return $src;
}
改版时只需 update_option( 'mmf_asset_release', time() ),全站资源一次性换 URL。
第二步:HTML 文档不要在 CDN 上长缓存
文章页 URL 是永久链接,不能版本化,所以策略应该是:CDN 上要么不缓存 HTML,要么短 TTL + 主动清除。Cloudflare 可以用页面规则或 Cache Rules 把 /wp-admin/*、/wp-json/*、含 wordpress_logged_in Cookie 的请求设为 Bypass;HTML 的 Cache-Control 在 WordPress 侧控制:
// 让文章页在 CDN 上最多存活 5 分钟,且允许被清除
add_action( 'send_headers', function () {
if ( is_singular( 'post' ) && ! is_user_logged_in() ) {
header( 'Cache-Control: public, max-age=300, s-maxage=300' );
}
} );
s-maxage 是给共享缓存(CDN)看的,max-age 是给浏览器看的,两者要分开设。
第三步:内容变更时联动清除 CDN
以 Cloudflare 为例,用 save_post 钩子在文章更新后清除该文章、首页、归档页几个 URL。注意不要在 save_post 里同步发 HTTP 请求(会拖慢后台保存),丢给计划任务异步执行:
add_action( 'save_post', function ( $post_id, $post, $update ) {
if ( ! $update || wp_is_post_revision( $post_id ) ) {
return;
}
if ( 'publish' !== get_post_status( $post_id ) ) {
return;
}
$urls = [
get_permalink( $post_id ),
home_url( '/' ),
get_post_type_archive_link( $post->post_type ),
];
$urls = array_values( array_unique( array_filter( $urls ) ) );
wp_schedule_single_event( time() + 5, 'mmf_cdn_purge', [ $urls ] );
}, 10, 3 );
add_action( 'mmf_cdn_purge', function ( $urls ) {
if ( ! defined( 'CF_ZONE_ID' ) || ! defined( 'CF_API_TOKEN' ) ) {
return; // 凭证写在 wp-config.php 的常量里,不要硬编码
}
$response = wp_remote_request(
'https://api.cloudflare.com/client/v4/zones/' . CF_ZONE_ID . '/purge_cache',
[
'method' => 'DELETE',
'timeout' => 15,
'headers' => [
'Authorization' => 'Bearer ' . CF_API_TOKEN,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode( [ 'files' => array_slice( $urls, 0, 30 ) ] ),
]
);
if ( is_wp_error( $response ) ) {
error_log( 'CDN purge failed: ' . $response->get_error_message() );
return;
}
error_log( 'CDN purge: ' . wp_remote_retrieve_body( $response ) );
} );
请求体长这样,一次最多清 30 个 URL:
{
"files": [
"https://example.com/2026/10/some-post/",
"https://example.com/",
"https://example.com/blog/"
]
}
第四步:验证 CDN 是否真的失效了
for u in / /2026/10/some-post/ /wp-content/themes/mytheme/assets/css/main.css; do
echo "== $u"
curl -sI "https://example.com$u" \
| grep -iE 'cf-cache-status|age:|cache-control|etag'
done
# 连打两次看状态迁移:MISS/EXPIRED -> HIT
curl -sI https://example.com/ | grep -i 'cf-cache-status'
sleep 2
curl -sI https://example.com/ | grep -i 'cf-cache-status'
CF-Cache-Status 的常见取值:HIT(边缘命中)、MISS(回源了)、EXPIRED(过期回源)、BYPASS(规则要求不缓存)、DYNAMIC(默认不缓存的内容,如 PHP 生成的 HTML)。如果 CSS 文件长期是 HIT 而 Age 一直增长,说明版本化没生效。
常见踩坑
-
版本号写死:
wp_enqueue_style( 'x', $url, [], '1.0' )是无效版本化,改一百次内容 URL 都不变。 -
清除 API 同步调用:在
save_post里直接wp_remote_post,编辑器保存要等 2 秒,用户以为卡死,反复点保存,反而触发多次清除。 -
案例: 某站在每次
save_post时调用 Cloudflare 的purge_everything(清除全部)。编辑批量改 40 篇文章的当天,全站缓存被清了 40 次,回源流量暴涨,源站 4 核 8G 直接打满,前台间歇性 502。改成按 URL 批量清除后,回源 QPS 降了九成。 -
清除了 CDN 没清插件缓存:CDN 回源取到的仍然是插件缓存里的旧 HTML,看起来「清除无效」。清除顺序应该是:先清插件缓存,再清 CDN。
-
Cache-Control: private让 CDN 完全不缓存:有些插件为了安全给全站加private,结果 CDN 对静态资源也不缓存了,带宽费用没省下来。静态资源必须单独设public, max-age=31536000, immutable。 -
忽略
Vary头:如果 HTML 会因Accept-Encoding、User-Agent(移动端/桌面端不同输出)而不同,必须发Vary头,否则 CDN 会把移动端 HTML 发给桌面用户。 -
凭证写进主题文件:API Token 硬编码在
functions.php里,主题一发版进 Git 就泄露了。用wp-config.php的define()或环境变量。
总结
判断用哪种策略,看 URL 能不能变:能变就用版本化(filemtime 或构建哈希),让失效自动发生,这是最省心的;不能变(永久链接、首页)就走清除联动,且必须异步、按 URL 精准清除,别滥用 purge_everything。
行动清单:把所有 CSS/JS/图片的版本号改成内容指纹;静态资源 Cache-Control 设长缓存 + immutable;HTML 设短 TTL 并区分 s-maxage 与 max-age;save_post 里只排计划任务,真正清除放异步;API 凭证放 wp-config.php;每次上线用 curl -I 看 CF-Cache-Status 与 Age 验收。WordPress 静态资源缓存策略
