直接循环curl_exec()会因同步阻塞导致卡死、超时或打满php-fpm worker,应改用curl multi实现并发控制与队列化,并配合节流、异步日志和失败重试机制保障可靠性。

为什么直接循环 curl_exec() 发送 POST 会出问题
并发量稍高时,PHP 脚本容易卡死、超时或触发连接数限制,不是因为代码写错了,而是 curl_exec() 默认同步阻塞——上一个请求没返回,下一个就干等着。尤其在批量通知、日志上报、第三方回调补发等场景,队列化不是“更优雅”,而是“不得不做”。
- Apache/PHP-FPM 默认
max_children有限,密集curl_exec()容易打满 worker - 目标接口响应慢(比如微信模板消息平均 800ms),100 次串行就得耗 80 秒
-
set_time_limit(0)治标不治本,内存持续增长可能触发 OOM
用 cURL Multi 实现轻量级队列发送
不用引入 Redis 或 Beanstalkd,纯 PHP 就能做基础队列控制。核心是把多个 curl_init() 句柄塞进 curl_multi_init(),由 cURL 底层调度并发(通常默认 10 连接并行)。
关键步骤:
- 逐个调用
curl_init(),设置CURLOPT_URL、CURLOPT_POSTFIELDS、CURLOPT_RETURNTRANSFER等,但先不curl_exec() - 用
curl_multi_add_handle()批量注册句柄 - 用
curl_multi_exec()+curl_multi_select()循环等待完成(避免忙等) - 每次
curl_multi_info_read()拿到完成句柄后,用curl_getinfo()和curl_multi_getcontent()提取结果 - 记得
curl_multi_remove_handle()和curl_close()清理,否则句柄泄漏
示例片段(5 个并发上限):
立即学习“PHP免费学习笔记(深入)”;
$mh = curl_multi_init();
curl_multi_setopt($mh, CURLMOPT_MAXCONNECTS, 5);
foreach ($requests as $i => $req) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $req['url']);
curl_setopt($ch, CURLOPT_POSTFIELDS, $req['data']);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_multi_add_handle($mh, $ch);
}
$running = null;
do {
curl_multi_exec($mh, $running);
if ($running) curl_multi_select($mh);
} while ($running);
// 收集结果...
如何控制发送节奏避免被限流
很多 API(如企业微信、钉钉、短信网关)对 QPS 敏感,单纯并发不够,得加节流。别用 sleep() 塞满整个进程,而是在每次批处理之间控制间隔。
- 把 100 个请求拆成每批 10 个,用
cURL Multi发完一批后usleep(200000)(200ms)再发下一批 - 检查响应头中的
X-RateLimit-Remaining,若接近 0 则主动sleep()等待重置窗口 - 对失败请求(如 HTTP 429、503)单独记录,延迟 1–2 秒后加入重试队列,不要立刻重发
- 避免用
microtime(true)手动计时做滑动窗口——PHP 单进程难精准,交给 Nginx 的limit_req或 API 网关更稳
异步落地与错误兜底不能省
哪怕用了 cURL Multi,网络抖动、DNS 失败、SSL 握手超时仍会发生。不记录原始请求和响应,出问题根本没法对账。
- 每个请求执行前,把
$req['url']、$req['data']、时间戳写入本地文件或 MySQL 的post_log表,状态设为pending - 成功后更新状态为
success,并存http_code和响应体摘要(如前 200 字符) - 失败时状态设为
failed,记录curl_error($ch)和curl_errno($ch)(比如CURLE_COULDNT_CONNECT) - 另起一个简单 CLI 脚本定时扫描
failed记录,按失败次数递增延迟重试(1s → 3s → 10s)
真正的难点不在发出去,而在“发没发成”和“对方到底收没收到”。日志结构比并发逻辑更重要。











