1. 先搞清楚一件事:为什么传统 PHP 没有内存问题,换成长驻进程就"藏不住"了
我最早接触 Swoole 是在 2018 年前后,当时团队要把一个商品搜索接口改成常驻服务,第一版上线跑了不到半天,内存从启动时的 80MB 一路涨到 2GB,最后被监控系统强杀。后来做 WebMan 项目、再把 Laravel 项目迁到 Octane,这三条路我几乎都完整地踩过一遍。回头再看,大部分人遇到的内存泄露,并不是代码"写错了"这么简单,而是对长驻进程的内存模型缺乏一个底层认知。
1.1 请求结束时到底发生了什么
传统 PHP-FPM 模式下,每个请求都是一个独立的 PHP 进程生命周期:请求进来,PHP 解释器初始化,执行脚本,脚本结束,所有变量、对象、资源全部释放,进程归还给 FPM 池等待下一个请求。
这个"用完即焚"的机制,决定了传统 PHP 根本没有内存泄露的生存土壤——哪怕你在脚本里写了一万个静态变量、创建了十万个未销毁的对象,只要请求结束,这些内存全部跟着进程一起回收。
但长驻进程完全不是这个逻辑。Swoole、WebMan、Laravel Octane 的核心思想是一样的:进程启动后常驻内存,框架初始化一次,所有请求复用同一个进程环境。每次请求结束后,你创建的变量、对象、连接不会自动消失,而是继续占着内存。更麻烦的是,框架本身为了"复用",会把大量对象缓存起来,这些对象之间又互相引用,最终导致内存只进不出。
1.2 引用计数、垃圾回收在长驻模型下的失效场景
PHP 的内存管理主要靠两套机制:一是引用计数(refcount),二是周期性的垃圾回收器(GC)来清理循环引用。
引用计数的逻辑很直白:每个变量都有一个计数器,被赋值、被引用时 +1,被 unset、被覆盖时 -1,计数归零就立即释放。这在单请求场景下表现得很好,因为请求结束前你手动 unset 或者让变量自然出栈,计数就会归零。
问题出在循环引用上。两个对象互相持有对方的引用,双方 refcount 都不为零,常规的引用计数机制永远无法释放它们。PHP 的 GC 会在内存达到特定阈值时启动,扫描根缓冲区来清理循环引用。这个机制本身没毛病,但在长驻进程里,循环引用会累积得飞快,而且 GC 的扫描成本会越来越高,表现出来就是:内存曲线不但涨,而且越到后面涨得越猛,因为 GC 每次扫描的垃圾集合越来越大。
理解这个底层差异非常关键。在长驻进程里写代码,你要时刻问自己一句话:这个变量在这个请求结束后,还能被"下一次请求"访问到吗?如果答案是"能",那你就得想清楚它到底该不该存在、会不会无限增长。
1.3 三个框架的常驻模型差异决定了泄露模式不同
Swoole、WebMan、Laravel Octane 虽然都叫长驻进程框架,但它们的常驻方式和生命周期管理有本质区别。
Swoole 是最底层的方案,你直接使用 Swoole\Server 或者 Swoole\Http\Server,自己注册 onRequest 回调,自己管理一切。这种模式下你拥有最大的自由度,但也意味着最容易出错——因为框架不会替你做任何内存管理,所有请求间共享状态都得由你自己负责。
WebMan 是基于 Workerman 或 Swoole 的上层框架(默认 Workerman,也可以换 Swoole 驱动),它把常驻进程的复杂度封装了一层,开发者写的代码更接近传统 MVC 结构。但 WebMan 的模型是"进程内全局复用",控制器、模型、中间件等核心对象都是常驻的,你在里面装了什么全局状态,就会一直留着。
Laravel Octane 则是最特殊的:它把完整版 Laravel 框架装入常驻进程,启动时完成一次框架初始化,之后每个请求只是"在已有框架上执行一次"。Laravel 本身有非常强大的服务容器和大量单例服务,这些设计在传统 PHP 里没任何问题,但在常驻模式下就成了内存膨胀的温床。尤其要注意的是,Octane 的 tick 机制会把每次请求中标记为"可变"的对象刷新掉,而静态属性、全局变量、容器里的单例绑定,都不在这个刷新范围内。
简单总结一下三家在内存问题上的"户型"差异:
| 框架 | 常驻粒度 | 主要内存风险点 | 排查难度 |
|---|---|---|---|
| Swoole | 协程/进程级全手工 | 协程上下文、全局状态、连接复用 | 高 |
| WebMan | 全局复用+请求级刷新 | 静态属性、服务容器、忙碌标记 | 中 |
| Laravel Octane | 框架常驻+请求级刷新 | 容器单例、静态属性、Facade | 中偏高 |
所以,诊断方案必须基于你用的框架来定制,直接套用别人的"通用排查步骤"往往事倍功半。
2. 三个框架各自的"内存重灾区",我实际踩出来的经验
熟悉了底层模型之后,下面把三个框架里最容易出内存问题的具体场景逐个拆开。这些场景不是我凭空编的,都是我或我身边同事真实遇到、排查过、修复过的问题。
2.1 Swoole:协程上下文与全局状态需要盯死
Swoole 的坑主要在三个层面。
第一是协程上下文。Swoole 4+ 全面协程化之后,一个 Worker 进程里可以同时存在成千上万个协程,每个协程里你创建的局部变量、临时对象,默认都会跟着协程生命周期走。协程结束时,这些变量理论上应该释放,但如果你在协程里创建了闭包、注册了事件监听器、把局部变量放进了全局数组,协程结束这些引用却不会自动断开。
我遇到过一个很典型的案例:一个用 Swoole 做的 WebSocket 推送服务,每次连接到来会创建一个连接对象并存入全局数组(keyed by fd),断开时再移除。逻辑上看没问题,但服务跑了一天之后内存涨得离谱。排查发现,连接对象内部持有一个日志对象,日志对象又持有一个文件句柄,断开连接时我只 unset 了全局数组里的引用,但连接对象的析构函数里还写了"若未正常关闭连接则补发关闭帧",因为事件循环还没跑到异步清理那一步,对象根本没有被析构。
第二是全局状态。Swoole 开发有一种很常见的坏习惯:为了"性能"在全局数组里存数据。比如用$GLOBALS['cache']或者一个全局的单例类来存请求统计、用户登录态、临时结果。这些数据只要不住数组里添加,就会一直存在,直到 worker 重启。而且更阴险的是,很多全局数据是无意识加进去的——某个第三方库把数据缓存在静态属性里,你根本不知道。
第三是连接复用带来的"隐性引用"。数据库连接、Redis 连接、HTTP 客户端这些长连接对象放在 Swoole 里可以被跨请求复用,这本是好事。但这些连接对象内部往往有缓冲区、响应解析器、任务队列。如果某个请求没有正确消费掉响应数据,或者连接进入了一个异常状态,缓冲区会越积越深。典型的例子是:用 Swoole HTTP 客户端请求外部服务时没有设置超时或没有清理响应体,时间一长,那个连接对象的 buffer 可能吃掉几百 MB。
2.2 WebMan:比 Swoole 友好,但踩坑姿势更隐蔽
WebMan 如果是基于 Workerman 运行,它的内存模型和 Swoole 类似但不完全一样。Workerman 本身是事件驱动的,一个 Worker 进程内也是全局复用,但它的协程模型是后来加的(默认 PHP 原生环境没有协程并发,靠事件轮询)。WebMan 框架帮你做了一层"请求级加载与销毁"的抽象,控制器、业务类在使用后会尽量清理。
WebMan 里最常见的内存泄露有两类。
一类是静态缓存失控。用注解路由时框架会缓存路由表,用中间件时框架会缓存中间件列表,这些还好说,因为数量固定。问题出在业务代码里的静态数组被当成临时存储。比如写了一个静态数组来记每个请求的耗时,以便最后汇总输出,结果请求一多数组无限膨胀。
另一类是服务容器里的大对象常驻。WebMan 的容器和 Laravel 的容器类似,里面可以绑定单例。一旦你把一个"连接池对象"或者"带大量属性的配置对象"绑成单例,而它内部又引用了一些每次请求都不一样的上下文,那么每次请求都会往这个单例对象里追加新引用,内存就这样悄无声息地涨了。
WebMan 还有一个容易踩的坑:换 Swoole 驱动时行为不一致。你用 Workerman 跑的时候内存表现正常,切到 Swoole 跑突然开始涨——因为 Swoole 的协程调度引入了并行执行,原本"单请求独占全局变量"的假设被打破了,多个请求同时往同一个全局数组里写数据,数据量反而变多,而且清理逻辑也互相干扰。
2.3 Laravel Octane:框架自身的"重量级单例"最烧内存
Laravel Octane 的定位是"把 Laravel 变成常驻进程",它引来了 Laravel 的全部家当:服务容器、Eloquent ORM、队列系统、事件系统、各种 Facade。这些东西在传统 FPM 模式里每个请求都重新初始化,内存用完即弃;在 Octane 里它们是常驻的,共享的。
经过实际压测和排查,我总结出 Octane 下四个典型内存问题源:
第一,Eloquent 模型查询累积。在长驻进程里,同一个模型实例被复用(比如通过容器获取),如果你在一个请求里调用了Model::where(...)->get()并且结果集没有被正确释放,模型的静态属性(比如$booted、$globalScopes)里可能会累积每次查询的状态。不过更常见的问题是:模型事件、观察器注册了太多监听器,每次请求往事件里塞闭包,闭包捕获的变量一直留在事件分发器里。
第二,Facade 背后的静态引用。Laravel 的 Facade 本质是访问容器服务的静态代理,它本身不会造成内存泄露。但如果你在服务提供者的boot()方法里用 Facade 做了一些"有状态"的操作,比如用Cache::put()存了一个持续增长的数据结构,那这个数据结构就会作为容器内单例对象的一部分被保留。
第三,队列 Worker 与 Octane 的组合。很多团队在 Octane 里同时跑队列消费,队列 Job 如果内部持有大量数据(比如装载了巨型模型集合),Job 执行完后这些数据应该释放。但如果 Job 是通过容器解析出来的单例,或者 Job 内部用了静态属性缓存,那么每个 Job 执行后内存都会涨一点。
第四,config()和env()的误用。某些人在运行时动态修改 config 数组的值,比如config(['app.name' => 'xxx'])。传统 FPM 下每次请求都是独立的 config 数组,改了也就改了,影响仅限当前请求;Octane 下 config 是常驻的,改一次就全局永久生效。你可能会看到内存里积累了大量这样的"动态配置",尤其当值是数组、对象时。
2.4 一个被大多数人忽略的共性源头:第三方扩展的内存行为
排查内存泄露时,很多人只会盯着自己的业务代码,但实际经历告诉我,第三方扩展才是最大的不定时炸弹。
先说 PHP 扩展层面。Swoole 扩展本身不会泄露,但如果你在 Swoole 的协程环境里使用了非协程安全的扩展(比如某些老版本的面包屑扩展、某些本地 IO 库),它们内部维护的缓冲区不会随协程结束而释放,内存直接漏掉。这个问题最恶心的点在于:你无论怎么排查业务代码都找不到原因,最后只能通过逐步禁用扩展来定位。
再说 Composer 包层面。有些包的设计没有考虑长驻场景。比如一个 HTTP 客户端库,它内部为了连接复用,会把所有 Host 的链接池对象放到一个静态数组里,你每请求一个不重复的域名,数组就多一项;再比如某个日志包,它的处理器是单例的,内部把所有日志条目存在一个数组中,只在达到某个阈值时批量落盘,如果阈值设得很大,内存就不断涨。
我自己遇到过最离谱的一次:某第三方"验证码识别"包,内部用了一个静态数组缓存"识别后的训练样本",每识别一次就往数组里 push 一个结果对象,代码注释写着"便于后续自动学习"。结果生产环境跑了三天,内存从 300MB 飙到 5GB,排查了两天才翻到这个被隐藏起来的静态数组。
经验提醒:排查内存问题时,第一步先把你项目里
composer.json的全部依赖列出来,逐个过一遍,看哪些包有明显"缓存型静态属性",优先排查它们,这能帮你节省大量时间。
3. 诊断内存泄露的完整链路:从观测到定位,整个过程要像侦探一样
很多初学者遇到内存泄露的第一反应是"我代码肯定有 unset 没写对",然后开始疯狂加 unset、加 gc_collect_cycles(),最后发现毫无用处。正确的做法是用数据驱动排查。
3.1 先回答三个问题:内存在涨吗?涨得快吗?涨到哪里去了?
在动手改任何代码之前,先把这三个问题搞清楚。
第一个问题是"内存在涨吗"。注意不是说内存变大就意味着泄露——长驻进程启动后,框架本身会预加载一堆类,内存有 100~200MB 的初始占用是非常正常的。你要关注的是稳态之后是否还在持续上涨。如果启动 1GB,跑了 24 小时还是 1GB,那就没事;如果从 1GB 涨到 1.5GB、2GB,那就说明有东西一直在堆积。
第二个问题是"涨得多快"。如果几分钟就涨 100MB,那是极严重泄露,定位起来反而容易;如果一天涨 100MB,那定位难度就大多了,因为泄露点往往很隐蔽。监控内存的时候,我建议你记录一个分钟级采样,画出内存曲线,曲线越接近线性增长,越说明是"每请求固定泄露固定量";曲线如果是指数级增长,说明很可能有一个对象持有大量循环引用,越往后 GC 越扫不动。
第三个问题是"涨到哪里去"。这个问题最难回答,因为你不能直接看到一个进程的堆里具体有哪些对象。但我有一个实用技巧:在进程内用memory_get_usage(true)配合gc_status()采样,把每次请求前后的内存差、GC 扫描的根数、收集的循环引用数记录下来。如果 GC 的collected一直为零但roots疯狂增加,说明循环引用的垃圾正在堆积但 GC 阈值还没到;如果collected持续增长,说明 GC 在努力帮你清理,但生成垃圾的速度远大于清理速度。
3.2 压力测试脚本与观测命令:最朴素的往往最有效
诊断内存泄露最直接的方法就是压测+观测,模拟真实请求高频打进去,然后盯进程内存。
我用的是很朴素的做法:写一个压测脚本,不限并发地把某个接口打一万次,每次请求之间调用一个"打点"函数,记录内存。以下是我常在 Swoole 项目里用的观测脚本片段:
<?php // 内存观测脚本:每次请求结束时记录关键指标 // 在 WebMan/Octane 中可以注册一个全局中间件/监听器来调用 function memory_probe(string $label): void { $memReal = memory_get_usage(true); $memPeak = memory_get_peak_usage(true); $gc = gc_status(); // 返回 roots, collected, running 等字段 $line = sprintf( "[%s] memory=%dMB peak=%dMB gc_roots=%d gc_collected=%d\n", $label, $memReal >> 20, $memPeak >> 20, $gc['roots'], $gc['collected'] ); file_put_contents('/tmp/memory_probe.log', $line, FILE_APPEND); }压测命令我用过 wrk、ab、JMeter,最终用得最多的是 wrk,因为它够轻量:
wrk -t4 -c100 -d 60s http://127.0.0.1:8080/api/demo压测开始前记录初始内存,压测结束后再记录一次,配合每分钟一次的采样日志,基本就能判断有没有泄露、泄露速率是多少。
如果手里有 Grafana 那更好,直接把memory_get_usage()推给 Prometheus,配合容器内存指标,曲线一目了然。没有监控体系的话,上面这种朴素的日志方案也足够用了。
3.3 二分定位法:快速把嫌疑范围缩到最小
定位内存泄露,我强烈推荐用"二分定位法"而不是"逐行检查法"。
具体操作是这样的:
- 把你项目的所有接口/任务/入口全部列出来。
- 单独压测其中一个接口,只打它,观察内存是否增长。如果增长,说明泄露就在这个接口的调用链上;如果不增长,就换下一个接口。
- 找到有问题的接口后,进入它的调用链,把链路上的模块逐一"关闭"——比如挨个注释掉中间件、服务类、模型查询,每关一个就重新压测,观察内存增长是否停止。
- 反复二分,直到锁定某个具体的文件、类、方法。
这个过程听起来繁琐,但实际效率极高。我有一次定位 Octane 的内存问题,第 1 步压测了 8 个接口,发现只需要打/order/list接口内存就疯涨,于是把它锁定为"订单列表"相关链路。然后我把列表接口拆成"读取订单模型"和"格式化输出"两步,分别测试,最后发现泄露竟然发生在订单模型的append()访问器上——每查一次订单列表,模型就把一堆"格式化后的数据"塞进静态缓存里。
3.4 用数据而不是猜:看懂 gc_status 和 memory_get_usage 的输出
很多人听说过gc_status()这个函数,但不知道它具体怎么看。它的返回值包含三个关键字段:
roots:根缓冲区中等待扫描的根数。根的本质是"可能形成循环引用的对象"。正常情况下降,每请求结束都会有一段根被清理掉;如果 roots 只增不减,说明有大量循环引用在生成且 GC 还没有机会去扫它们。running:表示 GC 当前是否正在运行。如果它一直是 1,说明 GC 忙得停不下来,内存压力已经很大。collected:累计收集并清理的循环引用对象数。如果 collected 不停地增长但你内存还是在涨,说明清理的速度赶不上生成的速度,或者 GC 还没有达到触发条件。
配合memory_get_usage(true)的真实内存占用,你就能拼出完整的画像。举个例子,我项目里观察到:active 请求数 100,每分钟内存增长 200MB,gc_status 显示 roots 从 200 涨到 3000 但没有启动 GC,gc_collect_cycles()手动调用后内存瞬间回落 100MB——这说明代码里的循环引用已经在堆积,但 PHP 的 GC 默认阈值(据说在 10000 个根左右)还没到,所以它一直在"装死"。这种情况下即使你不改任何代码,只要调低 GC 阈值或者手动触发收集,也能临时缓解,但根治还是要找到生成循环引用的位置。
一个小经验:生产环境不要轻易开
zend.enable_gc = 0。我见过有些"性能优化"帖子让大家关掉 GC 来提升性能,这在传统 FPM 下可能行得通,但在长驻进程下关掉 GC 约等于自杀,roots 会无限膨胀,最终内存直接撑爆。
4. 修复内存泄露的常用解法与代码模式:照着改就能落地
定位到泄露点之后,修复就相对简单了。下面分享几个我实际项目里最常用的修复模式,每种都附带了代码示例和适用场景。
4.1 静态变量与单例:能不用就不用,必须用就限定生命周期
静态变量是内存泄露的"状元"。一个静态数组、一个静态对象属性,只要业务代码往里塞数据,就会一直积累。我处理这种问题的优先级是这样的:
- 能改成局部变量的,绝不写成静态;
- 必须用静态的,限定数量,比如用定长数组 + 入队出队;
- 必须长期存活的静态对象,确保它内部不持有请求级数据。
举个例子,之前我在 WebMan 项目里发现一个MetricsCollector类,用来记录请求耗时,实现是静态数组:
class MetricsCollector { public static array $records = []; public static function record(string $name, float $duration): void { self::$records[] = ['name' => $name, 'duration' => $duration]; } }每次请求都会往$records里追加一条,只要进程不重启,数组就无限增长。修复方案是加一个滑动窗口,把超过 N 条的老数据丢弃:
class MetricsCollector { private const MAX_RECORDS = 10000; public static array $records = []; public static function record(string $name, float $duration): void { self::$records[] = ['name' => $name, 'duration' => $duration]; if (count(self::$records) > self::MAX_RECORDS) { array_shift(self::$records); // 丢弃最旧的一条 } } }再延伸一步:如果这类的数据最终要定期落盘/上报,你可以改成"先攒 1000 条就 flush 一次",既控制并发写入频率,又防止内存堆积。
4.2 容器、批量处理与连接管理:看清引用关系的"持有链"
在 Laravel Octane 或 WebMan 里,服务容器是内存管理的核心枢纽。容器内被绑定的单例对象,生命周期等同于进程本身。如果你在里面绑定了一个大对象,这个大对象内部又持有一个不断增长的数据结构,那内存就必然出问题。
一个很实用的排查办法:在容器解析对象时观察它的属性是否有"迭代感"。比如你绑定了HttpClientManager作为单例,它内部有一个$connections数组,每次请求新增一条连接记录,那它一定会泄露。
修复上,我们通常把"连接管理器"设计成"懒连接 + 空闲回收"模式:
class ConnectionManager { private array $connections = []; private array $lastUsedAt = []; public function get(string $key): mixed { $now = time(); // 每次获取时先顺手清掉超过 300 秒没有使用的连接 foreach ($this->lastUsedAt as $k => $t) { if ($now - $t > 300) { unset($this->connections[$k], $this->lastUsedAt[$k]); } } if (!isset($this->connections[$key])) { $this->connections[$key] = $this->createConnection($key); } $this->lastUsedAt[$key] = $now; return $this->connections[$key]; } }再来说批量处理。长驻进程里做批量任务(比如一次处理 10 万条 Excel 记录),很多人会用一个数组累加结果,最后统一返回。这在传统脚本里无所谓,但长驻进程里如果这个数组是任务对象的一个属性,那处理完一批后数组不会自动释放。我的建议是分块处理 + 及时 unset:
foreach ($largeCollection->chunk(1000) as $chunk) { $result = processChunk($chunk); // 立即写入外部存储,不要攒在内存里 $storage->append($result); // 把 chunk 结果置空 unset($chunk, $result); }4.3 日志、队列、异常处理:给"无界收集"加个盖子
日志系统是内存泄露的高发地。默认情况下,Monolog 之类的日志库是把日志条目放在一个内存 buffer 里,到一定数量再批量写出。在长驻进程里,如果 buffer 的 flush 条件没触发(比如 handler 是基于请求结束时才 flush 的),日志就会一直堆在内存中。
我遇到过 Octane + Monolog 的经典案例:日志 buffer 攒了上百万条,内存直接爆掉。修复方法是给日志 handler 加一个显式的"按条数或大小刷盘"策略,或者在每次请求结束时手动flush日志缓冲。
队列任务也一样。如果一个 Job 反复入队而消费速度跟不上,队列处理器内部会维护一个等待队列的对象。虽然这看起来是"队列积压"而不是"内存泄露",但在长驻进程中表现形式一致——进程内存被积压的任务对象占满。这种场景下,光靠写代码不够,还得在队列架构上控速(比如限制并发消费数、限制队列容量)。
异常处理也是很多人忽视的点。如果业务代码里用了类似"把所有异常堆到数组里最后统一上报"的设计,每个异常对象会携带堆栈 trace 字符串,那数组就是一台内存粉碎机。我建议改成"每收集一条就实时上报/每满 100 条上报一次并清空",绝不在内存里跨请求保留异常。
4.4 解决不了的泄露怎么"兜底":进程模型与安全重启
有些内存泄露你排查了几天,就是找不到根因;或者找到了根因但第三方库不给你改源码的机会。这种情况下,兜底策略就不是消灭泄露,而是控制泄露的破坏半径。
两种常见的兜底方案:
第一种,定时平滑重启 worker。无论是 Swoole、WebMan 还是 Octane,都支持设置 worker 的最大请求数或最大运行时长。例如 Swoole 可以这样限制:
$server->set([ 'max_request' => 5000, // 处理 5000 个请求后自动重启该 worker 'max_wait_time' => 60, // 重启前等待正在处理的请求完成 ]);Laravel Octane 也可以配置max_requests:
'octane' => [ 'server' => 'swoole', 'max_requests' => 5000, 'workers' => 8, ],这样即便有少量泄露,也会在 worker 重启时被彻底清掉,内存曲线会呈现"锯齿状"而不是"斜直线",服务就不会被拖垮。
第二种,把"不稳定业务"隔离到独立进程。团队在使用 Swoole 时,可以把某些特别容易产生内存问题的代码放到一个单独的 worker 或者单独的进程里运行,比如用Swoole\Process单独开一个子进程来跑那些"没法根治"的任务。子进程挂了、内存爆了,父进程把它拉起来就行,不影响主服务的稳定性。
不过要记住,兜底方案是"创可贴"不是"解药"。如果服务每 10 分钟就重启一次,用户侧会有明显的请求抖动,正确态度仍然是"兜底保命 + 持续追查根因"。
5. 别等问题爆发:从架构层面把内存问题"管起来"
聊完了诊断和修复,我想再强调一点:内存泄露这种问题,如果只靠"出事了再排查",成本太高了。更聪明的做法是在架构设计阶段就把内存问题纳入考量,让它很难发生,或者在早期就能被监控发现。
5.1 监控与预警:在泄露发生之前就发现它
我现在的团队有一套半自动化的内存监控策略,核心就一句话:"内存曲线不应该是单调递增的直线。" 所有长驻进程服务上线前,都要接一个内存监控,指标包括进程 RSS、PHPmemory_get_usage(true)、gc_status中的 roots 数量。
这套监控的报警规则设计得很粗暴:每分钟采集一次内存,如果连续 30 分钟内内存增量超过 300MB,直接告警出来,人工介入。为什么 30 分钟而不是 5 分钟?因为短期的内存抖动可能和瞬时请求量有关,30 分钟窗口能把"短期流量毛刺"过滤掉,只暴露真正的趋势性增长。
如果你没有专门的 APM 工具,可以用一个很轻量的方案:一个独立的 Crontab 脚本,每隔一分钟读取 PM2/Supervisor 管理的进程 RSS,追加到本地文件,用 Grafana 或者最简单的awk分析趋势。我自己在最早期就是这么干的——在生产服务器上跑了一个monitor.sh,每分钟把 Swoole 主进程的 RSS 写进 CSV,然后用 Excel 画图。虽然土,但真的好使,内存曲线开始往上拐的时候当天就能发现。
5.2 代码评审里的内存审查清单
长驻进程项目的代码评审,和传统 PHP 项目不一样。只看"功能对不对"远远不够,还要过一遍"内存审查清单"。我把常用的检查项列在这里,你可以直接抄走:
- 是否新增了静态属性/全局变量,且内容是随请求数量增长的?
- 是否在服务容器中绑定了新的单例?单例内部是否引用了请求级数据?
- 是否有循环引用:对象 A 引用了对象 B,B 又引用了 A,并且两者生命周期都很长?
- 是否在循环中创建了闭包,而闭包捕获了外部变量?
- 第三方库是否需要为长驻模式做特定配置(比如连接池、缓存池)?
- 是否在请求结束后显式清理了大对象,还是依赖 PHP 自动释放?
- 是否做了"请求高峰时临时缓存数据"的设计?这个缓存有上限吗?
这些条目看起来简单,但每个都对应过我上面描述的真实事故。把这份清单放进团队评审流程,能让很多内存问题在代码合入之前就被拦住。
5.3 压测回归:每一次发布都跑一遍内存曲线
最后,也是最容易被跳过的一步:每次发布新代码前,跑一遍带内存观测的压测回归。
我的经验是,内存泄露往往不是"新代码引入的新问题",而是"老代码到了新流量规模才暴露"。所以我在 CI 流程中加了一个手动触发的任务:部署到 staging 环境,用 wrk 对核心接口打 5 万次请求,压测结束等 1 分钟,然后检查进程 RSS 是否超过了启动时的 1.5 倍。如果超过了,直接阻断发布,先排查内存问题再谈上线。
这个规则不需要很精确,能拦住"明显泄露"就够了。实际执行中,它至少帮我拦下了 6 次本会上线的事故。其中一次是同事在 Octane 项目里加了个"Excel 导出"功能,导出 5 万行记录时把整个数据集的二维数组塞进了一个静态缓存,理由是"方便二次导出",结果压测一跑直接原形毕露。
最后说点个人体会。内存泄露在长驻进程框架里,永远不是一个"一次性修复"的问题,而是一个"持续治理"的问题。只要你在迭代业务、升级依赖、调整架构,新的隐患随时可能出现。把监控、评审、压测回归这三件事制度化,远比记住某一段"修复代码"更有长期价值。哪怕你今天一个内存问题都没遇到,也建议先把监控搭起来——等到线上真的出问题时,你就能庆幸自己手里有曲线、有日志、有数据,而不是像第一次接触 Swoole 的我一样,对着一个 2GB 的进程发愁。