首页
直播
壁纸
友链
搜索
1
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
266 阅读
2
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势
256 阅读
3
微信小程序自定义 tabBar 实战:custom-tab-bar 从适配到深色模式
255 阅读
4
微信小程序 Skyline 渲染引擎实战:worklet 动画从原理到落地
253 阅读
5
微信小程序分包进阶:独立分包、预下载与分包异步化实战
246 阅读
服务器运维
后端技术
前端技术
梯子
数据库
小程序
登录
搜索
标签搜索
fastadmin
Redis
微信小程序
前端开发
RabbitMQ
Go
服务器
codex
buildadmin
小程序
mysql
Nginx
Docker
Vue3
Node.js
MySQL优化
Linux
TypeScript
JWT
PHP
沿途的风景
累计撰写
74
篇文章
累计收到
0
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
24
篇与
» 后端技术
的结果
2026-08-22
webman 生产部署实战:进程守护、平滑发布、监控告警与微服务化
webman 生产部署实战:进程守护、平滑发布、监控告警与微服务化把 webman 写出来是一回事,让它安稳跑在生产上是另一回事。没有 PHP-FPM 和 supervisord 的成熟套路兜底,常驻内存服务的部署、发布、监控都有一套自己的打法。这篇是 webman 系列收官:从部署形态、平滑发布到监控告警体系,最后聊聊什么时候该拆微服务。一、部署形态:三种选择1. 裸机/虚拟机 + systemd(中小项目首选)# /etc/systemd/system/webman.service [Unit] Description=webman app After=network.target mysql.service redis.service [Service] Type=forking WorkingDirectory=/var/www/app ExecStart=/usr/bin/php start.php start -d ExecReload=/usr/bin/php start.php reload ExecStop=/usr/bin/php start.php stop Restart=always RestartSec=3 [Install] WantedBy=multi-user.targetsystemctl daemon-reload systemctl enable --now webman systemctl reload webman # 平滑发布用 reload要点:Type=forking(start.php -d 是 daemonize 模式)、Restart=always 兜底进程崩溃、ExecReload 挂 reload 实现无感发布。2. Docker 容器化FROM php:8.2-cli-alpine RUN apk add --no-cache libpq libzip-dev && docker-php-ext-install pdo_mysql opcache COPY . /app WORKDIR /app RUN composer install --no-dev --optimize-autoloader EXPOSE 8787 # 容器内前台运行(容器本身就是守护者,不需要 -d) CMD ["php", "start.php", "start"]容器化要点:容器里必须前台运行(去掉 -d),让 PID 1 就是 master 进程,容器崩溃重启策略(restart: always)接管守护职责。K8s 场景用 readiness 探针指向健康检查接口。3. Supervisor 托管(多语言混合部署环境)老牌方案,配置直观,适合还有 Python/Go 混跑的机器。二、平滑发布:让 reload 真正"无感"php start.php reload 的机制是逐个重启 worker(上一篇讲过),但要真正做到无感发布,还有四个坑要填:坑一:reload 不加载的东西reload 只重新加载 worker,master 进程内存里的东西不会更新:路由配置文件、config/ 目录、bootstrap 钩子。改了这些必须 restart(master 重建,有感知)。发布脚本要区分:#!/bin/bash # 简易发布脚本 git pull composer install --no-dev --optimize-autoloader php start.php reload # 常规发布 # 改了 config/route/bootstrap 时:php start.php restart坑二:发布瞬间的连接抖动worker 逐个重启期间,正在处理中的请求要等它完成(超过 max_wait 会被强杀)。发布时把 max_wait_time 临时调大、避开高峰期,长任务(导出、批处理)走独立进程不随 worker 重启。坑三:静态文件缓存webman 自带静态文件支持时,浏览器缓存的旧 JS 配合新 HTML 可能白屏——资源文件名带 hash(app.a3f8c2.js)彻底解决。坑四:发布失败的回滚# 用软链接做版本切换,回滚 = 切回旧链接 ln -sfn /var/www/releases/20260823-0200 /var/www/app_current php start.php restart # 软链指向变了,restart 生效保留最近 5 个版本目录,回滚 10 秒完成。没有回滚方案的发布都是赌博。三、监控告警体系:四个层次常驻内存服务必须主动监控——它出问题的方式是"安静地僵死",不是崩溃报错。层次一:进程存活(最基础)# 心跳脚本(cron 每分钟) process_num=$(pgrep -f "start.php" | wc -l) [ "$process_num" -lt 1 ] && curl -s "https://alert.example.com?msg=webman进程消失"systemd/Docker 的 restart 能自动拉起,但频繁重启本身就是告警信号——监控 restart 次数。层次二:业务健康检查// 健康检查接口:不只返回 200,要真的探测依赖 Route::get('/healthz', function () { try { Db::query('SELECT 1'); $redis = Redis::connection(); $redis->ping(); return json(['status' => 'ok']); } catch (\Throwable $e) { return json(['status' => 'degraded', 'error' => $e->getMessage()], 503); } });K8s 的 liveness/readiness、负载均衡的健康剔除都挂这个接口。层次三:业务指标埋点在全局中间件里做请求级指标采集,按分钟聚合推到 Prometheus Pushgateway(或直接暴露 /metrics):// 指标维度:路由 × 状态码 → count / 耗时分布 $metricKey = $request->path() . ':' . $response->getStatusCode(); Metrics::tick($metricKey, microtime(true) - $start);核心告警规则参考:告警阈值说明错误率5xx 占比 > 1%(5 分钟窗口)最核心的质量指标P99 延迟> 500ms体验底线QPS 突降环比跌 50%流量异常或半死状态内存水位worker RSS > 256M内存泄漏前兆重启频率10 分钟内重启 > 3 次崩溃循环worker 内存监控:php start.php status 能看到每个 worker 的内存,采集脚本每分钟解析一次,单独盯内存曲线——常驻内存服务的专属指标,FPM 时代不存在这个告警维度。层次四:日志聚合日志按天/大小切割,错误日志单独通道。EFK/Loki 都行,关键是错误日志要能按 trace-id 串起完整调用链——中间件生成 trace-id 写入 header 和日志上下文,跨服务传递。四、日志与排障生产排障三板斧:php start.php status # 进程/连接/请求分布 php start.php connections # 当前连接明细 tail -f runtime/logs/*.log # 慢请求日志、错误日志两个必开配置:// config/log.php:慢请求单独通道 'slow' => [ 'handler' => ..., 'formatter' => ..., ], // config/app.php 'default_timezone' => 'Asia/Shanghai', // 别让日志时间戳漂移OOM 被 kill 的进程:dmesg | grep -i kill 确认是内核 OOM killer 干的,然后查内存泄漏(系列第一篇的水位监控法)。五、微服务化:什么时候拆、怎么拆webman 常被用作微服务基座,但拆分要跟着业务痛点走:该拆的信号发布互相拖累:营销活动改一行,核心交易要陪着全量回归资源冲突:批量任务把 CPU 打满,拖垮在线接口团队协作:多组改一个仓库,merge 冲突不断不该拆的时候单体 + 分层(Controller / Service / Dao)+ 自定义进程隔离(在线/离线分开跑)能解决 80% 的问题。微服务是运维成本换研发效率的交易,团队没有 3 人以上运维能力前,慎拆。webman 微服务的标准拼图├── API 网关(webman,路由聚合/鉴权/限流) ├── 业务服务(webman,HTTP 或 JSON-RPC 内部通信) ├── 注册发现(consul / nacos / redis) ├── 异步解耦(Redis 流 / RabbitMQ,webman 的自定义进程消费) └── 链路追踪(trace-id 贯穿 + 日志聚合)服务间通信的务实选择:初期用 HTTP + JSON(简单直观,好排查),流量大了再换 gRPC 或原生 TCP 协议。webman 的 workerman/http-client 支持连接池复用,内部调用开销可控。进程内优先原则:同一个 webman 实例里,用"多应用目录"(app/api、app/admin)做逻辑隔离,比拆进程便宜得多。拆物理服务的唯一理由是上面的三个信号,而不是"架构图好看"。六、上线 Go/No-Go 清单发布前最后一遍:[ ] systemd / Docker 的自动拉起与重启告警就位[ ] healthz 探测 DB + Redis,负载均衡已接健康检查[ ] 发布脚本区分 reload/restart,回滚软链方案演练过[ ] 错误率 / P99 / 内存 / 重启频率四条告警全部在线[ ] 慢请求日志与 trace-id 链路打通[ ] 压测报告:目标 QPS × 2 压过,P99 达标[ ] 数据库连接预算核算(worker × 池上限 < max_connections × 0.8)[ ] 灰度方案:先切 10% 流量观察 30 分钟写在最后webman 系列到这里完整闭环:架构模型(常驻内存的边界)→ 工程骨架(中间件与容器)→ 数据层(连接与事务)→ 性能(协程与压测)→ 生产化(部署监控与演进)。常驻内存框架的一切实践都围绕同一个心法——你运维的不是"每次请求",而是"活着的进程"。把这个视角建立起来,webman 就是你手里性价比极高的高并发利器。
2026年08月22日
7 阅读
0 评论
0 点赞
2026-08-15
webman 高并发进阶:协程实战、连接池调优与压测方法论
webman 高并发进阶:协程实战、连接池调优与压测方法论webman 裸奔就能跑出很高的 QPS,但"框架快"不等于"业务快"——一个同步 HTTP 调用就能让 worker 的事件循环卡住,吞吐瞬间跌到个位数。这篇讲 webman 高并发的三板斧:协程化改造、连接池调优、压测方法论。这是 webman 中高级的分水岭。一、先理解瓶颈:worker 事件循环是怎么被卡死的webman 的 worker 是单进程单线程事件循环(Reactor 模型)。在同一个 worker 里,请求是串行处理的:worker-1 事件循环: 事件:请求A到达 → 处理A(其中调用外部API耗时 200ms,同步阻塞!) (这 200ms 内,分配给 worker-1 的所有其他请求全部排队) 事件:请求B到达 → 处理B推论:平均响应时间 = 排队时间 + 处理时间。 只要处理路径上有阻塞调用,worker 的吞吐上限就是 1 / 平均阻塞时长。200ms 的外部调用意味着单个 worker 每秒最多 5 个请求,16 个 worker 也就 80 QPS——和框架本身的几十万 QPS 上限毫无关系。解法只有两条:消灭阻塞(异步/协程化),或者增加并行度(协程)。二、协程:webman 的三种姿势1. 内置 Swoole/Swow 协程驱动webman 支持通过配置切换协程运行时:// config/server.php 'settings' => [ 'event_loop' => \Workerman\Events\Swoole::class, // 或 Swow ],开启后,worker 内自动协程化:遇到 IO(MySQL、Redis、HTTP)时挂起当前协程,让出事件循环处理其他请求——同一个 worker 从串行变成并发。Swoole 与 Swow 的选择:Swoole 生态成熟但与 PHP 原生扩展偶有兼容性问题(hook 覆盖不全的扩展会退化为阻塞);Swow 是 PHP 官方系方案更干净但生态年轻。生产选型建议:现有业务全是标准 PDO/cURL 的选 Swoole 一把梭;有奇怪扩展依赖的先在预发验证 Swow。2. 协程客户端协程化后最大的红利是并发聚合调用:use Swoole\Coroutine; use function Swoole\Coroutine\go; use function Swoole\Coroutine\waitGroup; // 聚合页:并行拉取用户/商品/优惠券,总耗时 = max 而不是 sum $wg = new waitGroup(); $results = []; go(function () use ($wg, &$results) { $wg->add(); $results['user'] = $this->userApi->get($uid); $wg->done(); }); go(function () use ($wg, &$results) { $wg->add(); $results['item'] = $this->itemApi->get($itemId); $wg->done(); }); $wg->wait(3.0); // 总超时 3s // 原来 200+150+100=450ms,现在 max(200,150,100)=200msBFF/聚合接口的标准优化手段,P99 延迟直接砍半。3. 协程的三个纪律纪律一:协程切换点后不信任局部状态。 yield 期间其他协程可能改了共享数据。请求级数据老老实实走 $request 属性,全局状态放 Redis。纪律二:连接必须绑协程。 协程 A 半截的事务,切换后协程 B 接着用同一个连接 = 事务串台。Swoole 的 PDO hook + webman/database 的连接池已处理绑定关系,但你自己 new PDO 存静态属性再跨协程用,必炸。纪律三:CPU 密集任务协程救不了。 协程只解决 IO 并发,CPU 密集计算照样卡死事件循环。重计算丢给自定义进程或队列,别在 worker 里跑。三、连接池:协程时代的正确配置协程化后并发量上来了,单连接成了新瓶颈,必须配连接池(webman 通过 webman/database/webman/redis 的连接池实现,Swoole 驱动下生效):// config/database.php(连接池相关参数) 'pool' => [ 'max_connections' => 32, // 每进程最大连接数 'min_connections' => 2, // 保底连接 'connect_timeout' => 3, // 取连接超时 'wait_timeout' => 3, // 等待池中连接的超时 'heartbeat' => -1, // 心跳检测间隔(配合 wait_timeout 用负数自动探测) 'max_idle_time' => 60, // 空闲连接回收 ],容量估算公式:每进程需要的连接数 ≈ 协程并发度(同时进行中的查询数) 总连接 = worker 数 × max_connections 例:16 worker × 32 连接 = 512 连接 < MySQL max_connections(1000) ✓常见翻车:池配了 32,MySQL max_connections 只有 100,另一台应用共用它——高峰期互相挤兑连接,报错 connection timeout 却以为是网络问题。跨应用共享数据库时,连接预算要全局规划。池的监控指标每个指标都应该有告警:指标健康值异常含义池利用率< 80%持续 100% = 池太小或查询太慢等待取连接耗时< 1ms高 = 池不够用活跃连接数平稳锯齿状抖动 = 连接泄漏慢查询数趋近 0高 = 先治慢查询再扩池四、压测方法论:别让数据骗了你压测的正确姿势预热:先压 1 分钟丢弃数据(连接懒加载、JIT、文件缓存都要热身)阶梯加压:并发 10 → 50 → 100 → 200,每档 3-5 分钟,找到拐点看 P99 而不是平均值:平均值 50ms、P99 3s 的服务,用户体感是灾难压测端别成为瓶颈:用独立机器跑 wrk/vegeta,本机压本机数据不可信# wrk 示例:16 线程 200 并发,持续 3 分钟 wrk -t16 -c200 -d180s -s post.lua http://10.0.0.5:8787/api/orders拐点诊断表现象第一嫌疑验证手段CPU 打满 100%业务计算 / 序列化开销perf top 看热点函数CPU 很低但延迟飙升阻塞 IO(同步调用没协程化)strace 看 worker 阻塞在哪个 syscall连接池等待耗时高池容量不足或慢查询慢查询日志 + 池监控MySQL CPU 高缺索引 / 慢 SQLexplain 逐条过延迟毛刺reload / GC / 心跳任务对齐时间线看毛刺时刻发生了什么一个容易被忽略的真相:压测时开着 Xdebug、开着 debug 日志、没开 opcache/jit,得出的"webman 不行"结论全是冤案。压测环境配置必须对齐生产:php start.php start -d(daemonize)、opcache 开启、日志降级。webman 侧的性能自检php start.php status # 每个worker的连接数、请求统计关注 total_request(累计请求数)和 request_per_second。如果各 worker 请求分布严重不均,检查是否用了 sticky 逻辑(比如按 uid hash 到特定连接)。五、一个真实调优案例场景:订单列表接口,压测到 800 QPS 后延迟从 40ms 涨到 2s。排查过程:status 显示 worker CPU 只有 30% → 不是 CPU 瓶颈strace 发现 worker 大量时间阻塞在 recvfrom(MySQL 等待)→ IO 等待慢查询日志:一条 ORDER BY created DESC LIMIT 20 OFFSET 5000 没有覆盖索引,深分页扫全表 1.2s修复:游标分页(WHERE id < last_id LIMIT 20)+ 联合索引复测:5000 QPS 稳定,P99 = 65ms教训:框架层的优化(协程/连接池)解决的是"传导"问题,SQL 层的慢查询是"源头"问题。源头不治,协程只是让请求更快地堵在数据库门口。六、优化优先级清单按投入产出比排序,从上往下做:1. 慢 SQL 治理(索引、深分页、N+1) ← 90% 的性能问题在这 2. 聚合接口并行化(协程并发调用) 3. 热点数据缓存(Redis,注意缓存一致性) 4. 协程运行时 + 连接池配置 5. opcache / JIT / preload 6. worker 数与压测验证写在最后高并发的本质是让每个 worker 的每一毫秒都在干活:协程消灭 IO 空转,连接池消灭建连开销,压测验证每一项优化真实生效。记住那条优先级清单——先治 SQL,再谈协程。最后一篇我们讲生产化:部署、进程守护、监控告警和微服务化实践。
2026年08月15日
6 阅读
0 评论
0 点赞
2026-08-15
webman 数据库实战:连接池、ORM 选型、事务边界与大数据处理
webman 数据库实战:连接池、ORM 选型、事务边界与大数据处理数据库是 webman 与传统 FPM 框架差异最大的环节。FPM 下每个请求开新连接、请求结束关闭,天然隔离;webman 的每个 worker 进程持有自己的连接并跨请求复用——连接管理从"框架的事"变成了"你的事"。这篇讲透连接池配置、think-orm 与 Eloquent 的选型、事务的正确边界,以及千万级数据的处理姿势。一、常驻内存下的连接模型先看清楚 webman 的数据库连接结构:worker-1 ─── 连接A(MySQL)──┐ worker-2 ─── 连接B ├── MySQL max_connections 上限(如 1000) worker-3 ─── 连接C │ ...(16 个 worker) │ └── Redis 连接同理推论一:最大连接数 = worker 进程数 × 每进程连接数。 16 个 worker、每个用了 2 个连接配置(读写分离),就是 32 个常驻连接。上线前先算这笔账,别和 MySQL 的 max_connections 打架。推论二:连接是懒建立的。 进程启动时不连库,第一次查询才建立。所以刚 reload 完的进程有冷启动延迟,压测时记得先预热。推论三:连接会"死"。 MySQL wait_timeout(默认 8 小时)内没有活动,服务端单方面断开,webman 侧还握着死连接,下一个请求直接 MySQL has gone away。二、断线重连:三层配置兜底以 think-orm(webman 默认集成)为例:// config/thinkorm.php return [ 'default' => 'mysql', 'connections' => [ 'mysql' => [ 'type' => 'mysql', 'hostname' => '127.0.0.1', 'database' => 'app', 'username' => 'root', 'password' => 'root', 'charset' => 'utf8mb4', 'break_reconnect' => true, // ① 断线自动重连 'fields_cache' => true, // ② 字段信息缓存(常驻内存的红利) ], ], ];三层兜底缺一不可:break_reconnect => true:ORM 层检测断连自动重连心跳保活:定时进程每 5 分钟发一条 SELECT 1,让连接永不超时(webman 的 config/process.php 里加自定义进程实现)重试幂等:断线重连只对查询安全,写操作重试必须保证幂等,否则可能双写// 心跳进程示例 return [ 'db-keepalive' => [ 'handler' => function () { while (true) { Db::query('SELECT 1'); sleep(300); } }, ], ];三、ORM 选型:think-orm 还是 Laravel Eloquent?webman 官方两者都支持,选型看团队基因:维度think-ormEloquent(illuminate/database)上手成本ThinkPHP 系团队零成本Laravel 系团队零成本模型功能基础够用关联预加载、访问器、软删更丰富常驻内存适配fields_cache 等针对性优化需要自己注意静态缓存生态迁移TP 项目平移Laravel 项目平移中大型项目的建议:ORM 只做"查询构造 + 结果映射",复杂查询直接裸 SQL 或查询构造器。模型层越薄,常驻内存下的心智负担越小。一个 Eloquent 用户必须知道的区别:FPM 下 DB::connection() 每请求新建,webman 下连接跨请求驻留——事务没有提交/回滚,下一个请求会接着在这个事务里跑,数据不一致的幽灵bug就是这么来的。think-orm 的 Db::startTrans() 同理。// ❌ 事故现场:异常没回滚,连接带着事务回到池里 try { Db::startTrans(); // ... 写操作 throw new \RuntimeException('boom'); // 没有 catch 回滚! } finally { Db::commit(); // finally 里 commit,异常时也 commit 了半截数据? }四、事务的正确姿势铁律一:事务边界必须完整闭合try { Db::startTrans(); $this->deductStock($skuId, $num); // 扣库存 $this->createOrder($uid, $skuId); // 建订单 Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; // 异常继续抛,别吞 }铁律二:事务里禁止外部 IO事务持锁期间调外部 HTTP / 发 MQ / 写 Redis,一旦外部服务慢,数据库连接和行锁被拖住,并发一上来就是雪崩。// ❌ 事务里调第三方风控接口(300ms),行锁拖 300ms Db::startTrans(); $stock = Stock::lock(true)->find($skuId); // SELECT ... FOR UPDATE RiskApi::check($uid); // 外部调用! $stock->num -= 1; $stock->save(); Db::commit(); // ✅ 外部检查前置,事务只做纯 DB 操作 RiskApi::check($uid); // 先调外部 Db::startTrans(); $stock = Stock::lock(true)->find($skuId); if ($stock->num < 1) throw new OutOfStock(); $stock->num -= 1; $stock->save(); Db::commit(); PublishOrderEvent::dispatch($order); // 后置异步动作铁律三:事务尽量短,锁尽量准悲观锁(lock(true))只锁必要的行,锁定前先校验数据存在的条件能用唯一索引防重(幂等)就别用"先查再插 + 锁"补充:嵌套事务与 savepointthink-orm 支持 startTrans 嵌套(底层转 savepoint),但事务边界只写在 Service 层一处,不要 Controller 包一层、Service 再包一层——嵌套事务的回滚语义很容易把人绕晕。五、大数据量:分批处理的三个工具1. chunk 分批读User::where('status', 1)->chunkById(1000, function ($users) { foreach ($users as $user) { // 逐批处理 } });用 chunkById 而不是 chunk:处理过程中如果更新了 offset 相关字段,chunk(基于 offset/limit)会漏数据或死循环,chunkById(基于主键游标)不会。2. 慎用 ORM 批量,必要时裸 SQLORM 逐条 save 在万级数据下慢得无法接受:// ❌ 10 万条逐条 update:几十分钟 foreach ($rows as $row) { Model::where('id', $row['id'])->update($row); } // ✅ 事务 + 批量拼接(注意分批,每批 ≤ 1000) Db::startTrans(); foreach (array_chunk($rows, 500) as $batch) { Db::table('users')->upsert($batch, ['id'], ['name', 'status']); // 批量 upsert } Db::commit();3. 超大导出:游标 + 流式输出百万行导出别 ORM ->get() 全量载入(内存直接爆),用游标逐行读、边读边写 CSV 流:$fp = fopen('php://output', 'w'); User::where('created', '>', $start)->select()->cursor()->each(function ($u) use ($fp) { fputcsv($fp, $u->toArray()); });cursor() 用 PDO 的 unbuffered 模式,内存占用恒定。六、读写分离与多库配置'connections' => [ 'mysql' => [ /* 主库写 */ ], 'mysql_read' => [ /* 从库读 */ ], ],think-orm/Eloquent 都支持配置读写分离自动路由,但强制主读(写后立读的场景:下单后立即查详情)必须显式指定主库连接,否则撞上主从延迟会查到旧数据:// 写后立刻要读的,强制走主库 $inDb = Order::on('mysql')->find($orderId);七、上线前检查清单[ ] 最大连接数核算:worker 数 × 每进程连接数 < MySQL max_connections × 0.8[ ] break_reconnect 开启 + 心跳进程就位[ ] 事务全部 try/catch/rollback 完整闭合(全局搜 startTrans 逐个人工审计)[ ] 长事务里无外部 IO(APM 看事务平均持锁时长)[ ] 大数据任务用 chunkById / cursor,无全量 ->get()[ ] ORM 字段缓存开启,生产关掉 SQL 日志写在最后数据库层的所有坑都指向同一个根因:连接的生命周期从"请求级"变成了"进程级"。把连接当进程级资源来管理(池化、心跳、重连),把事务当危险品来对待(短小、闭合、无 IO),webman 的数据库层就稳了。下一篇进入性能深水区:协程、连接池调优与压测。
2026年08月15日
6 阅读
0 评论
0 点赞
2026-08-06
webman 中间件与依赖注入实战:洋葱模型、PHP-DI 容器与 AOP
webman 中间件与依赖注入实战:洋葱模型、PHP-DI 容器与 AOPwebman 的请求处理链路看似简单(路由 → 中间件 → 控制器),但中间的洋葱模型和 PHP-DI 容器藏着不少中级开发者没吃透的细节。这篇文章把中间件的执行顺序、全局/路由级/方法级三种挂载方式、依赖注入的边界一次性讲清楚,最后再聊一个常驻内存框架特有的话题——AOP。一、洋葱模型:代码顺序与执行顺序是两回事class AuthMiddleware implements MiddlewareInterface { public function process(Request $request, callable $handler): Response { // ── 前置逻辑(进入时执行)── $userId = $this->jwt->parseToken($request); $request->userId = $userId; $response = $handler($request); // ← 下一层洋葱 // ── 后置逻辑(返回时执行)── $response->withHeader('X-Trace-Id', $request->header('x-trace-id')); return $response; } }三层中间件的执行顺序:请求 → [A前置] → [B前置] → [C前置] → Controller 响应 ← [A后置] ← [B后置] ← [C后置] ←最容易踩的坑:异常发生时的执行路径。 如果控制器抛异常,中间件的后置代码默认不会执行——除非你在中间件里 try/catch 包住 $handler($request)。跨域中间件经常因此"时灵时不灵":异常响应没带 CORS 头,浏览器报的不是后端错误而是跨域错误。所以 CORS 中间件要这样写:try { $response = $handler($request); } catch (\Throwable $e) { $response = response($e->getMessage(), 500); } return $response->withHeaders([ 'Access-Control-Allow-Origin' => '*', 'Access-Control-Allow-Headers' => 'Content-Type, Authorization', ]);二、三种挂载粒度1. 全局中间件(config/middleware.php)return [ // 每个请求都会经过 app\middleware\AccessLog::class, app\middleware\Cors::class, ];适合:日志、CORS、限流。注意全局中间件每个请求都会跑,别放重逻辑。2. 路由/应用级中间件// config/route.php Route::group('/admin', function () { Route::resource('/users', app\admin\controller\UserController::class); })->middleware([ app\middleware\AdminAuth::class, ]); // 或整个应用目录生效:app/admin/config/middleware.php return [ app\middleware\AdminAuth::class, ];多应用(多入口目录)模式下,app/{应用名}/config/middleware.php 只对该应用生效——这是做多租户后台、API 版本隔离的天然工具。3. 控制器方法级(中间件别名)// config/middleware.php 里注册别名 return [ 'auth' => [app\middleware\Auth::class], ]; // 控制器里按方法指定 class OrderController { #[Middleware(['auth'])] // 整个控制器生效 public function index() {} #[Middleware(['auth', 'throttle:60,1'])] public function create() {} }执行优先级:全局 → 应用级 → 路由级 → 方法级。同层内按注册顺序。三、实战:三个高频中间件的正确写法限流中间件(基于 Redis 滑动窗口)class Throttle implements MiddlewareInterface { public function __construct(private Redis $redis) {} public function process(Request $request, callable $handler): Response { $key = 'throttle:' . $request->getRealIp() . ':' . date('YmdHi'); $current = $this->redis->incr($key); if ($current === 1) { $this->redis->expire($key, 60); } if ($current > 120) { return json(['code' => 429, 'msg' => 'too many requests'], 429); } return $handler($request); } }注意 incr 后再 expire 的写法:只在第一次设置过期,避免每请求都重置 TTL。请求日志(含慢请求标记)class AccessLog implements MiddlewareInterface { public function process(Request $request, callable $handler): Response { $start = microtime(true); $response = $handler($request); $cost = round((micro(true) - $start) * 1000, 2); Log::info('access', [ 'ip' => $request->getRealIp(), 'path' => $request->path(), 'cost' => $cost, 'slow' => $cost > 500 ? '⚡' : '', ]); return $response; } }JWT 认证 + 用户上下文注入class Auth implements MiddlewareInterface { public function process(Request $request, callable $handler): Response { $token = $request->header('authorization', ''); try { $payload = JWT::decode(trim(str_replace('Bearer ', '', $token)), new Key(config('jwt.secret'), 'HS256')); } catch (\Throwable $e) { return json(['code' => 401, 'msg' => 'unauthorized'], 401); } // 挂到 request 上传递(请求级隔离,不用静态属性) $request->uid = $payload->uid; return $handler($request); } }控制器里 $request->uid 直接取——请求结束自动销毁,没有跨请求污染。四、依赖注入:webman 的 PHP-DI 容器webman 内置 PHP-DI。最常见的三个用法:1. 构造器注入(推荐)class OrderController { public function __construct( private OrderService $orderService, // 自动解析 private LoggerInterface $logger, ) {} }常驻内存的特性让注入几乎零成本:控制器和依赖都在进程启动阶段实例化一次,之后每个请求直接复用。这也意味着——注入的依赖必须是"无状态"或"请求安全"的。Service 里存了请求相关的属性就会串数据。2. 接口绑定// config/container.php return [ PayInterface::class => \DI\create(WechatPay::class), ]; // 之后注入 PayInterface 拿到的就是 WechatPay切支付渠道只改这一行。多环境(沙箱/生产)也可以在这里做条件绑定。3. 自动装配的边界PHP-DI 按类型提示递归解析依赖,但碰到标量参数就无能为力,需要显式定义:// config/container.php return [ \DI\create(SmsClient::class) ->constructor(\DI\value(config('sms.appkey'))), ];五、AOP:常驻内存框架的专属福利AOP(面向切面编程)在 FPM 下性能代价大,但 webman 的进程常驻让织入成本只付一次。webman 通过 php-di/AOP 代理实现,最常见的场景是缓存注解:#[Cacheable(prefix: 'user:', ttl: 300)] public function getUserProfile(int $uid): array { return $this->userDao->findWithRelations($uid); }切面拦截方法调用:先查 Redis,命中直接返回;未命中执行原方法并写缓存。类似的还有 #[Transactional](自动事务包裹)、#[Retry](失败重试)。两个注意点:AOP 只对容器管理的对象生效——new OrderService() 出来的裸对象没有代理拦截逻辑发生在方法级,粒度比中间件(请求级)细,适合业务横切(缓存/事务/重试),不适合请求横切(认证/日志)六、一张图理清请求全链路请求 → 全局中间件(日志/CORS/限流) → 应用/路由中间件(认证) → 方法级中间件(权限) → 中间件层容器解析控制器(构造器注入,进程级单例) → 方法内 AOP 切面(缓存/事务) → Service(业务逻辑,保持无状态) → Model/Dao每一层解决不同粒度的横切问题:请求级用中间件,业务级用 AOP,对象组装用容器——三者各司其职,别把认证写进 Service,也别把缓存注解挂在中间件上。写在最后中间件和依赖注入是 webman 工程化的骨架。写中间件时时刻记住两件事:异常会跳过后置逻辑、常驻内存下禁止请求级状态进静态属性;用容器时记住注入对象是进程级复用的,Service 必须无状态。下一篇讲数据库层——连接池怎么配、事务怎么包、大数据怎么跑,那是常驻内存框架最容易翻车的第二现场。
2026年08月06日
6 阅读
0 评论
0 点赞
2026-08-04
webman 架构解析:常驻内存进程模型与 PHP-FPM 的本质差异
webman 架构解析:常驻内存进程模型与 PHP-FPM 的本质差异webman 是基于 Workerman 构建的高性能 PHP 框架,官方基准 QPS 常态化压到十几万。但很多人把 webman 当"换个写法的 Laravel"来用,结果内存泄漏、全局状态污染这些经典问题轮番上演。要真正用好 webman,必须先理解它与 PHP-FPM 在架构上的本质差异——这是所有后续实践的地基。一、两种执行模型:请求级生命周期 vs 常驻内存PHP-FPM:每次请求都是新生浏览器 → Nginx → PHP-FPM(worker) → 加载框架 → 初始化 → 处理请求 → 返回 → 销毁一切FPM 模式下,每个请求结束都会释放全部内存、销毁所有变量。代价是每次请求都要重新加载框架(几十毫秒的 bootstrap),但好处是状态绝对隔离——你再怎么写全局变量,下一个请求都是白纸。webman:一次加载,永久服务浏览器 → webman(worker 进程) → 直接路由分发 → 处理请求 → 返回(框架和对象留在内存)webman 启动时把框架、路由表、配置全部加载进内存,之后每个请求只是"触发一次回调"。QPS 高的根源就在这:省掉了每请求一次的框架 bootstrap 开销。这个差异带来两个直接推论:性能提升的本质是"摊销":初始化成本只付一次,请求越多收益越大内存不自动释放:请求之间共享进程内存,全局状态会"存活"——这是福也是祸二、进程模型:master / worker 的分工master 进程(不处理业务) ├── 监听端口、管理 worker ├── fork 出 N 个 worker 进程 └── 收到 reload 信号时逐个平滑重启 worker │ ├── worker-1 ← 每个进程独立事件循环,可同时 hold 住数千连接 ├── worker-2 ├── ... └── worker-N(默认 = CPU 核数)三个关键认知:1. worker 数不是越多越好。 webman 是多进程 + 每进程事件循环(非阻塞 IO)模型,worker 数建议等于 CPU 核数。如果你的业务有阻塞调用(同步 HTTP 客户端、同步 RPC),连接会"卡住"事件循环,这时才需要加进程数来对冲——但更好的解法是消灭阻塞调用(后面协程篇细讲)。2. 进程间不共享内存。 你在 worker-1 里写的静态变量,worker-2 看不见。跨进程共享状态要用 Redis 或进程间通信,别指望 static 属性全站生效。3. request 对象请求间隔离,但对象池里的组件不是。 框架层面的组件(DB 连接、日志实例)跨请求复用,这是性能来源;你自己的类如果注册成了单例,它同样跨请求存活。三、启动流程:从 public/index.php 说起// public/index.php(简化) require_once __DIR__ . '/../vendor/autoload.php'; support\App::run();App::run() 内部做了什么:1. 加载 .env 与 config/*.php(此后修改配置需重启) 2. 扫描 app/controller 生成路由表(注解路由另算) 3. 初始化容器(webman 基于 PHP-DI,支持构造器注入) 4. 加载 bootstrap/*.php(进程启动时执行一次的钩子) 5. 初始化数据库/Redis 连接(懒加载,首次使用时建立) 6. master fork workers → 每个 worker 进入事件循环等待请求bootstrap 目录是理解 webman 的钥匙:放在这里的代码每个进程只执行一次。数据库连接的建立、连接池的初始化、Swoole 协程运行时的开启,都在这个阶段。官方的 webman/database、webman/redis 等插件都是通过 bootstrap 文件挂载的。四、常驻内存的四宗罪与解法罪一:静态属性 / 全局变量跨请求污染// ❌ 经典事故:上一单用户的数据泄漏给下一单 class OrderService { public static ?array $currentUser = null; } // 请求A设置了 currentUser,请求B进来时它还活着!解法:请求级数据一律走 Request 对象传递,或用 $request->xxx 属性挂载。静态属性只允许存"进程级不变量"(配置、编译好的正则、容器实例)。罪二:内存泄漏每请求增长的数组、未释放的大结果集、监听器越挂越多……FPM 下这些泄漏随请求结束被清零,webman 下会累积到 memory_limit 进程崩溃。排查手段:// 在中间件里记录内存水位 public function process(Request $request, callable $handler): Response { $before = memory_get_usage(); $response = $handler($request); $this->logger->info('mem delta', [ 'delta' => memory_get_usage() - $before, 'peak' => memory_get_peak_usage(), ]); return $response; }内存水位持续爬升就是泄漏信号。常见元凶:往静态数组里 push 不清理、查询返回百万行、日志 handler 无限累积。罪三:数据库连接断开(MySQL gone away)worker 空闲超过 wait_timeout(默认 8 小时)后,MySQL 单方面断开,而 webman 还握着死连接。解法:配置连接心跳 + 断线重连。用 ORM 插件时开启:// think-orm 配置 'break_reconnect' => true,更彻底的方案是连接池(数据库篇细讲),池化组件自带健康检查。罪四:代码改了不生效FPM 的肌肉记忆是"保存即生效",webman 下路由、配置、类定义都在内存里,必须 restart 或 reload。开发环境用 php start.php restart 太重,webman 有监视文件变自动 reload 的方案(workerman/webman-framework 配合 monitor 进程,dev 模式自动开启)——但注意生产环境要关掉。五、平滑重启的原理与边界php start.php reload # 平滑重启:逐个重启 worker php start.php restart # 硬重启:master 也重启 php start.php stop # 停止 php start.php status # 查看进程状态reload 的机制:master 收到信号后,先通知一个 worker 停止接收新请求,等它处理完当前请求再杀掉重建,依次轮转。这意味着:reload 只更新"启动时加载"的代码:controller 文件是每次请求 include 的吗?——webman 中 controller 默认支持 reload 更新(优化级别 support/App 的 reloadable 路径),但 config/、bootstrap/、路由定义的改动必须 restartreload 期间正在执行的长任务会被等待,但超过 $maxWaitTime(可配)会被强杀,注意保护长任务六、与 FPM 共存:渐进式迁移策略存量 Laravel/TP 项目不必推倒重来。实战迁移路径:新接口用 webman 写,旧接口留在 FPM,Nginx 按路径分流复用原有 Model 层:webman 支持 think-orm 和 Laravel Eloquent(laravel/database 插件),业务模型代码几乎平移逐步搬迁高频接口:把 QPS 最高、逻辑最薄的接口先迁过去,验证稳定性后再迁复杂业务分流配置示例:location /api/v2/ { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:8787; # webman } location /api/ { try_files $uri /index.php?$query_string; # 旧 FPM 应用 }写在最后webman 不是"更快的 Laravel",而是一种不同的运行时模型。把进程模型、启动流程、常驻内存的边界刻进脑子,后面的中间件、连接池、协程实践才有意义。下一篇我们进入请求处理的核心链路:中间件洋葱模型与依赖注入容器的实战。
2026年08月04日
4 阅读
0 评论
0 点赞
1
2
...
5
0:00