先把标题里的等号擦掉一半,我得说: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:host=127.0.0.1;dbname=test', '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:host=127.0.0.1;dbname=test', '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端观察到的连接数立刻变成了9(1个管理/连接 + 8个worker连接),Threads_running峰值能到7~8。
4.2 观测到的三类数据
全程我盯了三个指标,汇总在这张表里:
| 指标 | 单进程基线 | 8进程并发 |
|---|---|---|
| Threads_connected | 1~2 | 9 |
| Threads_running | 0~1 | 峰值7,均值3~5 |
| 每秒处理条数 | 约410 | 约2100 |
| CPU使用率 | 约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:host=127.0.0.1;dbname=test;charset=utf8mb4'; $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个进程但连接数=1 | fork前建连接,子进程复用fd | SHOW 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核数不足、慢SQL | top,H / SHOW PROCESSLIST | 调整进程数、优化SQL、加索引 |
排查思路其实就一句话:先看现象出现在哪一层。连接串线基本是应用层连接继承问题;连接数爆了要查有没有连接泄漏;锁等待时延要看InnoDB状态;吞吐上不去要回到CPU和慢查询。
拿我自己的压测举例,第一次遇到Lock wait timeout时,我第一反应是加进程数,结果加了之后更糟糕。后来看SHOW ENGINE INNODB STATUS,发现是间隙锁冲突,最后把任务按order_id % worker_count做分片,每个worker只更新自己那一份ID区间,锁等待立刻降下来了。这种从现象到根因的路径,比盲目调参靠谱得多。
最后再分享一点个人体会:把“PHP多进程”当成“给MySQL多加线程”的人,十个里有八个会栽在连接继承的坑上。你想让PHP多进程真正发挥作用,核心不是追求进程数,而是把任务划分、连接管理、事务边界这三件事理顺。每次压测之前先问自己一句:我加的是并发入口,还是并发负担?想清楚这个问题,MySQL的状态指标自然会告诉你答案。