前几天一个朋友给我打电话,说网站一到晚上八点就卡成 PPT,客户端大面积 502,重启一下 PHP-FPM 能好十分钟,然后又接着崩。我让他先看一眼pm.max_children当前配的多少,结果他反问我:PHP 不就是一个执行引擎吗,这个 pm 参数跟我有什么关系?
其实这正是很多 PHP 开发者的通病。我们天天写控制器、调 SQL、排 API,却很少停下来想一想:用户点下浏览器按钮之后,从 Nginx 把请求转给 PHP,到 PHP 执行完代码把结果返回,这中间到底经历了什么。一旦网站出问题,性能瓶颈不在业务代码,而在 PHP 本身的运行机制时,你不理解 CLI 和 FPM 的区别,连排查的第一步都迈不出去。
这篇文章想做的事情很朴素:把 PHP 最常见的两种运行模式,CLI(命令行接口)和 FPM(FastCGI 进程管理器),从头到尾拆开讲清楚。它们不是 PHP 的两个版本,而是同一门语言在完全不同的生命周期、进程模型和资源管理策略下的两种活法。搞清楚这些,你能解释很多奇奇怪怪的生产问题,比如"同一个脚本在命令行跑得好好的,挂到网页上就内存爆掉",也能真正看懂 php-fpm.conf 里每一行配置是干嘛的。下面的内容我尽量用大白话讲,新手能建立起完整的心智模型,老手也可以对照着排查清单查漏补缺。
1. 先看懂两句话:CLI 和 FPM 到底在干什么
PHP 本身只是一门语言,它要"运行"起来必须依赖一个宿主环境。这个宿主环境在 PHP 的术语里叫 SAPI(Server Application Programming Interface),你可以把它理解成 PHP 和外界之间的翻译官:外界把请求交给 SAPI,SAPI 叫醒 PHP 内核,PHP 干完活再把结果原路传回去。
PHP 历史上出现过很多种 SAPI。Apache 时代的 mod_php,Nginx 时代的 fpm,还有命令行下的 cli。但你在生产环境里真正会碰到的,基本就 CLI 和 FPM 两种。它们面对的是两个完全不同的世界:CLI 面对的是 Linux 终端、定时任务、队列消费者;FPM 面对的是浏览器请求、Nginx 转发、高并发流量。
要理解这两种模式,第一句要记住的话是:CLI 是“一次性”的,FPM 是“常驻”的。你在终端敲一句php index.php,PHP 启动、执行脚本、打印结果、退出进程,一切归零。而 FPM 启动之后就一直蹲在系统后台,时刻准备接收来自 Nginx 的请求,一个 Worker 进程处理完一个请求并不会退出,而是继续等待下一个请求。这个最基本的区别,会一路影响到后面所有的内存表现、性能行为和并发上限。
第二句要记住的话是:CLI 没有天然的“请求”概念,FPM 的一切都是围绕“请求”展开的。CLI 模式下,脚本从头到尾顺着执行,你就是整个世界的中心;FPM 模式下,每个请求都要被拆成“初始化环境 -> 执行脚本 -> 清理环境”这样一个标准循环。你平时写的$_GET、$_POST、$_SESSION这些超全局变量,只在 FPM 模式下才存在,CLI 里压根没有——因为根本不存在 HTTP 请求。弄懂这一点,就能理解为什么同一套业务代码在两种模式下跑,表现会像两个世界。
那这篇文章到底适合谁?我觉得两类人收获最大:一类是刚做完第一个 CRUD 项目,想搞清楚"网站到底是怎么跑起来的"的新手;另一类是被线上 502、内存溢出折磨过,想系统梳理 PHP 底层进程模型的进阶开发者。第一类人看完能建立完整的宏观认知,第二类人能按图索骥地排查实际问题。
1.1 两种 SAPI 的本质差别
很多人会把 CLI 和 FPM 当成 PHP 的两个"运行命令",其实它们背后是两套完全不同的进程模型。
CLI 的进程模型最简单:操作系统 fork 出一个新进程,进程里加载 PHP 解释器,执行完你指定的脚本文件,然后整个进程退出。注意,这里没有"等待下一个请求"这回事,没有网络监听,没有 socket,有的只是从 stdin 接收数据、往 stdout 写结果。它更像一个"翻译工具",你给它一段脚本,它把结果给你,然后下班。
FPM 的进程模型是一个完整的主从架构。一个 master 进程负责管理和调度,若干个 worker 进程负责真正执行 PHP 脚本。master 启动时读取配置、创建监听 socket、按配置拉起一批 worker;worker 启动后阻塞在 socket 上等待任务,收到一个请求就处理一个,处理完继续阻塞等待。这个结构是典型的"生产者-消费者"模型:Nginx 是生产者,向 socket 写入请求;worker 是消费者,取走请求并返回响应。
这两种模型还有一个行为上的巨大差异:错误处理和资源回收。CLI 模式下脚本崩了,进程直接退出,操作系统把它的内存全部收走,干净利落;FPM 模式下单个 worker 崩了,master 会拉一个新的 worker 补上,但其他 worker 还活着,正在处理中的请求不受影响。这也是为什么 PHP 跑在 FPM 里给人一种"皮实"的感觉——单点故障被进程隔离了。
2. PHP 生命周期:无论哪种模式都逃不过这五个阶段
要说清楚 CLI 和 FPM 的差异,得先讲 PHP 内核自身的生命周期。PHP 不管在哪个 SAPI 下运行,一次代码执行在宏观上都能分成五个阶段:模块初始化、请求初始化、执行脚本、请求关闭、模块关闭。这五个阶段是理解 PHP 内存行为的根基。
- 模块初始化(MINT):PHP 加载所有扩展、注册函数和类、读取并解析 php.ini 配置文件。你可以把这步理解成给整栋楼通电、铺水管,设施全部就位。
- 请求初始化(RINT):针对当前这一次请求分配独立的环境,比如创建符号表、初始化自动加载、把请求参数注册成超全局变量。相当于每个住客入住前,保洁把房间打扫干净、递上房卡。
- 执行脚本:你的代码在这里被编译成 opcode,然后由 Zend 引擎逐条执行。这是唯一一个你真正能通过写代码控制其行为的阶段。
- 请求关闭(RSHUTDOWN):清理本次请求产生的所有变量,执行
__destruct析构方法,调用register_shutdown_function注册的收尾函数,释放本次请求占用的大部分内存。 - 模块关闭(MSHUTDOWN):把扩展加载的资源、全局状态全部释放,PHP 彻底停业,进程准备退出。
这里就引出一个特别关键的点:你代码里创建的变量、对象、数据库连接,生命周期都在“请求初始化”到“请求关闭”这一段里。所以"内存泄漏"这个词必须分成两层来理解。如果你的代码在单次请求里申请了资源忘了释放,没关系,请求结束时会自动回收,对单次请求来说影响很小;但如果是一个常驻进程——比如 FPM 的 worker,或者 CLI 里用while(true)写的守护脚本——请求之间残留的全局变量、没关掉的连接、不断累加的静态属性,就会让内存越堆越高,最终把服务器拖垮。
2.1 生命周期五阶段
上面列的五个阶段,每个阶段在代码层面都有对应对钩子。你可能写过扩展开发里常见的PHP_RINIT_FUNCTION、PHP_RSHUTDOWN_FUNCTION,这就是 PHP 在请求初始化和请求关闭阶段回调到扩展的入口。业务代码接触不到这些底层钩子,但可以通过register_shutdown_function注册请求关闭时的回调,这是每个框架自带的最后一道防线。
这里我想特别强调一下"请求关闭"阶段为什么重要。很多人以为脚本执行完就结束了,其实 PHP 还有很多收尾工作要做:释放符号表里的变量引用、冲刷输出缓冲区、调用已注册的关闭函数、发送响应头。这些工作全部完成之后,内存才能回到请求开始前的状态。这也是为什么你在脚本里用exit退出时,register_shutdown_function 注册的函数照样会被执行——因为 exit 只是提前进入了请求关闭阶段,并没有跳过它。
2.2 CLI 与 FPM 的周期差异
CLI 的生命周期是"完整五阶段、一轮到底"。进程启动相当于模块初始化,脚本开始前做一次请求初始化,执行完脚本做请求关闭,进程退出前做模块关闭。整个过程只有一轮,干净利落,不存在内存累积问题,因为进程本身就是最大的一次性垃圾回收器。
FPM 的生命周期是"模块初始化一轮、请求循环 N 轮"。worker 进程启动时做一次模块初始化,然后不停循环"请求初始化 -> 执行脚本 -> 请求关闭",循环多少次由配置决定。等到满足一些条件(比如处理够了pm.max_requests指定的请求数),master 才让这个 worker 退出并拉一个全新的 worker 顶上。
这个结构带来的直接收益是:昂贵的模块初始化开销(特别是加载 OpCache、PDO 这些大扩展)被摊薄到了成千上万个请求上。你可以想象一下两种方案的成本差异——每次请求都重新加载一遍 PHP 和所有扩展,那是灾难级的性能浪费。这就是为什么 FPM 取代了早期 CGI 模式,成为现代 PHP Web 架构事实标准的核心原因。
提示:也正因为 FPM 的 worker 是常驻进程,生产环境一定要开 OpCache。PHP 脚本的编译结果直接驻留在共享内存里,一次编译、多次直接执行,页面响应时间能明显下降一个档次。你可以用
opcache_get_status()查看命中率,长期低于 90% 就说明配置或者代码结构有问题。
3. CLI 模式详解:命令行脚本的整套运行规则
3.1 一次 CLI 执行发生了什么
你在终端执行php /opt/scripts/report.php 2025-01-01时,操作系统先找到php这个可执行文件,把后面的参数原样交给它。之后,PHP 二进制自己按照下面的顺序干活:
- 加载配置。依次查找编译期指定的 php.ini 路径、
PHP_INI_SCAN_DIR环境变量指向的目录(一般是/usr/local/etc/php/conf.d/)、以及当前目录下的.user.ini。 - 模块初始化。启用所有编译进去和配置里 enabled 的扩展,注册函数。
- 解析命令行参数。这里有个特别常见的认知误区:
$argv和$argc在 CLI 模式下确实默认可用(因为register_argc_argv默认是 On),但很多新手把php script.php arg1 arg2和php -r 'echo 1;' arg1搞混。你要记住,$argv[0]是你的脚本文件名,$argv[1]才是第一个真正的业务参数。 - 执行脚本。编译并执行你指定的那个文件。
- 走完请求关闭和模块关闭,进程退出。此时返回给操作系统的退出码就是你脚本里
exit()传出的值,这个值对 shell 脚本来说极其重要,crontab 和 CI 判断任务成功还是失败,靠的就是它。
CLI 模式下display_errors默认是 On,错误直接打到屏幕上,开发调试很方便。但这也意味着,如果你把 CLI 命令包在一个 web 接口里用exec()去调,错误信息可能会混进 HTTP 响应体,造成莫名其妙的格式问题。生产环境我会习惯在 CLI 入口文件开头加上error_reporting(E_ALL)并显式设置ini_set('display_errors', '0'),把错误统一写进日志而不是直接喷到终端。
3.2 CLI 典型场景与使用细节
CLI 最适合做三类事情:定时任务、队列消费、一次性数据处理(比如数据迁移、报表导出、批量清洗数据)。我自己的方案是写一个统一的入口脚本,通过$argv[1]做命令分发,类似一个小型的命令行工具。举个例子:
# crontab 示例:每天凌晨两点执行报表生成 0 2 * * * /usr/local/bin/php /data/www/tools/report.php daily >/dev/null 2>&1这里有一条非常实用的经验:在 crontab 里千万别直接写php,要写绝对路径。crontab 的环境变量 PATH 默认极度精简,你在 shell 终端能用的php,在 crontab 里可能直接报 command not found。解决方式就是用which php查出来的完整路径,或者在脚本开头export PATH=/usr/local/bin:$PATH,二选一都能避免定时任务静默失败。
另一个 CLI 大坑是工作目录。你在终端手跑脚本,当前目录是你所在的那个目录;但 crontab 或者 systemd 拉起脚本时,工作目录可能是用户主目录甚至根目录。如果脚本里用了相对路径去读写文件,这就会成为间歇性 bug。我的建议是:CLI 脚本里所有文件操作一律拼接__DIR__来定位,绝对不要依赖当时的工作目录。
3.3 CLI 常见坑位
CLI 模式下max_execution_time默认是 0,也就是不限制执行时间。这既是优点也是隐患:一个写了死循环的脚本能一直跑下去,把你服务器的 CPU 打满。我见过太多次这种事故:有人写了个队列脚本忘加退出条件,第二天起来服务器负载直接飙到 30 多。我的习惯是脚本内部自己实现超时控制,或者用Laravel这类框架的 timeout 机制,实在不行就用系统自带的timeout 300 php daemon.php包一层,简单粗暴但有效。
再提醒一个大家容易忽略的:CLI 模式下没有$_SERVER,没有 Session,也没有 Cookie。如果你的业务代码里写了$_SERVER['REMOTE_ADDR']或者依赖 Session,脚本一跑就会报错或者拿到空值。业界通用做法是在框架的启动流程里加一个 SAPI 检测,判断当前是 CLI 还是 FPM,不同模式走不同的环境准备逻辑,避免同一份代码在两种模式下行为不一致。
还有一个长驻 CLI 任务的内存问题。FPM 的 worker 有pm.max_requests定期重启兜底,但 CLI 守护进程没有这个保护。你用while(true)消费队列时,必须在一轮循环结束后主动unset()掉大变量、断开不需要的连接,或者干脆每处理 N 条消息就主动exit,由 supervisor 重新拉起一个新进程。别小看这一步,它决定你的队列进程是稳定跑一个月还是三天就 OOM。
4. FPM 模式详解:这才是高并发网站的幕后主角
4.1 FastCGI 协议与 FPM 的定位
先把概念理清楚。CGI 是 PHP 最早的 Web 接入方案之一:Web 服务器每收到一个 HTTP 请求,就 fork 一个全新进程执行对应的 PHP 脚本,执行完进程退出。这种"一个请求建一个进程"的做法开销巨大,CPU 和内存都消耗在进程创建和销毁上。所以后来出现了 FastCGI 方案——让 PHP 进程常驻,Web 服务器通过 socket 或 TCP 协议与这些常驻进程通信,请求处理完进程不退出,继续等待下一个。
FPM 就是 PHP 官方对 FastCGI 协议的服务端实现,同时也是进程管理器,从 PHP 5.3.3 开始内置。如今主流的 Nginx + PHP 架构里,FPM 就是负责真正执行业务代码的那个角色。
通信过程大致是这样:Nginx 把SCRIPT_FILENAME、REQUEST_METHOD、QUERY_STRING、HTTP_HOST这些环境变量连同请求体一起,按 FastCGI 规范打包成消息,通过 unix socket(比如/run/php-fpm/www.sock)或 TCP(比如127.0.0.1:9000)发给 FPM。FPM 的 worker 接收并解析后,在内部把这些环境变量转成 PHP 的$_SERVER等全局变量,执行脚本,最后把响应内容按 FastCGI 格式写回给 Nginx。
4.2 master 与 worker 的协作
FPM 启动后会有两类进程:一个 master 和若干个 worker。master 不碰业务代码,它的职责非常纯粹:读取配置、监听 socket、管理 worker 的生命周期、处理信号。比如你改了 php-fpm.conf 之后执行kill -USR2 <master_pid>,master 就会重新加载配置并平滑重启一批 worker,线上用户几乎无感知。
worker 才是真正干活的角色。每个 worker 在同一时刻只处理一个请求,处理完回到空闲状态,继续等待下一个任务。这里最关键的数字是:网站的并发请求处理能力约等于 worker 数量。如果某个请求因为调用了慢速的外部接口卡了 5 秒,这 5 秒内对应的 worker 就一直被占用,后面进来的请求只能排队。所以你优化 FPM 配置之前,不如先想想自己的业务请求是不是被什么慢调用拖住了,否则配置调得再大也是白搭。
worker 的隔离性还有一个隐藏好处:一个 worker 因为执行了危险代码崩溃时,只会影响当前这一个请求,master 会立刻拉一个新的 worker 顶上,其他 worker 仍然正常工作。这种进程级隔离是 FPM 稳定性的重要来源。
4.3 三种 pm 模式怎么选
FPM 的进程管理模式由配置项pm决定,一共三种:
| 模式 | 核心逻辑 | 适用场景 |
|---|---|---|
| static | worker 数量固定为pm.max_children,启动后不增不减 | 流量稳定、内存富余的生产环境 |
| dynamic | 按负载在pm.min_spare_servers和pm.max_spare_servers之间动态增删,上限受pm.max_children约束 | 流量有明显波峰波谷的中型站点 |
| ondemand | 有请求时才创建 worker,空闲超时后销毁 | 低流量站点、开发环境,内存优先 |
我自己在几家不同量级的站点上都试过。流量稳定的大站用 static,最简单,性能也最好,因为没有任何动态创建的调度开销。中小站点用 dynamic,能兼顾峰值和闲时内存。开发机用 ondemand,一个 worker 不干活就自动退,确实省内存。
但不管选哪种模式,第一步永远是算好pm.max_children的上限。经验公式是:可用内存 / 单个 worker 平均内存占用。单个 worker 占多少可以用ps aux | grep php-fpm看一下,正常一个 worker 几十到一百多 MB 很常见,如果你的业务代码里有怪异的缓存对象,单 worker 飙到几百 MB 也不奇怪。你设的 max_children 就是并发天花板,超过这个数字的请求只能排队;要是内存不够,直接 OOM,整站全挂,比 502 惨多了。
4.4 FPM 请求处理全链路
一次典型的请求流程是这样的:
- 用户请求
https://example.com/index.php。 - Nginx 接收请求,按 location 规则匹配,把请求转发给
fastcgi_pass指定的 FPM 监听地址,同时带上 FastCGI 参数。 - FPM master 从监听 socket 里取出连接,分发给一个空闲 worker。如果没有空闲 worker,新请求就在队列里等待;队列满后直接拒绝,客户端表现为 502。
- worker 进入请求初始化:解析 FastCGI 参数,填充
$_SERVER、$_GET、$_POST等超全局变量。 - worker 执行脚本。Nginx 阻塞等待响应。
- worker 完成请求关闭,清理本次请求的状态,回到空闲状态。Nginx 拿到响应,拼装完整 HTTP 报文返回给浏览器。
这里面最容易被忽视的是第 3 步的队列。listen.backlog决定内核里这个监听队列能放多少个等待的连接,系统层的somaxconn又是另一个限制。这两个参数太小,流量稍微一冲,新连接直接进不来,表现也是 502。所以在排查 502 的时候,别只盯着 worker 数量,还要看一眼队列参数有没有被默认值坑了。
4.5 两个容易被忽略的配置
pm.max_requests是我极力推荐必须设置的参数。它表示一个 worker 处理完指定数量的请求之后自动重启。你可以把它理解成给 worker 做定期"大扫除",把内存碎片、泄漏的全局变量、不稳定的扩展状态全清理掉。线上我一般设置在 500 到 2000 之间。以pm.max_requests = 1000、每个请求平均耗时 30ms 来算,一个 worker 处理完 1000 个请求大约需要 30 秒,重启一个 worker 的开销完全可以忽略,但内存曲线会稳定非常多。
request_terminate_timeout同样重要。它限制单个请求的最长执行时间,超时直接 terminate 这个 worker。我见过有的团队关了它,结果某些脚本陷入死循环,worker 全部卡死,整个站呈现假死状态。设置它之后,顶多就是一个请求失败,其他 worker 不受影响。但要记得配合 Nginx 的fastcgi_read_timeout一起调,否则 FPM 那边还在处理,Nginx 这边已经等不及断开了,又会产生另一类超时问题。
最后强调一下运行身份。FPM 的 user 和 group 配置决定 worker 以什么用户身份运行,生产环境千万不能让它以 root 跑。一旦代码有文件上传或者命令执行漏洞,root 权限的 worker 被利用,整台服务器就等于交出去了。最常见的权限坑是"Permission denied":Nginx 能读文件但 PHP 写不了目录,或者反过来。排查这类问题时,先确认 Nginx worker 和 PHP worker 分别是什么身份,再逐层检查目录权限,不要上来就 chmod 777——那是给自己埋雷。
5. 实测对比:同一个脚本在 CLI 和 FPM 下的行为差异
5.1 一个内存残留脚本的两种命运
我设计一个极端例子来说明进程模型差异带来的直接影响。假设脚本往全局数组里塞大字符串:
<?php $pool = []; function handle() { global $pool; $pool[] = str_repeat('A', 1024 * 1024); } for ($i = 0; $i < 100; $i++) { handle(); } echo memory_get_peak_usage(true) / 1024 / 1024, " MB\n";在 CLI 模式下运行,这个脚本执行 100 次循环,占用 100 多 MB 内存,然后进程退出,内存全部归还系统。你反复执行这个文件 100 次,服务器内存也不会因此涨上去,因为每次都是全新的进程,跑完即焚。
但如果同样的逻辑出现在 FPM 的 worker 里,并且$pool被无意间附着在静态属性或者超全局变量上,那问题就来了。第一个请求把数组填满,请求结束后 worker 不退出,下次请求继续往同一个变量里追加数据。只要代码逻辑有重复累积的缺陷,worker 的内存就会一路走高,最终要么触发 OOM,要么被pm.max_requests强制重启。这也是为什么很多内存问题在测试环境复现不了——测试环境的请求量小、进程周期短,还没来得及把内存推高到危险线。
5.2 队列消费者的内存曲线
再举一个更贴近日常的案例。很多人用 CLI 写队列消费者:
<?php while (true) { $job = $queue->pop(); if ($job === null) { sleep(1); continue; } $result = process($job); // 内部可能用 Guzzle 调外部 API $queue->ack($job->id); }刚启动时内存很稳定,跑上一天之后发现 RSS 涨了七八十 MB。原因几乎必然是:$job对象里的引用没有被完全清除,Guzzle 请求之后的响应体没有释放连接,或者某个单例类全局缓存了越来越多的数据。这跟 FPM worker 的内存累积在本质上是一模一样的。
解决思路不外乎三种:每处理 N 条消息主动exit,交给 supervisor 重新拉起;或者在每一轮循环里手动unset掉大变量和连接资源;再升级一点,用pcntl_fork把单条消息的处理放到子进程里,处理完子进程退出,内存由操作系统兜底。任何方案都比不做处理强。我自己更推荐第一种,简单到不可能出错,配合pm.max_requests的理念如出一辙:与其费尽心思证明代码没有泄漏,不如定期让进程重获新生。
6. 快速排查清单与最后一点实用经验
6.1 常见问题速查表
| 现象 | 大概率原因 | 排查方法 |
|---|---|---|
| 频繁 502 Bad Gateway | worker 耗尽、socket 权限不对、backlog 太小 | ps -ef | grep php-fpm看 worker 数是否占满;检查 socket 文件权限;确认listen.backlog |
| 504 Gateway Timeout | 上游执行超时,或 Nginx 等待超时 | 调request_terminate_timeout、fastcgi_read_timeout;重点排查脚本里的慢 SQL 和外部调用 |
| 内存持续上涨直到 OOM | worker 内存泄漏、全局变量累积 | 设置pm.max_requests定期回收;用 pm.status 模块观察各 worker 内存均值 |
| 定时任务不执行 | PATH 不完整、工作目录不对 | crontab 里写php的绝对路径;脚本内部用__DIR__定位文件 |
| CLI 脚本 CPU 打满 | 死循环、且没有超时保护 | 代码内实现超时,或外部用timeout命令包裹 |
| 改了 php.ini 不生效 | 没有 reload、改错了配置文件路径 | 用php --ini查实际加载路径,然后kill -USR2 <master_pid>平滑重启 FPM |
这套表看起来简单,实际排查时还会遇到各种组合问题。记住一个原则:先分清楚是哪一层的问题。CLI 报错,第一反应是查脚本本身和工作目录;FPM 报错,第一反应是把请求链路拆开——Nginx 到 FPM 的通路、FPM 的 worker 状态、PHP 脚本本身的执行,三段分别定位。带着这个思路,很多事故都能在十分钟内缩小到具体环节。
6.2 我的排查习惯
说实在的,搞懂 CLI 和 FPM 最大的收获并不是上面任何一条命令,而是一种看问题的角度。以后不管遇到什么蹊跷事,你会下意识地开始归类:这是进程生命周期带来的内存残留问题,还是并发模型导致的排队问题?是请求初始化阶段的报错,还是脚本业务逻辑本身的 bug?有了这个分类意识,你的排查效率会明显提升一个台阶。
我最后再分享一个实用的小习惯:在每台 PHP 服务器上提前备好几个高频命令,关键时刻能救场。php -i | grep configure看编译参数,php-fpm -t校验配置文件语法,curl --unix-socket /run/php-fpm/www.sock http://localhost/status拉取 pm.status 的 JSON 指标(pm.status_path要先在配置里开启)。再有条件的话,用strace -p <worker_pid>跟踪一个 worker 的系统调用,你能亲眼看到一个请求进来时它先读什么、再调什么、最后写什么,这种直观感受比读十篇原理文章都深刻。排查急事的时候,这些命令比任何监控面板都靠谱。