ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP CLI与FPM运行模式详解:从进程模型到生产配置排查

PHP CLI与FPM运行模式详解:从进程模型到生产配置排查 前几天一个朋友给我打电话说网站一到晚上八点就卡成 PPT客户端大面积 502重启一下 PHP-FPM 能好十分钟然后又接着崩。我让他先看一眼pm.max_children当前配的多少结果他反问我PHP 不就是一个执行引擎吗这个 pm 参数跟我有什么关系其实这正是很多 PHP 开发者的通病。我们天天写控制器、调 SQL、排 API却很少停下来想一想用户点下浏览器按钮之后从 Nginx 把请求转给 PHP到 PHP 执行完代码把结果返回这中间到底经历了什么。一旦网站出问题性能瓶颈不在业务代码而在 PHP 本身的运行机制时你不理解 CLI 和 FPM 的区别连排查的第一步都迈不出去。这篇文章想做的事情很朴素把 PHP 最常见的两种运行模式CLI命令行接口和 FPMFastCGI 进程管理器从头到尾拆开讲清楚。它们不是 PHP 的两个版本而是同一门语言在完全不同的生命周期、进程模型和资源管理策略下的两种活法。搞清楚这些你能解释很多奇奇怪怪的生产问题比如同一个脚本在命令行跑得好好的挂到网页上就内存爆掉也能真正看懂 php-fpm.conf 里每一行配置是干嘛的。下面的内容我尽量用大白话讲新手能建立起完整的心智模型老手也可以对照着排查清单查漏补缺。1. 先看懂两句话CLI 和 FPM 到底在干什么PHP 本身只是一门语言它要运行起来必须依赖一个宿主环境。这个宿主环境在 PHP 的术语里叫 SAPIServer Application Programming Interface你可以把它理解成 PHP 和外界之间的翻译官外界把请求交给 SAPISAPI 叫醒 PHP 内核PHP 干完活再把结果原路传回去。PHP 历史上出现过很多种 SAPI。Apache 时代的 mod_phpNginx 时代的 fpm还有命令行下的 cli。但你在生产环境里真正会碰到的基本就 CLI 和 FPM 两种。它们面对的是两个完全不同的世界CLI 面对的是 Linux 终端、定时任务、队列消费者FPM 面对的是浏览器请求、Nginx 转发、高并发流量。要理解这两种模式第一句要记住的话是CLI 是“一次性”的FPM 是“常驻”的。你在终端敲一句php index.phpPHP 启动、执行脚本、打印结果、退出进程一切归零。而 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、按配置拉起一批 workerworker 启动后阻塞在 socket 上等待任务收到一个请求就处理一个处理完继续阻塞等待。这个结构是典型的生产者-消费者模型Nginx 是生产者向 socket 写入请求worker 是消费者取走请求并返回响应。这两种模型还有一个行为上的巨大差异错误处理和资源回收。CLI 模式下脚本崩了进程直接退出操作系统把它的内存全部收走干净利落FPM 模式下单个 worker 崩了master 会拉一个新的 worker 补上但其他 worker 还活着正在处理中的请求不受影响。这也是为什么 PHP 跑在 FPM 里给人一种皮实的感觉——单点故障被进程隔离了。2. PHP 生命周期无论哪种模式都逃不过这五个阶段要说清楚 CLI 和 FPM 的差异得先讲 PHP 内核自身的生命周期。PHP 不管在哪个 SAPI 下运行一次代码执行在宏观上都能分成五个阶段模块初始化、请求初始化、执行脚本、请求关闭、模块关闭。这五个阶段是理解 PHP 内存行为的根基。模块初始化MINTPHP 加载所有扩展、注册函数和类、读取并解析 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 21这里有一条非常实用的经验在 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_pidmaster 就会重新加载配置并平滑重启一批 worker线上用户几乎无感知。worker 才是真正干活的角色。每个 worker 在同一时刻只处理一个请求处理完回到空闲状态继续等待下一个任务。这里最关键的数字是网站的并发请求处理能力约等于 worker 数量。如果某个请求因为调用了慢速的外部接口卡了 5 秒这 5 秒内对应的 worker 就一直被占用后面进来的请求只能排队。所以你优化 FPM 配置之前不如先想想自己的业务请求是不是被什么慢调用拖住了否则配置调得再大也是白搭。worker 的隔离性还有一个隐藏好处一个 worker 因为执行了危险代码崩溃时只会影响当前这一个请求master 会立刻拉一个新的 worker 顶上其他 worker 仍然正常工作。这种进程级隔离是 FPM 稳定性的重要来源。4.3 三种 pm 模式怎么选FPM 的进程管理模式由配置项pm决定一共三种模式核心逻辑适用场景staticworker 数量固定为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 deniedNginx 能读文件但 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 Gatewayworker 耗尽、socket 权限不对、backlog 太小ps -ef | grep php-fpm看 worker 数是否占满检查 socket 文件权限确认listen.backlog504 Gateway Timeout上游执行超时或 Nginx 等待超时调request_terminate_timeout、fastcgi_read_timeout重点排查脚本里的慢 SQL 和外部调用内存持续上涨直到 OOMworker 内存泄漏、全局变量累积设置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 的系统调用你能亲眼看到一个请求进来时它先读什么、再调什么、最后写什么这种直观感受比读十篇原理文章都深刻。排查急事的时候这些命令比任何监控面板都靠谱。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进