PHP 大文件分片上传实战:秒传、断点续传与并发合并的完整实现

2026-11-03 0 112

事情起因很普通:客户要在后台上传课程视频,单个文件两三百兆,用的是移动网络。原来那个表单直接 POST 整包,php.ini 里 upload_max_filesize 调到 512M 之后勉强能过,但只要网络抖一下,浏览器就重新来过,一天下来客服接到七八个投诉。后来我把上传改成了分片模式,顺带把秒传和断点续传也做了。

整个方案没用框架,也没装 plupload 之类的第三方库,纯原生 PHP 加一点点原生 JS。原因很简单,项目是老的裸 PHP,引入框架的成本比手写这套逻辑还高。下面把完整实现和踩过的坑摆出来,谁有类似需求可以直接拿去改。

整体流程长什么样

一次上传分四步走:

  1. 前端读完文件,算出整个文件的 MD5,连同文件名、大小发给后端问一句“这个文件是不是已经在服务器上了”。
  2. 后端返回三种结果:已存在(秒传,直接结束)、部分分片已存在(返回已上传的索引列表)、什么都没有(从头开始)。
  3. 前端按固定大小切片,逐个或并发地把分片 POST 上去,后端落到以文件 MD5 命名的临时目录里。
  4. 最后调用合并接口,后端按索引顺序把分片拼成一个文件,校验 MD5 之后再挪到正式目录。

关键在于文件身份的标识是 MD5,不是文件名。用户把同一个视频改个名字再传一次,依然能命中秒传,这在课程后台上传场景里能省掉大量带宽。

目录与配置

先约定好存放位置,后面所有代码都基于这个结构:

project/
├── config.php
├── api/
│   ├── check.php
│   ├── upload.php
│   └── merge.php
├── lib/
│   └── helper.php
├── bin/
│   └── clean_chunks.php
└── storage/
    ├── chunks/          # 分片临时目录,按 md5 分子目录
    └── files/           # 正式文件目录
        └── 2025/01/

配置文件内容不多,主要是路径白名单和大小限制:

<?php
// config.php
return [
    'chunk_dir' => __DIR__ . '/storage/chunks',
    'file_dir'  => __DIR__ . '/storage/files',
    'allow_ext' => ['mp4', 'mov', 'zip', 'pdf', 'xlsx'],
    'max_size'  => 4 * 1024 * 1024 * 1024, // 上限 4G
    'chunk_ttl' => 86400,                  // 临时分片保留 24 小时
];

公共函数:先把安全边界收住

这段是整个方案里最容易被忽略、也最容易出事的部分。分片的目录名来自前端传的 MD5,如果直接拼接路径,构造一个 ../../etc 就能往任意位置写文件。所以必须先把 MD5 格式校验掉。

<?php
// lib/helper.php

function json_out(int $code, string $msg, array $data = []): void
{
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['code' => $code, 'msg' => $msg, 'data' => $data], JSON_UNESCAPED_UNICODE);
    exit;
}

function check_md5(string $raw): string
{
    if (!preg_match('/^[a-f0-9]{32}$/', $raw)) {
        json_out(400, '文件标识格式不正确');
    }
    return $raw;
}

function check_index(int $index, int $total): int
{
    if ($index < 0 || $index >= $total) {
        json_out(400, '分片索引越界');
    }
    return $index;
}

function safe_ext(string $name, array $allow): string
{
    $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
    if (!in_array($ext, $allow, true)) {
        json_out(400, '不支持的文件类型');
    }
    return $ext;
}

function chunk_path(string $md5, int $index): string
{
    $dir = rtrim(config('chunk_dir'), '/') . '/' . $md5;
    if (!is_dir($dir)) {
        mkdir($dir, 0755, true);
    }
    return $dir . '/' . $index . '.part';
}

config() 只是一个读全局配置的小函数,实现成静态变量缓存即可,这里不多占篇幅。

接口一:检查文件状态

这个接口承担秒传和断点续传两件事,参数是 md5、size、name、total。

<?php
// api/check.php
require __DIR__ . '/../lib/helper.php';

$md5   = check_md5($_POST['md5'] ?? '');
$size  = (int) ($_POST['size'] ?? 0);
$name  = $_POST['name'] ?? '';
$total = (int) ($_POST['total'] ?? 0);

