PHP如何实现类似网盘的大文件在线存储与分享系统

各位老铁,各位极客,大家下午好!

欢迎来到今天的“PHP大文件在线存储与分享系统”深度技术讲座。我是你们今天的讲师,一个在代码世界里摸爬滚打多年,头发比发际线退得还慢的资深PHP工程师。

今天我们要聊的话题,听起来挺高大上:“大文件存储与分享”。但这其实是个“坑”,是个“大坑”。如果你以前只是处理过那些几KB、几MB的图片或者txt文件,那你对“大文件”这个概念可能还停留在“青涩”的年纪。

在PHP的世界里,处理大文件就像是要用一根吸管喝干整个游泳池的水。默认的配置?别逗了,那个upload_max_filesizepost_max_size简直就是两个爱摆脸色的前任,稍微不注意就给你报错。

所以,为了不让你们的代码崩溃,为了不让服务器内存溢出,为了不让老板指着你的鼻子骂“你是怎么写代码的?”,我们必须祭出我们今天的核武器——分片上传

好,废话不多说,让我们直接切入正题。

第一部分:PHP的大文件恐惧症与解决方案

首先,我们要明白为什么PHP处理大文件这么费劲。

PHP(尤其是模组化的PHP-FPM环境)是同步阻塞的。当你在上传文件时,整个PHP进程(也就是那个负责干活的家伙)会被这个文件占用。如果文件有1GB,PHP默认的memory_limit是128M,脚本执行时间是30秒。这就像是你让你家厨房的厨子(PHP进程)一口气把一袋50公斤的大米(文件)吃完,还得在30秒内完成。结果是什么?老板骂,厨子晕,客户等。

解决方案:分而治之。

这就好比你不想一口吃成胖子,你把大蛋糕切成一个个小蛋糕,先吃第一块,吃完再吃第二块。对于文件来说,我们把文件切成一个个小块,分批次上传到服务器,最后在服务器端把这些小碎片重新拼起来。

这不仅仅是上传的问题,下载也是一样。如果直接下载一个1GB的文件,用户浏览器如果卡了一下,或者网断了,那就完了。下载也要分片,要有断点续传。

第二部分:分片上传的艺术——前端篇

在开始写PHP之前,我们得先看看前端怎么切。虽然PHP是后端,但现在的Web开发是全栈的,不懂点JavaScript,你怎么跟前端的小姑娘/小伙子谈理想?

前端分片的核心思想很简单:利用Blob.slice()方法。

假设我们要上传一个电影文件。我们在JS里把它切成一个个4MB的小块。为什么要4MB?这是一个经验值,太大了,容易出错(比如网络波动一下就断了,重新切太慢);太小了,HTTP请求头太啰嗦,服务器开销大。

让我们写一段极其“优雅”的JavaScript代码:

/**
 * 文件分片上传控制器
 * @param {File} file - 要上传的文件
 * @param {number} chunkSize - 每个分片的大小(字节)
 */
function startChunkedUpload(file, chunkSize = 4 * 1024 * 1024) {
    const totalChunks = Math.ceil(file.size / chunkSize);
    let currentChunk = 0;

    // 首先获取文件的MD5,用来做唯一标识,防止重复上传
    const fileHash = calculateFileHash(file); // 这里需要一个计算MD5的库,比如spark-md5

    console.log(`文件总大小: ${file.size}, 分片数量: ${totalChunks}`);

    function uploadNextChunk() {
        const start = currentChunk * chunkSize;
        const end = Math.min(file.size, start + chunkSize);

        // 这就是切片的核心魔法
        const blob = file.slice(start, end);

        const formData = new FormData();
        formData.append('file', blob); // 每一片的文件对象
        formData.append('chunkIndex', currentChunk); // 当前是第几片
        formData.append('fileHash', fileHash); // 整个文件的指纹
        formData.append('totalChunks', totalChunks); // 总片数

        // 发送请求
        fetch('/api/upload_chunk.php', {
            method: 'POST',
            body: formData
        })
        .then(response => response.json())
        .then(data => {
            if (data.success) {
                console.log(`第 ${currentChunk} 片上传成功`);
                currentChunk++;
                if (currentChunk < totalChunks) {
                    // 递归调用,继续上传下一片
                    uploadNextChunk();
                } else {
                    console.log('所有分片上传完成,正在请求合并...');
                    mergeChunks(fileHash, totalChunks); // 通知服务器合并
                }
            } else {
                console.error('上传失败:', data.message);
            }
        })
        .catch(err => {
            console.error('网络错误:', err);
            // 这里可以加入断点续传的逻辑:记录 currentChunk,下次从 currentChunk 开始
        });
    }

    // 启动第一次上传
    uploadNextChunk();
}

