本文约 5,549 字,预计阅读 14 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:购物车里出现了陌生人的名字
一位做外贸独立站的朋友发来截图:WooCommerce 站点开启页面缓存后,首页顶部的「你好,Anna」问候语出现在了所有访客的浏览器里,购物车角标上还挂着别人添加的 3 件商品。更麻烦的是这段错误的 HTML 同时被 CDN 边缘节点缓存,等于把某位真实用户的会话状态公开展示了几个小时,直到有人手动清了缓存才恢复。
这类事故的根源不是插件有 bug,而是页面缓存的模型和动态内容的模型天然对立。页面缓存的目标是「同一个 URL,全人类共用一份 HTML」,速度就来自这份共用;而登录态、购物车、会员价、库存、个性化推荐这些内容的本质是「同 URL 不同人结果不同」。只要有一个动态片段被混进共享缓存,就会产生串号。
所以解决问题的思路不是「关掉缓存」,而是把站点切分成静态片段与动态片段两部分,静态部分照常共享缓存,动态部分走排除、ESI 或私有缓存。下面按这个思路逐层展开。
原理分析:缓存到底缓存了什么
页面缓存插件(WP Rocket、LiteSpeed Cache、W3 Total Cache、WP Super Cache)的工作位置在 template_redirect 之后、实际渲染之前:命中缓存时直接输出此前保存好的 HTML 文件,整个主题、数据库查询、大部分插件逻辑都不执行。
| 内容类型 | 典型例子 | 是否可共享缓存 | 推荐方案 |
|---|---|---|---|
| 完全静态 | 文章正文、关于我们、列表页 | 可以 | 共享页面缓存,TTL 尽量长 |
| 半动态 | 侧栏最新文章、热门排行 | 可以,容忍延迟 | 缓存 + 定时刷新或 AJAX 补 |
| 按用户 | 会员价、已购内容、账户入口 | 不可共享 | 私有缓存 / ESI / AJAX |
| 强一致 | 库存、购物车、结账、支付回调 | 完全不可 | 直接排除缓存 |
三个必须理解的概念:
排除(Exclude / Reject):命中条件的请求压根不查缓存、不写缓存,每次都真实渲染。最安全,也最慢,适用于购物车、结账、我的账户这类页面。
ESI(Edge Side Includes):HTML 里留一个占位标签,边缘服务器在回源时把占位替换成动态片段的真实内容。共享页面缓存照常命中,只有那一个小片段单独请求一次。LiteSpeed、Varnish、Cloudflare 的部分方案都支持。
按用户缓存(Private Cache):仍然缓存,但缓存键里加入用户标识(会话 ID 或用户 ID),每个登录用户一份副本。它比不缓存快,但缓存命中率随用户数下降,磁盘占用会涨,必须给私有缓存设一个较短的 TTL。
保姆级实操
第一步:先把站点的动态片段列成清单
拿一张纸,把所有「因人而异」的输出点列出来:顶部登录条、会员价、购物车角标、心愿单、已购标记、个性化推荐、A/B 测试分组、Cookie 同意横幅。清单之外的内容默认都当静态处理。这一步做不扎实,后面所有规则都会漏项。
第二步:给 WP Rocket 写排除规则
WP Rocket 对 WooCommerce、Easy Digital Downloads 有内置兼容,会自动排除 /cart/、/checkout/、/my-account/ 并识别 woocommerce_items_in_cart 等 Cookie。但自研的会员系统、自定义的动态模板它不认识,需要手动补充:
<?php
// 子主题 functions.php 或 Code Snippets
// 1) 按 URI 片段排除:命中任意一个即不生成缓存
add_filter( 'rocket_cache_reject_uri', function ( $uris ) {
$uris[] = '/cart/';
$uris[] = '/checkout/';
$uris[] = '/my-account/';
$uris[] = '/members-area/'; // 自研会员专区,插件不认识
$uris[] = '/api/live-price/'; // 实时报价接口
return $uris;
} );
// 2) 按 Cookie 排除:请求带上这些 Cookie 就走 BYPASS
add_filter( 'rocket_cache_reject_cookies', function ( $cookies ) {
$cookies[] = 'edd_items_in_cart';
$cookies[] = 'my_membership_session';
return $cookies;
} );
// 3) 只因来源不同、内容完全相同的参数,各自缓存一份即可,不必全排除
add_filter( 'rocket_cache_query_strings', function ( $qs ) {
$qs[] = 'utm_source';
$qs[] = 'utm_campaign';
return $qs;
} );
// 4) 极端场景:某个模板永远不缓存
add_action( 'template_redirect', function () {
if ( is_page_template( 'template-live-price.php' ) ) {
define( 'DONOTCACHEPAGE', true );
}
} );
第三步:LiteSpeed Cache 用 ESI 做局部动态
如果你跑在 LiteSpeed / OpenLiteSpeed 上,最划算的方案是开 ESI。进入「LiteSpeed Cache → 缓存 → ESI」,把 ESI 打开;WooCommerce 的购物车小组件、近期浏览等会被自动切成 ESI 块。私有缓存的 TTL(「缓存」设置里的私有缓存项,默认通常较短)建议保持在 60–180 秒,别设成和公共缓存一样的长值,否则会员改了资料半天不生效。
非 LiteSpeed 环境下想做局部动态,最通用的替代是 AJAX:页面照常缓存成静态,加载完再用 admin-ajax.php 或 REST 路由拉一次用户态数据,把结果填进占位容器。WordPress REST API 自定义接口
第四步:服务器层再兜一层 Cookie 排除
插件层规则只在 PHP 执行时生效,如果 Nginx 还有一层 FastCGI 缓存,需要让两层规则保持一致,否则会出现「插件没缓存但 Nginx 缓存了」的诡异串号:
# http 块:定义哪些请求不该碰共享缓存
map $http_cookie $no_shared_cache {
default 0;
"~*wordpress_logged_in" 1;
"~*wp-postpass" 1;
"~*comment_author" 1;
"~*woocommerce_items_in_cart" 1;
"~*wp_woocommerce_session" 1;
"~*edd_items_in_cart" 1;
}
map $request_uri $no_shared_uri {
default 0;
"~^/(cart|checkout|my-account|add-to-cart)" 1;
"~^/wp-json/" 1;
"~^/wp-admin/" 1;
}
# server 块的 PHP 段
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_cache_bypass $no_shared_cache $no_shared_uri;
fastcgi_no_cache $no_shared_cache $no_shared_uri;
add_header X-Cache-Status $upstream_cache_status;
}
fastcgi_cache_bypass 表示不读缓存,fastcgi_no_cache 表示不写缓存,两个都要写——只写前者会出现「请求绕过缓存但响应被存了下去」这种最隐蔽的问题。
第五步:上线前跑一遍回归验证
# 1) 匿名请求:静态页应当 HIT
curl -sI https://example.com/sample-page/ | grep -i 'x-cache-status'
# 2) 带登录 Cookie:必须 BYPASS
curl -sI https://example.com/sample-page/ \
-b 'wordpress_logged_in_abcdef=tester' | grep -i 'x-cache-status'
# 3) 强一致页面逐条确认
for u in /cart/ /checkout/ /my-account/ /wp-json/wp/v2/posts; do
printf '%-28s -> ' "$u"
curl -sI "https://example.com$u" | awk 'tolower($1)=="x-cache-status:"{print $2}'
done
# 4) 批量扫一遍:看有没有页面在带 Cookie 时仍然 HIT
while read -r u; do
s=$(curl -sI "https://example.com$u" -b 'wordpress_logged_in_abcdef=tester' \
| awk 'tolower($1)=="x-cache-status:"{print $2}')
[ "$s" = "HIT" ] && echo "危险:$u 登录态命中共享缓存"
done < url-list.txt
常见踩坑
-
只排除了页面 URL,没排除 Cookie:会员入口嵌在全站头部,只有
/my-account/被排除没用,所有页面都会带上用户名。Cookie 排除优先级高于 URI 排除。 -
缓存键里没有 Cookie,却在响应里
Set-Cookie:命中缓存时响应头里的Set-Cookie也会一起被复用,等于把别人的会话 ID 发给了新访客。可以用fastcgi_ignore_headers显式忽略,或在写缓存前删掉该头。 -
案例: 某站点把「限时折扣倒计时」做成了 PHP 输出的时间戳,缓存 TTL 设成 24 小时。活动开始后倒计时一直停在旧值,用户在结算页看到的价格和倒计时对不上,当天退款 17 笔。后来改成页面缓存 + 前端 JS 读取服务器时间戳重算,问题消失。
-
ESI 块自身被缓存:ESI 片段的 URL 也走同一套缓存规则,如果片段地址没被排除,ESI 就退化成静态值,看起来「开了 ESI 但没效果」。
-
私有缓存不设 TTL,磁盘被吃满:几千个登录用户各存一份 HTML,
wp-content/cache一周涨到 20GB,备份直接失败。 -
AJAX 补动态内容却被浏览器缓存:请求动态数据的接口必须带
Cache-Control: no-store,否则浏览器会缓存 JSON 结果,用户刷新也不变。 -
预加载(Preload)把动态页也爬了一遍:WP Rocket、LiteSpeed 的预加载会遍历站点地图,务必确认 sitemap 里不含
/cart/、/checkout/这类地址,否则预加载会直接生成一份带「空购物车」的共享缓存。 -
CDN 与插件两层规则不一致:插件改了排除规则但 CDN 缓存规则还是旧的,一定在改完后主动清一次 CDN 缓存。WordPress CDN 缓存清除
总结
处理缓存与动态内容的冲突,判断标准只有一条:这份输出对不同的人是否可能不同。可能不同就进排除或私有缓存,绝不可能不同才进共享缓存。
行动清单:先列出全站动态片段清单;再用 Cookie + URI 两条线配置插件与服务器层排除(两层规则必须一致);购物车、结账、账户页强制排除;头部登录态、会员价优先用 ESI 或 AJAX 局部化;私有缓存 TTL 控制在 3 分钟以内;上线前用带 Cookie 与匿名两组请求各跑一遍,确认强一致页面全部 BYPASS。