$cfg = config();
if ($size <= 0 || $size > $cfg['max_size']) {
    json_out(400, '文件大小超出限制');
}
if ($total <= 0) {
    json_out(400, '分片数量不正确');
}
safe_ext($name, $cfg['allow_ext']);

// 1. 秒传判断:正式目录里已经有同名同 MD5 的文件
$exists = glob($cfg['file_dir'] . '/*/*/' . $md5 . '.*');
if (!empty($exists)) {
    json_out(0, '秒传成功', [
        'skip' => true,
        'url'  => '/download?f=' . basename($exists[0]),
    ]);
}

// 2. 断点续传判断:扫一下临时目录里已经上传了哪些分片
$uploaded = [];
$dir = $cfg['chunk_dir'] . '/' . $md5;
if (is_dir($dir)) {
    foreach (scandir($dir) as $f) {
        if (preg_match('/^(d+).part$/', $f, $m)) {
            $uploaded[] = (int) $m[1];
        }
    }
    sort($uploaded);
}

json_out(0, 'ok', [
    'skip'     => false,
    'uploaded' => $uploaded,
]);

秒传这里用 glob 匹配 {md5}.*,是因为正式目录里文件名设计成 md5.ext 的形式。之所以不直接存原始文件名,是为了屏蔽重复上传和特殊字符,另外顺便解决了一个隐私问题——用户上传的原始文件名不要出现在 URL 上。

接口二:接收分片

每片单独一次请求,参数是 md5、index、total,分片数据放在 $_FILES['chunk']。

<?php
// api/upload.php
require __DIR__ . '/../lib/helper.php';

$md5   = check_md5($_POST['md5'] ?? '');
$index = check_index((int) ($_POST['index'] ?? -1), (int) ($_POST['total'] ?? 0));

if (!isset($_FILES['chunk']) || $_FILES['chunk']['error'] !== UPLOAD_ERR_OK) {
    json_out(400, '分片数据缺失');
}

$target = chunk_path($md5, $index);

// LOCK_EX 防止同一分片被并发写坏
$ok = file_put_contents(
    $target,
    file_get_contents($_FILES['chunk']['tmp_name']),
    LOCK_EX
);

if ($ok === false) {
    json_out(500, '分片写入失败');
}

json_out(0, 'ok', ['index' => $index]);

这里几个考虑。file_get_contents 一次性读进内存,因为单片控制在 2M 以内,不会有压力;如果分片更大,应该换成流式 fopen + stream_copy_to_stream。LOCK_EX 是必须加的,前端并发上传时偶尔会因为重试把同一片发两次,没有文件锁的话可能写出半截内容。

另外,chunk_path 里会自动 mkdir,所以不需要在这里再判断目录是否存在。但要注意,如果上传中途用户取消,那个目录会留下来,交给后面的清理脚本处理。

接口三:合并

这是最容易出问题的环节。思路是按索引顺序用追加模式写入,边写边算 MD5,最后和客户端传来的值比对。

<?php
// api/merge.php
require __DIR__ . '/../lib/helper.php';

$md5   = check_md5($_POST['md5'] ?? '');
$name  = $_POST['name'] ?? '';
$total = (int) ($_POST['total'] ?? 0);

$cfg = config();
$ext = safe_ext($name, $cfg['allow_ext']);

$chunkDir = $cfg['chunk_dir'] . '/' . $md5;
$lockFile = $chunkDir . '/.merge.lock';

if (!is_dir($chunkDir)) {
    json_out(400, '分片数据不存在');
}

// 合并锁:防止前端重复点合并,或用户刷新后再次触发
$fp = fopen($lockFile, 'c');
if (!$fp || !flock($fp, LOCK_EX | LOCK_NB)) {
    json_out(409, '文件正在合并中,请稍候');
}

