APCu单机内存缓存实战:与PHP-FPM配合的命中率排查

个人站长谈到「缓存」,条件反射就是装个 Redis,然后在代码里到处 $redis->get()。这套组合在数据量大、需要跨机器共享、需要持久化的场景里确实是对的。但一个日 PV 几千到几万的中小站点,最常见的缓存需求其实是:把一份几百 KB 的配置数组、一个复杂的分类树、一份访问计数,放在「当前这台机器的 PHP 进程里」,让它在下一次请求时能立刻拿到,不用网络、不用序列化、不用考虑并发写。

这个需求 Redis 解决得并不好——每次 get 都是一次 TCP 往返加反序列化,虽然只有零点几毫秒,但在一个需要读十几次缓存的页面上就会累积成几毫秒。而 APCu 恰好就是干这个的:单机、进程内、共享内存,读取是纯内存操作。这篇讲清楚 APCu 到底适合什么、怎么和 PHP-FPM 配合、以及那些让你的缓存「看起来开了却完全没命中」的坑。

一、APCu 是什么,和 OPcache 差在哪

先说最容易混淆的一点:APCu 不是 OPcache。OPcache 缓存的编译后的 PHP 字节码,键是文件路径,你不用管它,它也完全透明。APCu 缓存的是你自己代码里存进去的任意 PHP 变量,键是你自己起的名字,必须显式 apcu_store / apcu_fetch。

两者都基于共享内存,都被 FPM 的多个 worker 进程共享,都随 PHP 重启而清空。所以可以同时启用,互不冲突。一个健康的 FPM 部署通常是:OPcache 开满,APCu 分配 64 到 256MB 用于业务缓存。

APCu 的边界也很清楚:只在一台机器上有效,只在 PHP 进程存活期间有效,不持久化。所以凡是需要「重启后还在」或「多机共享」的数据,都不该放 APCu。反过来说,凡是「丢失了能自动重建、且重建成本可接受」的数据,都是 APCu 的完美客户。

二、安装与给 FPM 单独配额

Debian/Ubuntu 上装 php8.2-apcu 之后,配置会分到两个地方:CLI 的 /etc/php/8.2/cli/conf.d/20-apcu.ini 和 FPM 的 /etc/php/8.2/fpm/conf.d/20-apcu.ini。默认配置往往把 apc.enable_cli 关掉,如果你写的是 CLI 脚本长时间跑批处理并且想复用缓存,需要单独打开。

关键参数只有四个,写在 FPM 的 conf.d 里:

; /etc/php/8.2/fpm/conf.d/99-apcu.ini
extension=apcu.so
apc.enabled=1
apc.shm_size=128M
apc.ttl=7200
apc.gc_ttl=3600
apc.entries_hint=8192
apc.smart=600
apc.enable_cli=0

apc.shm_size 是共享内存段大小,注意它是按 segment 分配的,在多进程模式下会被划分为多个段,单个大对象放不进去。所以不要试图用一个键存一个 50MB 的数组,那会静默失败(apcu_store 返回 false 但不报错)。apc.ttl 是条目在「被访问过」的情况下的最长存活,apc.gc_ttl 是当共享内存满时,未访问条目能被回收的宽限时间。这两个值配合决定了内存紧张时的淘汰行为。

apc.smart 是一个容易忽略但很有用的参数:它设置一个内存占比阈值(单位是 1/1000),超过阈值后 APCu 停止缓存新的条目,只保留已有的。这样可以防止突发的缓存穿透把共享内存瞬间打满,导致已有的热数据被全部挤掉。中小站建议设成 500 到 700。

改完配置要 systemctl reload php8.2-fpm。

三、缓存什么,以及怎么设计键

按「重建成本 / 数据变更频率」两个维度来挑,APCu 最合适的四类数据:

一是配置与规则表。例如站点设置、导航菜单、分类树、SEO 规则、敏感词表。这类数据读极频繁、写极少,变更时手动清缓存即可。

二是模板渲染结果。一个侧边栏、一个热门文章列表,渲染一次要查三张表,缓存成字符串直接 echo。

三是计数类的近似值。访问量、阅读数这类允许有延迟的数据,用 APCu 在内存里累加,定时批量刷回数据库,可以把每次请求一次的 UPDATE 降为每分钟一次。

四是负缓存。查不到的数据(不存在的 ID、已删除的文章)也缓存一个空标记,避免恶意请求反复穿透到数据库。这一条经常被忽略,但对抗扫描器非常有效。

键的设计要包含「版本」,这是让缓存失效变得简单的关键。不要用 article_123 这种裸键,而是给整个站点的缓存加一个版本前缀:

define('APCU_NS_VERSION', 'v7');
function cache_key(string $k): string {
    return 'blog:' . APCU_NS_VERSION . ':' . $k;
}

function cache_get(string $k) {
    $ok = false;
    $v = apcu_fetch(cache_key($k), $ok);
    return $ok ? $v : null;
}

function cache_set(string $k, $v, int $ttl = 600): bool {
    return apcu_store(cache_key($k), $v, $ttl);
}

// 发布新文章、改配置后,把版本号 +1 就相当于全站缓存失效
// 旧键会随 TTL 自然过期,不需要逐个删除

