首页
直播
壁纸
友链
搜索
1
微信小程序 Skyline 渲染引擎实战:worklet 动画从原理到落地
327 阅读
2
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
308 阅读
3
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势
280 阅读
4
小程序接口安全攻防:签名、防重放、防刷与数据加密实战
276 阅读
5
微信小程序分包进阶:独立分包、预下载与分包异步化实战
272 阅读
服务器运维
后端技术
前端技术
梯子
数据库
小程序
登录
搜索
标签搜索
fastadmin
Redis
微信小程序
前端开发
RabbitMQ
Go
Golang
服务器
codex
buildadmin
小程序
mysql
Nginx
Docker
Vue3
Node.js
MySQL优化
Linux
TypeScript
JWT
沿途的风景
累计撰写
82
篇文章
累计收到
0
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
82
篇与
» admin
的结果
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日
35 阅读
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日
34 阅读
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日
34 阅读
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日
36 阅读
0 评论
0 点赞
2026-08-04
uni-app 状态管理实战:Pinia 集成、持久化适配与登录态设计
页面通信篇里简单提过 Pinia,但真实项目的状态管理远不止"装个库"。多端 storage 差异、持久化插件适配、登录态设计、模块划分,每一步都有坑。本文把 uni-app 状态管理的完整方案一次性讲透。一、为什么 uni-app 项目需要状态管理先看反例——不用状态管理时的典型代码:// 每个页面都要重复拉用户信息 onShow() { const userId = uni.getStorageSync('user_id') const res = await request.get(`/users/${userId}`) this.user = res }问题:重复请求、数据不同步(A 页改了昵称 B 页还是旧的、storage 读写散落各处。状态管理的本质是把跨页面共享的响应式数据收口到一个地方。哪些数据该进全局 store:数据类型示例方案登录态token、用户信息store + 持久化业务共享状态购物车、选中的收货地址store页面间临时传值列表页→详情页参数URL / EventChannel单页面私有状态表单数据data / ref判断标准:两个以上无父子关系的页面需要读同一份数据 → 上 store;只在跳转链上传递 → 用页面通信方案。不要为了"显得规范"把所有东西塞进 store。二、Pinia 集成// main.js import { createSSRApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' export function createApp() { const app = createSSRApp(App) app.use(createPinia()) return { app } }// stores/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: '', userInfo: null }), getters: { isLoggedIn: (state) => !!state.token, displayName: (state) => state.userInfo?.nickname || '未登录' }, actions: { async login(code) { const res = await request.post('/auth/wx-login', { code }) this.token = res.accessToken this.userInfo = res.user }, logout() { this.token = '' this.userInfo = null uni.reLaunch({ url: '/pages/login/login' }) } } })// 任意页面使用 import { useUserStore } from '@/stores/user' const userStore = useUserStore() userStore.isLoggedIn // getter,响应式 await userStore.login(code) // action三、持久化:storage 适配是关键直接持久化的问题小程序端没有 localStorage,Pinia 官方的 pinia-plugin-persistedstate 默认走 localStorage,在小程序端直接报错。两个解决路径:方案一:手写持久化(推荐,无依赖)// utils/persist.js import { watch, toRaw } from 'vue' /** * 将 store state 持久化到 uni storage * @param {Store} store pinia store 实例 * @param {string} key 存储键名 */ export function persistStore(store, key) { // 启动时恢复 const saved = uni.getStorageSync(key) if (saved) { store.$patch(JSON.parse(saved)) } // 变更时保存(deep watch) store.$subscribe((mutation, state) => { try { uni.setStorageSync(key, JSON.stringify(toRaw(state))) } catch (e) { console.warn('持久化失败', e) } }) }// stores/index.js —— 统一初始化 import { createPinia } from 'pinia' import { persistStore } from '@/utils/persist' import { useUserStore } from './user' import { useCartStore } from './cart' const pinia = createPinia() // 在 App.vue onLaunch 里调用,确保 uni storage 可用 export function initStores() { persistStore(useUserStore(), 'app:user') persistStore(useCartStore(), 'app:cart') } export default pinia// App.vue export default { onLaunch() { initStores() } }$subscribe 是 Pinia 内置的订阅机制,任何 mutation 都会触发,比手动 watch 更可靠。方案二:persist 插件 + 自定义 storage如果坚持用 pinia-plugin-persistedstate,给它传入适配 uni storage 的 driver:// main.js import { createPinia } from 'pinia' import { createPersistedState } from 'pinia-plugin-persistedstate' const pinia = createPinia() pinia.use(createPersistedState({ storage: { getItem: (key) => uni.getStorageSync(key) || null, setItem: (key, value) => uni.setStorageSync(key, value), // uni storage 没有.removeItem 语义?有:uni.removeStorageSync } })) export function createApp() { const app = createSSRApp(App) app.use(pinia) return { app } }持久化的粒度控制不是所有 state 都值得持久化。用户偏好(主题、字体大小)全量持久化;购物车持久化但要注意登录后与服务器合并;临时 UI 状态(弹窗开关)绝不持久化。用插件的话按 store 配置 paths 字段挑选。四、登录态设计:完整方案登录态是小程序里最重要的全局状态,完整链路:静默登录 → 检查有效期 → 请求拦截 → 失效处理。// stores/user.js 完整版 import { defineStore } from 'pinia' import request from '@/utils/request' export const useUserStore = defineStore('user', { state: () => ({ token: '', refreshToken: '', userInfo: null, wxSessionKey: '' }), getters: { isLoggedIn: (state) => !!state.token }, actions: { /** * 静默登录:App 启动即调用,用户无感知 * 小程序 wx.login 换 code → 后端换 openid → 绑定/注册用户 → 发 token */ async silentLogin() { if (this.token) return try { const [err, res] = await uni.login({ provider: 'weixin' }) if (err) return const data = await request.post('/auth/silent-login', { code: res.code }) this.token = data.accessToken this.refreshToken = data.refreshToken } catch (e) { // 静默失败不弹窗,需要用户信息时再引导 console.warn('静默登录失败', e) } }, /** 强制登录:访问需要身份的页面时调用 */ async ensureLogin() { if (this.isLoggedIn) return true await this.silentLogin() if (this.isLoggedIn) return true // 静默失败(未注册),跳登录页 const pages = getCurrentPages() const current = pages[pages.length - 1] uni.navigateTo({ url: `/pages/login/login?redirect=/${current.route}` }) return false }, async fetchProfile() { this.userInfo = await request.get('/me') }, logout() { this.$reset() // 重置到初始 state uni.reLaunch({ url: '/pages/login/login' }) } } })// App.vue export default { onLaunch() { initStores() useUserStore().silentLogin() } }五、store 模块划分中大型项目的目录建议:stores/ ├── index.js # pinia 实例 + initStores ├── user.js # 登录态、用户信息 ├── cart.js # 购物车 ├── app.js # 全局配置:主题、系统信息、定位城市 └── address.js # 收货地址列表划分原则:按业务域拆,不按页面拆(订单相关的状态跟订单 store,即使被 5 个页面用)store 之间可以互相引用:useCartStore 内部调用 useUserStore 检查登录态,Pinia 支持这种组合action 里做业务编排:加入购物车 = 检查登录 → 调接口 → 更新 state → toast 提示,这一串逻辑写进 action 而不是页面里,页面只管调用// stores/cart.js import { defineStore } from 'pinia' import { useUserStore } from './user' import request from '@/utils/request' export const useCartStore = defineStore('cart', { state: () => ({ items: [] }), actions: { async add(goods, count = 1) { const userStore = useUserStore() // store 组合 const ok = await userStore.ensureLogin() if (!ok) return // 本地乐观更新 + 服务端同步 const local = this.items.find(i => i.goodsId === goods.id) if (local) { local.count += count } else { this.items.push({ ...goods, goodsId: goods.id, count }) } try { await request.post('/cart/add', { goodsId: goods.id, count }) } catch (e) { uni.showToast({ title: '同步失败', icon: 'none' }) } } } })六、多端差异注意点storage 大小限制:单 key 上限 1MB,总上限 10MB(微信)。购物车这类大对象要控制规模,必要时只存 ID 列表,详情进 store 后从接口拉App 端可以直用 localStorage:但为了代码统一,全端都用 uni.setStorageSyncstorage 是同步 API:批量写入会阻塞逻辑层,高频写入(如实时输入内容)做节流// 写入节流 let saveTimer = null store.$subscribe(() => { clearTimeout(saveTimer) saveTimer = setTimeout(() => { uni.setStorageSync(key, JSON.stringify(toRaw(store.$state))) }, 300) })总结跨页面共享的响应式数据进 store,跳转传参用页面通信,不要过度集中小程序端持久化必须适配 uni storage:手写 $subscribe 方案零依赖最稳登录态标准链路:onLaunch 静默登录 → ensureLogin 强制拦截 → action 内编排业务store 按业务域划分,action 做业务编排,store 之间可组合storage 有 1MB 单 key 限制,大对象持久化要做节流和规模控制下一篇讲登录授权的"最后一公里"——手机号验证码、头像昵称填写这些微信持续收紧的接口怎么优雅应对。
2026年08月04日
30 阅读
0 评论
0 点赞
1
2
3
4
...
17
0:00