ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PHP常驻进程内存泄露实战排查与修复:Swoole/WebMan/Octane

PHP常驻进程内存泄露实战排查与修复:Swoole/WebMan/Octane 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每分钟内存增长 200MBgc_status 显示 roots 从 200 涨到 3000 但没有启动 GCgc_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 记录很多人会用一个数组累加结果最后统一返回。这在传统脚本里无所谓但长驻进程里如果这个数组是任务对象的一个属性那处理完一批后数组不会自动释放。我的建议是分块处理 及时 unsetforeach ($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_requestsoctane [ 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 引用了对象 BB 又引用了 A并且两者生命周期都很长是否在循环中创建了闭包而闭包捕获了外部变量第三方库是否需要为长驻模式做特定配置比如连接池、缓存池是否在请求结束后显式清理了大对象还是依赖 PHP 自动释放是否做了请求高峰时临时缓存数据的设计这个缓存有上限吗这些条目看起来简单但每个都对应过我上面描述的真实事故。把这份清单放进团队评审流程能让很多内存问题在代码合入之前就被拦住。5.3 压测回归每一次发布都跑一遍内存曲线最后也是最容易被跳过的一步每次发布新代码前跑一遍带内存观测的压测回归。我的经验是内存泄露往往不是新代码引入的新问题而是老代码到了新流量规模才暴露。所以我在 CI 流程中加了一个手动触发的任务部署到 staging 环境用 wrk 对核心接口打 5 万次请求压测结束等 1 分钟然后检查进程 RSS 是否超过了启动时的 1.5 倍。如果超过了直接阻断发布先排查内存问题再谈上线。这个规则不需要很精确能拦住明显泄露就够了。实际执行中它至少帮我拦下了 6 次本会上线的事故。其中一次是同事在 Octane 项目里加了个Excel 导出功能导出 5 万行记录时把整个数据集的二维数组塞进了一个静态缓存理由是方便二次导出结果压测一跑直接原形毕露。最后说点个人体会。内存泄露在长驻进程框架里永远不是一个一次性修复的问题而是一个持续治理的问题。只要你在迭代业务、升级依赖、调整架构新的隐患随时可能出现。把监控、评审、压测回归这三件事制度化远比记住某一段修复代码更有长期价值。哪怕你今天一个内存问题都没遇到也建议先把监控搭起来——等到线上真的出问题时你就能庆幸自己手里有曲线、有日志、有数据而不是像第一次接触 Swoole 的我一样对着一个 2GB 的进程发愁。
返回列表