ThinkPHP 8 常驻内存实战:单例污染、静态残留与内存泄漏治理

2026-10-16 0 777

去年年底给一个电商项目做性能优化,把 ThinkPHP 8 从 FPM 迁到 think-swoole,QPS 从 900 涨到 4200,机器减了三分之二。上线第三天收到客服转来的一张截图:用户 A 的”我的地址”页面里,出现了一个完全不认识的收货人姓名和手机号。

技术群里当时一下就炸了。用户数据串号,这在电商项目里是最高等级的线上事故。第一反应是查数据库,看是不是并发写错了。翻了一遍 binlog,那条地址的归属一直是对的,没有被人修改过。那么问题一定出在读的环节。

排查到第二天下午才锁定。是常驻内存模式下,一个静态数组缓存没有被清掉,把上一个请求的地址数据带给了下一个请求。

这篇文章把那次事故的排查过程和后续三个月的治理经验整理出来。不是介绍怎么装 think-swoole,而是把常驻内存改变的那些”默认假设”一条一条摆出来。

常驻内存到底改变了什么

FPM 模式下的 PHP,每次请求都从零开始:加载框架、解析路由、初始化容器、执行控制器、销毁所有变量。请求之间是彻底隔离的。你在代码里怎么折腾都行,反正请求结束,一切归零。

Swoole(或者 Workerman)模式下,进程只启动一次。所有应用层面的对象——框架容器、模型、单例、静态变量——都留在内存里,跨请求复用。这就是性能提升的来源:省掉了每次重新加载和初始化的成本。

代价是原来由”进程退出”自动完成的清理工作,现在需要开发者手动完成。所有默认假设”请求结束就没了”的东西,在常驻内存下都会累积或者残留。

具体哪些东西会残留?我列了一个清单,这是排查那次事故之后,团队一条一条补上的。

坑一:容器的单例

ThinkPHP 的容器默认对 bind 是单例的——第一次实例化之后,后续请求拿到的都是同一个对象。这在 FPM 下没问题,因为容器本身就是每次请求重建的。常驻内存下就变成了跨请求复用同一个对象。

看一段容易出事的代码:

namespace appservice;

class OrderService
{
    // 请求级别的数据
    private $currentUser = null;
    private $cache = [];

    public function setUser($user)
    {
        $this->currentUser = $user;
    }

    public function getCurrentUser()
    {
        return $this->currentUser;
    }

    public function find($id)
    {
        if (!isset($this->cache[$id])) {
            $this->cache[$id] = OrderModel::find($id);
        }
        return $this->cache[$id];
    }
}

这个 Service 是通过 app()->make(OrderService::class) 或者直接依赖注入使用的。第一次 make 之后它进了容器,后面每次请求拿到的都是同一个实例。$currentUser 不重置,下一个请求在 setUser 之前如果先调用了 getCurrentUser,拿到的就是上一个请求的用户。

那次事故里的地址缓存其实更直接——$cache 数组按 id 做键,跨用户查询时如果 id 撞上(比如两个用户都查了 id=100 的地址,但那条地址本身经过权限校验是当前用户的,逻辑上不该撞),但代码里在 find 之前没有做归属校验,直接命中了别人的缓存。

修复的方向有两个。

方向一:请求进入时清空请求级状态。在中间件或者 onRequest 事件里统一处理:

// app/middleware/ResetRequestState.php
namespace appmiddleware;

use Closure;
use thinkRequest;
use thinkResponse;

class ResetRequestState
{
    public function handle(Request $request, Closure $next): Response
    {
        // 重置所有登记在册的请求级服务
        $this->resetServices([
            appserviceOrderService::class,
            appserviceCartService::class,
        ]);

        return $next($request);
    }

    private function resetServices(array $classes): void
    {
        foreach ($classes as $class) {
            if (app()->exists($class)) {
                $instance = app()->make($class);
                if (method_exists($instance, 'reset')) {
                    $instance->reset();
                }
            }
        }
    }
}

对应的 Service 里加一个 reset() 方法,把请求级字段清空。这是个约定,一旦约定就要写成规约,代码评审卡住。新人写新 Service 的时候不一定记得,所以下面的方向二更根本。

