上个月接手了一个文档协作系统,之前跑在传统 FPM 模式下,日活不高,一直很稳。今年业务要对外,预估会有几百人同时在线,运维同事提了一句”要不要试试常驻内存模式跑一下,机器能省一半”。我们几个后端商量了一下,觉得收益划算,就安排了一周时间改造。
改造过程出乎意料地顺。装上 Swoole 扩展,配置文件里改几个开关,命令行启动,接口全部正常返回,本地跑了一遍功能测试没有任何问题。大家当时都觉得”这不就完事了”。
结果压测那天,问题来了。
压测里最诡异的一段日志
压测用的是 JMeter,八百个并发用户,每个用户固定一个账号,各自操作自己的文档。跑了大概三分钟之后,运维那边截图发过来:日志里出现了大量的 403 报错,但是仔细一看,这些 403 的请求里,访问的文档 ID 明明是用户自己的,用户也确实有权限,为什么会没权限?
更奇怪的是,这些报错不是固定的某几个用户,而是随机分布。有时候 A 用户访问自己的文档被拒,下一次同样的请求又是 200。像是权限系统偶尔”认错人”。
我第一反应是缓存穿透或者 Redis 里的 session 出问题了,翻了一圈没查到。接着看权限中间件的代码,逻辑很清楚,先取当前用户,再查这个用户和文档的关联,有关联就放行,没有就 403。看着没问题。
直到我把日志加上用户的 UID 和文档的 owner_id,跑了一次小规模压测(50并发),才发现规律:报错的请求里,取到的当前用户和实际请求的用户对不上,串号了。A 用户的请求,权限校验的时候拿到的是 B 用户的信息。
为什么 FPM 没问题,常驻内存就出问题
这个现象其实很好解释。FPM 模式下,每个请求是一个独立的 PHP 进程生命周期,进程处理完请求就被销毁或者复位。哪怕代码里写了全局变量、静态变量,请求结束就没了,下一个请求进来是干净环境。
常驻内存模式下,PHP 进程从启动到停止一直在跑,请求只是进程里的一个”事件”。前一个请求在全局状态里留下的东西,如果没人清理,下一个请求就能看到。用户信息这种东西一旦在全局或者单例里缓存下来,就变成了”上一个人的数据被别人看见”——这不仅仅是权限问题,更是数据泄露。
我把项目里的代码翻了一遍,找出了三个典型的”FPM 能跑、常驻内存会炸”的写法,写在这里给同样在改造的人提个醒。
第一个:静态属性缓存当前用户
项目的权限服务类大概是这样写的:
namespace appservice;
class PermissionService
{
private static ?array $currentUser = null;
public function getCurrentUser(): array
{
if (self::$currentUser === null) {
self::$currentUser = $this->loadUserFromToken();
}
return self::$currentUser;
}
private function loadUserFromToken(): array
{
$token = request()->header('Authorization', '');
// ...根据 token 查询用户
return $user;
}
}
写这段代码的人初衷是好的:一个请求生命周期内多次调用 getCurrentUser(),避免重复查库或者解码 token。在 FPM 下确实没问题,因为下一次请求进来,self::$currentUser 一定是 null(进程重启了)。
但在常驻内存下,第一个请求进来,缓存了 A 用户;第一个请求结束,静态属性没清;第二个请求进来直接命中缓存,拿到 A 用户。这就是串号的直接来源。
改法有两种。一种是把静态缓存换掉,用 ThinkPHP 提供的 Request 对象上的属性做缓存:
namespace appservice;
class PermissionService
{
public function getCurrentUser(): array
{
$request = request();
if (!$request->has('__current_user')) {
$request->__current_user = $this->loadUserFromToken();
}
return $request->__current_user;
}
}
ThinkPHP 8 里每个请求对应一个 Request 对象实例,请求结束这个对象就会销毁,缓存在上面的数据自然清掉。这是改造过程中最基本的一条原则:凡是”一个请求内有效”的缓存,都挂在 Request 对象上,不要挂静态属性。
另一种更彻底的做法是用 ThinkPHP 8 的 Context 机制(如果项目里用了 thinkContext 或者类似的自定义上下文管理)。不过我们项目没引入,上面这套 Request 属性的做法已经够了。
第二个:容器的错误绑定方式
ThinkPHP 8 有一个依赖注入容器,很多项目会把一些有状态的服务注册为单例(默认情况下容器里的对象就是单例),然后全局用 app()->make() 拿。这个用法本身没问题,出问题的地方是“单例本身有状态”。
我们的代码里有这么一个场景:一个 DocStatistics 类用来累计本次请求里读了多少文档、命中多少次缓存,请求结束的时候写一次日志。
namespace appservice;
class DocStatistics
{
private int $readCount = 0;
private int $hitCount = 0;
public function markRead(): void
{
$this->readCount++;
}
public function markHit(): void
{
$this->hitCount++;
}
public function flush(): void
{
thinkfacadeLog::info('docs read: ' . $this->readCount
. ', hit: ' . $this->hitCount);
$this->readCount = 0;
$this->hitCount = 0;
}
}
这个类是通过容器自动单例化的。在 FPM 下,每次请求容器重建,统计值从 0 开始。在常驻内存下,容器在应用启动时创建一次,之后一直复用。虽然 flush() 里手动归零了,但只要有一个请求过程中抛了异常,flush() 没执行到,脏数据就带到下一个请求里去了。
这种问题的排查特别难受,因为日志里看到的统计值会莫名其妙地偏大,但业务逻辑又是对的,很容易被当成”偶发异常”忽略掉。
改法有两种。一是把这个类注册成非单例:
// 在 app/provider.php 或者单独的服务注册文件里
$this->app->bind('doc_stats', appserviceDocStatistics::class, false);
ThinkPHP 8 的 bind 方法第三个参数是”是否强制每次实例化”,传 false 表示每次 make 都新建对象。这样每次拿到的 DocStatistics 都是新的,不会有状态残留。
另一种是把这个类改成不持有状态,统计值由调用方传入或者放在 Request 属性里。我们选了第二种,因为统计这个动作的调用点很多,改成无状态每次都要传一堆参数,反而更乱。
判断标准很简单:这个类的方法调用会不会改变内部属性?会,就一定要考虑是不是单例。 如果是单例,就得问自己”这个状态跨请求保留有危险吗”。有,就得换。
第三个:Facade 的缓存行为
ThinkPHP 的 Facade 机制在项目里用得很多,比如 thinkfacadeCache、thinkfacadeRequest。这个机制本身是给常驻内存设计的,用起来也安全,但有一个细节很多人不知道:Facade::getFacadeRoot() 拿到的实例,在常驻内存模式下是被缓存的。
我们项目里有一处自研的 Facade,用来封装一个第三方 SDK 客户端:
namespace appfacade;
class CloudDoc extends thinkFacade
{
protected static function getFacadeClass()
{
return appserviceCloudDocClient::class;
}
}
问题是 CloudDocClient 在构造函数里读了一次环境变量(env('CLOUD_DOC_TOKEN'))然后缓存到自己的属性里。FPM 下每次请求重新构建客户端,环境变量就算变了也能反映。常驻内存下这个客户端只构建一次,环境变量再改也不会生效。
我们压测时遇到的另一个怪现象跟这个有关:灰度环境切了 token,一部分请求走新 token,一部分走旧 token。当时排在前面的一批容器因为启动早,用的是旧 token,后面的容器用的是新 token,滚动发布过程中流量就出现抖动。排查花了一个下午,最后才发现是客户端缓存了旧值。
改法很直接:别在构造函数里读环境变量。要么改成每次调用的时候读(成本很低),要么在容器服务注册的地方用闭包重新构建。
// 构造函数不读环境,改成懒加载
class CloudDocClient
{
private ?string $token = null;
private function token(): string
{
if ($this->token === null) {
$this->token = env('CLOUD_DOC_TOKEN', '');
}
return $this->token;
}
public function upload(...): void
{
// 调用时用 $this->token() 取
}
}
这样单例实例依然复用,但读的是”调用时刻”的环境值,不会因为容器启动太早而”卡”在旧配置上。
改造之后的压测结果
上面三处改完,重新跑了一遍同样的压测。800 并发,跑了十分钟。结果对比:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 403 错误率 | 约 3.2% | 0 |
| 平均响应 | 87ms | 81ms |
| P99 | 430ms | 290ms |
| 日志中”统计值异常” | 偶发 | 无 |
响应时间的改善来自两方面:一是没有串号之后少了大量无效的权限重查;二是 DocStatistics 改成非单例之后,每次都新建的开销其实可以忽略,但避免了异常场景下的脏数据传递,间接减少了日志系统的压力。
怎么避免在改造期间重蹈覆辙
改造完之后我整理了一份清单,后来团队内部推行,现在分享一下。
第一条,搜所有 static 属性。 用 IDE 全局搜 private static、protected static、public static,把每一个都过一遍。判断标准:这个属性是用来”缓存不变的东西”还是”缓存随请求变化的东西”。前者留着无妨(比如一个不变的配置数组),后者必须改。
第二条,看构造函数的副作用。 每个服务类的构造函数里,有没有读环境变量、有没有读 request、有没有读 user。只要有,就得考虑这个类在常驻内存下会不会被复用出问题。
第三条,用工具静态扫描。 phpstan 配到 level 6 之后,能扫出一部分”在构造函数里读 request”的代码。它扫不出所有问题,但至少能把最明显的几处标出来。
第四条,压测时特意写上请求标识。 每个请求进来时生成一个 uuid,打到日志里。压测结束用脚本比对:请求 uuid 和日志 uuid 有没有对不上的情况。这个手段是排查串号的”X 光机”,特别好用。我们就是靠这个把问题从”偶发”变成”10 分钟内必现”。
第五条,日志里打印当前的 Request 对象 id 或者进程 id。 Swoole 下常驻内存的进程有固定的进程 id,日志里带上,就能看出同一进程处理了哪些请求。如果同一个请求在两份日志里出现了不同的进程 id,那是另一个层面的问题(一般是共享内存或者协程上下文泄漏),也能顺着查。
要不要用常驻内存模式
最后说一个经常被问到的问题:常驻内存模式到底值不值得上。
我的看法是分场景。如果你的应用是 IO 密集型的(访问数据库、调用外部接口为主),常驻内存的收益主要在节省进程启动开销和减少连接建立开销上,中等规模能省 30% 到 50% 的机器资源。这个收益值得投入改造。
如果你的应用是 CPU 密集型的(大量计算、图像处理、模板渲染),常驻内存基本没有收益,甚至因为共享状态和内存泄漏的问题,反而更不稳定。这种情况我建议老老实实跑 FPM。
如果团队对新范式的接受度不高,也要谨慎。常驻内存要求每个开发者在写代码的时候脑子里都挂着”这是复用的进程”这个前提,一旦有人写了静态缓存当前用户的代码,整个服务就可能出现串号,而且这种 bug 在功能测试里根本发现不了,只有压测或线上才暴露。这种隐蔽性比 FPM 下的任何 bug 都更难排查。
如果是首次尝试,我的建议是先在一个非核心的服务上跑,比如图片处理、文件转换这类无状态的服务,跑半年看看稳不稳,再考虑推到核心业务。我们这次是直接把核心业务推上去的,中间踩的坑比预期多,好在最后跑通。
写在最后
从 FPM 到常驻内存,本质上是把”进程生命周期”和”请求生命周期”这两件事解耦了。以前它们天然绑定,程序员写代码不用管;现在需要分清楚哪些东西活在进程里、哪些活在请求里。
这个转变其实和虚拟线程、和协程的思路是一致的。大家都在同一个方向上走:把并发单元做得更轻,代价是开发者需要更清楚自己在写什么东西。
我们这次改造最后的感受是:出问题的从来不是”常驻内存”这个模式本身,而是项目里那些”在 FPM 下侥幸能跑”的写法。常驻内存只是把这些隐藏问题从地底下翻出来了。从这个角度说,改造一次,代码质量本身也会被迫上一个台阶。
如果你的项目也准备动,别急着改配置,先把上面那五条清单过一遍,能省至少一个星期的排查时间。

