You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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();
}

问题根因

之前的方案不生效是原理层面的缺陷,不是简单的代码拼写错误:

  1. PHP是无状态执行模型,每一次HTTP分片请求都会启动独立的PHP进程/线程处理,请求结束后所有内存变量、类属性都会被回收,跨请求根本不共享,用类属性计数从设计上就不可能工作。
  2. 文本文件计数逻辑存在三个致命问题:
    • 没有加文件排他锁,多个并发请求同时读写文件时会出现竞态条件,计数要么被覆盖要么错乱,结果完全不准
    • 没有绑定连接生命周期:分片传输完成、用户暂停下载断开连接时,不会主动递减计数,计数只增不减,暂停恢复后自然会触发错误拦截
    • 没有做用户维度隔离,所有用户的计数存在同一个文件里,根本无法实现付费/免费用户的差异化规则
  3. 原代码开启了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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 21:27:25