方向二:请求级状态不用类属性。把它作为方法参数传递,或者放进 Request 对象里。Request 对象在常驻内存下是每次请求新建的,天然隔离。

class OrderService
{
    public function find($id, Request $request)
    {
        $user = $request->user; // 中间件里塞进去的
        // 从数据库查询时带上归属条件
        return OrderModel::where('id', $id)
            ->where('user_id', $user->id)
            ->find();
    }
}

这次迁移之后我们把项目里所有 Service 类的请求级属性全部改成了参数传递。代码看着稍微啰嗦了点,但常驻内存下的隐患彻底消除。

另外提醒一句:不要盲目把容器里的东西当成单例用。app()->make() 的行为受 bind / instance 影响,默认情况下会缓存实例。如果要拿一个全新的对象,用 app()->invokeClass() 或者直接 new。

坑二:静态属性与全局变量

比容器单例更隐蔽的是静态属性。上一篇文章里写多租户隔离的时候,我用过一个 static $tenantSuspended 做临时开关,配合 try/finally 恢复。这在 FPM 下一点问题都没有——无论怎么操作,请求结束这个静态值就消失了。

常驻内存下,这个静态值会一直留在进程里。如果某次请求因为某个异常路径绕过了 finally(比如 set_time_limit 0 导致的强制中断,或者 Fatal Error),静态值就永远是 true 了,之后所有请求都不会再加租户条件——这就是一个持续性的数据泄露。

排查工具是静态分析。写一个简单的 PHPStan 规则或者 grep 脚本,扫出所有 static $var 和 self::$var 的赋值语句:

grep -rn "static $[a-zA-Z_]* = " app/ --include="*.php"
grep -rn "self::$[a-zA-Z_]* = " app/ --include="*.php"
grep -rn "::$[a-zA-Z_]* = " app/ --include="*.php"

扫出来的每一处都要确认:这个值是否需要在请求之间保留?如果不需要,要么换成实例属性,要么在请求开始时显式重置。

递归处理,别忘了父类。写多租户那次用的静态属性定义在抽象类上,子类继承过来的静态属性是共享的——一个子类改了这个静态值,父类和所有其他子类看到的都跟着变。这个行为在 FPM 下感知不到,常驻内存下会很严重。

坑三:数据库连接池与事务

Swoole 模式下,数据库连接是放在连接池里复用的。这是性能的另一个来源,但也是坑最多的地方。

坑 3.1:事务没提交就还回连接池

看这段代码:

public function transfer($from, $to, $amount)
{
    Db::startTrans();
    try {
        Db::name('account')->where('id', $from)
            ->dec('balance', $amount)->update();
        Db::name('account')->where('id', $to)
            ->inc('balance', $amount)->update();
        Db::commit();
    } catch (Throwable $e) {
        Db::rollback();
        throw $e;
    }
}

看起来没问题。但如果 rollback() 本身也抛异常呢——比如网络突然断了,回滚语句没法发出去。这时候连接还处于”未结束事务”的状态,被还回连接池。下一个拿到这个连接的请求,做的所有操作都会被套在那个未完成的事务里,直到它自己 commit 或者 rollback 才结束。而它的 commit 会连带把上一个失败事务的部分修改一起提交。

处理办法是在连接归还之前强制结束事务。think-swoole 的连接池有 Db::close() 之类的机制,但更稳妥的做法是在异常路径里加一层保护:

public function transfer($from, $to, $amount)
{
    Db::startTrans();
    try {
        Db::name('account')->where('id', $from)
            ->dec('balance', $amount)->update();
        Db::name('account')->where('id', $to)
            ->inc('balance', $amount)->update();
        Db::commit();
    } catch (Throwable $e) {
        try {
            Db::rollback();
        } catch (Throwable $inner) {
            // 强制丢弃这个连接,不要还回池子
            Db::close();
        }
        throw $e;
    }
}

宁可销毁连接也不要带事务归还。这是常驻内存模式下关于连接池最重要的一条原则。

坑 3.2:事务没提交就发起了新的查询

Swoole 的协程模式下,同一个请求里的数据库操作是串行的。但如果代码里用了 Db::query() 又用了 Db::name()->...,有可能底层用了不同的连接。事务跨连接是无效的,会出现”部分数据提交了、部分回滚了”的诡异情况。

