去年年底给一个电商项目做性能优化,把 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 了,代码质量本身也提升一个档次。
如果正在考虑迁移,我建议不要一次性全量切。先挑一个访问量大但逻辑简单的接口试水,观察一两周。跑通了再逐步扩大。这条路走起来不会太顺,但只要方向对,早晚会过去。

