AJAX异步调用PHP长任务致页面阻塞的解决方案
问题根因
你遇到的页面阻塞问题是两个核心原因导致的:
- Session文件锁阻塞:PHP默认以文件形式存储Session,请求调用
session_start()后会独占锁定Session文件,直到当前请求完全执行结束才会释放锁。你的原代码在整个长耗时任务执行期间都持有Session锁,其他页面请求要读写Session必须等锁释放,自然会出现长时间加载。 - 长任务同步占用Web进程:批量补单逻辑需要循环调用第三方cURL接口,本身耗时极长,你直接把这段逻辑放在HTTP请求的同步响应流程里执行,就算解决了Session锁问题,也会长期占用PHP-FPM/Apache的工作进程,并发量上来后会直接占满所有Web进程,导致站点无法访问。
解决方案
核心思路是把长耗时的补单逻辑和Web请求响应流程解耦,Web接口只负责触发任务、立刻返回结果,补单逻辑完全在后台运行,同时提前释放Session锁,彻底避免阻塞其他请求。
第一步:快速临时修复(仅解决Session阻塞,不推荐长期用)
如果你暂时不想调整架构,可以先通过提前释放Session锁解决页面卡住的问题:
注意:这个方案只是让其他页面能正常打开,长任务依然会占用Web进程,订单量较大时依然会拖慢站点。
- 所有需要读写Session的逻辑(比如管理员权限校验)全部放在代码最前面执行,执行完立刻调用
session_write_close()释放Session锁,后续执行数据库查询、cURL请求的长逻辑时,就不会阻塞其他请求的Session访问。 - 不要把任务执行结果存在
$_SESSION里,改成存到数据库、临时文件或者缓存中,避免需要重新打开Session锁。
第二步:后台异步任务方案(彻底解决问题,生产环境推荐)
这个方案下AJAX请求触发任务后会立刻得到响应,补单逻辑完全在后台CLI进程中运行,完全不占Web服务进程,任务执行期间站点访问完全不受影响。
前置准备
首先新建一张后台任务表,用来记录任务状态和执行结果,执行以下SQL:
CREATE TABLE `background_tasks` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `task_id` varchar(32) NOT NULL DEFAULT '', `task_type` varchar(32) NOT NULL DEFAULT '', `status` varchar(16) NOT NULL DEFAULT 'pending', `result` text, `create_time` datetime DEFAULT NULL, `finish_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `task_id` (`task_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
然后新建独立的CLI执行脚本run_refill_task.php,这个脚本只允许命令行运行,专门处理补单逻辑:
<?php // 禁止Web访问 if (php_sapi_name() !== 'cli') { exit('Access denied'); } // 引入你项目的初始化文件(加载数据库连接、SMMApi类等,按实际路径修改) require __DIR__ . '/init.php'; // 接收命令行传入的任务ID、站点ID $task_id = $argv[1]; $web_id = $argv[2]; $error = 0; $success = 0; $smmapi = new SMMApi(); // 标记任务为处理中 $stmt = $conn->prepare("UPDATE background_tasks SET status = 'processing' WHERE task_id = :tid"); $stmt->execute(['tid' => $task_id]); try { // 原补单查询逻辑 $orders = $conn->prepare("SELECT * FROM orders INNER JOIN service_api ON service_api.id=orders.order_api WHERE orders.website_id=:web_id "); $orders->execute(array("web_id"=> $web_id)); $orders = $orders->fetchAll(PDO::FETCH_ASSOC); foreach($orders as $order){ $result = $smmapi->action(array('key' =>$order['api_key'],'action' =>'refill','order' =>$order['api_orderid']),$order['api_url']); $result = json_decode( json_encode($result), true); if(!empty($result['error'])){ $error++; }else{ if(!empty($result['refill'])){ $conn->beginTransaction(); $insert= $conn->prepare("INSERT INTO refill SET refill_order_id=:order_id, refill_api_order_id=:api_order_id, refill_client_id=:client_id, refill_service_id=:service_id, refill_mode=:mode, refill_status=:status, last_updated=:updated, refill_created=:created, website_id=:web_id "); $insert_res = $insert->execute(array("order_id"=>$order["order_id"],"api_order_id"=>$result['refill'],"client_id"=>$order['client_id'],"service_id"=>$order['service_id'],"mode"=>"auto","status"=>"pending","updated"=>date("Y-m-d H:i:s"),"created"=>date("Y-m-d H:i:s"),"web_id"=>$web_id )); if( $insert_res ){ $conn->commit(); $success++; }else{ $conn->rollback(); $error++; } }else{ $error++; } } } if($orders){ $result_text = "<strong>Command executed successfully</strong> <br> <strong>Failed refills:</strong> $error <br> <strong>Successful refills:</strong> $success"; }else{ $result_text = "<strong>Command executed successfully</strong> <br> <strong>No refillable orders found</strong>"; } // 标记任务完成,写入结果 $stmt = $conn->prepare("UPDATE background_tasks SET status = 'done', result = :res, finish_time = NOW() WHERE task_id = :tid"); $stmt->execute(['tid' => $task_id, 'res' => $result_text]); } catch (Exception $e) { // 标记任务失败 $stmt = $conn->prepare("UPDATE background_tasks SET status = 'failed', result = :res, finish_time = NOW() WHERE task_id = :tid"); $stmt->execute(['tid' => $task_id, 'res' => 'Task error: ' . $e->getMessage()]); }
修改原Web接口
把AJAX请求的补单接口里的长耗时逻辑全部删掉,改成触发后台任务后立刻返回:
// 权限校验等需要Session的逻辑全部放在最前 session_start(); // 这里放你原有的管理员权限判断逻辑 if (!is_admin()) { exit(json_encode(['code' => 403, 'msg' => 'No permission'])); } session_write_close(); // 提前释放Session锁,不阻塞其他请求 // 生成唯一任务ID $task_id = uniqid('refill_', true); // 写入待执行任务记录 $stmt = $conn->prepare("INSERT INTO background_tasks (task_id, task_type, status, create_time) VALUES (:tid, 'refill', 'pending', NOW())"); $stmt->execute(['tid' => $task_id]); // 拼接后台执行命令,按服务器实际情况修改php路径、脚本路径 // Linux环境PHP路径一般为/usr/bin/php,Windows环境替换为实际php.exe路径 $php_path = '/usr/bin/php'; $script_path = __DIR__ . '/run_refill_task.php'; $cmd = sprintf("%s %s %s %s > /dev/null 2>&1 &", $php_path, $script_path, $task_id, WEBSITE_ID); // 后台启动CLI脚本,不等待脚本执行完成 exec($cmd); // 立刻返回任务ID给前端,接口响应时间仅几毫秒 echo json_encode([ 'code' => 0, 'msg' => 'Task started', 'task_id' => $task_id ]); exit;
新增任务状态查询接口
新建一个轻量接口供前端查询任务执行状态,比如路径为admin/orders/get-task-status:
session_start(); if (!is_admin()) { exit(json_encode(['code' => 403])); } session_write_close(); $task_id = $_GET['task_id']; $stmt = $conn->prepare("SELECT status, result FROM background_tasks WHERE task_id = :tid"); $stmt->execute(['tid' => $task_id]); $task = $stmt->fetch(PDO::FETCH_ASSOC); echo json_encode([ 'code' => 0, 'data' => $task ]); exit;
修改前端AJAX逻辑
把原来等待任务执行完成的逻辑改成触发任务后轮询状态:
$(".refill_all").click(function(){ var site_url = $('head base').attr('href'); $.LoadingOverlay("show"); $.ajax({ url: site_url+"admin/orders/multi-action?bulkStatus=orders_refill", method: "GET", dataType: 'json', success: function(res){ if(res.code != 0){ alert(res.msg); $.LoadingOverlay("hide"); return; } var task_id = res.task_id; // 每2秒轮询一次任务状态 var pollTimer = setInterval(function(){ $.get(site_url+"admin/orders/get-task-status", {task_id: task_id}, function(res){ if(res.data.status == 'done' || res.data.status == 'failed'){ clearInterval(pollTimer); $.LoadingOverlay("hide"); $("#bulk_refill_ajax").hide(); $("#bulk_refill_ajax_result").show(); $("#bulk_refill_ajax_result").html(res.data.result); } }, 'json'); }, 2000); } }); });
额外优化建议
- 给cURL请求加上超时限制,避免第三方接口无响应拖慢整个任务,在SMMApi类的curl配置中新增两行:
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); // 连接超时10秒 curl_setopt($ch, CURLOPT_TIMEOUT, 30); // 整个请求最长等待30秒 - 如果单次补单的订单量超过1000条,可以在循环中每处理20个订单加100毫秒的休眠,避免瞬间打挂第三方接口,也降低数据库压力。
- 如果服务器禁用了
exec函数无法启动CLI进程,可以退而求其次使用fastcgi_finish_request()方案:在接口中把响应返回给前端后,再继续执行补单逻辑,注意必须提前调用session_write_close()释放Session锁,这个方案的缺点是长任务依然会占用PHP-FPM进程,仅适合订单量不大的场景。
内容的提问来源于stack exchange,提问作者Ashvin Bhagat
相关产品推荐
相关产品推荐

