PHP多进程与MySQL并发:fork后连接继承与锁竞争深度解析

发布时间:2026/10/21 9:45:53
PHP多进程与MySQL并发:fork后连接继承与锁竞争深度解析
先把标题里的等号擦掉一半我得说PHP多进程不等于MySQL多进程。这个疑问我太熟了几乎每个把业务脚本从单进程改成多进程的人都会在某个深夜盯着MySQL连接数发呆“我不是开了8个PHP进程吗怎么MySQL这边跟我想的不一样” 这篇文章想解决的就是这件事把PHP多进程和MySQL侧到底发生了什么讲透顺便把fork之后数据库连接继承、连接串线、锁竞争这些坑一并说清楚。适合正在用pcntl做CLI多进程、或者用消息队列压消费吞吐的同学也适合那些准备优化线上数据同步脚本、还没决定进程数怎么开的前后端开发。1. 先厘清概念两个“多进程”隔着一整条网络链路不少人的误解来自缩写PHP“多进程”、MySQL“多进程”听起来都是“多个跑着干活的东西”于是潜意识里认为PHP开了8个进程MySQL也会跟着并行处理8个SQL。要破掉这个直觉得从上到下看一遍一条SQL的完整路径。1.1 PHP进程和MySQL进程是谁也看不见谁的关系PHP多进程通常指的是应用服务器上的N个PHP进程并行运行。以最常见的CLI脚本为例你用pcntl_fork创建worker子进程这些子进程各自执行自己的任务逻辑。它们跑在PHP一侧彼此隔离共享的是同一台机器的CPU和内存。MySQL侧跑的是mysqld一个常驻的数据库服务进程。PHP进程和mysqld之间是客户端-服务端关系通信走的是TCP/IP或者本地Unix Socket。PHP进程发出一条SQL本质是往这个TCP连接里写了一段文本协议mysqld解析之后在自己的线程里执行再把结果集通过同一连接传回来。所以“PHP多进程”发生在应用层、业务逻辑层“MySQL多进程”如果存在的话发生在数据库服务层这两者之间隔着一整条网络链路。PHP开8个进程MySQL根本不知道有8个PHP进程存在它只知道出现了多少个客户端连接、多少个正在执行的会话。1.2 “MySQL多进程”的正确叫法单进程多线程服务端严格来说很少说“MySQL多进程”。mysqld本身几乎总是单进程架构但内部是典型的多线程模型。一个客户端连接到来时通常对应一个连接线程SQL执行、事务处理、存储引擎的读写背后还有InnoDB的多个后台线程在协作。因此把“MySQL多进程”替换成“MySQL多线程”才更准确。你可以这么类比mysqld是一家餐厅的老板只有一个但他招了一堆服务员线程连接线程和厨师线程InnoDB工作线程。每个PHP进程到店就是来了一位顾客老板安排一个服务员接待。顾客越多服务员开得越多但后厨出菜的速度并不完全取决于服务员数量还得看灶台够不够、锅够不够、原料加工有没有互相抢。1.3 等式拆解更准确写法是“PHP多进程 → MySQL多连接 → MySQL内部多线程”把等式拆开看实际发生的是下面这条链路环节作用对应MySQL侧PHP多进程应用层并行执行业务逻辑产生多个客户端连接MySQL多连接每个连接分配会话/连接线程Threads_connected 上升MySQL内部多线程调度线程执行SQL、InnoDB并发读写Threads_running、锁等待、CPU使用率换句话说PHP多进程只是创造了“多路同时访问数据库”的可能但它并不是直接命令MySQL把SQL拆成多份并行执行。MySQL最终表现怎么样取决于它自己的线程调度、锁机制、磁盘IO。别急着往下写代码先把这条链路印在脑子里后面所有坑都从这里蔓延开。2. fork之后PHP子进程和数据库连接到底谁继承了谁如果说第1节是概念澄清那这一节就是真正栽跟头的地方。我见过太多人写多进程脚本时图省事父进程先new一个PDO再fork几个子进程然后所有子进程直接用同一个$pdo。表面看着没问题跑起来全是幺蛾子。2.1 进程复制是“写时复制”文件描述符表却是实打实复制的pcntl_fork创建子进程时内存页是写时复制也就是说父子进程共享同一份物理内存谁先写入谁触发复制。但有一个东西不是写时复制而是实实在在复制了一份过去进程的打开文件描述符表。MySQL连接在PHP里看着是个PDO对象内核里就是一个socket文件描述符。父进程建立连接后socket fd已经被打开fork之后子进程会继承这个fd。于是父进程和所有子进程手里拿的是同一个socket的引用底层其实是同一个TCP连接。2.2 一次糟糕的实验父进程建连接然后fork八个子进程我给你复现一段典型的错误写法?php $pdo new PDO(mysql:host127.0.0.1;dbnametest, user, pass); for ($i 0; $i 8; $i) { $pid pcntl_fork(); if ($pid 0) { // 子进程直接使用父进程的 $pdo for ($j 0; $j 100; $j) { $pdo-query(SELECT sleep(0.1)); } exit(0); } } // 父进程回收这段代码跑起来之后MySQL端看到的连接数还是1个很正常因为8个子进程复用同一个socket。问题在于8个进程同时往同一个TCP连接里写SQL、读响应TCP是流式协议没有“这条请求属于哪个进程”的封装结果就是协议字节流互相穿插。我实测遇到过的报错包括SQLSTATE[HY000]: General error: 2013 Lost connection to MySQL server during querySQLSTATE[HY000]: General error: 2006 MySQL server has gone awayPackets out of order这在MySQL协议层相当常见更隐蔽的是有时候不报错但是A进程发的SQL结果被B进程读走了两边数据全对不上。这种错乱在低并发下很难复现稍微调大进程数或者请求频率就爆发。2.3 正确打开方式子进程自己的连接自己建正确的姿势很简单fork之后每个子进程先关闭继承下来的连接再自己new一个PDO。或者更进一步父进程只负责调度和等待根本不要碰数据库连接。?php for ($i 0; $i 8; $i) { $pid pcntl_fork(); if ($pid 0) { // 关键子进程自己建立独立连接 $pdo new PDO(mysql:host127.0.0.1;dbnametest, user, pass, [ PDO::ATTR_TIMEOUT 5, PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, ]); for ($j 0; $j 100; $j) { $pdo-query(SELECT sleep(0.1)); } exit(0); } }这样8个子进程就有了8条独立连接MySQL端会开8个连接线程这才是真正的并发访问。假如父进程之前建过连接子进程记得先unset($pdo)或者把连接变量替换掉避免继承的fd留在子进程里占着MySQL连接数不释放。一个小经验判断你的多进程脚本是不是有连接继承问题看MySQL的Threads_connected就行。如果开了8个进程但连接数始终是1八成就是所有进程共用一条连接如果连接数波动很大、时不时多出很多不再活跃的连接可能是子进程继承了fd但不关闭父进程又没有释放。3. MySQL服务端如何处理“来自PHP多进程的压力”理解了应用层的连接继承问题接下来该看MySQL这边怎么工作了。这部分搞清楚你就知道哪些参数值得调哪些调了也没用。3.1 每个连接一个线程PHP开多少进程MySQL就开多少会话线程默认情况下MySQL对每个客户端连接都会分配一个线程来处理。你可以用下面几条命令看到当前状态SHOW GLOBAL STATUS LIKE Threads_connected; SHOW GLOBAL STATUS LIKE Threads_running; SHOW PROCESSLIST;在PHP多进程脚本运行期间你会发现Threads_connected基本稳定在“进程数 其他客户端数”。而Threads_running是真正正在执行SQL的线程数通常比连接数小因为大部分连接处于空闲等待状态。所以准确说法是PHP开了8个进程MySQL端就多了8个会话线程但这是“接待线程”不等于8个SQL同时在跑。MySQL内部有自己的调度比如CPU核数有限时线程需要排队。3.2 真正限制并发的不是PHP进程数而是这四件事我把自己调过的案例归了一下类大多数并发上不去的项目瓶颈其实不在PHP开不够进程而在下面四项第一连接数上限。如果PHP进程数开到50而MySQL的max_connections默认只有151左右你再算上其他业务连接很容易撞顶。连接数一满新连接直接报Too many connections。解决方案要么控制进程数要么调大max_connections但调大之后还要注意系统fd限制和内存占用。第二行锁和表锁等待。多进程并行写同一张表甚至同一行时彼此会互相阻塞。进程越多锁等待越明显。InnoDB的innodb_lock_wait_timeout默认50秒表面看起来是“响应变慢”实际是A进程握着行锁B进程在等。等超时了直接报Lock wait timeout exceeded; try restarting transaction。第三InnoDB内部并发限制。即使你开了几十个连接线程InnoDB也不会让所有线程都同时读写磁盘。它有自己的并发控制像innodb_thread_concurrency等参数会限制真正进入内核读写的线程数量。这个参数默认是0表示不限制但在高并发下反而需要按CPU核数设置避免线程过多互相切换导致性能跳水。第四单条SQL质量。这一点最容易被忽略。满表扫的一条慢查询开再多进程也救不回来反而会把磁盘IO打满。多进程本质上是让多条SQL同时执行如果每条SQL都在拖泥带水并行起来只会互相拖累。3.3 为什么开了8个PHP进程吞吐反而没涨多少这是我在某个内部数据同步任务里真实遇到过的现象。但先说原因多数时候问题出在任务本身。如果是CPU密集型计算8个进程在2核机器上只会加剧CPU切换吞吐不涨反跌。如果是数据库写密集行锁竞争会让进程排队。如果是IO密集但MySQL不在同一台机器网络带宽也可能成为瓶颈。所以看到“多进程之后吞吐没涨”第一步不是加进程数而是先查瓶颈看CPU是跑在用户态还是等待IO看SHOW ENGINE INNODB STATUS里有没有大量锁等待看Threads_running长期飘在什么位置。想清楚瓶颈再决定进程数怎么开。4. 一个真实压测案例8个Worker与8个连接线程的差距为了把上面这些讲清楚的路上不空谈我搭了一个模拟环境做压测。环境不复杂4核8G的云主机、PHP 7.4 CLI、MySQL 8.0任务是一个“订单状态批量更新”脚本从文件里读待处理的订单ID逐条执行UPDATE。这种任务非常典型既会涉及PHP进程调度也会涉及MySQL行锁和事务提交。4.1 测试场景和脚本结构订单表大概50万行脚本每次读取一个ID列表文件然后逐条执行UPDATE order_table SET status 2 WHERE order_id {:id}单进程先跑了一遍基线处理速度大概是每秒410条左右CPU占用约28%MySQL的Threads_running长期在0~1之间跳动几乎全程在等磁盘提交。然后我改成8个worker子进程每个子进程独立建PDO连接任务通过Redis队列分发。MySQL端观察到的连接数立刻变成了91个管理/连接 8个worker连接Threads_running峰值能到7~8。4.2 观测到的三类数据全程我盯了三个指标汇总在这张表里指标单进程基线8进程并发Threads_connected1~29Threads_running0~1峰值7均值3~5每秒处理条数约410约2100CPU使用率约28%约93%锁等待/死锁无偶发 Lock wait timeout数据看起来不错8进程把吞吐提到了大约5倍而不是8倍。仔细看MySQL的锁等待日志问题出在多个worker更新订单时撞到了相邻行的间隙锁。虽然订单ID是主键但update操作本身会加行锁两批任务如果ID区间挨得很近就容易互相等待。4.3 实验结论PHP进程数是“并发入口数”不是SQL并行度这个压测给了我很直观的体会PHP多进程真正提供的是“并发的入口”。它让MySQL在同一时刻能看到多个活跃会话从而有机会让CPU和磁盘并行利用起来。但MySQL是否真的并行处理、并行到什么程度取决于InnoDB的锁机制、磁盘IO和CPU资源。假设你的任务全是无锁读操作8个进程可能真能逼近8倍吞吐一旦有写、有事务、有锁这个线性关系就会破功。所以下次有人问“PHP多进程MySQL多进程”你可以回答PHP多进程是往MySQL这个水池里多开了几个水龙头但水池出水速度不仅取决于水龙头数量还取决于水管粗细和底下有没有闸板。5. 正确姿势与踩坑实录从“盲开进程”到“可控并发”这一节是实操部分我会把多进程连数据库的代码模板、参数选择和排查链路一次说清楚。你在自己项目里可以直接抄作业然后根据监控数据微调。5.1 写多进程脚本前先做三件事第一明确任务是CPU密集还是IO密集。CPU密集以计算为主进程数一般设为CPU核数左右IO密集存在大量等待进程数可以略高于核数比如核数的1.5~2倍。第二算好目标并发的连接数。比如你打算开30个PHP进程每个进程一个连接那MySQL这边就要能承受至少30的连接先看一眼max_connections别让应用层先崩。第三准备好监控手段。最少也要能在压测时执行这几条命令mysql -e SHOW GLOBAL STATUS LIKE Threads_connected; mysql -e SHOW GLOBAL STATUS LIKE Threads_running; mysql -e SHOW PROCESSLIST; top -H有了这三样后面排查任何问题都有依据。5.2 每个子进程单独建连接一个可以直接用的代码模板我把前面零散的代码整合成一个可以直接跑的CLI模板核心点都写了注释?php $dsn mysql:host127.0.0.1;dbnametest;charsetutf8mb4; $user user; $pass pass; $workerCount 8; // 父进程不建立任何DB连接只负责分发和回收 for ($i 0; $i $workerCount; $i) { $pid pcntl_fork(); if ($pid -1) { throw new Exception(fork failed); } if ($pid 0) { // 子进程自己的连接自己建 $pdo new PDO($dsn, $user, $pass, [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT 5, ]); try { while (true) { // 从队列取任务没有任务则 sleep $task ...; if (!$task) { sleep(1); continue; } $pdo-beginTransaction(); // 执行你的SQL $pdo-commit(); } } catch (Throwable $e) { // 记录日志、回滚事务、选择性退出 if ($pdo-inTransaction()) { $pdo-rollBack(); } } exit(0); } } // 父进程回收所有子进程 while (pcntl_waitpid(0, $status) ! -1) { // 等待子进程退出 }这个模板里有三个细节要特别注意子进程内new PDO之前如果父进程之前建过连接变量要覆盖掉父进程本身不要持有连接。事务里处理完业务要尽快commit减少行锁持有时间。异常捕获里要判断inTransaction()否则回滚会报错。5.3 长驻Worker的连接保活与重连多进程脚本如果是一次跑完的连接问题不大。但如果是常驻worker比如监听Redis队列的消费进程就必须考虑连接保活。MySQL的wait_timeout默认8小时一个worker连着好几天不退出很可能某天早上发现连接已经被服务端断开你再执行SQL就报 “MySQL server has gone away”。应对方式很简单每条任务处理前执行一次轻量心跳try { $pdo-query(SELECT 1); } catch (Throwable $e) { // 重建连接 $pdo new PDO($dsn, $user, $pass, [/* ... */]); }心跳频率不用太高每个任务循环里查一次SELECT 1是能接受的。另外连接断掉之后PDO的事务状态也要清掉重建连接自然就解决了。5.4 典型故障排查链路从现象到根因我在做多进程开发时踩过的坑基本能对应到下面这张表现象直接原因排查命令对策8个进程但连接数1fork前建连接子进程复用fdSHOW PROCESSLIST子进程内重新new PDO连接数暴涨甚至超过max_connections连接泄漏、子进程继承fd但不关闭SHOW GLOBAL STATUS LIKE Threads_connected统一连接生命周期子进程退出前释放资源Packets out of order / 数据错乱多进程共享同一TCP连接并发读写查看PHP报错日志每子进程单独连接Lock wait timeout exceeded多worker写同一行/相邻区间SHOW ENGINE INNODB STATUS任务分片按ID哈希、减小事务粒度、加重试吞吐远低于进程数比例锁竞争、CPU核数不足、慢SQLtop,H / SHOW PROCESSLIST调整进程数、优化SQL、加索引排查思路其实就一句话先看现象出现在哪一层。连接串线基本是应用层连接继承问题连接数爆了要查有没有连接泄漏锁等待时延要看InnoDB状态吞吐上不去要回到CPU和慢查询。拿我自己的压测举例第一次遇到Lock wait timeout时我第一反应是加进程数结果加了之后更糟糕。后来看SHOW ENGINE INNODB STATUS发现是间隙锁冲突最后把任务按order_id % worker_count做分片每个worker只更新自己那一份ID区间锁等待立刻降下来了。这种从现象到根因的路径比盲目调参靠谱得多。最后再分享一点个人体会把“PHP多进程”当成“给MySQL多加线程”的人十个里有八个会栽在连接继承的坑上。你想让PHP多进程真正发挥作用核心不是追求进程数而是把任务划分、连接管理、事务边界这三件事理顺。每次压测之前先问自己一句我加的是并发入口还是并发负担想清楚这个问题MySQL的状态指标自然会告诉你答案。