事情起因很普通:客户要在后台上传课程视频,单个文件两三百兆,用的是移动网络。原来那个表单直接 POST 整包,php.ini 里 upload_max_filesize 调到 512M 之后勉强能过,但只要网络抖一下,浏览器就重新来过,一天下来客服接到七八个投诉。后来我把上传改成了分片模式,顺带把秒传和断点续传也做了。
整个方案没用框架,也没装 plupload 之类的第三方库,纯原生 PHP 加一点点原生 JS。原因很简单,项目是老的裸 PHP,引入框架的成本比手写这套逻辑还高。下面把完整实现和踩过的坑摆出来,谁有类似需求可以直接拿去改。
整体流程长什么样
一次上传分四步走:
- 前端读完文件,算出整个文件的 MD5,连同文件名、大小发给后端问一句“这个文件是不是已经在服务器上了”。
- 后端返回三种结果:已存在(秒传,直接结束)、部分分片已存在(返回已上传的索引列表)、什么都没有(从头开始)。
- 前端按固定大小切片,逐个或并发地把分片 POST 上去,后端落到以文件 MD5 命名的临时目录里。
- 最后调用合并接口,后端按索引顺序把分片拼成一个文件,校验 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 会成为瓶颈,到时候可以考虑用对象存储的多段上传接口直接替换掉合并这一步,但前端切片和秒传的逻辑基本不用动,改造起来不算麻烦。眼下这套先跑着,够用了。