try {
    // 先确认所有分片都在
    for ($i = 0; $i < $total; $i++) {
        if (!is_file($chunkDir . '/' . $i . '.part')) {
            throw new RuntimeException("分片 {$i} 缺失");
        }
    }

    $dir = $cfg['file_dir'] . '/' . date('Y/m');
    if (!is_dir($dir)) {
        mkdir($dir, 0755, true);
    }

    $finalPath = $dir . '/' . $md5 . '.' . $ext;
    $tmpPath   = $finalPath . '.merging';

    $out = fopen($tmpPath, 'wb');
    $ctx = hash_init('md5');

    for ($i = 0; $i < $total; $i++) {
        $src = fopen($chunkDir . '/' . $i . '.part', 'rb');
        if (!$src) {
            throw new RuntimeException("分片 {$i} 读取失败");
        }
        while (!feof($src)) {
            $buf = fread($src, 1024 * 1024);
            if ($buf === false) {
                fclose($src);
                throw new RuntimeException('读取分片出错');
            }
            hash_update($ctx, $buf);
            fwrite($out, $buf);
        }
        fclose($src);
    }

    fclose($out);

    $realMd5 = hash_final($ctx);
    if ($realMd5 !== $md5) {
        unlink($tmpPath);
        throw new RuntimeException('文件校验失败,请重新上传');
    }

    rename($tmpPath, $finalPath);

    // 清理分片目录
    array_map('unlink', glob($chunkDir . '/*.part'));
    @rmdir($chunkDir);

    json_out(0, '上传完成', ['url' => '/download?f=' . basename($finalPath)]);

} catch (Throwable $e) {
    json_out(500, $e->getMessage());
} finally {
    flock($fp, LOCK_UN);
    fclose($fp);
}

几个细节值得说一下。

flock 用了 LOCK_NB,拿不到锁直接返回 409,而不是干等。因为合并 4G 文件可能跑二三十秒,让第二个请求排在那儿没有意义,前端收到 409 之后去轮询一下最终文件是否存在就行。

合并期间写的是 .merging 临时文件,成功后再 rename。这样即使进程中途被杀,正式目录里也不会留下一个半截文件。PHP 的进程在超时被杀时不会走 finally,清理脚本得兜住这种情况。

MD5 校验失败时只删 .merging 文件,不删分片。这是有意的,因为校验失败通常是某一片传输过程中损坏,保留分片能让用户重试时只上传那一片,而不是全部重来。

前端:切片与带重试的并发上传

MD5 计算用现成的 spark-md5,几行就够。重点是并发池和失败重试。

async function uploadFile(file) {
  const CHUNK = 2 * 1024 * 1024;
  const total = Math.ceil(file.size / CHUNK);

  // 1. 计算整文件 md5
  const md5 = await calcFileMd5(file);

  // 2. 询问服务端状态
  const check = await postForm('/api/check.php', {
    md5, size: file.size, name: file.name, total
  });
  if (check.data.skip) return check.data.url;

  const done = new Set(check.data.uploaded);

  // 3. 生成待上传队列
  const tasks = [];
  for (let i = 0; i < total; i++) {
    if (done.has(i)) continue;
    tasks.push(i);
  }

  // 4. 并发上传,单个分片最多重试 3 次
  const LIMIT = 4;
  let cursor = 0;

  async function worker() {
    while (cursor < tasks.length) {
      const index = tasks[cursor++];
      const blob = file.slice(index * CHUNK, (index + 1) * CHUNK);

      for (let attempt = 1; attempt <= 3; attempt++) {
        try {
          const fd = new FormData();
          fd.append('md5', md5);
          fd.append('index', index);
          fd.append('total', total);
          fd.append('chunk', blob);

          const res = await fetch('/api/upload.php', {
            method: 'POST', body: fd
          }).then(r => r.json());

          if (res.code !== 0) throw new Error(res.msg);
          break;
        } catch (e) {
          if (attempt === 3) throw e;
          await new Promise(r => setTimeout(r, 300 * attempt));
        }
      }
    }
  }

  await Promise.all(Array.from({ length: LIMIT }, worker));

  // 5. 触发合并
  const merged = await postForm('/api/merge.php', {
    md5, name: file.name, total
  });
  if (merged.code !== 0) throw new Error(merged.msg);
  return merged.data.url;
}

并发数设成 4 是权衡过的。浏览器对同一域名通常有 6 个连接上限,留两个给页面其他请求,4 个分片并发在大多数网络下就能把带宽吃满,再往上加反而会因为 TCP 拥塞控制导致整体变慢。

