前言
"我改了代码,也清了缓存,页面还是旧的。"更让人抓狂的是它的间歇性:同一次发布,有人刷新就变了,有人还是老页面;重启 PHP-FPM 后好了,过一会儿又不行了。
根本原因在于:"缓存"在 PHP 应用里不是一个东西,而是七八个东西。 你以为的"清缓存"只清了其中一个,剩下的照旧活着------OPcache 存编译后的字节码、realpath 缓存存路径映射、APCu 存用户态共享内存变量、框架缓存存路由/配置/模板、数据缓存存 Redis/Memcached 业务数据,外加浏览器缓存和 CDN 缓存。它们各有各的生命周期、清除方式和作用范围(本文末尾的汇总表逐层列出)。
正确的排查顺序是沿请求路径从外往内逐层确认:先 CDN 和浏览器(最易忽略,刷新一下就能排除),再应用层缓存,最后 OPcache 和 realpath 缓存(最隐蔽,它不改变数据、只改变"代码版本")。本文用 PHP 8.3 按此顺序给出排查手段和一段可直接运行的诊断脚本。
一、先分清两类问题:数据旧还是代码旧
这一步决定了后面所有的排查方向,必须先做。
"代码旧"的特征:
- 在 PHP 文件里加一行
error_log('新代码'),日志里没有输出。 - 改了模板里的文案,页面上的文案没变。
- 新加的方法调用报
Call to undefined method------加载的还是旧版本的类。 - 重启 PHP-FPM 之后立刻正常,跑一段时间后又变旧。
"数据旧"的特征:
- 代码逻辑变了(比如改了排序规则),但列表顺序还是老的。
- 数据库里改了记录,接口返回的还是老值。
- 重启 FPM 也没用,换个 key 或手动
DEL一下就好了。
"代码旧"重点看 OPcache 和 realpath 缓存;"数据旧"重点看应用层缓存和 HTTP 缓存。这两类的排查路径完全不同,别混着查。
二、OPcache 与 realpath 缓存:两个最隐蔽的元凶
2.1 OPcache 的 revalidate 机制
php.ini 里这几个配置决定了 OPcache 什么时候"发现"文件变了:
ini
opcache.enable=1
; 设为 0 时永不检查文件变更,必须重启才生效
opcache.validate_timestamps=1
; 单位秒。0 表示每次请求都检查(最灵敏也最耗 IO)
opcache.revalidate_freq=2
; 文件数量上限,超过后旧的被淘汰,可能导致性能抖动
opcache.max_accelerated_files=10000
; 哈希表大小(MB)
opcache.memory_consumption=128
最容易踩的一条:revalidate_freq。 它默认 2 秒,但很多生产模板会调成 60 甚至 600,理由是"减少 stat 系统调用"。于是你 10:00 部署了新代码,10:00:30 看还是旧的------不是没生效,是还没到检查时间点。
先确认当前实际配置,不要凭记忆:用 opcache_get_configuration()['directives'] 看全部指令,用 opcache_get_status(false) 看运行统计。注意 opcache_get_status() 第二个参数必须传 false,它才不返回每个脚本的明细------脚本很多时传 true 会返回一个巨大数组,直接把内存打爆,这是一个真实的坑。完整脚本见第四节。
2.2 CLI 和 FPM 是两套完全独立的 OPcache
这是"清了没用"的头号原因。
bash
# ❌ 在命令行里清 OPcache
php -r "opcache_reset();"
这条命令清的是 CLI 进程自己的 OPcache。 用 php-fpm 跑 Web 请求时,OPcache 存在 FPM master 启动时申请的共享内存段里,CLI 进程根本访问不到。命令行里 opcache_reset() 返回 true,看着像成功了,实际对网站毫无影响。
Web 环境下正确清法是通过 HTTP 触发一次:
php
<?php
// clear_opcache.php ------ 放 Web 目录下,用完立刻删除(运行环境:PHP 8.1+)
// 必须鉴权,否则任何人都能重置你的 OPcache
$secret = getenv('OPCACHE_RESET_TOKEN') ?: '';
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ($secret === '' || !hash_equals($secret, $_GET['token'] ?? '')
|| !in_array($ip, ['127.0.0.1', '::1'], true)) {
http_response_code(403);
exit('forbidden');
}
if (!function_exists('opcache_reset')) {
exit("OPcache 扩展未加载\n");
}
header('Content-Type: text/plain; charset=utf-8');
echo opcache_reset() ? "opcache 已重置\n" : "opcache 重置失败\n";
clearstatcache(true); // 顺带清 realpath 缓存
注意 opcache_reset() 也有作用范围限制 :它清的是"进程池自己那一份共享内存"。如果服务器上跑着多个 FPM 池(pool),或前面有多个后端节点,在 A 节点执行只清了 A,多节点环境每个节点都要触发一次。
2.3 opcache_invalidate() 不生效的三种情况
单文件失效是更精细的做法:
php
<?php
// 运行环境:PHP 8.1+
$file = '/var/www/app/src/Service/UserService.php';
// $force = true:无论 validate_timestamps 是什么都强制失效
var_dump(opcache_invalidate($file, true));
它不生效的三种真实情况:
$file不是绝对路径。 传相对路径时按当前工作目录解析,结果和 OPcache 记录的 key 对不上,返回false但不报错,容易被当成"成功了"。- 符号链接(symlink)导致路径不一致。 OPcache 记录的是解析后 的 realpath。代码通过
/var/www/current/...(软链接指向/var/www/releases/20260929/)访问,你传进去的却是/var/www/current/src/...,两者对不上。这就是软链接发布要特别小心 realpath 缓存的原因。 - 文件不在 OPcache 里。 该文件从没被请求过,缓存里没有它,函数返回
false,这是正常的。
2.4 realpath 缓存:软链接部署的隐形杀手
PHP 会把"路径 → 真实路径"的解析结果缓存起来,默认 realpath_cache_ttl = 120 秒。用户态 API:
php
<?php
// 运行环境:PHP 8.1+
printf("realpath 条目数 %d / 占用 %d 字节\n",
count(realpath_cache_get()), realpath_cache_size());
clearstatcache(true, '/var/www/app/config/routes.php'); // 清单个文件
clearstatcache(true); // 清全部
clearstatcache() 的签名是 clearstatcache(bool $clear_realpath_cache = false, string $filename = "")。默认第一个参数是 false,也就是不清 realpath 缓存。 很多代码写 clearstatcache() 以为清干净了,实际 realpath 缓存原封不动------这是本文最值得记住的细节。
配合软链接部署(current → releases/xxx)时,完整切换顺序:
bash
# 1) 切软链接
ln -sfn /var/www/releases/20260929 /var/www/current.new
mv -T /var/www/current.new /var/www/current
# 2) 清 OPcache(每个节点都要执行)
curl -s "http://127.0.0.1/clear_opcache.php?token=$TOKEN"
# 3) 重启 FPM(最彻底,能同时清掉 realpath 缓存和 APCu)
systemctl reload php8.3-fpm
systemctl reload(发 SIGUSR2)对 OPcache 是有效的 ,它让 FPM 优雅重启 worker,worker 重启后重新加载文件。但 reload 不清那块共享内存本身;如果配置里 validate_timestamps=0,新 worker 依然会从共享内存读到旧的字节码,这种配置下只有 restart 有效。
三、应用层缓存:最容易清错 key 的地方
3.1 前缀与命名空间
Redis / Memcached 客户端库通常会给 key 加前缀。你在命令行 DEL user:1001,实际存进去的 key 是 myapp:user:1001 或 laravel_database_user:1001。
bash
# 正确做法是先看实际存在哪些 key
redis-cli --scan --pattern '*user:1001*'
redis-cli TTL "myapp:user:1001"
KEYS * 在生产环境是要出事的 ------它是 O(N) 阻塞操作,几十万 key 就能把 Redis 卡住好几秒。用 SCAN(redis-cli --scan 就是它的封装),增量式、不阻塞。
3.2 缓存键里带版本号
比"清缓存"更可靠的设计是让旧缓存自动失效:把"版本号"拼进 key。
php
<?php
// 运行环境:PHP 8.3
final class VersionedCache
{
public function __construct(
private readonly \Redis $redis,
private readonly string $namespace = 'myapp',
) {}
/** 取当前版本号;用 incr 保证并发下不被覆盖回旧值 */
private function version(string $group): int
{
$key = "{$this->namespace}:ver:{$group}";
$v = $this->redis->incr($key);
if ($v === 1) {
$this->redis->expire($key, 86400 * 30); // 首次补过期,防止永久占内存
}
return (int) $v;
}
public function key(string $group, string $id): string
{
return "{$this->namespace}:{$group}:v{$this->version($group)}:{$id}";
}
/** "清缓存"变成把版本号 +1,旧数据自动不可达,由 TTL 淘汰 */
public function flush(string $group): int
{
return $this->version($group);
}
}
价值在于:flush 是 O(1) 的,不遍历删除任何数据,也就不存在"漏删"的可能。 旧数据留在 Redis 里靠 TTL 自然过期,代价是短时间内有一批"垃圾 key"占内存,需给 TTL 设合理上限。
3.3 APCu 也是一样的问题
php
<?php
// 运行环境:PHP 8.1+
$info = apcu_cache_info(true);
printf("APCu 条目数: %d\n", $info['num_entries'] ?? 0);
apcu_clear_cache(); // 清空整个用户缓存(无参数)
APCu 是共享内存 ,同一 FPM master 下所有 worker 共享它,apcu_clear_cache() 一次全清。但要注意两件事:
- CLI 与 FPM 不共享。 和 OPcache 一样,CLI 里清的 APCu 和 Web 的不是同一块内存。要让 CLI 用 APCu 得开
apc.enable_cli=1,但那样它又是另一块独立内存。 - 多台服务器之间更不共享。 每台机器缓存内容都不一样。这就是"刷新几次就好了、再过一会儿又不对了"的典型成因:负载均衡把请求轮流打到不同机器,有的已更新,有的还没。
四、写一个诊断脚本
把上面的检查串起来,就是一份可以直接放到服务器上跑的排查脚本。
php
<?php
declare(strict_types=1);
/**
* 缓存层诊断脚本(运行环境:PHP 8.3)
* 用法:php cache_doctor.php (CLI 视角)
* 放到 Web 根目录访问 (FPM 视角)
*/
function line(string $title): void
{
echo str_repeat('-', 8), ' ', $title, ' ', str_repeat('-', 8), PHP_EOL;
}
line('运行环境');
printf("PHP %s / SAPI %s / PID %d\n", PHP_VERSION, PHP_SAPI, getmypid());
line('OPcache');
if (!extension_loaded('Zend OPcache')) {
echo "未加载 OPcache 扩展\n";
} else {
$d = opcache_get_configuration()['directives'] ?? [];
printf("enable=%s / validate_timestamps=%s / revalidate_freq=%s 秒\n",
$d['opcache.enable'] ?? '?', $d['opcache.validate_timestamps'] ?? '?',
$d['opcache.revalidate_freq'] ?? '?');
$st = opcache_get_status(false); // 传 true 时脚本多会返回超大数组
if ($st === false) {
echo "OPcache 已加载但未启用(enable=0 或 enable_cli=0)\n";
} else {
printf("脚本数=%d / 命中率=%.2f%% / 剩余=%d 字节 / OOM 重启=%d\n",
count($st['scripts'] ?? []),
$st['opcache_statistics']['opcache_hit_rate'] ?? 0,
$st['memory_usage']['free_memory'] ?? 0,
$st['opcache_statistics']['oom_restarts'] ?? 0);
}
}
line('realpath 缓存');
printf("条目数=%d / 占用=%d 字节 / ttl=%s 秒\n",
count(realpath_cache_get()), realpath_cache_size(), ini_get('realpath_cache_ttl'));
line('APCu');
if (!extension_loaded('apcu')) {
echo "未加载 APCu 扩展\n";
} else {
printf("apc.enabled=%s / apc.enable_cli=%s\n", ini_get('apc.enabled'), ini_get('apc.enable_cli'));
$info = apcu_cache_info(true);
if (is_array($info)) {
printf("条目数=%d / 命中=%d / 未命中=%d\n",
$info['num_entries'] ?? 0, $info['num_hits'] ?? 0, $info['num_misses'] ?? 0);
} else {
echo "APCu 未启用(CLI 下需 apc.enable_cli=1)\n";
}
}
line('结论提示');
echo "1) SAPI 为 cli 时,上面的 OPcache/APCu 数据与 Web 无关,必须放到 Web 目录再跑一次\n";
echo "2) validate_timestamps=0 时必须 restart(不是 reload)PHP-FPM\n";
echo "3) 多节点部署时,每个节点都要单独执行一次清理\n";
常见坑点
1. 命令行里 opcache_reset() 以为清了 Web 的
❌ php -r "opcache_reset();" ------ 清的是 CLI 进程自己的缓存,返回 true,网站却毫无变化。 ✅ 用带 token 鉴权的 Web 脚本经 HTTP 触发,或直接重启 FPM。CLI 与 FPM 不共享任何缓存内存。
2. clearstatcache() 不带参数,realpath 缓存没清
❌ clearstatcache(); ------ 第一个参数默认 false,只清 stat 缓存;软链接部署下路径解析结果依然是旧的。 ✅ clearstatcache(true); 清全部,或 clearstatcache(true, $absolutePath); 清指定文件。
3. 用相对路径调 opcache_invalidate()
❌ opcache_invalidate('src/User.php', true); ------ OPcache 内部记录 realpath,相对路径解析后对不上,返回 false 且不报错,你完全看不出来。 ✅ 一律传 realpath($file) 的绝对路径,并检查返回值。
4. 只看 validate_timestamps,不看 revalidate_freq
❌ 确认 opcache.validate_timestamps=1 就放心,忽略 revalidate_freq=60,部署后一分钟内看到的还是旧代码,误判为"缓存没清干净"。 ✅ 在部署脚本里 显式执行一次 opcache_reset(),别依赖自动检测窗口。自动检测粒度不可控,主动失效才确定。
5. 用 KEYS * 或 flushall 清 Redis
❌ redis-cli KEYS "*user*" | xargs redis-cli DEL ------ KEYS 是 O(N) 阻塞命令,线上几百万 key 会卡住整个 Redis;FLUSHALL 更狠,共享实例上的业务一起遭殃。 ✅ 用 SCAN 增量遍历(redis-cli --scan),或改用"key 带版本号"的设计。共享实例上执行 FLUSHALL 基本等同一次小型事故。
6. 清了缓存但 TTL 是 0(永不过期)
❌ $redis->set('cfg', $value) 不带过期时间 ------ 清缓存那一刻确实生效,但下次写入又留下一个永不过期的 key;某次清理失败它就变成"永久脏数据"。 ✅ 所有缓存 key 都必须有 TTL。它是最后一道兜底:即使清理逻辑全部失效,数据也会自己纠正过来。
7. 只清了服务端,忘了 HTTP 层
❌ 响应头带着 Cache-Control: max-age=3600,服务端数据已更新,用户浏览器还在用一小时前的副本,CDN 上还有一份。 ✅ 动态接口用 Cache-Control: no-store 或短 TTL 配合 ETag;静态资源用"文件名带内容哈希"做永久缓存,靠改名而不是清缓存来更新。
8. 缓存的 key 里包含了不该有的东西
❌ $key = 'user_' . $userId . '_' . $_SERVER['REQUEST_TIME']; ------ 时间戳进了 key,每次请求都是新 key,命中率永远是 0,看起来"缓存生效了"(数据总是新的),实际是根本没在用缓存 。 ✅ key 必须由"数据的唯一标识"构成,不能含时间、随机数这类每次都在变的值。命中率异常低时,第一件事就是把 key 打出来看。
总结
| 层 | 清除方式 | 作用范围 | 最常见的错 |
|---|---|---|---|
| OPcache | opcache_reset() 或 restart FPM |
单个 FPM master | 在 CLI 里执行,清了等于没清 |
| OPcache 单文件 | opcache_invalidate(realpath, true) |
同上 | 传相对路径,静默失败 |
| realpath 缓存 | clearstatcache(true, $file) |
单个进程 | 忘传第一个参数 true |
| APCu | apcu_clear_cache() |
单个 FPM master | 忘了多机之间不共享 |
| Redis/Memcached | DEL / 版本号 +1 |
跨机器 | 用 KEYS * 阻塞线上实例 |
| 框架缓存 | 框架提供的命令 | 取决于后端 | 只清了数据没清路由/模板 |
| 浏览器/CDN | 响应头 / 刷新任务 | 每个客户端 | 完全忘记这一层的存在 |
排查"缓存清了不生效",最高效的方法不是逐个去试,而是先确定问题属于"代码旧"还是"数据旧" ------这一步能砍掉一半的排查方向。然后记住三条判据:CLI 与 FPM 不共享任何缓存内存;每个节点都要单独清;opcache.validate_timestamps=0 时只有 restart 有效。
最后一条架构建议:能靠 TTL 和版本号自动失效的,就不要依赖"记得去清缓存"。 任何需要人工执行的清理步骤,都一定会在某个深夜的紧急发布里被漏掉。