去年年底把公司的一套订单服务从 PHP-FPM 迁到 think-swoole,理由是那台跑 FPM 的机器已经扛不住了。迁移本身花了三天,效果确实可以,压测从单机 300 QPS 提到了 1800 左右,机器数量砍掉一半。
但接下来两周,是我做后端这些年最难受的一段时间。不是崩溃,是那种偶尔出现、重启就好了、日志里看不出任何异常的故障。前后一共出了五个,每一个都让我在工位上坐了很久。这篇文章把五个坑的完整排查过程都记下来,如果你也准备把 ThinkPHP 8 项目搬上 Swoole,可以省掉这些时间。
文章里的代码都基于 ThinkPHP 8.0 + think-swoole 4.1,PHP 8.2 环境。
开篇先讲清楚常驻内存到底改变了什么
FPM 的模型是”一次请求一个进程”,请求结束进程就退出。这意味着所有全局状态、静态变量、单例对象都会随着请求结束自动清空。PHP 程序员写代码时并不需要特意维护”状态边界”这件事,因为框架帮你兜住了。
Swoole 的模型完全反过来。一个 worker 进程启动之后会一直存活,处理成百上千个请求。请求进来、处理、返回,进程不退出,内存里的一切都还在。ThinkPHP 为了适配这个模型做了很多工作,比如协程上下文、连接池、请求对象的隔离,但有些东西它没法替你管,那就是你自己写的代码。
五个坑全部来自这个错位:代码是按 FPM 的心智写的,运行在常驻内存的环境里。
第一个:用户 A 的订单被返回给了用户 B
这是最早暴露的一个,也是最吓人的一个。客服反馈有客户投诉,说打开订单详情看到的不是自己的订单,刷新一下又正常了。
第一反应是缓存 key 写错了,翻了半天 Redis 的读写代码,没找到问题。后来在日志里给每个请求打上用户 ID,复现了大概二十几次,发现规律:出错的两个请求,几乎在同一毫秒进来,被同一个 worker 处理。
这就指向了请求对象被跨请求共享。翻代码,找到罪魁祸首:
<?php
// app/service/OrderService.php —— 错误示范
class OrderService
{
protected Request $request;
public function __construct(Request $request)
{
$this->request = $request;
}
public function currentUser()
{
return $this->request->user;
}
}
然后在 app/provider.php 里做了这么一行:
<?php
// app/provider.php
return [
'order' => appserviceOrderService::class,
];
问题出在 ThinkPHP 的容器默认按共享实例(singleton)来注册绑定。也就是说第一次 app('order') 时容器 new 了一个 OrderService,把当时的 Request 注入进去,然后把这个实例缓存住。之后所有请求再 app('order'),拿到的还是同一个对象,里面 $this->request 还是第一个请求的。
FPM 下为什么没出事?因为一次请求就是一个进程,容器生命周期内只有一个 Request,压根没有第二个请求进来。竞态条件根本不存在。
修复思路两条。第一条,别在单例里缓存 Request。要拿的时候现取:
<?php
// 修复方式一:每次都从上下文里取
class OrderService
{
public function currentUser()
{
return request()->user;
}
}
第二条,如果你确实需要在单例里注入别的东西(比如一个日志组件),那就把 Request 从构造函数里摘出去,改成方法参数传进来:
<?php
// 修复方式二:把 Request 变成方法参数
class OrderService
{
public function currentUser(Request $request)
{
return $request->user;
}
}
我在项目里统一选了第一种,因为改起来最快,而且 request() 在协程环境下会自动从当前协程的上下文里取,返回的是当前请求的对象,不会串。
排查完之后我写了个脚本,全局搜索所有在 __construct 里出现 Request 类型的类,一个一个看,一共发现七个类有潜在风险,都是之前某个同事习惯性注入的。这个坑不会每次必现,只在高并发的时候才暴露,很能拖时间。
第二个:每天凌晨三点,批量任务全军覆没
这个问题出现得非常有规律,每天 03:00 左右,跑批量任务的几个接口全部返回 500,报错内容统一是:
SQLSTATE[HY000]: General error: 2006 MySQL server has gone away
gone away 是 MySQL 那句著名的”我主动断开了”,通常是服务端的 wait_timeout 把空闲太久的连接给踢掉了。默认值是 8 小时,也就是说从最后一次使用到下一次使用超过 8 小时,连接就作废了。
凌晨三点这个词很关键。我们的低峰期正好是这个时间,从晚上七八点业务降温,到凌晨三点,差不多七八个小时没人用,连接池里的连接全都被 MySQL 干掉了。凌晨三点的定时任务去连接池取连接,取到的是死的,第一波查询全部失败。
FPM 下为什么没这个问题?因为 FPM 每个请求结束后连接就关了,下次请求重新连,从来不”留着”连接。
修复分两层。第一层是让框架在遇到断连时自动重连。ThinkPHP 8 的数据库配置里有这个开关:
<?php
// config/database.php
return [
'connections' => [
'mysql' => [
'type' => 'mysql',
'hostname' => '127.0.0.1',
'database' => 'order',
'username' => 'root',
'password' => '',
'hostport' => '3306',
'break_reconnect' => true,
],
],
];
break_reconnect 设为 true 之后,驱动在执行 SQL 前会先发一个 ping,发现连接已断就重新建立再执行。这个 ping 有一定开销,但相比报错要好得多。
第二层是连接池本身的配置。Swoole 的连接池不会主动清理空闲连接,要手动设存活时长:
<?php
// config/swoole.php
return [
'pool' => [
'db' => [
'max_active' => 100,
'max_wait_time' => 5,
'max_idle_time' => 3600,
],
],
];
max_idle_time 是 3600 秒,也就是空闲一小时以上的连接会被回收。这比 MySQL 的默认 8 小时短得多,专门用来对付这种场景。
改完之后还加了一样东西:一个健康检查。用命令行每隔半小时往数据库发一条 SELECT 1,保证连接池里的连接不会长时间处于完全静止状态。有点粗暴,但确实管用。这个检查是放在 config/console.php 里注册的一个命令,启动时跟着 worker 一起跑。
第三个:购物车数量偶发错误,测试环境永远复现不了
这个坑花的时间最长,因为报错的现象很奇怪。用户反馈购物车里的数量有时候多一件,有时候少一件,但订单本身是对的。刷新页面之后大部分时候能恢复,所以用户也不一定每次都会报。
翻遍业务逻辑没找到问题,最后是通过对比两台机器的行为差异才摸到线索:一台机器上出现的频率明显高,另一台基本没出现。两台唯一区别是 worker 数量不同,一台 8 worker,一台 4 worker。
worker 越多越容易出,这暗示问题是共享状态。搜代码找到这么一段:
<?php
// app/controller/Cart.php
class Cart
{
private static array $cache = [];
public function detail($id)
{
if (isset(self::$cache[$id])) {
return self::$cache[$id];
}
$data = Db::name('cart')->where('id', $id)->find();
self::$cache[$id] = $data;
return $data;
}
}
这个静态缓存的本意是防止同一请求内重复查询,在 FPM 里完全没问题,因为一个进程处理完一个请求就退出,静态数组跟着消失。
但在 Swoole 里,静态数组挂在 worker 进程上,会跨请求存活。请求 A 查了 id=100 的购物车,缓存了数据。请求 B(另一个用户)也访问 id=100,直接拿到请求 A 缓存的数据——那是 A 的购物车。改完数量之后页面显示 A 的,下单走的又是 B 自己的数据,所以”页面和订单对不上”。
为什么 FPM 下没出这问题?因为 FPM 进程是单请求的,静态缓存实际上只在单次请求内有效,天然正确。迁移之后,静态缓存的语义就从”请求级”变成了”进程级”,这是灾难性的语义变化,而且 IDE 和静态检查都发现不了。
修复的最简方式是把静态数组挪出类,改用框架提供的请求级缓存,ThinkPHP 8 里是 Cache 门面配合 cache 驱动,或者直接用协程上下文:
<?php
// 修复方式一:用 Context 存请求级数据
use thinkswoolecontextContext;
class Cart
{
public function detail($id)
{
$key = "cart_{$id}";
if (Context::has($key)) {
return Context::get($key);
}
$data = Db::name('cart')->where('id', $id)->find();
Context::set($key, $data);
return $data;
}
}
更彻底的做法是根本不给缓存加在进程上,请求结束就销毁。我给项目定了条规矩:Swoole 环境下,所有类的静态属性都视为进程级状态,除了常量之外一律不写。这个规矩之后,同类问题再没出现过。
排查这个 bug 的过程里我还顺带发现几处类似的问题:一个静态的 $requestId 用来打日志,一个静态的 $traceStack 用来做调用链,全都是同一个毛病。它们在其他场景下都被当成”无害的优化”,直到迁到常驻内存才集体引爆。
第四个:Redis 偶发 read timeout,一天几十次
报错是这样的:
RedisException: read error on connection to 10.0.0.15:6379
这个报错频率不高,一天几十次,但每次出现都会让一个用户的请求 500。日志里的分布很散,没有一个明显的时间规律,也没跟一个固定的接口挂钩。
这个坑比较难定位,因为报错来自 phpredis 扩展。我先把原因放到几个可能上:网络抖动?Redis 负载高?连接池配置不对?
监控面板翻了一遍,Redis 本身的 CPU 和内存都很稳,网络延迟的 P99 是 2ms,看不出高负载。最后在一根日志线里抓到了问题:超时的那几次请求,连接的创建时间都很早,创建到出错之间隔了四五个小时。
这就对上了:phpredis 连接的 timeout 参数管的是连接建立超时,不是读超时,而读超时用的是另一个参数 read_timeout。我们只设了 timeout,read_timeout 用了默认值,在长连接场景下这个默认值有时候不够。
ThinkPHP 的 Redis 配置里可以显式指定:
<?php
// config/cache.php
return [
'stores' => [
'redis' => [
'type' => 'redis',
'host' => '10.0.0.15',
'port' => 6379,
'timeout' => 3,
'read_timeout' => 3,
'persistent' => true,
],
],
];
但只是加这个还不够。Redis 的连接在空闲时间太长之后,服务端可能已经关闭,但连接池里没感知。这跟前面 MySQL 的 gone away 是同一类问题,处理方式也一样,就是在连接池里设空闲回收:
<?php
// config/swoole.php
return [
'pool' => [
'redis' => [
'max_active' => 50,
'max_wait_time' => 3,
'max_idle_time' => 1800,
],
],
];
另外一个容易忽略的点是心跳。很多团队会在 Redis 上配个 tcp-keepalive,但如果客户端这边不主动发心跳,服务端的设置不一定生效。我加了一个简单的心跳机制,在 worker 启动时挂一个定时器,每 5 分钟对 Redis 发一次 PING。代码在 app/event.php 里注册一个 swoole.workerStart 事件监听,五六十行搞定。
改完之后 read timeout 从一天几十次降到零,但也留下了经验:常驻内存下每一类长连接都要单独考虑它的存活时长和失败重连。连接池替你管了分配,但不管死活。
第五个:定时任务每天跑 8 次,日志里全是重复记录
最后一个坑我到现在想起来都觉得丢人。我们有个每天凌晨 1 点跑的报表任务,用 Swoole 的 timer 起的,迁到 think-swoole 之后改成了框架给的 thinkswooleTimer。改完之后运行得很正常,直到有天运营反馈”报表数据翻倍了”。
翻日志,发现任务确实每天跑了 8 次,每次的数据都往同一个批次里插。8 这个数字跟 worker 数量一模一样。
原因是 think-swoole 的 Timer 默认在 每个 worker 进程里 都创建一次。8 个 worker 就有 8 个定时器,同时触发,跑八遍。框架本身没有做去重,因为它的设计意图是让用户自己决定定时器绑在哪个 worker 上。
解决办法是把定时器绑到 worker 0 上,或者干脆起一个专门的进程:
<?php
// config/swoole.php
return [
'timer' => [
'enable' => true,
// 只在一个 worker 上执行
'worker_id' => 0,
],
];
或者用 app/event.php 里监听 swoole.workerStart 事件,判断当前 worker 的 id:
<?php
// app/event.php
use SwooleServer;
return [
'listen' => [
'swoole.workerStart' => [
function (Server $server, int $workerId) {
if ($workerId !== 0) {
return;
}
thinkswooleTimer::tick(60000, function () {
// 每分钟执行一次的任务
});
},
],
],
];
但判断 workerId 并不是最稳的做法,因为 worker 会因为各种原因重启,编号不变但进程是新的,定时器会被重新注册。我现在的做法是把周期性任务从 Swoole 定时器里挪出来,交给系统的 crontab 去调度,通过 php think xxx 调起一个独立的命令行进程执行。
理由很朴素:Swoole 的定时器适合做毫秒级的任务,比如清理连接、检测心跳,这种任务天生就该跟进程绑在一起。而报表、同步数据这种”每天一次”的任务,天然应该由独立的调度器管。混在一起用,就会出这种”跑了几次”的笑话。
改完之后我把项目里所有用到 Timer 的地方重新梳理了一遍,只剩两个是真正需要毫秒级精度的,其余全部转到 crontab。
五个坑总览
| 问题 | 触发条件 | 核心原因 | 修复手段 |
|---|---|---|---|
| 请求串号 | 高并发 | 单例持有 Request | 用 request() 现取或用方法传参 |
| MySQL gone away | 低峰期空闲超时 | 长连接存活超过 wait_timeout | break_reconnect + max_idle_time |
| 购物车数据错乱 | 多 worker 并发 | 静态数组跨请求存活 | 改用 Context 或干脆不缓存 |
| Redis read timeout | 连接存活过久 | 未配置 read_timeout 和空闲回收 | 显式配 read_timeout + 心跳 + max_idle_time |
| 定时任务执行 8 次 | 多 worker 部署 | Timer 在每个 worker 都创建 | 绑定 worker 0 或改用 crontab |
这一圈下来,最大的收获不是学会了怎么配 Swoole,而是意识到在常驻内存环境下,编程范式变了。所有”利用生命周期”的写法都得重新评估:静态变量不再是一次请求的生命周期,单例不再是一次请求的生命周期,全局变量不再是一次请求的生命周期。
迁移完成后我加的三条规矩
第一,禁止在类的构造函数里注入 Request,一律用 request() 现取。这条规矩在代码评审时会被自动检查,发现了直接打回。写作上可能有点强迫症,但这是我从那次串号事故里学到的最重要一课。
第二,静态属性只用来放常量,其他任何”缓存””计数器””状态”一律用 Context 或者 Redis 存。这条规矩最容易违背,因为静态缓存写起来太顺手了,但它的行为在常驻内存和 FPM 下不一样,是个定时炸弹。
第三,所有长连接(MySQL、Redis、Memcached、甚至 HTTP 客户端)都要显式配置存活时长和健康检查。默认值在常驻内存场景下几乎都不够用,必须自己配上。
压测之后的数字
把五个问题都修完之后,重新做了一次压测,用 wrk 打 300 并发持续 60 秒,在 FPM 和 Swoole 两种模式下各跑了一遍(同一台 8C16G 的机器,同一份 MySQL)。
| 模式 | QPS | P99 延迟 | 内存占用 | 错误率 |
|---|---|---|---|---|
| PHP-FPM | 约 310 | 1.2s | 每 worker ~45MB | 0 |
| Swoole(修复后) | 约 1750 | 180ms | 8 worker 合计 ~320MB | 0 |
数字好看,但要澄清一点:这个提升不是白来的。FPM 模式下,即使代码里有静态变量污染、有单例持有 Request,也跑得稳。而 Swoole 把这些隐患全部暴露出来,逼着代码必须写得规规矩矩。所以迁移真正的成本不在框架本身,而在于你的代码质量。代码越旧、写得越随意,迁移之后暴露的问题越多。
给准备迁移的人一句提醒
不要一上来就全量迁。先把一个相对独立、代码干净、流量不大的模块迁过去,跑上两三个月,把上面这些坑提前踩掉。我们这次是全量切的,第一个 bug 出来之后整个后端的信任度都受影响,后来花了很长时间才把团队的信心补回来。
另外在迁移之前,写一个静态检查脚本,扫描项目里的这几类模式:构造函数注入 Request、类的静态属性、全局变量、Timer 使用、连接池配置缺失。这个脚本可能只需要一百来行正则,但能提前发现百分之七八十的隐患。我们这次是出了问题才写的,如果早点动手,前面那些夜就不用熬了。

