PHP高并发架构实战:ThinkPHP6处理万级并发请求的优化之路
PHP高并发架构实战:ThinkPHP6处理万级并发请求的优化之路
【摘要】我们厂的MES报工接口在交接班时段被打崩过三次,最惨那天PDA报工全线超时,车间只能退回纸质流转卡。这篇文章记录我从压测基线320 QPS一路优化到12800 QPS的完整过程,包括配置层零成本优化、Redis缓存击穿踩坑、Swoole常驻内存导致的跨请求数据串号事故、以及用队列把写请求削峰的具体做法,附完整代码、五阶段QPS/TP99量化对比表和实施顺序建议。
栏目:MES高并发架构优化 | 发布日期:2026-08-15 | 环境:ThinkPHP 6.1 / PHP 8.1 / Swoole 5.0 / Redis 6.2 / MySQL 8.0 / CentOS 7.9(8核16G x 3)
一、问题背景:交接班那十分钟,MES被自己人打崩了
先说清楚我们的场景。我所在的是一家做功率半导体后段封测的工厂,MES是自研的,后端用ThinkPHP6,前端是车间的PDA手持机加看板大屏,另外还有一套设备数采程序往MES推数据。平时白天的接口压力并不大,日常QPS只有一百多,跑得很安稳。真正出问题的是交接班时段。我们是三班制,早班八点、中班十六点、夜班零点交接,交接前后十分钟,上一班要把所有未报的产出批量补报完,下一班要拉当班的工单和工艺路线,同时设备数采程序按整点触发批量上传前一小时的机台参数。三股流量叠在同一分钟里。
第一次崩是去年三月的中班交接。表现是PDA点「提交报工」转圈三十秒然后超时,车间主任电话直接打到我手机上。我登上服务器看,nginx错误日志刷满了「connect() to unix:/run/php-fpm.sock failed (11: Resource temporarily unavailable)」,php-fpm的进程全部处于Running状态,MySQL的Threads_connected顶到最大连接数,慢查询日志里全是工艺路线的多表关联查询。更麻烦的后果是数据错了:工人看到转圈没反应就反复点提交,有个工单的完工数量被记成了实际的三倍,后面盘账花了两天才把账对回来。那天最后是车间退回纸质流转卡撑到下班的。
事后复盘,我犯的第一个错是把「平时够用」当成了「架构没问题」。MES这类系统的流量特征跟电商完全不同,它不是平滑的日活曲线,而是被生产节拍和班次强行对齐的尖峰,波峰波谷能差六十倍以上。第二个错是没有基线数据,出事后我甚至说不出「崩之前系统到底能扛多少并发」,只能靠猜。所以那次之后我做的第一件事不是改代码,而是把压测环境搭起来,把基线量出来。我给自己定的目标是:在3000并发下TP99控制在500毫秒以内、错误率低于千分之一,并且要能扛住万级并发的峰值不崩,因为随着两条新线投产,报工终端会从180台涨到接近500台,未来的峰值必然翻倍。
这篇文章就是那之后半年的完整记录。我按时间顺序拆成五个阶段,每个阶段只动一件事,压完测再动下一件,这样才能知道收益到底来自哪。中间有一次因为Swoole常驻内存导致跨工位数据串号的事故,我也会原原本本写出来,因为那个坑我相信很多从fpm迁到Swoole的人都会踩。
二、技术原理:为什么fpm在千级并发就撑不住了
2.1 进程模型的本质差别
php-fpm是「进程池 + 每请求重建执行环境」的模型。一个请求进来,fpm挑一个空闲worker,worker从零开始执行:加载框架入口、注册自动加载、构造容器、读配置文件、注册中间件、解析路由表、初始化数据库连接,最后才跑到我的业务代码。请求结束,整个PHP变量空间销毁,下一个请求重新来一遍。我在基线阶段用xhprof量过,ThinkPHP6在我们这套配置下光引导阶段就要38到45毫秒,业务逻辑本身只有12毫秒。也就是说四分之三的CPU时间花在了重复做同样的准备工作上。
更要命的是并发上限。fpm的pm.max_children是硬上限,我们8核16G配的是64。这意味着同一时刻最多只有64个请求在处理,第65个开始排队。而报工接口里有一次数据库事务写入,事务耗时大约80毫秒,这80毫秒里worker什么都不干,纯粹在等IO返回,但它占着进程不放。算一下就明白了:64个worker除以0.08秒,理论吞吐上限只有800 QPS,而且这是最理想情况。一旦MySQL因为连接暴涨而变慢,事务耗时涨到300毫秒,吞吐立刻掉到213 QPS,队列越积越长,最后就是雪崩。
Swoole的模型不一样。它以常驻进程方式启动,框架只在进程启动时引导一次,之后所有请求复用同一份内存中的容器、路由表和配置,引导那38毫秒从每请求一次变成整个生命周期一次。更关键的是协程:当业务代码执行数据库查询时,协程会主动让出CPU,让同一个worker去处理别的请求,IO返回后再恢复现场继续跑。一个worker可以同时挂着几百个处于IO等待的协程,这就把「进程数决定并发数」变成了「CPU算力决定并发数」,天花板完全不同。
2.2 缓存与队列在这套架构里各自解决什么
很多人把缓存和队列混着谈,其实它们解决的是两类完全不同的问题。缓存解决的是读放大:工艺路线、BOM、设备主数据、班次日历这些东西一天可能改一次,但每次报工都要读,读写比大概是5000比1。这类数据放Redis,把一次三表关联的12毫秒查询变成0.4毫秒的内存读取,还顺带把MySQL的负载卸掉。队列解决的是写尖峰:报工写入是刚性的、不能丢的,但它不需要同步完成。工人按下提交,他真正关心的是「系统收到了没有」,而不是「数据库事务提交了没有」。这两件事之间的时间差,就是我们能做削峰的空间。
把写入改成异步之后,接口耗时从80毫秒的事务变成2毫秒的入队,单机吞吐直接提了一个量级。峰值来的时候队列会积压,但积压是可控的:我们监控队列长度,只要消费速率大于平均生产速率,积压就会在峰值过后自然消化。我们实测交接班那十分钟最多积压过两万三千条消息,四个消费进程用了一分四十秒消化完,车间完全无感。
异步化带来的代价是一致性和幂等。接口返回「已受理」但落库还没完成,这段时间里如果有人查这张工单的完工数,会看到旧值。我们的处理办法是分场景:报工提交这种写多读少的走异步,而在制品数量校验这种必须实时的,用Redis里的计数器加Lua脚本原子扣减,在入队之前就把库存判断做完。这样既保住了不超报的业务约束,又不用等数据库。幂等则靠「工单+工序+设备+时间窗」拼出来的键,用SET NX兜住重复提交,这一条直接解决了当初PDA重复点击导致数量翻倍的老问题。
图1:每一阶段只改一件事,压测数据来自同一台压测机、同一份真实报工样本,横轴是优化阶段,柱状为QPS,折线为TP99。阶段3的跳变来自框架不再重复引导。
三、实战案例:五个阶段,一次事故
3.1 阶段一:零成本的配置层优化,从320到780
第一阶段我没写一行业务代码,纯改配置,结果QPS从320涨到780,这也是我建议所有人先做的一步。具体动了五处:开启opcache并把opcache.validate_timestamps设为0(生产环境文件不会变,省掉每次stat文件的开销);执行php think optimize:route和optimize:config生成路由与配置缓存;关闭app_debug和trace;把日志级别从debug提到warning;还有一个容易被忽略的,把日志写入方式从单文件改成按小时切分。最后这一条的效果超出我预期,因为debug模式下每个请求写十几行日志,几十个worker抢同一个文件的写锁,磁盘IO反倒成了瓶颈。
3.2 阶段二:Redis缓存与那次缓存雪崩
第二阶段做热点数据缓存,把工艺路线、BOM展开结果、设备与工位映射、班次日历这四类丢进Redis,QPS到2150。但上线第三天早上八点整,系统又卡了一次,这次持续了大约四十秒。查下来是我自己埋的雷:我给所有缓存都设了统一的3600秒TTL,而这批缓存是上线那一刻同时写进去的,于是它们也在同一秒同时失效。那一秒钟涌进来的几百个请求全部穿透到MySQL,同时执行同一个重查询,MySQL瞬间被打满。这就是典型的缓存雪崩,书上都写过,但真轮到自己身上才知道那种手心出汗的感觉。
修的办法有三层。第一层,TTL加随机抖动,我们用的是基础3600秒加上0到600秒的随机值,把失效时刻打散。第二层,热点键的重建加互斥,第一个发现缓存失效的请求拿到Redis锁去查库回填,其他请求短暂等待后重读缓存,而不是一起冲向数据库。第三层是逻辑过期:我们给缓存值里存一个业务过期时间戳,物理TTL设得更长,读到「逻辑上过期但物理还在」的数据时,先把旧值返回给用户,同时丢一个异步任务去刷新。对工艺路线这种几分钟内不变的数据,返回一个几秒钟前的旧值完全可以接受,但避免了任何一个请求被阻塞。
3.3 阶段三:Swoole常驻内存,以及跨工位数据串号事故
第三阶段上Swoole,QPS一跃到6400,TP99从720毫秒降到310毫秒,这是整个优化过程收益最大的一步。但也是这一步让我在生产上出了一次真正的事故,严重程度比性能问题高得多,因为它是数据正确性问题。
上线第二天中午,质检的同事在群里贴了张截图问我:为什么B区打磨工位的报工记录里,操作员显示的是A区一个封装工位的人?我当时后背就凉了。排查了两个多小时,根因是我们有个UserContext服务类,早年为了省事写成了单例,里面有个$currentUser属性,在中间件里赋值,后面业务代码到处从它取当前用户。在fpm下这么写完全没问题,因为每个请求的内存空间是独立的,请求结束就销毁了。但在Swoole常驻模型下,这个单例的生命周期跟worker进程一样长,两个协程并发跑的时候,协程A刚把用户设成张三,协程B紧接着把它改成李四,协程A从IO等待里恢复过来再去读,读到的就是李四。
这个坑的可怕之处在于它不是必然发生的,并发低的时候一个月都不出一次,并发一高就开始随机串号,测试环境根本复现不出来。我们最后的解决办法有三步:第一步紧急止血,先回滚到fpm跑了两天,同时用SQL把当天所有报工记录跟PDA终端登录日志做交叉核对,捞出17条串号记录人工修正。第二步是治本,把所有请求态数据从单例里挪进Swoole的协程上下文Context,以协程ID为隔离粒度,协程结束自动回收。第三步是防复发,我在CI里加了一条静态检查规则,扫描所有单例类的属性,凡是名字里带user、request、token、session、tenant这类请求态特征的一律拦下来,同时在团队编码规范里写死一条:常驻内存模式下单例只允许存无状态的配置和连接资源。
3.4 阶段四与阶段五:队列削峰和连接池调优
第四阶段把报工、参数上传这些写接口改成入队异步落库,QPS到10200,TP99降到165毫秒。这里踩的坑是消费者内存泄漏:消费进程跑了大概十几个小时后内存从80兆涨到1.4G,最后被系统OOM杀掉。原因是队列消费进程也是常驻的,代码里有个地方用静态数组做了「本地缓存」,每处理一条消息就往里加一个键,永远不清理。我们的处理方式是两条:一是把那个静态数组换成有容量上限的LRU结构,二是给消费进程设置max-jobs参数,每处理一万条消息主动退出让supervisor拉起来,用重启兜住所有我们没发现的泄漏。这个做法不优雅,但在生产上非常实用。
第五阶段是连接池和worker参数调优,QPS到12800。这里最重要的一条经验是:连接池上限必须和数据库的最大连接数联合计算。我们一开始给每个节点的MySQL池设了100,三个节点就是300,再加上定时任务和数采程序的连接,直接把MySQL的max_connections(当时设的500)顶穿了,报出Too many connections。后来改成每节点60,三节点180,给其他系统留出足够余量,反而更稳。worker数量我们最终定在CPU核数的两倍,也就是16,再往上加协程调度本身的开销就开始吃掉收益了,这个值建议每个人在自己机器上压出来。
图2:fpm在1000并发后TP99急剧劣化,因为worker数量固定、每个请求都要重新引导框架;Swoole方案在12000并发时TP99仍然178ms,曲线斜率明显更平。
四、完整代码:报工接口的削峰、幂等与原子校验
下面这段是我们线上报工接口的核心代码,做了脱敏但逻辑完整。它把本文讲的几个关键点串在了一起:用Lua脚本在Redis里一次性完成幂等判断和在制品原子扣减、接口只入队不落库、消费端负责事务写入与失败补偿。这段代码在我们线上单机扛过12800 QPS,你可以直接对照着改自己的接口。
// app/api/controller/Report.php —— PDA报工接口(削峰入队 + 幂等 + 库存原子校验)
namespace app\api\controller;
use think\facade\Cache;
use think\facade\Queue;
class Report