版本号方式的好处是:不需要遍历删除(APCu 没有可靠的「按键前缀批量删除」),也不存在漏删的风险。改一次 APCU_NS_VERSION 就是一次「逻辑上的 flush」。

四、进程间共享的坑:你以为共享,其实没有

这是最容易出问题的地方,也是最容易被误判成「APCu 不生效」的地方。分三种情况:

情况一:FPM 的 pm 必须是 dynamic 或 static。如果用了 pm=ondemand,worker 进程会在空闲时被全部回收,缓存在进程里?不,APCu 的共享内存段是随 FPM master 的共享段建立的,master 存活期间数据还在。但如果 apc.use_request_time 与 TTL 配合不当,加上 worker 频繁重生,你会观察到命中率忽高忽低。

情况二:多个 FPM 池之间不共享。一个机器上跑两个 pool(比如 www 和 www2,用 listen 不同的 socket),它们是两个独立的 master 进程,各自有独立的 APCu 共享段。缓存写在这边,那边读不到。这是最常见的「缓存明明存了却读不到」的原因,很多人在负载均衡配置里分了两个池来隔离站点,之后就一直困惑命中率为什么只有一半。

情况三:CLI 与 FPM 不共享。运维脚本里 php clear_cache.php 去 apcu_clear_cache(),清的是 CLI 自己的那块内存,对 FPM 毫无影响。要不要清 FPM 的缓存,得通过 Web 侧的一个受保护接口,或者干脆重启 FPM。

对应的排查方法:在页面里输出 apcu_cache_info(true) 的 num_entries 与 mem_size,连续请求同一个页面两次,对比这两个值是否增长。如果不增长,先怀疑是不是两个不同的 worker/pool 在处理请求。

五、命中率上不去的五个具体原因

第一,键里带了每次都会变的东西。把用户的 session id、时间戳、完整 URL(带 UTM 参数)、或者 $_SERVER['REQUEST_TIME_FLOAT'] 拼进键,命中率必然是零。检查方法:把生成的键写进日志看是否每次都不同。

第二,TTL 设得太短。apcu_store($k, $v, 1) 几乎等于没缓存。一个需要查三张表、耗时 20ms 的渲染结果,TTL 至少应该是 60 秒起。

第三,共享内存反复写满。看 apcu_cache_info() 里的 num_hits 与 num_misses,如果 misses 里有大量是「刚存进去就被挤掉」,就去调大 shm_size 或加一层 apc.smart 保护。这里常见的失败模式是:一个巨大的分类树数组占了 80% 内存,剩下的小条目全被挤出。

第四,缓存的是资源或闭包。APCu 只能存可序列化的标量、数组、普通对象。存了 PDO 连接、文件句柄、匿名函数,apcu_store 会悄悄返回 false(或者存进去后 fetch 拿到残缺数据)。发布前用 serialize() 试一下,能通过才存。

第五,代码路径根本没走到缓存。比如 catch 分支里直接 return,或者模板里在缓存判断之前就 echo 了。这类只能靠日志打点确认。

六、和 Redis 怎么分工

如果站点已经装了 Redis,不必把 APCu 换掉,两者分工明确反而更好:

APCu 放「极热、极小、可重建、单机够用」的数据:配置数组、菜单、负缓存、定点计数。读取路径上零网络开销。

Redis 放「需要跨机共享、需要原子操作、需要持久化」的数据:分布式锁、队列、Session、需要精确一致的计数、跨进程的限流窗口。

一个典型的三层读路径是:APCu(进程内,微秒级)→ Redis(本机或内网,亚毫秒级)→ MySQL(毫秒级)。每层都设 TTL,且 TTL 逐层递减(例如 APCu 60 秒、Redis 600 秒),这样数据库永远不会被瞬间打穿,而最热的数据又被压在最快的层。

需要注意的是,APCu 命中时拿不到「数据是何时写入的」这种精确信息,只适合那些对时效性不敏感的场景。凡是「用户改了资料必须立刻看到」的页面,不要走 APCu,或者走的时候按用户 id 分键并主动失效。

七、上线前的检查清单

简单收个尾,这七条是我实际部署时每次都过一遍的:

1. php -m | grep apcu 里同时有 apcu,但没有 apc(老的 apc 扩展和 APCu 冲突,同时加载会崩)。2. FPM 与 CLI 两份 ini 都改过了,不要只改一份。3. apc.shm_size 根据实际键数量估算过,且不小于 64M。4. 键名带命名空间版本,便于整体失效。5. 存进去之前确认可序列化。6. 单机单 pool,不用 pm=ondemand 做极端省内存的配置。7. 有一个能在页面上看到命中率的入口(放在后台、加 IP 白名单),否则出问题只能靠猜。

最后提醒一句:APCu 是「加速器」而不是「数据存储」。它最大的价值是把数据库压力从「每个请求一次」降到「每分钟一次」,一旦你开始指望它在重启后还记得什么,方向就错了。把它当成一台机器上的一次性快表,用它最擅长的部分,剩下的交给 Redis 和磁盘。

Last modification:October 20th, 2026 at 09:24 pm

Leave a Comment