PHP实现下载连接数限制 解决IDM多线程计数失效问题
问题场景
正在开发支持分级权限管控的文件传输脚本,需要为premium付费用户、免费用户分别设置下载速度、并发连接数等差异化限制规则。当前基于网上找到的支持HTTP_RANGE断点续传的PHP代码做二次开发,代码如下:
date_default_timezone_set('GMT'); //1- file we want to serve : $data_file = "movie.mp4"; $data_size = filesize($data_file); //Size is not zero base $mime = 'application/otect-stream'; //Mime type of file. to begin download its better to use this. $filename = basename($data_file); //Name of file, no path included //2- Check for request, is the client support this method? if (isset($_SERVER['HTTP_RANGE']) || isset($HTTP_SERVER_VARS['HTTP_RANGE'])) { $ranges_str = (isset($_SERVER['HTTP_RANGE']))? $_SERVER['HTTP_RANGE']:$HTTP_SERVER_VARS['HTTP_RANGE']; $ranges_arr = explode('-', substr($ranges_str, strlen('bytes='))); //Now its time to check the ranges if ((intval($ranges_arr[0]) >= intval($ranges_arr[1]) && $ranges_arr[1] != "" && $ranges_arr[0] != "" ) || ($ranges_arr[1] == "" && $ranges_arr[0] == "") ) { //Just serve the file normally request is not valid :( $ranges_arr[0] = 0; $ranges_arr[1] = $data_size - 1; } } else { //The client dose not request HTTP_RANGE so just use the entire file $ranges_arr[0] = 0; $ranges_arr[1] = $data_size - 1; } //Now its time to serve file $file = fopen($data_file, 'rb'); $start = $stop = 0; if ($ranges_arr[0] === "") { //No first range in array //Last n1 byte $stop = $data_size - 1; $start = $data_size - intval($ranges_arr[1]); } elseif ($ranges_arr[1] === "") { //No last //first n0 byte $start = intval($ranges_arr[0]); $stop = $data_size - 1; } else { // n0 to n1 $stop = intval($ranges_arr[1]); $start = intval($ranges_arr[0]); } //Make sure the range is correct by checking the file fseek($file, $start, SEEK_SET); $start = ftell($file); fseek($file, $stop, SEEK_SET); $stop = ftell($file); $data_len = $stop - $start; //Lets send headers if (isset($_SERVER['HTTP_RANGE']) || isset($HTTP_SERVER_VARS['HTTP_RANGE'])) { header('HTTP/1.0 206 Partial Content'); header('Status: 206 Partial Content'); } header('Accept-Ranges: bytes'); header('Content-type: ' . $mime); header('Content-Disposition: attachment; filename="' . $filename . '"'); header("Content-Range: bytes $start-$stop/" . $data_size ); header("Content-Length: " . ($data_len + 1)); //Finally serve data and done ~! fseek($file, $start, SEEK_SET); $bufsize = 50; ignore_user_abort(true); @set_time_limit(0); while (!(connection_aborted() || connection_status() == 1) && $data_len > 0) { echo fread($file, $bufsize); $data_len -= $bufsize; usleep(1000); flush(); } fclose($file);
开发中遇到IDM多线程下载时并发连接数限制不生效的问题:之前尝试通过fopen读写文本文件统计脚本执行次数、类属性计数的方案,连接数达到阈值时调用exit终止,但均未生效。排查确认IDM每次发起新的分片连接请求时,整个PHP脚本都会从头开始执行,现有计数逻辑仅在首次启动下载时生效,用户点击暂停后再恢复下载时,计数逻辑会异常失效。原有错误计数代码如下:
$file=fopen('text.txt','wr'); //..... code if ($count < 6){ exit(); }
问题根因
之前的方案不生效是原理层面的缺陷,不是简单的代码拼写错误:
- PHP是无状态执行模型,每一次HTTP分片请求都会启动独立的PHP进程/线程处理,请求结束后所有内存变量、类属性都会被回收,跨请求根本不共享,用类属性计数从设计上就不可能工作。
- 文本文件计数逻辑存在三个致命问题:
- 没有加文件排他锁,多个并发请求同时读写文件时会出现竞态条件,计数要么被覆盖要么错乱,结果完全不准
- 没有绑定连接生命周期:分片传输完成、用户暂停下载断开连接时,不会主动递减计数,计数只增不减,暂停恢复后自然会触发错误拦截
- 没有做用户维度隔离,所有用户的计数存在同一个文件里,根本无法实现付费/免费用户的差异化规则
- 原代码开启了
ignore_user_abort(true),即使用户端主动断开连接,PHP脚本还会在后端空跑,计数不会及时释放。
可行解决方案
优先选用Redis作为共享计数存储,支持原子操作、自动过期,性能足以支撑下载场景的高并发请求;无Redis环境时可使用带排他锁的文件方案做应急,但是高并发下性能较差。
1. 配置用户分级规则与唯一标识
在脚本最开头增加用户识别与分级规则配置,优先用登录用户ID作为唯一标识,未登录场景用IP兜底:
session_start(); // 按自身用户系统逻辑调整用户ID、等级获取方式 $user_id = $_SESSION['user_id'] ?? $_SERVER['REMOTE_ADDR']; $user_level = $_SESSION['user_level'] ?? 'free'; // 等级分为free/premium两类 // 差异化限制规则 $rules = [ 'free' => [ 'max_conn' => 2, // 免费用户最大并发2连接 'speed_limit' => 1024*200, // 单连接限速200KB/s 'bufsize' => 20480 // 单次读取块大小20KB ], 'premium' => [ 'max_conn' => 8, // 付费用户最大并发8连接 'speed_limit' => 1024*1024*5, // 单连接限速5MB/s 'bufsize' => 1024*100 // 单次读取块大小100KB ] ]; $current_rule = $rules[$user_level]; $conn_key = "download_conn:{$user_id}";
2. 原子性并发校验与连接生命周期管理
通过Redis原子操作实现精准计数,同时绑定脚本退出事件自动释放计数,避免计数泄漏:
// 连接Redis,按自身部署调整连接参数 $redis = new Redis(); $redis->connect('127.0.0.1', 6379); // 原子递增当前用户连接数,键不存在时自动初始化为0再+1 $current_conn = $redis->incr($conn_key); // 给计数键设置10秒过期,异常断连未触发递减时,10秒后自动释放计数 $redis->expire($conn_key, 10); // 超出并发阈值直接返回429错误 if ($current_conn > $current_rule['max_conn']) { header('HTTP/1.1 429 Too Many Requests'); exit('当前账号并发连接数超出限制,请升级会员或关闭多余下载任务'); } // 注册关闭函数:脚本正常结束、用户断连、报错退出时都会自动执行,递减计数 register_shutdown_function(function() use ($redis, $conn_key) { $redis->decr($conn_key); if ($redis->get($conn_key) <= 0) { $redis->del($conn_key); } });
注意:将原代码中的
ignore_user_abort(true)改为ignore_user_abort(false),用户断开连接时PHP会立刻终止执行,触发关闭函数释放计数,避免脚本空跑占资源。
3. 修复原代码Bug并加入限速逻辑
原代码存在MIME类型拼写错误、Range边界校验缺失、限速逻辑不可控的问题,替换原有文件传输段代码如下:
// 补充Range边界校验,非法Range请求直接返回416 if ($start > $data_size - 1 || $stop > $data_size -1 || $start < 0) { header('HTTP/1.1 416 Range Not Satisfiable'); header("Content-Range: bytes */$data_size"); exit; } // 修正MIME类型拼写错误 $mime = 'application/octet-stream'; // 省略原有头部发送逻辑...... // 替换原有的文件传输循环 $bufsize = $current_rule['bufsize']; $speed_limit = $current_rule['speed_limit']; fseek($file, $start, SEEK_SET); ignore_user_abort(false); @set_time_limit(0); $start_time = microtime(true); $total_sent = 0; while (!(connection_aborted() || connection_status() == 1) && $data_len > 0) { $read_size = min($bufsize, $data_len); echo fread($file, $read_size); $total_sent += $read_size; $data_len -= $read_size; flush(); // 动态计算休眠时间实现精准限速 $elapsed = microtime(true) - $start_time; $should_use = $total_sent / $speed_limit; $sleep = $should_use - $elapsed; if ($sleep > 0) { usleep($sleep * 1000000); } // 每1秒刷新一次计数键过期时间,活跃连接的计数不会被提前释放 if ($elapsed > 1) { $redis->expire($conn_key, 10); $start_time = microtime(true); $total_sent = 0; } } fclose($file);
无Redis应急方案
无Redis环境时可使用带排他锁的文件实现计数,注意必须加锁避免并发写入错乱:
$count_file = sys_get_temp_dir() . "/download_conn_{$user_id}.cnt"; $fp = fopen($count_file, 'c+'); // 加排他锁,阻塞等待锁释放避免并发写入冲突 flock($fp, LOCK_EX); $current_conn = (int)fread($fp, 1024) + 1; // 文件超过10秒未更新则重置计数,处理异常断连场景 if (time() - filemtime($count_file) > 10) { $current_conn = 1; } // 超出并发限制直接退出 if ($current_conn > $current_rule['max_conn']) { flock($fp, LOCK_UN); fclose($fp); header('HTTP/1.1 429 Too Many Requests'); exit('并发连接数超出限制'); } // 写入新计数,更新文件修改时间 ftruncate($fp, 0); rewind($fp); fwrite($fp, $current_conn); fflush($fp); touch($count_file); flock($fp, LOCK_UN); // 注册关闭函数递减计数 register_shutdown_function(function() use ($fp, $count_file) { flock($fp, LOCK_EX); $count = (int)fread($fp, 1024) - 1; ftruncate($fp, 0); rewind($fp); if ($count <= 0) { fclose($fp); unlink($count_file); } else { fwrite($fp, $count); fflush($fp); flock($fp, LOCK_UN); fclose($fp); } });
注意事项
- 不要用Session做计数存储,Session默认带文件锁,同一个用户的多个并发请求会被锁串行化,多线程下载会直接退化为单线程
- 计数键过期时间设置为10-15秒即可,正常IDM分片请求间隔不会超过5秒,既不会误杀正常连接,也能在断连后快速释放计数
- 如果前端套了CDN或Nginx反向代理,需要开启Range请求转发配置,避免代理层缓存分片请求导致计数不准
内容的提问来源于stack exchange,提问作者Overtag

