简介:面向PHP开发者的Webman手册整理包,主要帮助从传统php-fpm架构迁移到常驻内存模式的开发者,快速了解并落地webman在网站、HTTP接口、微服务以及WebSocket、TCP/UDP等自定义进程场景下的用法。资源共57个文件,以47个Markdown文档为主体,内容覆盖安装配置、路由、控制器、数据库模型与关联、中间件、AOP、依赖注入、队列、Redis、多应用、定时任务、验证器等常用专题;同时附带了HTML、CSS、JS搭建的本地阅读入口,以及用于性能对比的PNG图表和社群二维码图片。打包后仅404KB,方便下载后离线或边看边查。目前已有748人学习下载。手册从快速入门到进阶实践逐层展开,还涉及Excel、支付、微信等业务组件,目录按功能模块划分得比较直观,既能满足初次接触者系统学习,也能让有一定经验的开发者在日常开发中快速定位对应知识点,整体实用价值较高。
1. Webman 手册到底解决什么问题:从 PHP-FPM 到常驻内存的一次翻车复盘
把 PHP 服务改成常驻内存运行,听上去只是换了个启动方式,真正上线后才发现踩坑点全在“内存还在”这四个字上。我所在团队第一次用 Webman 时,QPS 确实翻了几倍,可第三天内存涨了 300MB,改完代码 reload 后老逻辑还在跑,查了一下午才意识到 reload 和 restart 的分工跟我们想的不一样。webman-manual 这份手册要解决的,就是这类问题:把 Webman 常驻内存模型下的进程配置、路由写法、数据库连接管理、日志与任务进程、常见故障排查整理成一份能照着落地的技术手册,而不是收集一堆框架文档链接。适合想把 Webman 引入生产环境,但又不愿意靠零散资料和试错来补课的团队。
2. 常驻内存模型怎么工作:看懂 worker 进程、生命周期与 QPS 来源
2.1 从 PHP-FPM 到常驻内存:请求生命周期变了,代码写法也得变
传统 PHP-FPM 的模型是每个请求拉起一个 PHP-CGI 进程,把框架从零初始化一遍,执行完就释放。这个模型简单可靠,但大部分开销花在重复初始化上。Webman 的常驻内存模型方向相反:进程启动时加载一次框架、一次配置、一次路由,之后一个进程连续处理成千上万个请求,数据库连接、Redis 连接、对象池都能留在内存里复用。QPS 提升的主要来源不是某段代码更快,而是初始化成本和连接建立成本从“每请求一次”变成“每进程一次”。
composer create-project workerman/webman webman-demo cd webman-demo php start.php startcreate-project 会拉取当前发行版的 Webman 脚手架,webman-demo 是项目目录名,可以按自己习惯改。php start.php start是前台启动,日志直接打到终端,方便第一回跑通;按 Ctrl+C 会退出。确认能跑后再用php start.php start -d转后台。
注意参数:-d 是 daemon 的缩写,表示守护进程方式,和 debug 无关。我见过有人把 -d 当成调试开关,折腾了半天日志,实际上它只是把进程放到后台。涉及常驻进程的命令还有 reload 和 restart,后面章节会专门展开。
这个生命周期变化会直接影响代码习惯。传统模型里一个请求结束,所有全局变量、静态变量、文件句柄全部随进程释放;常驻内存里它们全都保留。如果你依赖“请求结束自动清理”这个隐含假设,比如在静态数组里存用户数据,就会出现 A 请求的数据被 B 请求读到。所以换 Webman 不只是换框架,还要把代码里的状态管理习惯一起换掉。
2.2 进程模型与连接分发:worker 进程、自定义进程和定时器
Webman 运行起来后至少有两类进程。主进程负责管理子进程、接收信号、维护整个服务状态;worker 进程才是真正干活的人,每个 worker 独立 accept 网络连接并处理请求。worker 数量由配置里的 count 决定,默认参考 CPU 核数。理解这个模型对排查很关键:不同 worker 之间内存不共享,你在一个 worker 里修改的全局变量,另一个 worker 里看不到;跨进程通信要用 Redis、数据库或者消息队列。
| 对比项 | 传统 PHP-FPM | Webman 常驻内存 |
|---|---|---|
| 框架初始化 | 每个请求一次 | 进程启动时一次 |
| 数据库连接 | 每请求建立与释放 | 进程内长期复用 |
| 全局数据 | 请求结束即回收 | 跨请求保留 |
| 代码更新 | 新请求即用新代码 | 需要 reload 或 restart |
| 内存清理 | 随进程释放 | 依赖 max_request 兜底 |
自定义进程是 Webman 手册里容易被忽略的一块。它不处理 HTTP 请求,而是用来跑定时任务、消费队列、维护推送连接。它的生命周期也是常驻的,适合放定时器,但定时器的注册位置有讲究,放在请求回调里会让定时器越加越多,这个坑后面详细说。
2.3 用 status 命令看见进程的真实状态
php start.php status php start.php restartstatus 会把 master 进程和每个 worker 进程的 PID、内存占用、请求数打出来。我习惯先看 worker 数量是否和配置相等,再看内存是不是随请求数线性上涨,这两点基本能判断常驻进程是否健康。restart 用于配置或进程结构调整,reload 用于业务代码更新,这个分工在第 5 章展开。
这套模型决定了你在 Webman 里处理问题的方式。性能变差先看哪个 worker 内存异常;代码不生效先确认是不是改了配置文件却没有 restart;数据串了先查静态属性和单例。把这几个方向记住,后面翻手册时就有了索引。
3. 手册落地的第一份配置:server 参数、自定义进程与日志怎么设
手册真正能复现的部分从配置开始。Webman 的配置集中在 config 目录,改动频率最高的是 server.php、process.php、log.php 三个文件。下面按我实际的调参顺序展开。
3.1 config/server.php:先定监听、进程数和兜底上限
<?php return [ 'listen' => 'http://0.0.0.0:8787', 'transport' => 'tcp', 'name' => 'webman', 'count' => 4, 'user' => 'www', 'group' => 'www', 'max_request' => 100000, 'pid_file' => 'runtime/webman.pid', 'log_file' => 'runtime/workerman.log', 'stdout_file' => 'runtime/stdout.log', ];listen 决定对外服务的地址与端口,0.0.0.0:8787 表示监听本机所有网卡的 8787 端口,生产环境常见。count 是 HTTP worker 进程数,纯计算型业务按 CPU 核数设置,带较多 IO 等待的业务可以适当上调,但不能无限加,进程太多会带来上下文切换成本。user 和 group 生产环境建议用专用系统账户,避免用 root 跑服务。
max_request 是常驻进程里的后悔药。它表示单个 worker 进程处理完多少请求后主动退出并由主进程拉起新进程,作用是把内存泄漏、句柄泄漏兜底切断。设太大兜不住内存问题,设太小连接池和 opcache 收益会打折,我一般从 10 万到 100 万之间取一个中间值,再根据 status 里的内存增长曲线调整。pid_file、log_file、stdout_file 是进程管理文件和日志落盘位置,按项目目录组织即可。
3.2 config/process.php:自定义进程的参数与一个定时任务样例
自定义进程经常用来做定时任务。process.php 里每一项对应一类进程,handler 指向进程类,count 决定这类进程起几个。这里最容易翻车的参数就是 count:如果任务依赖 Timer 定时器,count 必须设为 1,因为每个进程实例都会注册一次 Timer,count 设成 2,任务就会每分钟执行两遍。
<?php return [ 'cron' => [ 'handler' => \process\CronProcess::class, 'count' => 1, 'user' => '', 'listen' => '', ], ];<?php namespace process; use Workerman\Timer; class CronProcess { public function onStart($worker): void { Timer::add(60, function () { // 每分钟执行一次:清理过期订单 // 这里可以放心调用 Db、Redis 等长连接组件 }); } }onStart 在对应进程启动时执行一次,适合注册定时器。listen 字段为空表示这个进程不监听端口,它是纯任务进程;如果想让自定义进程对外提供服务,才需要在 listen 里填地址。echo 或日志输出会进入 stdout 文件,任务一旦不落日志就很难查,建议在回调里写一条业务日志。
3.3 config/log.php:把 access 日志和业务日志分开
常驻进程下日志文件句柄会长期持有,所以日志配置不建议写完就丢。我习惯把访问日志和业务日志分文件,访问日志用中间件记录,业务异常走统一日志。下面是日志 handler 的关键配置,通常已经由脚手架生成,按需改路径和保留天数。
[ 'class' => Monolog\Handler\RotatingFileHandler::class, 'constructor' => [ runtime_path('logs/webman.log'), 7, Monolog\Logger::DEBUG, ], ]这是按天滚动的文件日志,7 表示保留 7 天。runtime_path() 是框架提供的路径助手,如果你的发行版里没有这个函数,把它替换成项目下的绝对路径即可。注意改完配置后不要只 reload,配置文件不一定随业务代码一起重载,直接 restart 更可靠。
<?php namespace app\middleware; use Webman\MiddlewareInterface; use Webman\Http\Request; use Webman\Http\Response; class AccessLog implements MiddlewareInterface { public function process(Request $request, callable $handler): Response { $start = microtime(true); $response = $handler($request); $cost = round((microtime(true) - $start) * 1000, 2); // 记录请求方法、路径、状态码、耗时 return $response; } }中间件是在业务代码执行前后各插一截逻辑的入口。这个类在请求进入时记录开始时间,业务处理完后计算耗时再返回响应。注意不要把 $request 存到静态属性里,中间件对象虽然常驻,但 request 对象是每请求独立的,静态保存会串数据。
4. 在 Webman 里跑通业务:路由、控制器、数据库与任务进程的完整写法
配置只是让服务跑起来,真正要照着手册落地的是业务代码写法。常驻内存模型下,路由、控制器、数据库事务的写法都有一些隐含约束,下面从最小可运行路径开始。
4.1 config/route.php:闭包路由适合验证,控制器路由适合工程
<?php use Webman\Route; Route::get('/user/{id}', [app\controller\UserController::class, 'detail']); Route::post('/order/create', [app\controller\OrderController::class, 'create']); Route::group('/api', function () { Route::get('/ping', function () { return json(['ts' => time()]); }); });第一行把 GET /user/123 映射到 UserController 的 detail 方法,第二个参数是 [类名, 方法名]。闭包路由适合临时接口和简单的健康检查,但正式项目更推荐控制器路由,因为后续可以统一挂中间件、做权限校验。修改路由后建议执行php start.php reload让新路由生效。
强调一点:不要在路由闭包里做阻塞性的远程调用。一个 worker 同时服务多个连接,闭包里 sleep 或同步等待第三方接口返回时,这个 worker 上的其他请求都会排队。耗时操作应放到异步任务或独立进程里执行,别让路由层变成性能瓶颈。
4.2 控制器:用短生命周期对象传数据,不要依赖静态变量
<?php namespace app\controller; use support\Request; use support\Response; class UserController { public function detail(Request $request, int $id): Response { $user = db('user')->find($id); if (!$user) { return json(['code' => 404, 'msg' => 'user not found'], 404); } return json(['code' => 0, 'data' => $user]); } }Request 对象封装了当前请求的参数、请求头、会话等信息,Response 对象是返回给客户端的数据。每个请求进来时这两个对象都是新建的,请求结束后销毁。所以它们天然适合传递请求内数据,不要为了图方便把它们塞进静态数组——静态数组在常驻进程里跨请求存活,存进去的数据会漏到下一个请求。
反例说明一下:
private static array $context = []; public function detail(Request $request, int $id): Response { self::$context['user'] = $request->user('id'); // 下一个请求会在这里读到上一个请求的用户 }这个写法在传统 PHP 模型里问题不大,因为请求结束进程就没了;常驻模型里静态数组会一直活着,高并发下必然出现数据串台。正确做法是用请求对象传递上下文,或者用参数把数据显式传给下层方法,用完即弃。
4.3 数据库连接与事务:复用是真福利,污染是真麻烦
<?php return [ 'default' => 'mysql', 'connections' => [ 'mysql' => [ 'driver' => 'mysql', 'host' => '127.0.0.1', 'port' => 3306, 'database' => 'demo', 'username' => 'demo_user', 'password' => 'change_me', 'charset' => 'utf8mb4', 'prefix' => '', 'strict' => true, ], ], ];这份配置和传统框架差异不大,但常驻内存模型的连接复用逻辑完全不同。worker 进程拿着连好的数据库连接反复用,省去每请求握手,这是性能红利;代价是连接状态必须由代码自己负责清理,尤其事务。
use support\Db; Db::beginTransaction(); try { Db::table('order')->insert([ 'order_no' => date('YmdHis') . mt_rand(1000, 9999), 'amount' => 199, ]); Db::table('stock')->where('sku_id', 1001)->decrement('num', 1); Db::commit(); } catch (\Throwable $e) { Db::rollBack(); throw $e; }事务内做了两步写入,全部成功才 commit,任何一步抛异常就 rollback。关键点在于 catch 里必须 rollback,不能只 throw。很多翻车现场是业务里有多个分支返回,某个分支直接 return json 忘了 rollback,事务就一直挂在连接上。下一次请求复用到这个连接时,会发现“上一个事务还没结束”,表现为偶发的 lock wait timeout 或者查到未提交的中间状态。
注意:事务必须保证每个分支都有明确的提交或回滚出口,这是常驻内存模型下最容易被忽视的约定。
为了避免这种污染,我习惯把事务封装在统一的服务方法里,所有分支都走同一个 commit/rollback 出口,而不是在控制器里散着开事务。事务里不要混入外部接口调用,否则持锁时间过长,并发一高就死锁。耗时任务放自定义进程,控制器里只做快路径,这在第 3 章的 CronProcess 里已经演示过了。
5. 常驻进程下的踩坑排查:5 个反复出现的现象与解决路径
Webman 的手册如果不写踩坑记录,那和框架文档没什么区别。下面 5 个问题是我在线上线下帮人排查时出现频率最高的,每个都按现象、原因、解决三步记录。
5.1 代码改了不生效:先分清 reload 和 restart 的边界
现象:改了控制器方法,执行php start.php reload,请求还是旧结果;有些人连续执行多次 reload 也没变化。
原因:reload 主要重载业务代码,但进程调度、自定义进程数量、listen 这种进程级配置不会随 reload 重建。配置文件即便被重新读取,进程本身的结构没变,改了也相当于白改。
解决:业务代码变更用 reload,涉及 process.php、server.php、日志配置等进程结构变更时直接 restart。发布脚本里我固定写两条命令,先 reload 再 status 确认,必要时 restart。
5.2 内存只涨不降:全局数组与 max_request 兜底
现象:运行一周后,status 里某个 worker 内存从 80MB 涨到 800MB,重启后立刻回落。
原因:常驻进程里任何向全局数组、静态属性、单例容器追加数据的操作都会让内存只增不减,比如把每次请求的日志存进数组、把用户对象塞进静态缓存且没有淘汰策略。
解决:先用 status 定位是哪个进程异常,再检查代码里所有静态和全局写入点。如果暂时找不到根因,把 max_request 调小作为兜底,让进程定期重建,但要接受连接池被频繁打断的代价。我一般先兜底上线,再回头清全局数组。
5.3 事务连接被污染:偶发的 lock wait timeout 从哪来
现象:接口平时正常,高峰时随机报 lock wait timeout exceeded 或 MySQL server has gone away,重启后恢复,过一段时间又出现。
原因:某条请求路径里开启了事务,分支返回时没有 commit 也没有 rollback,连接归还到进程池时还带着未结束事务。下一个请求复用到这条连接,要么被旧事务阻塞,要么提交了不该提交的数据。
解决:全局搜索 beginTransaction 的调用点,检查每个分支是否都在 try/catch 结构内有明确的结束动作;把事务收敛到单个方法里,禁止在控制器里散开使用;必要时在压测环境加大并发复现,并用数据库 general log 定位持锁连接。
5.4 定时器越跑越多:Timer 注册位置决定生死
现象:定时任务明明只配了一个进程,日志里每分钟执行次数却是 2 倍、3 倍往上涨,最后把任务队列堵死。
原因:Timer::add 被写在请求回调或热加载的代码里,每个请求或每次 reload 都会追加一个定时器,而旧的定时器没有移除;常驻进程不退出,定时器就无限累积。
解决:定时器只允许注册在进程类的 onStart 或 onWorkerStart 这类进程启动回调里,业务代码里禁止直接调用 Timer::add。排查时用 status 看进程数和内存,定时器累积通常伴随快速内存上涨。
5.5 静态变量串数据:常驻进程的隔离边界在哪
现象:用户 A 偶尔能看到用户 B 的订单数据,不是每次必现,压力越大越容易出现。
原因:代码里用静态数组或单例缓存了用户维度的数据。传统 PHP 进程每请求释放,掩盖了这个问题;常驻进程里静态属性跨请求存活,读取时就串了。
解决:用户维度数据放进 Request 对象或显式作为参数传递,进程级缓存只放与用户无关的配置类数据。如果必须用静态缓存,key 里带上用户 ID,并设计淘汰策略,不能只增不清。写完这条我再也没在控制器里写过静态缓存。
6. 从手册到生产:压测验证、进程监控与热更新习惯
6.1 用 status 命令看三个关键指标
上线前我只看三件事:worker 进程数是否和配置一致,内存是否随请求量缓慢增长,异常重启是否频繁。把php start.php status的采样结果留档,每次发布前对比一次,比盲目相信压测报告有用得多。
6.2 压测时别只用单发模式:keep-alive 的价值
Webman 是长连接友好的,压测要开 keep-alive 参数才能真正反映常驻模型的能力。用压测工具打业务接口时,关注两个场景的差距:每请求新建连接 vs 复用连接的 QPS 差距通常很大。差距不明显时先查是不是连接没有真正复用,比如压测工具默认关闭了 keep-alive。
6.3 把 reload、restart 写进部署脚本
我现在发布时固定走同一套顺序:拉代码,跑 reload,然后 status 确认 worker 数量和内存基线;如果这次变更涉及进程配置或依赖,直接 restart 而不是赌 reload 能覆盖。这套顺序已经用了一年多,没再出过线上下错版本的事。
最开始我也觉得手册是给新手看的,后来发现常驻内存模型的坑光靠脑子记不住,每次排障还是要翻记录。把命令、参数判断、踩坑路径写下来,才是这份手册对我自己最大的价值。希望帮到你。
本文还有配套的精品资源,点击获取