上周三下午三点多,客服在群里甩了张截图:A 用户点开”我的订单”,页面上显示的却是另一个用户的手机号和收货地址。刷新一下正常了,再刷一次又串了。第一反应是前端缓存,把 nginx 的 proxy_cache 关掉、CDN 刷新、浏览器无痕模式重开,问题依旧。第二反应是 Redis 串 key,查完发现我们那个接口压根没走缓存。
后来折腾了一整天才把根因揪出来,问题出在去年把项目从 PHP-FPM 迁到 Workerman 常驻内存之后——代码里几处”看起来完全没问题”的写法,在进程不复活的模型下变成了定时炸弹。这篇把整个过程和修复方案完整写下来。
一、先把环境交代清楚
- PHP 8.2.13,开 opcache,swoole 未启用,走的是 workerman 路线
- ThinkPHP 8.0.4
- topthink/think-worker 4.0(内部封装 workerman/workerman 4.1)
- MySQL 8.0 + 读写分离(主从),Redis 7 做会话
- 启动方式:
php think worker:server -d,进程数 8
迁移前是标准的 FPM 模式,每处理完一个请求,进程状态就随着请求结束被 PHP 回收(准确说是回到干净的脚本上下文)。迁到 Workerman 之后,进程是常驻的,类属性、静态属性、容器里注册的实例、数据库连接对象,全都会活到下一个请求进来。这个差别是后面所有问题的总根源。
二、最小复现:两个接口就能复刻
我们线上出问题的是一个带鉴权的接口组,路由大致长这样:
<?php
// route/app.php
use thinkfacadeRoute;
Route::group('api', function () {
Route::get('profile', 'User/profile');
Route::get('orders', 'Order/index');
Route::get('ping', 'Index/ping'); // 白名单,不需要 token
})->middleware(appmiddlewareAuthCheck::class);
鉴权中间件负责解析 token 并把用户对象存到一个上下文类里:
<?php
// app/middleware/AuthCheck.php
declare(strict_types=1);
namespace appmiddleware;
use appcommonAuthContext;
use thinkfacadeDb;
class AuthCheck
{
public function handle($request, Closure $next)
{
$token = (string) $request->header('authorization', '');
if ($token !== '') {
$user = Db::name('user')->where('token', $token)->find();
if ($user) {
AuthContext::setUser($user);
}
}
return $next($request);
}
}
<?php
// app/common/AuthContext.php
declare(strict_types=1);
namespace appcommon;
class AuthContext
{
protected static array $user = [];
public static function setUser(array $user): void
{
self::$user = $user;
}
public static function user(): array
{
return self::$user;
}
public static function id(): int
{
return (int) (self::$user['id'] ?? 0);
}
}
控制器直接读上下文:
<?php
// app/controller/User.php
declare(strict_types=1);
namespace appcontroller;
use appcommonAuthContext;
use thinkfacadeDb;
class User
{
public function profile()
{
$user = AuthContext::user();
return json([
'id' => $user['id'] ?? 0,
'mobile' => $user['mobile'] ?? '',
]);
}
}
而白名单接口是这样写的:
<?php
// app/controller/Index.php
declare(strict_types=1);
namespace appcontroller;
use appcommonAuthContext;
use thinkResponse;
class Index
{
public function ping()
{
return Response::create('pong');
}
public function whoami()
{
// 为了排查临时加的一个口子
return json(AuthContext::user());
}
}
看到这里你大概已经猜到了:/api/whoami 走白名单,中间件里 $token 为空,setUser() 根本没被调用,AuthContext::$user 里留着的还是上一个请求写进去的那个人。FPM 下每次请求脚本重新加载,静态属性归零;Workerman 下它就一直躺在那里。
三、定位过程:先确认请求落在了哪个进程
光靠读代码猜是不够的,我们当时先在入口处把 PID 和 traceId 打出来,看看并发的两个用户是不是被同一个进程处理了。
<?php
// app/middleware/Trace.php
declare(strict_types=1);
namespace appmiddleware;
use thinkfacadeLog;
class Trace
{
public function handle($request, Closure $next)
{
$traceId = bin2hex(random_bytes(8));
Log::info('[trace] begin', [
'trace' => $traceId,
'pid' => getmypid(),
'uri' => $request->url(),
'token' => substr((string) $request->header('authorization', ''), 0, 8),
'mem' => round(memory_get_usage(true) / 1048576, 2) . 'MB',
]);
try {
return $next($request);
} finally {
Log::info('[trace] end', ['trace' => $traceId, 'pid' => getmypid()]);
}
}
}
压了 20 个并发之后日志立刻现原形:用户 token 尾号 a1b2c3d4 和 9f8e7d6c 的请求,前后脚落在 同一个 pid 上。这就直接把嫌疑锁定在了”进程内跨请求残留”这一类问题上,跟 MySQL 慢查询、网关、前端彻底无关。
顺带说一句,日志里加 memory_get_usage(true) 也很好用。我们那次还发现一个统计接口把每次请求的查询结果往静态数组里塞,跑了三天内存涨到 400 多兆,属于同一类病。
四、三个真正的坑
4.1 静态属性存放请求级数据
就是上面那段 AuthContext。它本身设计没问题,FPM 下跑了两年也没出事,问题在于它把请求级的数据放在了进程级的容器里。
关键的坑点不在于”没赋值”,而在于白名单分支、token 失效分支、异常提前返回分支,都会走到”不调用 setUser”的路径上,于是旧值就被继承下来了。这类 bug 的特点是:单人自测永远正常,只有在并发且恰好命中同一个 worker 进程时才复现,概率看着低,但线上 QPS 一上来就变成必现。
4.2 容器里被注册成单例的服务
第二个坑更隐蔽。我们有一个 UserService,里面缓存了用户的一些关联数据,为了”省一次查询”,在服务提供者里把它注册成了共享实例:
<?php
// app/provider.php
use appserviceUserService;
return [
'user_service' => UserService::class,
];
<?php
// app/AppService.php
declare(strict_types=1);
namespace app;
use appserviceUserService;
use thinkService;
class AppService extends Service
{
public function register(): void
{
// 第三个参数 true 表示共享实例,容器内只 new 一次
$this->app->bind('user_service', UserService::class, true);
}
}
<?php
// app/service/UserService.php
declare(strict_types=1);
namespace appservice;
use thinkfacadeDb;
class UserService
{
/** @var array 用户资料缓存 */
private array $cache = [];
public function detail(int $userId): array
{
if (!isset($this->cache[$userId])) {
$this->cache[$userId] = Db::name('user')->where('id', $userId)->find() ?: [];
}
return $this->cache[$userId];
}
}
按 userId 做键,看起来并不会串。但问题有两个:一是这个 $cache 永远不会释放,跑一周下来能把进程内存吃穿,触发 OOM 重启;二是如果哪天有人在里面加了 private array $currentUser 这种字段,立刻就是串号。
我的建议很直接:常驻内存环境下,业务 Service 一律不要注册成共享实例,让它每次 app()->make() 出来一个新对象。真需要跨请求缓存,写进 Redis 并显式设 TTL,不要把生命周期交给框架容器。
4.3 事务出错路径没有回滚,连接被污染
第三个坑跟数据串号严格来说不是同一类,但它导致的后果更严重,而且同样只在常驻内存下暴露。
<?php
// 出问题的写法
Db::startTrans();
try {
Db::name('order')->insert($order);
$this->deductStock($order['goods_id'], $order['num']); // 这里可能抛异常
Db::commit();
} catch (Throwable $e) {
// 只记录了日志,忘了 rollback
Log::error($e->getMessage());
throw $e;
}
FPM 下忘了回滚没什么大碍,连接在脚本结束时被销毁,MySQL 自动回滚。但在 Workerman 里,连接池里的这条连接还带着一个开着的事务回到池子,下一个请求如果拿到这条连接,它的写操作就会被卷进上一个未提交的事务里——要么查不到刚写的数据,要么在很久之后跟着一起提交,表现就是”偶发性数据不一致”。
think-orm 提供了 Db::transaction() 这个闭包写法,异常时会自动回滚,能省掉一半的心:
<?php
Db::transaction(function () use ($order) {
Db::name('order')->insert($order);
$this->deductStock($order['goods_id'], $order['num']);
});
// 闭包内抛异常会自动回滚,正常结束自动提交
如果非要手写 startTrans,那就必须保证 catch 和 finally 里都有 Db::rollback(),并且要判断当前是否还在事务中。Db::rollback() 在没有活动事务时调用不会报错,可以放心兜底。
五、修复方案
5.1 上下文类改成”先重置、后赋值、用完清空”
<?php
// app/common/AuthContext.php
declare(strict_types=1);
namespace appcommon;
class AuthContext
{
protected static array $user = [];
public static function reset(): void
{
self::$user = [];
}
public static function setUser(array $user): void
{
self::$user = $user;
}
public static function user(): array
{
return self::$user;
}
public static function id(): int
{
return (int) (self::$user['id'] ?? 0);
}
}
<?php
// app/middleware/AuthCheck.php
declare(strict_types=1);
namespace appmiddleware;
use appcommonAuthContext;
use thinkexceptionHttpResponseException;
use thinkfacadeDb;
use thinkResponse;
class AuthCheck
{
public function handle($request, Closure $next)
{
// 1. 无条件重置,杜绝继承上一个请求的状态
AuthContext::reset();
$token = (string) $request->header('authorization', '');
$route = $request->rule()->getRoute();
$needAuth = !in_array($route, ['api/ping'], true);
if ($needAuth) {
if ($token === '') {
throw new HttpResponseException(
Response::create(['code' => 401, 'msg' => '未登录'], 'json', 401)
);
}
$user = Db::name('user')->where('token', $token)->find();
if (!$user) {
throw new HttpResponseException(
Response::create(['code' => 401, 'msg' => '登录已失效'], 'json', 401)
);
}
AuthContext::setUser($user);
}
// 2. 无论正常返回还是抛异常,都要清干净
try {
return $next($request);
} finally {
AuthContext::reset();
}
}
}
这里的核心思路是把”重置”从”可选分支”变成”必经路径”。只要中间件总入口被触发,就一定会 reset();只要请求结束(包括异常、401、超时中断),finally 一定会把它清掉。两层保险下来,没给串号留口子。
5.2 Service 不再共享
<?php
// app/AppService.php
declare(strict_types=1);
namespace app;
use appserviceUserService;
use thinkService;
class AppService extends Service
{
public function register(): void
{
// 去掉共享标记;如果原本用了 instance() 绑定,改成 bind()
$this->app->bind('user_service', UserService::class);
}
}
拿的时候保持原样即可:
$service = app()->make('user_service');
每次 make() 出一个新实例,$cache 自然只在当前请求内有效。真要缓存用户信息,改走 Redis:
public function detail(int $userId): array
{
$key = 'user:detail:' . $userId;
$cached = Cache::get($key);
if ($cached !== null) {
return $cached;
}
$data = Db::name('user')->where('id', $userId)->find() ?: [];
Cache::set($key, $data, 300);
return $data;
}
5.3 加一层”请求级变量”的兜底约定
光靠人记是记不住的。我们在项目里加了一条硬约定,写在 README 最上面:
- 禁止在
app/目录下出现任何static非只读属性,除非它是常量语义(配置、映射表、正则)。 - 禁止把
thinkRequest对象赋值给类的属性并长期持有。 - 禁止使用
$app->instance()手动往容器里塞对象,除非你确定它是无状态的。 - 所有涉及请求上下文的类,必须提供
reset()方法,并在中间件的finally里调用。
另外提一句,ThinkPHP 8 里其实已经有官方的请求上下文思路可以借鉴:thinkRequest 本身通过 app('request') 获取,框架在每次请求开始时重新绑定,正是为了避免这个问题。我们自己的上下文类,就是在模仿这个模式。
六、修复后怎么验证
改完代码不压测等于没改。用下面这个 PHP 脚本跑并发,比 ab 好用,因为它能给每个请求带上不同的 token 并校验返回值:
<?php
// check_concurrency.php
declare(strict_types=1);
$base = 'http://127.0.0.1:8787';
$tokens = [
't1' => 1,
't2' => 2,
't3' => 3,
't4' => 4,
't5' => 5,
];
$mh = curl_multi_init();
$handles = [];
for ($round = 0; $round < 60; $round++) {
foreach ($tokens as $token => $expectId) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $base . '/api/profile',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_HTTPHEADER => ['Authorization: ' . $token],
]);
curl_multi_add_handle($mh, $ch);
$handles[] = ['ch' => $ch, 'expect' => $expectId, 'token' => $token];
}
}
$running = null;
do {
curl_multi_exec($mh, $running);
curl_multi_select($mh, 0.05);
} while ($running > 0);
$bad = 0;
foreach ($handles as $item) {
$raw = curl_multi_getcontent($item['ch']);
$data = json_decode((string) $raw, true);
$got = (int) ($data['id'] ?? 0);
if ($got !== $item['expect']) {
$bad++;
printf("串号: token=%s 期望=%d 实际=%dn", $item['token'], $item['expect'], $got);
}
curl_multi_remove_handle($mh, $item['ch']);
curl_close($item['ch']);
}
curl_multi_close($mh);
echo $bad === 0 ? "共 " . count($handles) . " 次请求,全部通过n" : "共 {$bad} 次串号n";
exit($bad === 0 ? 0 : 1);
跑之前把 worker 进程数调成 2,这样并发请求更容易撞到同一个进程,复现概率大幅提高;确认修复后再恢复到 8 复测一遍。脚本返回非 0 就说明还有残留,可以直接挂到 CI 里当一个冒烟用例。
顺手再确认一下内存:
# 压测前后各看一次
for i in $(seq 1 10); do curl -s -o /dev/null -H "Authorization: t1" http://127.0.0.1:8787/api/profile; done
ps -o rss= -C php | awk '{s+=$1} END {print s/1024 " MB"}'
如果修复后 RSS 还在稳定上涨,说明进程里还有别的地方在攒东西,重点排查静态数组、闭包捕获的长生命周期对象、以及没被清理的事件监听器。
七、上线前的自查清单
把这次踩的坑整理成了一份清单,迁常驻内存、或者升级 ThinkPHP 8 之后逐条过一遍,能省很多夜里爬起来查日志的时间:
- 全局搜
static $和static array,逐个确认它存的是常量语义还是请求数据。 - 检查所有
bind()调用,第三个参数为true的,确认对应类是无状态的。 - 检查
Db::startTrans()的所有调用点,确保异常路径有rollback;能改闭包Db::transaction()的就改掉。 - 检查中间件的提前
return和throw分支,确认它们不会留下脏状态。 - 确认
thinkfacadeRequest没有被存进任何类的属性里。 - 压测脚本挂进 CI,每次发版跑一遍。
最后一句话总结这次的经验:从 FPM 迁到常驻内存,改的不是性能配置,而是整个程序的心智模型。同一个进程要服务成千上万个请求,”请求结束”不再等于”状态清零”。凡是把请求级数据放在进程级容器里的代码,都值得重新审一遍。

