主线程阻塞源于开发者将耗时操作放入http请求生命周期,应识别耗时点、切出主流程、用队列兜底;外部api调用、报表生成、大文件处理、批量db写入等必须移出请求;队列需正确配置retry_after、守护进程及专用队列;库存更新须用原子sql;事务中禁发http请求;select for update须走主键索引;队列任务中超时需手动控制。

主线程阻塞不是 Laravel 框架的问题,而是开发者把不该放进去的逻辑塞进了 HTTP 请求生命周期里。真正要做的,是识别耗时点、切出主流程、用队列兜底。
哪些操作必须从请求中移出
以下行为在高并发下极易卡住整个 PHP 进程,且无法靠加机器缓解:
- 调用外部 API(
Http::get()、GuzzleHttp\Client)——哪怕只等 200ms,100 个并发就是 20 秒线程挂起 - 生成 PDF/Excel 报表(
Dompdf、PhpSpreadsheet)——内存暴涨 + CPU 占满,直接触发 PHP-FPMmax_execution_time终止 - 处理大文件上传后的压缩、转码、OCR(
Imagick、ffmpeg)——阻塞式系统调用,PHP 无法中断 - 循环写数据库(如批量导入 1000 条记录用
foreach+save())——每条都走 Eloquent 生命周期,锁表+事件+日志全开
队列不是“加个ShouldQueue就完事”
常见错误是只改了接口实现,没动调度和消费逻辑,结果任务全堆在 Redis 里不动,或 worker 频繁重启丢任务。
- 用
redis驱动时,retry_after必须大于任务$timeout(例如$timeout = 90,则retry_after = 120),否则任务还没执行完就被标记为失败重试 - 不要用
php artisan queue:work跑在前台——它挂了任务就停;生产环境必须用supervisor或systemd守护,确保常驻 - database 驱动下,如果
queue:work进程被 kill -9,jobs表里的reserved_at不会自动清空,任务永远“正在处理中”——得靠queue:restart或定时清理脚本 - 报表类任务别混进默认队列,单独建
low_priority队列,并用--sleep=30降低消费频率,避免抢走关键任务资源
DB 并发更新别碰 Model->save()
库存扣减、余额变更这类场景,Product::find($id)->decrement('stock') 看似简洁,实则是竞态放大器。
- 它本质是两步:
SELECT * FROM products WHERE id = ?+UPDATE products SET stock = ? WHERE id = ?,中间有毫秒级窗口 - Eloquent 会加载整行、触发访问器、填充
updated_at、广播事件——锁持有时间翻倍 - 正确做法是单条原子 SQL:
DB::update('UPDATE products SET stock = stock - 1, updated_at = NOW() WHERE id = ? AND stock >= 1', [$id]) - 返回值判断更可靠:
if (DB::affectingStatement() === 0) { // 库存不足 },而不是查完再 if 判断
事务里千万别发 HTTP 请求
一个 DB::transaction() 包着 Http::post(),等于把数据库锁和网络延迟绑死——超时不是问题,死锁才是。
- 远程通知(如支付成功后调微信模板消息)必须在事务提交后发起,用
after_commit或独立 job 分发 - 若通知失败,靠异步监控任务补发,而不是在事务里重试——主流程不能为下游稳定性买单
- MySQL 默认隔离级别
REPEATABLE READ下,SELECT FOR UPDATE锁的是索引范围,WHERE 条件不用主键或无索引字段,可能锁住整张表 - 显式加锁推荐写法:
DB::select('SELECT * FROM orders WHERE id = ? FOR UPDATE', [$orderId]),比Order::lockForUpdate()->find()更干净
最易被忽略的一点:队列任务里的 sleep() 或 usleep() 不受 PHP 的 max_execution_time 控制,一旦卡住,worker 就假死。所有外部依赖必须自己设超时,比如 Http::timeout(5)、Redis::setex(..., 30)。