calcFileMd5 用的是分块读取,2G 的文件在低端机上算完大概要十几秒,进度条得给用户看到,不然会以为卡死了。这部分代码比较长,就不贴了,spark-md5 官方文档里有现成的 ArrayBuffer 分块示例。

清理脚本:不写这个早晚会出事

所有上传方案里,被取消的上传都是常态。用户传了 80%,觉得慢,关掉页面走了,那 80% 的分片就永远躺在磁盘上。我们上线第二周,storage/chunks 已经涨到 40 多个 G。

<?php
// bin/clean_chunks.php
require __DIR__ . '/../lib/helper.php';

$cfg = config();
$ttl = $cfg['chunk_ttl'];
$now = time();
$removed = 0;

foreach (glob($cfg['chunk_dir'] . '/*', GLOB_ONLYDIR) as $dir) {
    // 目录里最新的分片时间作为活跃时间
    $latest = 0;
    foreach (glob($dir . '/*.part') ?: [] as $f) {
        $latest = max($latest, filemtime($f));
    }
    if ($latest === 0) {
        // 空目录,直接删
        @rmdir($dir);
        continue;
    }
    if ($now - $latest > $ttl) {
        array_map('unlink', glob($dir . '/*.part'));
        @unlink($dir . '/.merge.lock');
        @rmdir($dir);
        $removed++;
    }
}

echo date('Y-m-d H:i:s') . " 清理完成,删除 {$removed} 个目录n";

挂到 crontab 里每小时跑一次就行。注意判断依据是目录里最新分片的修改时间,不是目录本身的 mtime。因为往目录里新增文件时,目录 mtime 会变,但删除文件时不一定,用文件时间更准。

四个上线后实际遇到的问题

一、没加锁导致合并出损坏文件。最早那版合并接口是直接顺序写的,测试时一直没问题。上线后有人网络慢,合并请求超时了他又点了一次,两个请求同时往同一个文件里写,最后拼出来的 MP4 只播放到一半。加上 flock 之后这类事故再没出现过,代价也只是多返回一个 409。

二、Nginx 的 client_max_body_size 忘了改。分片大小是 2M,按说不会触发,但有人用很小的分片测试(比如 64K),前端凑巧把整包当成一片发了出去。Nginx 默认限制 1M,直接返回 413,而浏览器拿到的是 HTML 错误页,res.json() 解析失败,报错信息完全对不上。后来在 upload.php 里加了一行检查 $_FILES['chunk']['size'] 上限,同时在 Nginx 里把 client_max_body_size 调到 8M,两边都收一下。

三、路径穿越。这个在写完公共函数时就想到了,但实际测试时才发现另一个变种:md5 参数传的是合法 32 位十六进制,但 name 参数传了 ../../evil.php。虽然 safe_ext 会拦下 php 后缀,但如果白名单里恰好包含 zip,构造一个 ../../shell.zip 就绕过去了。最终方案是丢掉原始文件名,正式文件名统一用 md5 + 白名单后缀,原始名只存在数据库里做展示。这样文件名完全不受用户控制。

四、合并时内存被打爆。最初版本是把每个分片 file_get_contents 后拼成一个大字符串再 file_put_contents,本地 20M 的 zip 测着没事,上线传 800M 的视频时直接 Allowed memory size exhausted。后来改成现在的流式写法,1M 一个缓冲区,内存占用恒定,才算彻底解决。这里也提醒一句,memory_limit 不要为了这个去调高,写流式代码才是正路。

写在最后

这套东西加起来不到四百行代码,前后端各一半。秒传命中率在我们的场景下接近三成(课程视频经常重复上传),断点续传把用户投诉从每周七八个降到了零。

如果后面流量再往上走,临时目录和合并时的磁盘 IO 会成为瓶颈,到时候可以考虑用对象存储的多段上传接口直接替换掉合并这一步,但前端切片和秒传的逻辑基本不用动,改造起来不算麻烦。眼下这套先跑着,够用了。

PHP 大文件分片上传实战:秒传、断点续传与并发合并的完整实现
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php PHP 大文件分片上传实战:秒传、断点续传与并发合并的完整实现 https://www.taomawang.com/server/php/3010.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务