排查方式是打开 think-swoole 的连接日志,看一次请求里到底拿了几次连接。一次事务里如果有多次不同途径的数据库调用,都要确认它们是否走同一个连接。

坑 3.3:连接池的”空闲检测”没有对

MySQL 有一个 wait_timeout 参数,默认 8 小时。连接池里的空闲连接超过这个时间会被服务端断开。如果连接池没有做存活检测,下一个拿到的是个死连接,请求直接失败。而且这种失败是”偶尔一次”,非常难排查。

think-swoole 的连接池配置里有 max_idle_time,把它设成 3600 秒左右,比 MySQL 的 wait_timeout 小就行。同时开启 heartbeat 心跳检测。

// config/swoole.php
'db' => [
    'max_idle_time' => 3600,
    'heartbeat'     => true,
],

坑四:日志上下文串号

上一篇文章里我讲了用 Log::withContext() 把 TraceId 塞进日志上下文。这个机制在常驻内存模式下需要重新审视。

ThinkPHP 的 Log 类内部维护了一个 $context 数组。withContext 会往里面加数据,但是不会自动清理。FPM 下请求结束整个进程就退了,无所谓;常驻内存下,上一个请求的 TraceId 会留在下一个请求的日志里。

表现是:日志文件里同一段代码的日志,trace_id 全部相同,根本不是一次请求产生的。排查的时候会把人带偏——看起来像是一个请求跑了很久,实际是好几个请求被记成了同一个。

解决办法是每次请求开始前主动清空上下文:

namespace appmiddleware;

use Closure;
use thinkfacadeLog;
use thinkRequest;
use thinkResponse;

class InitLogContext
{
    public function handle(Request $request, Closure $next): Response
    {
        // 清掉上一个请求残留的上下文
        Log::reset();

        // 重新设置本请求的上下文
        $traceId = $request->header('X-Trace-Id') ?: $this->generateTraceId();
        app()->bind('trace_id', $traceId);

        Log::withContext([
            'trace_id' => $traceId,
            'uri'      => $request->url(),
            'method'   => $request->method(),
        ]);

        return $next($request);
    }

    private function generateTraceId(): string
    {
        return sprintf('%08x%08x', time(), random_int(0, 0x7fffffff));
    }
}

Log::reset() 就是清空上下文的。如果没有这个方法,就要手动调用 Log::withContext([]) 把上下文覆盖为空数组。要注意不同版本的 API 有差异,迁移之前先确认当前项目用的版本有没有这个方法。

顺带提醒一个类似的坑:Request 对象上的用户信息也可能残留。有的项目习惯把登录用户塞进 Request 之后,在其他地方通过单例访问。而如果是把用户信息存在某个静态的位置,就会串号。原则很简单:任何”请求级”的状态都必须挂到 Request 对象或者容器里,且容器里的对象必须每次请求新建。

坑五:内存泄漏

常驻内存的进程,内存是长期持有的。所有需要长期保存的东西都会累积,如果写了代码忘了清理,进程会慢慢吃光内存然后被 OOM Kill 或者卡死。

常见的泄漏源:

静态数组无限增长。比如把查询结果缓存在 static $cache = [] 里,随着请求数增加不断 put 新键,从来不清。一天下来几十万个键,占几百 MB 内存。

闭包和事件监听累积。每次请求都 Event::listen() 一次,常驻内存下监听器会不断堆积,响应时间越来越慢,内存越来越高。

日志对象持有大对象引用。有的项目在日志的 context 里放了完整的数据对象而不是摘要,这个对象会被持有到进程结束。

排查方式是用 think-swoole 自带的进程监控,或者直接用 Swoole 的 Server::stats() 看内存占用曲线:

swoole_server->stats()
// 输出里的 memory_usage 字段

更直接的方式是打印某几个关键点的内存:

use thinkfacadeLog;

// 在中间件的入口和出口各记一次
Log::info('mem', [
    'usage' => memory_get_usage(true),
    'peak'  => memory_get_peak_usage(true),
]);

把这两组数字对时间做图,如果 usage 会持续上涨,说明有泄漏。

