php-fpm进程复用导致内存不归还是正常设计,RSS持续上涨需调小pm.max_requests;global/static/超全局变量、未关闭的长连接、循环引用是三大主因,应合理设max_requests、及时unset资源、手动触发gc_collect_cycles()。

脚本结束但内存没归还,先查 php-fpm 进程是否复用
PHP CLI 脚本执行完通常会清空全部内存,但 Web 场景下(如 Apache + mod_php 或 php-fpm)进程是常驻的。php-fpm worker 进程会复用,脚本结束只清空本次请求的符号表和 zval,而进程本身继续持有已分配的内存池(emalloc heap),memory_get_usage(true) 可能仍显示高位值。这不是泄漏,是设计使然。若观察到单个 php-fpm 进程 RSS 持续上涨(比如从 40MB → 120MB),重点看 pm.max_requests 是否设得过大——默认 0 表示永不清退,建议设为 500 左右,强制进程在处理若干请求后重启释放底层内存。
global、static 和超全局变量可能拖住内存
这些变量生命周期跨越请求,容易成为“内存锚点”。例如:
global $cache; $cache = []; // 每次请求都往里塞数据,不重置也不 unset
常见陷阱包括:
-
global $db持有 PDO 实例,且未显式调用$db = null或unset($db) -
static $buffer = []在工具函数中累积数据,无清理逻辑 -
$_SESSION或$_SERVER被意外写入大数组或资源句柄(如fopen()返回的 resource) - 类中
static属性引用了对象,而该对象又持有了循环引用(如对象 A → 数组 → 对象 B → 对象 A)
长连接(MySQL/PDO、Redis、Curl)不关闭会锁住内存
PDO 默认启用持久连接(PDO::ATTR_PERSISTENT => true),连接对象不会随脚本结束销毁,其内部缓冲区、查询缓存、结果集指针等持续占内存。Redis 扩展同理,new Redis() 后不调用 $redis->close(),连接资源会滞留。Curl 句柄更隐蔽:curl_init() 返回资源,必须配对 curl_close(),否则底层 socket 和 buffer 不释放。
立即学习“PHP免费学习笔记(深入)”;
验证方式:
- 运行
lsof -p <php-fpm-pid> | grep -E 'tcp|redis|mysql'</php-fpm-pid>查看连接数是否异常增长 - 在脚本末尾加
echo count(get_resources('stream'));看是否有残留流资源 - 用
gc_collect_cycles()前后对比memory_get_usage(),若回收量极小,说明资源未被 GC 触达(因资源类型不参与引用计数)
循环引用 + 未触发 GC 导致 zval 滞留
PHP 7.4+ 默认开启循环检测,但 GC 并非每轮都运行——它依赖缓冲区满(默认 10,000 个可能垃圾节点)或显式调用 gc_collect_cycles()。典型场景:
$a = new stdClass(); $b = new stdClass(); $a->b = $b; $b->a = $a; unset($a, $b); // refcount 未归零,zval 进入 GC 缓冲区,但未触发回收
这种结构在 ORM、树形结构解析、事件监听器注册中高频出现。解决要点:
- 避免在对象间建立双向引用;改用弱引用(
WeakReference,PHP 8.0+)或 ID 映射替代直接持有 - 对已知存在循环结构的代码块,手动插入
gc_collect_cycles()(注意:它有开销,别滥用) - 用
xdebug_debug_zval()检查关键变量的refcount和is_ref,确认是否真被其他作用域隐式持有
最易被忽略的是:你认为“脚本结束了”,其实只是当前请求上下文销毁了;而 php-fpm 进程、全局状态、未关闭的资源、GC 缓冲区里的待回收节点,全都在后台静默存活——它们不报错,也不提示,只悄悄吃掉内存。



















