PHP 8.3 缓存清除不生效怎么排查

前言

"我改了代码,也清了缓存,页面还是旧的。"更让人抓狂的是它的间歇性:同一次发布,有人刷新就变了,有人还是老页面;重启 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));

它不生效的三种真实情况:

  1. $file 不是绝对路径。 传相对路径时按当前工作目录解析,结果和 OPcache 记录的 key 对不上,返回 false 但不报错,容易被当成"成功了"。
  2. 符号链接(symlink)导致路径不一致。 OPcache 记录的是解析后 的 realpath。代码通过 /var/www/current/...(软链接指向 /var/www/releases/20260929/)访问,你传进去的却是 /var/www/current/src/...,两者对不上。这就是软链接发布要特别小心 realpath 缓存的原因。
  3. 文件不在 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() 一次全清。但要注意两件事:

  1. CLI 与 FPM 不共享。 和 OPcache 一样,CLI 里清的 APCu 和 Web 的不是同一块内存。要让 CLI 用 APCu 得开 apc.enable_cli=1,但那样它又是另一块独立内存。
  2. 多台服务器之间更不共享。 每台机器缓存内容都不一样。这就是"刷新几次就好了、再过一会儿又不对了"的典型成因:负载均衡把请求轮流打到不同机器,有的已更新,有的还没。

四、写一个诊断脚本

把上面的检查串起来,就是一份可以直接放到服务器上跑的排查脚本。

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 和版本号自动失效的,就不要依赖"记得去清缓存"。 任何需要人工执行的清理步骤,都一定会在某个深夜的紧急发布里被漏掉。

相关推荐
朝朝辞暮i1 小时前
C++ 第 6 课:switch —— 多状态选择
开发语言·c++·算法
小凡geo2 小时前
本地商家 GEO:用脚本一键生成 FAQPage 结构化数据,让 AI 切片更稳
开发语言·人工智能·python·microsoft·搜索引擎·ai
朝朝辞暮i2 小时前
C++ 第 4 课:if / else —— 让程序自己做决定
开发语言·c++
波力海苔夹心脆6752 小时前
C# 值类型与引用类型详解:存储位置、赋值机制、参数传递、相等比较、装箱拆箱与常见陷阱
开发语言·jvm·经验分享·笔记·c#·.net
m0_380743872 小时前
Qt中导航栏实现的详细指南
开发语言·c++
mhmh1232 小时前
Python 对象模型与数据模型:魔术方法、描述符与元类深度解析
开发语言·python
估值探索者2 小时前
【Python量化策略实战 #02】多因子合成mom和vol两因子打分合成
开发语言·c++·python·数据挖掘·c#
m0_380743873 小时前
PHP7.3和7.2字符串功能有什么不同
开发语言·php
m0_380743874 小时前
PHP 8.4的新语法怎么用才规范
开发语言·php