1. 为什么要把 ThinkPHP 项目跑在 Swoole 上
先聊个现象。很多 PHP 开发者一说 Swoole 就摇头,觉得那是“常驻内存”的东西,心智负担重,学了容易忘,项目一忙就想扔回 FPM 的怀抱。但真实场景是——你的 ThinkPHP 项目一旦流量上来,传统的 PHP-FPM 模式就会暴露两个非常扎心的问题。
第一,每次请求都得重新初始化框架。ThinkPHP 启动时要加载配置、注册服务、创建容器,这一套下来几十毫秒就没了。第二,连接资源没法复用。数据库连接、Redis 连接,每个请求结束就断开,下次再连,握手成本摆在那里。这两件事叠加起来,在高并发下会出现一个诡异的曲线:服务器 CPU 还有余量,但响应时间却已经飞到天上去了。
Swoole 干的事情就是把这层“每次请求都重新来一遍”的浪费给抹平。进程常驻内存,框架只启动一次,请求来了直接用,不再重新加载;连接池帮你把 MySQL、Redis 连接留着,下次直接拿现成的。ThinkPHP 官方也意识到了这个趋势,从 6.0 开始通过 think-swoole 扩展提供了非常成熟的支持,不是社区随便拉的野路子项目,而是官方仓库在维护。
这篇文章讲的不是入门文档里那几句“安装后 run 一下”的敷衍话,而是从环境准备、集成方式、代码改造,到常见坑位的完整实战记录。我会把每一步为什么这么做讲清楚,也会把我在真实项目里踩过、填平过的坑直接摊开给你看。无论你是第一次接触 Swoole,还是已经跑过几个项目想进一步优化,这篇都值得你花十分钟看完。
1.1 Swoole 到底解决了 ThinkPHP 的什么痛点
传统的 Nginx + PHP-FPM 是一个“一无所有,每次重建”的模型。PHP-FPM 拿到请求后,要先 fork 一个进程(或者复用已有进程),这个进程里没有任何框架状态,需要从头把 ThinkPHP 跑一遍。以 ThinkPHP 6 为例,一次完整请求大约要执行 2000 到 4000 个文件调用,加载这些文件的时间在某些低配机器上可能占到整个响应时间的 30% 以上。
更难受的是资源无法复用。框架底层每次都要重新配置 Redis 连接、MySQL 连接,在高并发下连接数成了瓶颈。数据库默认连接上限 100 到 200,你这边开了 500 个 PHP-FPM 进程,数据库先被压垮了。我们团队之前做过一个活动页,压测 2000 并发时数据库连接数直接打到 1500,那叫一个酸爽。
换成 Swoole 之后,应用进程只有几个,但全部常驻内存。ThinkPHP 容器、配置、路由表、ORM 元数据,这些全部只加载一次。MySQL、Redis 连接可以放进连接池复用。压测数据很直观:同一个 ThinkPHP 6 接口,FPM 模式下 QPS 大概是 200 到 300,换到 Swoole 常驻模式能到 1500 到 2000,响应时间从平均 50 毫秒下降到 8 到 12 毫秒。
当然这不是没代价的,代价就是——你的代码必须适应常驻内存的环境。这个我后面会单开一节专门讲,因为大部分第一次接 Swoole 的团队,不是死在性能上,是死在“代码写法没改过来”上。
2. 环境准备与基础集成
2.1 PHP 版本与 Swoole 扩展安装
先说硬性版本要求。ThinkPHP 6.0 要求 PHP >= 7.2.5,官方推荐 7.4 或 8.0;ThinkPHP 8.0 要求 PHP >= 8.0。Swoole 这边,think-swoole 4.x 版本要求 Swoole >= 4.8,我实测下来 Swoole 5.0 也没问题,但生产环境建议稳定优先,用 Swoole 4.8.x LTS 版本就好。
Swoole 扩展安装最好用 pecl 一行搞定:
pecl install swoole装完之后在php.ini里启用:
extension=swoole.so验证一下是否安装成功:
php -m | grep swoole php -i | grep "swoole version"这里有个容易踩的坑——CLI 的 PHP 和 FPM 的 PHP 可能是两套。你用php -v看到的是命令行 PHP,但 Nginx 走得是 FPM 的 PHP,两个是独立的二进制和配置。Swoole 只需要 CLI 环境支持就行,因为常驻进程是命令行启动的,FPM 那边完全不需要加载 Swoole。所以当你执行php -m发现没有 swoole 时,先确认你是不是看错 PHP 了。
如果是自己编译安装的 PHP,编译 Swoole 时记得带上这几个参数,后面跑 HTTPS、WebSocket 都用得上:
./configure --enable-openssl --enable-http2 --enable-swoole-curl make -j$(nproc) make install--enable-swoole-curl这个很多人忽略,但如果你项目里用了 Guzzle 这类 HTTP 客户端,在协程环境下没有 swoole-curl 的加持,请求会自动降级为同步阻塞,等于把协程优势丢了一半。
2.2 Composer 安装 think-swoole
这一步没什么花活,正常 composer 装包就行:
composer require topthink/think-swoole装完之后在 ThinkPHP 项目根目录执行:
php think swoole如果一切正常,终端会显示 Swoole 服务正在监听 0.0.0.0:8000,这时候你访问http://localhost:8000就能看到你的 ThinkPHP 应用响应了。
这是最简单的跑通方式,但生产环境我们很少直接裸跑这个命令。通常情况下,我会封装一个自定义命令来管理服务的启停。think-swoole 自带的服务命令支持start|stop|restart|reload,基本够用:
php think swoole:start php think swoole:stop php think swoole:restart php think swoole:reloadreload是热重载,只重载业务代码和配置,不重启进程,适合更新代码后快速生效。注意 reload 对 static 属性里的老数据不一定生效,静态缓存类的数据最好用stop + start彻底重启,这个我们后面讲坑位时会展开。
安装完成后,你会看到config/swoole.php出现在配置目录下。这个文件就是整个 Swoole 服务的总配置中心,后续所有调整都围绕它进行。
2.3 swoole.php 配置文件逐项解读
config/swoole.php里最核心的几个配置项,我挑实际用得上的列个表:
| 配置项 | 默认值 | 说明 | 我通常的建议 |
|---|---|---|---|
server.host | 0.0.0.0 | 监听地址 | 0.0.0.0 |
server.port | 8000 | 监听端口 | 用 9501 或 8000 都行,别跟别的服务撞 |
server.socket | tcp | 协议类型 | 标准 HTTP 用tcp,WebSocket 用ws |
server.options.pid_file | runtime/swoole.pid | PID 文件路径 | 默认即可 |
server.options.log_file | runtime/swoole.log | 日志文件路径 | 默认即可 |
server.options.log_level | info | 日志级别 | 生产建议 warning |
server.options.task_worker_num | 1 | Task 进程数 | 按 CPU 核数设定 |
server.options.worker_num | 自动 | Worker 进程数 | 建议显式设置 |
server.options.max_request | 5000 | 进程最大请求数 | 防止内存泄漏,建议 5000-10000 |
server.options.max_wait_time | 3 | 停止进程时最大等待时间 | 默认就行 |
server.options.buffer_output_size | 2M | 响应缓冲区大小 | 大响应体要调大 |
worker_num是个值得说两句的配置。很多人以为 worker 越多越好,线加到 64,结果还不如默认的 8。Swoole 的 worker 是常驻内存的,每个 worker 里跑着完整的 ThinkPHP 容器,内存占用大概 80 到 120 MB。你机器总共 16 GB,去掉 MySQL 和 Redis 占用的,能分给 PHP 的也就 6 到 8 GB,worker 数算下来 32 个就是上限了。经验公式是worker_num = CPU 核数 * 2,比如 4 核就设 8,8 核设 16。
max_request这个参数务必留着,别改成 0。它做的事情是让每个 worker 处理完一定数量的请求后主动退出重启,防止内存慢慢涨上去。常驻进程最怕内存泄漏,PHP 代码里一个 static 变量没用对,内存就悄悄涨,有了max_request兜底,至少不会涨到 OOM 才被发现。
3. 注册服务与启动运行
3.1 把 Swoole 服务注册到系统服务
直接在前台跑php think swoole显然不适合生产。我的做法是注册成 systemd 服务,或者至少用 nohup 挂后台。
如果你用的是 systemd,先创建一个服务文件:
[Unit] Description=ThinkPHP Swoole Server After=network.target [Service] Type=forking WorkingDirectory=/www/wwwroot/your-project ExecStart=/usr/bin/php think swoole:start ExecStop=/usr/bin/php think swoole:stop ExecReload=/usr/bin/php think swoole:reload Restart=always User=www [Install] WantedBy=multi-user.target然后:
systemctl daemon-reload systemctl enable think-swoole systemctl start think-swoole这里有个细节——Type=forking有点讲究。think-swoole 的start命令默认是后台启动,父进程会 fork 出子进程然后退出,所以用 forking 类型是最合适的。如果你用Type=simple,systemd 会误以为主进程就是那个启动命令的进程,一旦处理完就认为服务挂了。
如果不想折腾 systemd,一个简单的守护脚本思路也行:写个 Shell 脚本检查 PID 文件,检测到进程挂了就重新拉起。但说实话,都 2025 年了,systemd 已经是 Linux 标配,直接用就行。
3.2 启动后如何验证服务是否正常
启动完成之后,别急着关终端,先做一轮“体检”。
第一,瞟一眼 PID 文件:
cat runtime/swoole.pid第二,看 master 和 worker 进程状态:
ps aux | grep swoole正常情况下你会看到类似输出——一个 master 进程,几个 worker 进程。注意 worker 的数量应该等于你配置的worker_num,如果少了,去日志里翻。
第三,直接请求验证:
curl http://127.0.0.1:8000/如果你的项目用的是路由,测试一个真实接口:
curl http://127.0.0.1:8000/api/v1/users curl -X POST http://127.0.0.1:8000/api/v1/users -d 'name=test'第四,检查日志。runtime/swoole.log里会记录请求情况、异常堆栈、连接状态。如果出现WARNING级别的消息,比如连接关闭异常、任务处理超时,就得留意了。
最后一个很多人忽略的验证——opcache 的状态。Swoole 常驻进程里的 PHP 不会每次请求都重新加载文件,如果你开了 opcache,文件内容更新后 opcache 的validate_timestamps还是默认行为,那会出现改了代码但线上不生效的情况。建议路径是重启服务而不是依赖 opcache 的自动失效。
4. 代码改造:常驻内存下的关键注意事项
4.1 全局变量与静态属性是最大隐患
一个 PHP-FPM 项目迁到 Swoole 上,第一个炸雷的地方就是全局变量和静态属性。
传统 FPM 模式下,每次请求结束,PHP 会释放所有内存和变量。你的global $db、static $config、单例对象,全部销毁,下次请求重新来。所以很多老代码写得“很随便”——全局变量随便用,静态属性当缓存随便存。
但在 Swoole 常驻模式下,Worker 进程不销毁,内存里的数据就会跨请求留存。这带来一个极其隐蔽的 bug:某个静态属性记录了上一次请求的数据,下一次请求直接读到了脏数据。表现是——随机性地、偶发地出现接口返回了别人的数据,特别难查,因为本地复现不了,只有线上高并发时才会偶尔冒出来。
举一个真实案例。我们团队接手的项目中有一个配置读取类,用了静态属性做缓存:
class Settings { protected static $data; public static function get($key) { if (self::$data === null) { self::$data = self::load(); } return self::$data[$key] ?? null; } }这代码在 FPM 模式下完全没问题,每次请求self::$data都是 null,重新加载,不存在并发问题。迁到 Swoole 后,一旦某个多线程或协程场景下多个请求交错执行,同一个静态属性被多个协程共享,数据就乱了。
这部分严格来说是得改的,因为 Swoole 的协程调度是抢占式的,同一个 Worker 进程里多个请求交错执行时,PHP 里的静态变量就是所有协程共享的。解决思路是:
禁用全局静态缓存,让每次请求都实时读取配置。但这样会损耗性能,折中方案是:用context保存请求级数据。think-swoole 提供了Context类,为每个请求创建独立上下文,请求结束自动清理:
use think\swoole\concern\InteractsWithHttp; Context::set('config_data', $data); $data = Context::get('config_data', []);基本原则就一条——不要在静态属性里存和用户/请求相关的数据。存那些全局固定不变的东西(比如配置文件、注解元数据)是安全的,动态数据就别碰。
4.2 数据库、Redis 连接必须走连接池
在 FPM 模式下,PHP 进程用完 MySQL 连接就断,属于“用完即走”。到了 Swoole 常驻,连接是能复用的,但如果你像 FPM 那样每次请求都new PDO()或者每次请求都connect()拉新的 Redis 连接,那就白瞎了 Swoole 的资源复用优势,还会造成连接数量膨胀。
ThinkPHP 6 的数据库网关在 Swoole 环境下会自动使用连接池(think-swoole 底层集成了think\swoole\pool组件),但有个前提——你用的驱动要支持。默认的PDO驱动没问题,但如果你用了自定义数据库驱动或者某些第三方 ORM,就得自己确认连接池逻辑。
更稳妥的做法是,数据库连接池统一配置在swoole.php的pool节:这里注意,连接池并不是越大越好。你开 64 个连接放池子里,数据库那边连接占着不用,等于浪费资源。我一般把 pool 的并发数控制在 worker 数乘 2 以内,比如 8 个 worker 就 16 个连接,再配一个maxLifetime做回收兜底:
'pool' => [ 'db' => [ 'enable' => true, 'max_connections' => 16, 'min_connections' => 2, 'wait_timeout' => 3, 'max_idle_time' => 60, ], 'cache' => [ 'enable' => true, 'max_connections' => 8, 'min_connections' => 1, 'wait_timeout' => 3, 'max_idle_time' => 60, ], ]连接池配置的原理是:Worker 进程启动时预创建一定数量的连接放进池子,请求来了直接取,用完归还放回去。由于常驻内存,连接不会因为请求结束而销毁,长期运行的连接和数据库服务端之间没有断开,就会遇到“MySQL server has gone away”或者“Lost connection”这类错误。这时候调整maxLifetime、max_idle_time参数,让池子里闲置过久的连接主动关闭重建,问题就解决了。
另外一个相关注意事项:如果你的 MySQL 开启了wait_timeout比较短,比如默认 8 小时,其实关系不大,因为生产中一般给 MySQL 的wait_timeout设得比较长。真正要注意的是数据库服务端的max_connections不要设太小。连接池虽然控制着客户端连接数,但如果你多个项目共用同一个数据库实例,还是得核算总连接数上限。
4.3 单例模式在 Swoole 下的正确用法
PHP-FPM 时代的单例模式和 Swoole 时代的单例模式语义完全不同。
FPM 时代,单例是“请求内单例”——同一个请求里多次获取同一个对象,返回同一个实例,跨请求不保留。Swoole 时代,因为进程常驻,单例是“进程级单例”——同一个 Worker 进程内所有请求共享同一个实例,这个实例里的状态是跨请求共享的。
你写一个下单的服务类用了单例模式,里面有个$this->currentUser属性,某个请求走到一半把当前用户塞了进去,下个请求进来拿到的可能还是上一个用户的数据。这就是经典的 Swoole 单例污染问题。
解决思路有两种。要么放弃把状态存在属性里,改为方法传参,让服务类保持无状态;要么把这种动态数据存到Context中,让每个请求各自独立。我的经验是——把所有服务类都写成无状态设计,方法所需的数据全部从参数传进去。这样不管并发多高都不会串数据,还能天然适配合并可复用。
举一个稍微完整的例子。你原来可能这样写:
class OrderService { private $userId; public function setUserId($id) { $this->userId = $id; return $this; } public function create($data) { return $this->buildOrder($this->userId, $data); } }改成:
class OrderService { public function create($userId, $data) { return $this->buildOrder($userId, $data); } }改完之后的OrderService是完全可以注入到容器里单例复用的,不存在串数据问题。这个过程叫“消除状态”,是 Swoole 项目里代码评审最常检查的点。
4.4 Header、会话与请求数据的隔离处理
FPM 环境下每个请求都有独立的$_GET、$_POST、$_SERVER、$_SESSION。Swoole 常驻环境下,这些超全局变量仍然是正常的,因为 think-swoole 在处理每个 HTTP 请求时,会自动把这些数据注入到当前的请求上下文里,并且请求结束时会清理。所以你在控制器里用$this->request->param()这类现代写法是安全的。
但如果你还保留老式代码,直接在业务逻辑里访问$_SESSION或者用session_start(),那就危险了。原因是$_SESSION在 Swoole 环境下的语义变得非常不直观,多个协程共享同一个进程的全局 session 数组,分分钟串数据。ThinkPHP 的 Session 类已经适配好了,你就用框架的Session::set()、Session::get()就安全。
同理,$_FILES也是安全的,但要注意上传文件是临时文件,请求结束会被清理,所以上传后需要移动文件到持久化位置。
Header 处理也有个隐蔽陷阱。某个请求设置了一个响应头X-Custom-Header: foo,下一个请求如果不显式覆盖,某些情况下这个响应头会被复用,导致响应头混乱。think-swoole 每次请求结束后会清理响应对象,这个问题只在极端场景下出现,但保险起见,每个请求应该根据自己的需求设置 Header,而不是依赖上一次残留。
5. 高级特性:WebSocket、协程、任务队列与定时器
5.1 WebSocket 服务与 ThinkPHP 原生集成
把 Swoole 接上之后,有很大一部分人的目的不只是提升 HTTP 接口性能,还要上 WebSocket。比如实时的消息推送、客服系统、在线协同编辑、大屏数据更新等等。
ThinkPHP 结合 Swoole 做 WebSocket 有两种风格。官方风格是将 WebSocket 和 HTTP 服务同时跑在一个 Swoole Server 上,配置server.socket为ws,然后在项目里写一个 WebSocket 处理类,监听事件。我推荐这种方式,因为一个端口同时支持 HTTP 和 WS,部署简单,不像以前还要单独起一个 WS 服务。
先用 composer 安装常用的 WebSocket 辅助包:
composer require topthink/think-swoole然后在config/swoole.php中启用 WebSocket 模式:
'server' => [ 'host' => '0.0.0.0', 'port' => 9501, 'mode' => SWOOLE_PROCESS, 'socket' => SWOOLE_SOCK_TCP | SWOOLE_SOCK_SECURE?, // 如果没配证书就用 tcp 'sock_type' => SWOOLE_SOCK_TCP, 'swoole_class' => \ThinkSwoole\WebSocket\Server::class, ]注意:你要配合证书做 WSS 时才需要加上SWOOLE_SOCK_SECURE,否则保持SWOOLE_SOCK_TCP就行。
接着写一个处理类。think-swoole 支持使用WebSocketHandler来处理消息:
namespace app\websocket; use think\swoole\websocket\Handler; use Swoole\WebSocket\Frame; use Swoole\Http\Request; use Swoole\Http\Response; class SocketHandler extends Handler { public function onOpen(Request $request, Response $ws) { echo "连接打开\n"; } public function onMessage(Server $server, Frame $frame) { $data = json_decode($frame->data, true); // 处理业务 $this->push($frame->fd, json_encode(['code' => 0, 'msg' => '收到:' . $data['msg']])); } public function onClose(Server $server, int $fd) { echo "连接关闭: {$fd}\n"; } }然后注册到swoole.php:
'websocket' => [ 'enable' => true, 'handler' => \app\websocket\SocketHandler::class, ],跑起来之后,你用wscat这个命令行工具连一下:
npm install -g wscat wscat -c ws://127.0.0.1:9501输入{"msg":"hi"},如果返回了业务处理结果,说明链路已经全通了。
WebSocket 的难点往往不在开发,而在部署。Nginx 需要配置升级头,把客户端 Upgrade 请求转发给 Swoole 的端口:
location /ws { proxy_pass http://127.0.0.1:9501; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; }一个容易踩的坑——Nginx 默认的proxy_read_timeout是 60 秒。WebSocket 长连接里客户端和服务端可能很久没有消息,超过 60 秒 Nginx 就掐断了。解决方法是把超时拉长:
proxy_read_timeout 3600s; proxy_send_timeout 3600s;或者是由应用层设计心跳机制,定期 ping-pong 维持连接活跃。真实上线项目中两者都要做:Nginx 放长超时,业务层约 30 到 60 秒发一次心跳。
5.2 协程并发调用外部接口的写法
Swoole 最强悍的地方在于协程。一个 Worker 进程里可以同时挂很多个协程,某个协程在等待 IO 时不阻塞其他协程的执行。这对批量调用外部接口的场景是降维打击。
举个例子。你的项目有一个聚合查询接口,需要同时去三个服务拉数据:商品中心、库存中心、价格中心。FPM 模式下串行请求,三个接口服务各自 100 毫秒,总耗时 300 毫秒。换成协程并发,三个请求一起发出去,总耗时大概 100 毫秒出头。
ThinkPHP 里写协程并发最简单的是用Coroutine类:
use Swoole\Coroutine; use Swoole\Coroutine\Channel; $channel = new Channel(3); Coroutine::create(function () use ($channel) { $product = $this->httpGet('http://product-center/api/info'); $channel->push(['type' => 'product', 'data' => $product]); }); Coroutine::create(function () use ($channel) { $stock = $this->httpGet('http://stock-center/api/stock'); $channel->push(['type' => 'stock', 'data' => $stock]); }); Coroutine::create(function () use ($channel) { $price = $this->httpGet('http://price-center/api/price'); $channel->push(['type' => 'price', 'data' => $price]); }); $result = []; for ($i = 0; $i < 3; $i++) { $item = $channel->pop(); $result[$item['type']] = $item['data']; }这里的关键是用了Channel做协程间的通信。每个协程把结果推入通道,主协程依次弹出。如果你在 Swoole 5.0 里,Channel的 API 略有变化,但整体模式一样。
另一个更简单的方式是直接用Swoole\Coroutine\go()加上闭包:
go(function () { $res = $this->httpGet('http://api1.com'); }); go(function () { $res = $this->httpGet('http://api2.com'); });但注意go()开的协程之间没有直接的数据返回机制,要拿结果还得配合 Channel,所以上面那个 Channel 写法是更完整的方案。
再补充一个重点:上面httpGet必须使用 Swoole 协程客户端,比如Swoole\Coroutine\Http\Client,或者 think-swoole 集成的think\swoole\concern\InteractsWithHttp里封装的 HTTP 客户端,才能发挥协程优势。如果你内部还是用了传统的 cURL 同步阻塞请求,那你的“协程并发”就是假的——请求发出后协程挂起等同步 IO,其他协程也动不了。这一点在代码评审时要特别留意。之前接手过一版代码,协程用的是有模有样,里面的 HTTP 请求却在用file_get_contents,并发优化回来一看,瓶颈没降,只是换了一种写法。
5.3 Task 任务机制与定时器实现
异步任务和定时任务是常驻内存场景下的两大刚需。比如用户下单后要发短信通知,发邮件的耗时(几百毫秒)不应该阻塞主流程。FPM 模式下常见做法是丢进 Redis 队列,再让 worker 去消费。Swoole 环境里可以直接用 Task 组件,无需额外起队列服务。
think-swoole 对 Task 的封装很轻,你只需要定义一个任务类,继承think\swoole\task\Task:
namespace app\task; use think\swoole\task\Task; class SendSmsTask extends Task { public $mobile; public $content; public function run() { // 真实发短信逻辑 return sms()->send($this->mobile, $this->content); } }然后业务侧投递任务:
use app\task\SendSmsTask; SendSmsTask::dispatch([ 'mobile' => $user->mobile, 'content' => '订单支付成功', ]);Task 是异步的,投递后立即返回,真正的任务在 Task Worker 进程里执行。Task 里的代码如果有异常,会被记录进日志,不影响主进程。
定时器这块,如果你要在 Swoole 进程里跑定时任务,不要用 Crontab。一个原因是 Crontab 分不清你是集群部署还是单机部署,可能会有重复执行的问题;另一个原因是 Swoole 的定时器能做到毫秒级精度,执行任务前不用重新初始化框架,效率高。think-swoole 也提供了监听manager的定时器注册方式,简单用可以在worker_start时注册tick:
$server->tick(60000, function () { // 每分钟执行一次 $orderModel = new Order; $orderModel->closeTimeoutOrders(); });但说实话,如果要在生产环境稳定使用定时任务,我更建议单独起一个 ThinkPHP 命令行脚本,配合 systemd timer 或者 Crontab 跑,而不是塞进 Swoole 进程。原因是 Swoole 常驻进程的tick回调里跑重逻辑容易阻塞 Worker 进程,影响正常请求处理,除非你开独立的 Task 进程来做。权衡之下,Cron 调度简单、可控、好排查问题,除非你的任务本身就依赖常驻内存(比如每隔几百毫秒检查一次共享缓存),否则别把定时器全部塞进 Swoole。
6. 常见问题与排查技巧实录
6.1 端口冲突与 Worker 进程崩溃
问题表现:启动时直接报Address already in use,或者运行一段时间后某个 Worker 突然退出,日志里出现Fatal error或Segmentation fault。
排查思路:
端口冲突,先用ss -lntp | grep 9501或者netstat -tlnp | grep 9501查谁的端口被占了。如果被 Nginx 占用了,就换 Swoole 的端口或者调整 Nginx 配置,让 Nginx 反代到 Swoole 的端口。如果你跑了多个项目共用同一台服务器,更要做好端口规划。
Worker 崩溃的情况,分两种:一种是代码本身有问题,比如调用了不存在的类方法、内存分配不足,这种在日志里能看到明确的错误堆栈;另一种是扩展崩溃,常见于opcache和swoole版本不兼容,或者第三方的 C 扩展和 Swoole 的协程冲突。解决办法依次尝试:更新扩展到最新版本、把 opcache 的validate_timestamps设为1观察是否复现、减少并发 worker 数量降低资源争抢。
6.2 修改代码后不生效:热重载与 opcache 的坑
问题表现:改完控制器方法,curl测试发现返回的还是旧逻辑,于是怀疑 Swoole 是不是有缓存。
原因:Swoole 常驻内存的特性决定了代码一次性加载进内存后不会自动重新读取文件。除非你显式执行php think swoole:reload或者完全重启,否则 Worker 内存里跑的还是旧代码。
还有一个叠加因素是 opcache。即使执行了 reload,opcache 可能缓存了旧的 opcode,所以最可靠的方案是:
php think swoole:stop opcache_reset()? // 一般不这么干 php think swoole:start也就是彻底重启,让 PHP 重新从磁盘加载文件并生成新的 opcode。生产环境部署脚本里一定要把“重启 Swoole 服务”作为一个固定步骤写进去,别以为只是替换文件就完事。
6.3 MySQL 连接断开的处理
问题表现:服务运行几天后,接口偶发报 500 错误,日志里有SQLSTATE[HY000] [2002] Connection refused或者PDOException: SQLSTATE[HY000] [2006] MySQL server has gone away。
原因:连接池里存的 MySQL 连接长时间闲置,服务端的wait_timeout到期后把连接关闭了。连接池不知道,继续用旧连接请求,自然报错。
解决方案:在连接池配置中,给max_idle_time设置一个比 MySQLwait_timeout短的值,让连接在空闲超过设定时间后被回收重建。通常 MySQL 默认wait_timeout是 8 小时,我一般把max_idle_time设成 3600 秒(1 小时)。还可以在数据库配置里设置PDO::ATTR_TIMEOUT和异常重连逻辑,让框架在捕获ConnectionException时自动重建连接并重试一次。
6.4 请求串数据与用户信息错乱的排查
问题表现:线上偶尔出现用户 A 的请求返回了用户 B 的数据,测试环境很难复现。
原因:几乎可以断定是静态属性、单例、全局变量里存储了请求级动态数据。排查方向就是全项目搜索static $、static function、global关键词,看看哪些类在语法上是静态的,然后逐个排查是否存了动态数据。
从我自己的经验看,最容易藏脏数据的位置有三个:HTTP 客户端里存的请求头/鉴权 token、ORM 模型里的当前用户属性、各种 Service 类里的临时缓存字段。修复方式就是文章前面讲过的——消除状态,改成方法传参,或者用Context做请求级隔离。
这部分的排查还有一个技巧:打开 think-swoole 的请求日志,把单次请求的完整调用链打出来,对比异常请求的调用链和正常请求的差异。有时候脏数据的写入点在某个很隐蔽的父类静态方法里,靠看代码不一定能抓到,靠日志更快。
6.5 内存持续增长的兜底方案
问题表现:free -m看到内存占用稳步上升,几天后接近上限,服务变得极慢甚至被系统 OOM Kill。
原因:代码里某个地方没有释放大对象,或者循环里累积了不可释放的引用。常驻进程下,内存只增不减直到被系统干掉。
兜底方案:无论如何都要保留max_request配置。Worker 进程处理完指定数量的请求后自动退出重建,内存随之释放。我生产环境设置的是max_request => 5000,对于大多数业务都够用。如果一个 Worker 处理 5000 个请求后内存已经累积了 200 到 300 MB,重建后回到 100 MB,这个波动是完全正常的。
另外可以做一层监控配合:用 Supervisor 之类的进程管理工具监控 Swoole 主进程,发现内存超过阈值就自动重启。systemd 配合WatchdogSec也可以实现类似效果,不过多一层监控多一点保障,值得做。
7. 我的实战建议与最后的经验分享
从 FPM 迁到 Swoole,换的不只是运行方式,而是写代码的思维方式。PHP 从一个“用完就毁”的语言变成了“常驻共享”的语言,这意味着必须对状态管理有洁癖。所有和具体请求相关的数据,要么方法传参,要么 Context 隔离,绝不允许靠静态属性偷偷存。
如果你第一次在公司项目里上 Swoole,我的建议是走一条稳妥的渐进路线。第一步,先只把 Swoole 当作 HTTP 服务器来用,业务代码不做大改动,跑一段时间看稳定性。第二步,再把 Redis、数据库连接池开起来,观察性能收益。第三步,去处理代码里的静态状态问题,优化核心接口的响应速度。最后一步,才是上 WebSocket、协程并发这些高级玩法。一次步子迈太大,出了问题排查范围会非常大,很容易劝退团队。
几个我踩过之后特别想提醒的点:
第一,Swoole 不是银弹。如果你项目业务逻辑里大量的耗时不是 IO 而是 CPU 密集计算(比如图片处理、复杂的循环算法),Swoole 的收益不会太明显,因为 CPU 密集任务本质上不能靠常驻内存来解决。这种情况优化算法、加机器、用更快的处理库是更好的方向。
第二,日志千万别省。常驻进程的排查难度比 FPM 高一个量级,如果没有完善的请求日志、异常日志、慢查询日志,出了问题会抓瞎。think-swoole 里我强烈建议你把log_level保持为 warning 以上,并接入一个远程日志收集服务,比如 ELK,出了问题直接集中查日志。
第三,部署之后要给服务设置探活。最简单的探活就是定时 curl 一个轻量接口,如果连续几次失败,就让 systemd 或 Supervisor 重启 Swoole 服务。这个探活接口要足够轻,别走数据库查询,像/health这样随便 return 一个 JSON 即可。
第四,团队内部一定要有一份“Swoole 环境下的开发规范”。比如禁止在静态属性里存动态数据、禁止使用$_SESSION、禁止在 Task 里跑占比过重的 CPU 任务、用什么方式做协程并发等等。我见过很多项目引入 Swoole 后上线就崩,不是因为 Swoole 不行,而是因为代码没适配,一个静态变量引发雪崩。有了规范文档,至少能拦住大部分坑。
最后再分享一个我自己的习惯——每次改动核心代码后,先压测再上线。工具用 ApacheBench 或者 wrk 就够了,在部署前拿同一套接口对比一下改动前后的 QPS 和响应时间,如果发现退步就得认真排查。压测是对 Swoole 项目最好的体检,很多隐蔽的代码问题在高并发下会暴露得特别快。
Swoole 和 ThinkPHP 的这个组合,从技术方案的成熟度来说,已经完全可以支撑生产环境了。我从它还算小众的时候开始用,到现在看着它变成 PHP 高并发项目的常用选择,踩过的坑不计其数,但也正是这些坑让我对“常驻内存”这四个字有了比文档更深刻的理解。希望这篇教程能帮你少走几步弯路,哪怕只是避开一两个我最开始摔过的跤,这三千多字也算没白写。