首页
直播
壁纸
友链
搜索
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
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
74
篇与
» admin
的结果
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-18
让 YoduPlayer 切页不断歌:给 Typecho 博客定制 PJAX 的完整实战
让 YoduPlayer 切页不断歌:给 Typecho 博客定制 PJAX 的完整实战博客装了 YoduPlayer 这款背景音乐播放器后,一直有个很破坏体验的问题:音乐正放着,点进一篇文章,页面一刷新,歌就断了。访客每看一篇新文章就要重新点一次播放,背景音乐的"背景"两个字完全名存实亡。这篇文章记录我把这个问题彻底解决的全过程:从原理分析,到方案选型,再到自己动手写一个约 200 行的轻量 PJAX,最后处理掉一串切页后才会暴露的隐藏 Bug。完整代码都在文中,可以直接抄走用。一、先搞清楚:歌为什么会断浏览器里的音频播放依赖一个 DOM 元素 <audio>(YoduPlayer 里就是全局的 yaudio 对象)。而传统页面跳转的本质是:点击链接 → 浏览器销毁整个文档 → 请求新页面 → 重新解析 HTML/CSS/JS文档销毁的瞬间,挂在文档里的 <audio> 元素跟着被销毁,播放自然中断。新页面里插件重新输出了一套播放器 HTML 和 JS,但那是一个全新的 yaudio,播放进度、当前曲目全部归零。所以问题的根源不在插件,而在于整页刷新这种导航方式本身。想让音乐不断,就不能销毁承载播放器的那份文档。二、方案选型:为什么是 PJAX解决思路业界已经很成熟了——局部刷新:导航时只替换页面的内容区域,头部、底部、播放器所在的 DOM 保持不动。实现方式常见的有三种:方案原理缺点iframe把整站套进框架页URL 不变、SEO 灾难、移动端体验差SPA 改造前端框架接管路由Typecho 主题基本要重写,成本过高PJAXAJAX 拉取新页面 + History API 改地址只需主题小幅配合PJAX(PushState + AJAX)的原理一句话就能说清:拦截链接点击 → preventDefault 阻止跳转 → XHR 拉取新页面 HTML → 用 DOMParser 解析 → 只取内容区域替换进当前文档 → pushState 更新地址栏整个过程文档从未销毁,<audio> 一直活着,音乐自然不断。同时 URL 会真实变化、浏览器前进后退可用、对搜索引擎完全透明——这是它碾压 iframe 的地方。YoduPlayer 的 README 里也写了"需要主题支持 pjax 或 instantclick",说明作者早就预留了这条路,缺的只是主题侧的实现。我的主题是 Joe,直接引现成的 PJAX 库和主题代码有各种兼容性小毛病,所以干脆自己写了一个定制版。三、动手实现3.1 确定内容容器边界第一步是划分"哪些 DOM 切页时要换,哪些不能动"。看 Joe 主题的 index.php 结构:<div id="Joe"> <!-- 文章列表 / 正文 / 侧边栏 / footer 都在这里面 --> </div> <?php $this->footer(); ?> <!-- YoduPlayer 的播放器 HTML 输出在这里 --> </body>很清晰:#Joe 是内容容器,切页时替换它的 innerHTML;播放器、脚本初始化代码都在容器外,天生不受影响。如果你的主题结构不同,把这层边界划对是第一件事。3.2 核心 PJAX 脚本在主题里新建 assets/lib/pjax/pjax.js,核心不到 200 行:(function () { 'use strict'; var CONTAINER = '#Joe'; var NO_INSTANT = 'data-no-instant'; var xhr; history.replaceState({ url: location.href }, '', location.href); // 1. 拦截站内链接点击 document.addEventListener('click', function (e) { if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; var link = e.target.closest('a'); if (!link) return; if (link.target === '_blank' || link.hasAttribute('download')) return; var href = link.getAttribute('href'); if (!href || href.charAt(0) === '#' || href.indexOf('javascript:') === 0) return; var url; try { url = new URL(link.href); } catch (err) { return; } if (url.origin !== location.origin) return; // 外链放行 if (url.pathname.indexOf('/admin/') === 0) return; // 后台放行 if (url.href.split('#')[0] === location.href.split('#')[0]) return; // 沿 DOM 向上检查 data-no-instant,标记了就走原生跳转 var el = link; while (el && el !== document.documentElement) { if (el.hasAttribute && el.hasAttribute(NO_INSTANT)) return; el = el.parentNode; } e.preventDefault(); loadPage(url.href, true); }); // 2. 前进 / 后退 window.addEventListener('popstate', function (e) { loadPage((e.state && e.state.url) || location.href, false); }); // 3. 拉取新页面 function loadPage(url, push) { if (xhr) xhr.abort(); xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.timeout = 15000; xhr.onload = function () { var ct = xhr.getResponseHeader('Content-Type') || ''; if (xhr.status >= 200 && xhr.status < 400 && ct.indexOf('text/html') >= 0) { applyPage(xhr.responseText, url, push); } else { location.href = url; // 兜底:非 HTML 直接真跳转 } }; xhr.onerror = function () { location.href = url; }; xhr.ontimeout = function () { location.href = url; }; xhr.send(); } // 4. 替换内容 + 重执行脚本 function applyPage(html, url, push) { var newDoc = new DOMParser().parseFromString(html, 'text/html'); document.title = newDoc.title; var oldC = document.querySelector(CONTAINER); var newC = newDoc.querySelector(CONTAINER); if (!oldC || !newC) { location.href = url; return; } oldC.innerHTML = newC.innerHTML; // 关键:innerHTML 插入的 <script> 不会执行,手动重建 var scripts = oldC.querySelectorAll('script'); for (var i = 0; i < scripts.length; i++) { var s = scripts[i]; if (s.hasAttribute(NO_INSTANT)) continue; // 带标记的跳过 var ns = document.createElement('script'); for (var j = 0; j < s.attributes.length; j++) { ns.setAttribute(s.attributes[j].name, s.attributes[j].value); } ns.textContent = s.textContent; s.parentNode.replaceChild(ns, s); } if (push) history.pushState({ url: url }, '', url); window.scrollTo(0, 0); // 重新触发 DOMContentLoaded,让主题的初始化逻辑跑一遍 try { document.dispatchEvent(new Event('DOMContentLoaded')); } catch (err) {} // 广播事件,其他脚本可监听 document.dispatchEvent(new CustomEvent('pjax:complete', { detail: { url: url } })); } })();几个容易踩坑的点单独说一下:innerHTML 里的脚本不执行。这是浏览器的安全设计,所以必须手动 createElement('script') 重建节点。而 data-no-instant 标记的脚本(比如插件的初始化配置)跳过不执行——它们已经在页面上活着了,再执行一遍等于重置播放器。一定要有兜底。请求失败、超时、返回的不是 HTML、新页面找不到容器,任何一种异常情况都直接 location.href 真跳转,宁可断歌也不能白屏。popstate 处理前进后退。pushState 时带上的 state.url 在这里取出来用,否则回退会没有反应。最后在 public/include.php 里引入,注意自身要带 data-no-instant,防止被未来的自己重复执行:<script src="<?php _getAssets('assets/lib/pjax/pjax.js'); ?>" data-no-instant></script>3.3 与 YoduPlayer 的分工做完上面这步,音乐其实已经不会断了。剩下的工作是让播放器的 UI 在切页后依然正常。YoduPlayer 的加载分两部分,正好对应两种处理方式:// Plugin.php footer() 中的关键输出 // 音频引擎 + 核心函数:带 data-no-instant,切页不重执行 <script data-no-instant> var yaudio = new Audio(); var musicArr = [{title:"xxx", artist:"xxx", mp3:"http:xxx", cover:"xxx"},]; var sj = musicArr[0]; yaudio.src = sj.mp3; </script> <script src=".../js/player.js" data-no-instant></script> // UI 层:不带标记,切页后随容器重建 <script src=".../js/prpr.js"></script>分工非常清晰:player.js + 内联配置:持有 yaudio、musicArr 这些全局状态和 playbtu()、next() 这些核心函数,标记为 data-no-instant,一次加载终身有效;prpr.js:负责播放器界面,重新生成歌单列表 DOM、把播放/切歌按钮绑定到还活着的 yaudio、同步当前曲目和封面。它不带标记,每次 PJAX 切页后都会重新执行一遍,相当于给新 DOM"接上"旧引擎。如果你在给其他播放器做类似改造,把"状态"和"UI"拆开、状态部分标记为不重执行,就是最核心的思路。四、不断歌之后,才轮到真正的坑音乐连续了只是第一步。整页刷新被干掉后,一堆原本"刷新后自然解决"的问题全部浮出水面:4.1 主题初始化不再触发Joe 主题把文章列表懒加载、轮播、各种事件绑定全放在一个 DOMContentLoaded 监听器里。PJAX 换完内容后这个事件不会自己触发,页面看着是换了,交互全是死的。解法就是上面代码里的那句:try { document.dispatchEvent(new Event('DOMContentLoaded')); } catch (err) {}手动补发一次,主题的初始化逻辑就会对新内容重跑一遍。4.2 评论表单的安全令牌失效这是最隐蔽的一个。上线后发现:PJAX 切页后发的第一条评论永远失败,报"评论发表失败"。排查后发现 Typecho 有个防spam机制:页面 head 里有一段内联脚本,会在评论表单里动态注入一个 name="_" 的隐藏字段作为令牌,服务端校验它。这段脚本在 <head> 里,PJAX 只替换内容容器根本不会碰它,于是新页面里表单是新的、令牌是旧的,校验必然失败。解法是在 applyPage 里补一段:解析新页面 head 中的内联脚本,把包含 name = '_'(防spam)和 TypechoComment(评论回复逻辑,它的 respondId 是每篇文章独有的)的挑出来手动执行:var headInline = newDoc.head.querySelectorAll('script:not([src])'); for (var hi = 0; hi < headInline.length; hi++) { var hcode = headInline[hi].textContent; if (hcode.indexOf("name = '_'") !== -1 || hcode.indexOf('TypechoComment') !== -1) { var hns = document.createElement('script'); hns.textContent = hcode; document.head.appendChild(hns); document.head.removeChild(hns); } }4.3 评论重复提交评论能发之后,又发现每条评论提交了两次。原因是主题的 joe.global.min.js 在补发的 DOMContentLoaded 里又绑定了一次 AJAX 评论处理器,和 PJAX 脚本里的处理器叠加了。解法是绑定评论事件时用 e.stopImmediatePropagation() 抢占,并在每次切页后 $form.off('submit') 清掉旧绑定再重绑,保证表单上永远只有一个处理器。4.4 收起状态记忆失效播放器有个收起/展开状态存在 localStorage 里。切页后新 DOM 是初始展开状态,需要在 PJAX 完成后重新读取并应用。YoduPlayer 的 prpr.js 开头有现成逻辑:if (localStorage.getItem("yoduplayer_collapsed") === "1") { document.getElementById('bgmplayer').classList.remove("bgmon"); }这也侧面验证了前面"UI 层每次重执行"设计的正确性——这类状态同步逻辑放在 UI 层,天然会在每次切页后自动跑一遍。五、总结回头看,整件事的技术含量不在 PJAX 本身(核心逻辑不到 200 行),而在于理解"整页刷新"一直在默默帮你处理什么,并在干掉它之后把这些事情一一接手:脚本重执行、主题重初始化、评论令牌重注入、事件重绑定。总结几条可复用的经验:容器边界划分是 PJAX 改造的第一步,播放器必须在容器外;状态与 UI 分离,状态脚本标 data-no-instant,UI 脚本每次重建后重执行;补发 DOMContentLoaded 让主题的初始化逻辑重跑;异常兜底真跳转,任何失败路径都不能让用户白屏;切页后评论、表单类功能务必手动测一遍,令牌和事件绑定最容易漏。现在博客的背景音乐从进站到离开可以一路连续播放,切页只换内容不换"灵魂"。如果你也在用 Typecho + YoduPlayer,希望这篇能帮你少踩几个坑。插件地址:YoduPlayer - GitHub,感谢作者 Jrotty 的开源贡献。
2026年08月18日
5 阅读
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 点赞
1
2
...
15
0:00