找到具体泄漏点之后,处理方式大同小异——改成有界缓存(LRU 或者限个数)、在请求结束时清空、或者在合适的时机 unset。最重要的原则是:任何进程级的缓存都必须有明确的容量上限。没有上限的缓存,就是隐藏的内存泄漏。

隔离测试:怎么确认常驻模式没问题

前面这些坑靠代码审查发现不了,得靠测试。做一次”隔离测试”,能覆盖绝大部分场景:

public function testRequestIsolation()
{
    // 第一次请求:用户 A 查他自己的数据
    $res1 = $this->withHeader(['Authorization' => 'Bearer ' . $tokenA])
        ->get('/api/address');
    $this->assertSame(0, $res1->getJSON()['code']);

    // 第二次请求:用户 B 查他自己的数据
    $res2 = $this->withHeader(['Authorization' => 'Bearer ' . $tokenB])
        ->get('/api/address');
    $body2 = $res2->getJSON();

    // B 拿到的数据不能包含 A 的任何东西
    $this->assertSame(0, $body2['code']);
    foreach ($body2['data'] as $row) {
        $this->assertNotEquals($userAId, $row['user_id']);
        $this->assertNotEquals($phoneA, $row['phone']);
    }

    // 再发一次请求:模拟 B 在后面
    $res3 = $this->withHeader(['Authorization' => 'Bearer ' . $tokenB])
        ->get('/api/address');
    $this->assertEquals($body2['data'], $res3->getJSON()['data']);
}

这个测试要点是在同一个进程里连续发多个请求。PHPUnit 默认每个测试方法跑在独立进程中,得关掉 processIsolation,或者在 Swoole 环境下用 Swoole 自己的测试工具。否则每次请求都在新进程里跑,测不出常驻内存的问题。

把常见的用户身份数据(订单、地址、银行卡、购物车)都做一遍类似的隔离测试,跑通之后再上线。这次事故里的地址串号,如果有这么一条测试,进入生产前就被拦住了。

迁移之前要做的事

回头看,如果当时能按下面这个顺序做准备工作,会顺畅很多。

第一步,扫出所有静态属性。用前面那几个 grep 命令,把 static $x = 和 self::$x = 全部列出来,逐个确认是否要在请求之间保留。不需要的先改。

第二步,扫出容器里的单例服务。重点是那些持有请求级数据的 Service。app()->make() 拿到之后,如果里面有请求级字段,要么改成参数传递,要么加 reset 方法。

第三步,检查连接池配置。确认 max_idle_time 小于 MySQL 的 wait_timeout,开启心跳检测。

第四步,加上日志上下文清理。在中间件的入口处调用 Log::reset() 或者清空上下文。

第五步,写隔离测试。挑几个最典型的接口写第一版,后面慢慢补全。

第六步,上线后观察内存曲线。第一周每半小时记一次 memory_get_usage(true),看是不是稳定。如果缓慢上升,说明还有没找到的泄漏点。

第六步之后,如果内存曲线是平的,说明迁移基本稳了。剩下的就是持续维护——每一段新代码都要过一遍这六条。

写在最后

常驻内存带来的性能提升是实实在在的——这个项目迁移之后单个容器从 8 核跑到 30 核都撑得住,内存占用不到原来一半。但它把”进程隔离”这道安全网拆掉了。

FPM 时代,很多问题被”请求结束进程就退”这个行为掩盖了。那些没有好好管理状态的代码,在 FPM 下跑了好几年都没事。迁到常驻内存之后,问题立刻暴露。这不是 Swoole 的锅,是原来代码里本来就有的隐患,只是被进程隔离掩盖了。

我现在的看法是:常驻内存不是一个”性能开关”,是一次”代码质量审计”。它逼着你把请求级状态和进程级状态清晰地分开,逼着你管好容器和连接,逼着你给缓存加上限。做完这些之后,即使将来不用 Swoole 了,代码质量本身也提升一个档次。

如果正在考虑迁移,我建议不要一次性全量切。先挑一个访问量大但逻辑简单的接口试水,观察一两周。跑通了再逐步扩大。这条路走起来不会太顺,但只要方向对,早晚会过去。

ThinkPHP 8 常驻内存实战:单例污染、静态残留与内存泄漏治理
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 常驻内存实战:单例污染、静态残留与内存泄漏治理 https://www.taomawang.com/server/thinkphp/2948.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务