看懂了吗?这段代码非常“干净”。它没有阻塞页面,用了Promisefetch。每一片上传完,我们告诉服务器:“这是第X片,这是整个文件的指纹,然后我去睡一觉(递归),等上传完再说。”

第三部分:后端接收——不仅是接收,还要防守

好了,前端切完了,我们把锅扔给PHP。PHP怎么接收这些碎片?别忘了,我们得把这些碎片存下来,最后拼起来。

首先,我们需要一个存放碎片的临时目录,比如/tmp/uploads。千万不要存放在网站根目录下,安全是大忌。

1. 数据库设计

为了管理这些碎片,我们需要一个数据库表。别偷懒,别用JSON存,用MySQL或者SQLite。我们需要记录什么?

  • file_hash: 文件的唯一标识(MD5值)。
  • file_name: 原始文件名。
  • file_size: 文件总大小。
  • total_chunks: 总片数。
  • status: 状态(等待上传中、上传完成、合并失败)。
CREATE TABLE `upload_records` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `file_hash` varchar(32) NOT NULL COMMENT '文件MD5',
  `file_name` varchar(255) DEFAULT NULL,
  `file_size` bigint(20) DEFAULT 0,
  `total_chunks` int(11) DEFAULT 0,
  `status` tinyint(1) DEFAULT 0 COMMENT '0:等待, 1:上传中, 2:完成, 3:失败',
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `unique_file_hash` (`file_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2. PHP接收分片代码

当用户发送第一片时,PHP要做三件事:

  1. 检查这个文件哈希是否已经在数据库里存在。如果存在,说明用户之前传过,别传了,直接用现有的文件。
  2. 如果是新文件,创建数据库记录,状态设为“上传中”。
  3. 把这一片文件存到磁盘上的一个临时文件里。这个临时文件名要包含fileHashchunkIndex,比如 abc123_chunk_0.tmp
<?php
// api/upload_chunk.php

header('Content-Type: application/json');
require_once 'db.php';

// 获取前端传来的参数
$chunkIndex = isset($_POST['chunkIndex']) ? intval($_POST['chunkIndex']) : 0;
$fileHash = isset($_POST['fileHash']) ? $_POST['fileHash'] : '';
$totalChunks = isset($_POST['totalChunks']) ? intval($_POST['totalChunks']) : 0;
// 注意:实际项目中,这里还要校验 file 的 type 和 ext,防止上传恶意文件

// 配置
$uploadDir = __DIR__ . '/temp_chunks/' . $fileHash; // 按文件哈希分目录,防止文件太多爆盘
$chunkFile = $uploadDir . '/chunk_' . $chunkIndex . '.tmp';

// 创建目录,如果不存在的话
if (!file_exists($uploadDir)) {
    mkdir($uploadDir, 0755, true);
}

// 保存文件
if (move_uploaded_file($_FILES['file']['tmp_name'], $chunkFile)) {

    // 更新数据库,标记该分片已上传
    // 这里简化了,实际应该用 prepared statement 防止注入
    $db->query("UPDATE upload_records SET status = 1 WHERE file_hash = '$fileHash'");

    echo json_encode(['success' => true, 'message' => 'Chunk saved']);
} else {
    http_response_code(500);
    echo json_encode(['success' => false, 'message' => 'Failed to save chunk']);
}

第四部分:合并的艺术——重新拼凑

当所有分片(chunk_0, chunk_1, … chunk_n)都躺在那个目录里时,我们需要一个PHP脚本把它们串起来。

注意,这里有个“坑”。你不能像切香肠一样,把每个tmp文件的内容读出来,放到内存里,然后再写进去。因为PHP的memory_limit会瞬间爆炸。

我们必须使用流式处理。这就好比写文章,我们不要把整本书都背在脑子里写,我们打开书,抄一行,存到硬盘,再抄下一行。

<?php
// api/merge.php

header('Content-Type: application/json');
require_once 'db.php';

$fileHash = $_GET['hash'] ?? '';
$totalChunks = $_GET['total'] ?? 0;

$uploadDir = __DIR__ . '/temp_chunks/' . $fileHash;
$finalFilePath = __DIR__ . '/uploads/' . $fileHash; // 最终保存的位置,使用哈希做文件名,安全!

// 检查文件是否存在
if (file_exists($finalFilePath)) {
    // 如果文件已经存在,说明合并过了,直接返回成功
    // 或者你可以选择删除临时文件,重新合并
    echo json_encode(['success' => true, 'file_path' => $finalFilePath]);
    exit;
}

// 如果文件不存在,开始合并
// 使用 FILE_APPEND 标志,并且用 LOCK_EX 锁文件,防止并发写入冲突
$outHandle = fopen($finalFilePath, 'w');

$success = true;

for ($i = 0; $i < $totalChunks; $i++) {
    $chunkFile = $uploadDir . '/chunk_' . $i . '.tmp';

    if (!file_exists($chunkFile)) {
        $success = false;
        break;
    }

    $inHandle = fopen($chunkFile, 'r');

    if ($inHandle) {
        stream_copy_to_stream($inHandle, $outHandle);
        fclose($inHandle);
        // 合并后删除临时分片,释放磁盘空间(可选,视情况而定)
        // unlink($chunkFile); 
    } else {
        $success = false;
        break;
    }
}

fclose($outHandle);

if ($success) {
    // 更新数据库状态
    $db->query("UPDATE upload_records SET status = 2 WHERE file_hash = '$fileHash'");
    echo json_encode(['success' => true, 'file_path' => $finalFilePath]);
} else {
    // 合并失败,清理可能创建的半成品
    if (file_exists($finalFilePath)) unlink($finalFilePath);
    echo json_encode(['success' => false, 'message' => 'Merge failed']);
}

第五部分:下载与断点续传——别让用户干等

文件上传完了,我们得能下载。而且,下载必须支持断点续传。这是用户体验的关键。

当用户点击下载时,浏览器会发送一个HTTP请求,头信息里可能包含 Range: bytes=5000000-。这意思是:“嘿,服务器,我从第5MB的位置开始下载,你把剩下的发给我。”

我们的PHP下载脚本必须识别这个头。

<?php
// api/download.php

$hash = $_GET['hash'] ?? '';
$filePath = __DIR__ . '/uploads/' . $hash;

if (!file_exists($filePath)) {
    http_response_code(404);
    echo "文件不存在";
    exit;
}

$fileSize = filesize($filePath);

// 获取请求的 Range
$range = null;
$httpCode = 200;

if (isset($_SERVER['HTTP_RANGE'])) {
    // 处理 Range 头,例如 "bytes=1024-2048" 或 "bytes=1024-"
    $range = str_replace('bytes=', '', $_SERVER['HTTP_RANGE']);
    $range = explode('-', $range);

    $start = intval($range[0]);
    $end = isset($range[1]) ? intval($range[1]) : $fileSize - 1;

    // 检查 range 有效性
    if ($start >= $fileSize || $end >= $fileSize) {
        http_response_code(416); // Range Not Satisfiable
        exit;
    }

    $httpCode = 206; // Partial Content
}

// 设置头信息
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream'); // 或者根据文件类型设为 image/jpeg 等
header('Content-Disposition: attachment; filename="' . basename($filePath) . '"');
header('Expires: 0');
header('Cache-Control: must-revalidate');
header('Pragma: public');

// 关键:发送文件大小
header('Content-Length: ' . ($fileSize - $start));

// 关键:如果是断点续传,发送 Range 头
if ($range !== null) {
    header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize);
}

// 打开文件,并定位到 start 位置
$fp = fopen($filePath, 'rb');
fseek($fp, $start);

// 流式输出,不要用 readfile 全部读进内存!
while (!feof($fp)) {
    // 一次性读 8KB,减少 I/O 次数
    echo fread($fp, 8192);
    ob_flush();
    flush();
}

fclose($fp);

这段代码展示了对HTTP协议的深刻理解。特别是stream_copy_to_streamfread配合使用,保证了即使是几GB的文件,PHP也不会把内存撑爆。

第六部分:分享系统的实现——权限与链接

现在,我们有了文件,也有了下载接口。接下来是“分享”环节。

怎么分享?直接给链接?不行,那样谁都能下你的机密文件。
我们需要生成一个“签名链接”。

策略如下:

  1. 用户想要分享文件。
  2. 系统检查用户是否有权限(登录验证)。
  3. 生成一个随机字符串作为Token,或者使用时间戳+User_ID生成。
  4. 把这个Token和文件Hash存入数据库(或者Redis,Redis在这里是神器,查询速度快)。
  5. 把链接返回给用户:http://yourdomain.com/api/download.php?hash=xxx&token=yyy
<?php
// api/share.php

// 假设有一个验证登录的函数
if (!isLoggedIn()) {
    echo json_encode(['success' => false, 'message' => '请先登录']);
    exit;
}

$hash = $_POST['hash'] ?? '';
$expireMinutes = 60; // 链接有效时间

if (!file_exists(__DIR__ . '/uploads/' . $hash)) {
    echo json_encode(['success' => false, 'message' => '文件不存在']);
    exit;
}

// 生成随机 Token
$token = bin2hex(random_bytes(16));

// 保存到 Redis (假设已连接)
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->setex("share_token:$token", $expireMinutes * 60, $hash);

// 生成下载链接
$shareUrl = "http://your-domain.com/api/download.php?hash=" . urlencode($hash) . "&token=" . $token;

// 或者生成一个短链接(使用 Bitly API 或自建短链服务,这里简化处理)
$shortUrl = "http://your-domain.com/s/" . $token;

echo json_encode(['success' => true, 'share_url' => $shareUrl, 'short_url' => $shortUrl]);

第七部分:下载接口的鉴权逻辑

有了Token,我们在下载接口里就要验证它。

// api/download.php (修正版,增加 Token 验证)

$hash = $_GET['hash'] ?? '';
$token = $_GET['token'] ?? '';

if (empty($token)) {
    // 没Token,只允许预览或者直接下载(如果不需要鉴权)
    // header("HTTP/1.1 403 Forbidden");
    // exit;
}

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

// 从 Redis 查询这个 Token 是否有效
$validHash = $redis->get("share_token:$token");

if ($validHash === false || $validHash !== $hash) {
    http_response_code(403);
    echo "链接无效或已过期";
    exit;
}

// Token 验证通过,继续执行上面的下载逻辑...

第八部分:高级话题与性能优化

讲了这么多,是不是感觉大文件存储搞定了一半?别高兴得太早,这只是一个基础的框架。作为资深专家,我得给你们泼点冷水。

1. PHP的僵尸进程
当你在上传一个大文件时,PHP脚本执行时间很长。默认的max_execution_time是30秒。上传1GB的文件,可能需要几分钟。PHP脚本早就超时被杀掉了(进程被杀掉,但文件可能只上传了一半)。

  • 解决方案
    • max_execution_time设为0(不限制时间)。
    • 使用ignore_user_abort(true);,告诉PHP:“不管用户浏览器怎么关,你都要把活干完。”
    • 终极解决方案:不要用PHP来跑上传。用Nginx的client_max_body_size直接处理上传,上传完成后,通知PHP只负责处理数据。或者,用Rust/Go写一个上传服务,PHP只负责API交互。但既然你问的是PHP,我们就聊聊PHP怎么救场。

2. 数据库锁
在合并文件的时候,如果有两个用户同时上传同一个文件的最后一片,可能会发生文件损坏。上面的代码用了LOCK_EX,这是必要的。

3. 磁盘I/O瓶颈
如果你的文件特别多,千万不要把所有文件都放在根目录下,也不要把所有文件都放在uploads文件夹下。这会导致文件系统查找文件变慢。
最佳实践:使用“哈希目录”或“按日期/用户ID”分目录。
比如:uploads/a/b/c/abc123.jpg。每一层目录只放几千个文件,这样读取速度最快。

4. MD5 vs SHA1
别再用SHA1了,那是90年代的产物。用MD5(如果不在乎碰撞)或者SHA256。不过,对于大文件,计算MD5是很慢的。前端计算好,传给后端校验即可。后端也可以用md5_file()函数在合并时校验一下,确保万无一失。

第九部分:完整的工作流回顾

让我们把整个流程像过电影一样过一遍,确保逻辑闭环:

  1. 用户选择文件:前端JS计算文件MD5。
  2. 切片:前端将文件切成4MB的小块。
  3. 第一片上传
    • 前端请求 POST /upload_chunk,带上 chunkIndex=0, fileHash=xyz, file=blob
    • 后端PHP创建目录,接收文件,保存为 temp_chunks/xyz/chunk_0.tmp
    • 前端继续上传 chunk_1
  4. 上传完成
    • 前端上传完所有分片,请求 POST /merge?hash=xyz&total=100
    • 后端PHP检测所有 chunk_X.tmp 都存在。
    • PHP开启流式写入,将所有碎片拼成一个新文件 uploads/xyz
    • PHP更新数据库 upload_records 状态为“完成”。
  5. 生成分享
    • 用户点击分享,后端生成Token,存入Redis。
    • 返回链接 .../download.php?hash=xyz&token=abc
  6. 下载
    • 用户访问链接。
    • 后端验证Token(Redis查询)。
    • 后端处理HTTP Range头。
    • PHP流式输出文件。

第十部分:安全性的最后一点唠叨

好了,代码写完了,逻辑跑通了。最后我要提醒大家关于安全性。

  • 文件类型检查:PHP的$_FILES['file']['type']不可靠的。它只是看扩展名。坏人可以把木马改成 movie.jpg。后端一定要根据文件的魔数或者扩展名做严格的白名单校验。如果你存的是视频,只允许.mp4, .mov
  • 路径穿越:永远不要相信用户输入的文件名。我在上面的代码里用的是hash做文件名,这是一个极好的安全习惯。如果你用用户名做目录,用户可能会输入 ../../etc/passwd 来攻击你。
  • 上传限速:如果你的服务器带宽有限,一定要限制上传速度。否则,用户发一个1GB的文件,把你的服务器带宽吃满,其他用户连网页都打不开。

结语(虽然我说过不要总结,但作为讲座,必须收尾)

好了,今天的讲座就到这里。

我带大家构建了一个基于PHP的大文件存储与分享系统。我们避开了PHP默认的内存限制,使用了流式处理和分片上传技术;我们解决了断点续传的问题,让用户体验丝般顺滑;我们通过Redis和哈希算法保证了安全和高效。

这就是PHP的魅力,它虽然有时笨重,虽然被黑哥们嘲笑,但只要用对了姿势,它依然能搞定这些看似不可能的任务。

记住,代码不仅仅是写出来的,更是“磨”出来的。去写,去测试,去处理那些令人头秃的边界情况。如果你在实现过程中遇到了文件合并失败,或者下载乱码,别慌,检查一下fseek的位置,检查一下Content-Type头。

祝大家的网盘项目上线成功,老板给你发大红包!

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注