在 PHP 项目中实现普通文件上传并不难。
很多人的第一反应,是把 PHP 和 Nginx 的上传限制调大:
upload_max_filesize = 40Gpost_max_size = 41Gmax_execution_time = 0
Nginx 同样放开请求体限制:
client_max_body_size 41g;
配置调整完成后,服务器理论上可以接收 30GB 以上的请求。
但真正进入生产环境后,你会发现:
大文件上传,真正困难的不是“能不能传”,而是“中断以后怎么继续,重复以后怎么处理,失败以后怎么恢复”。
对于这种体量的文件,通常不能再使用一个 HTTP 请求上传完整文件,而应该采用分片上传。
整体流程如下:
创建上传任务 ↓客户端切分文件 ↓并发上传分片 ↓查询缺失分片 ↓补传失败分片 ↓触发文件合并 ↓校验最终文件 ↓标记上传完成
这套方案真正需要解决的,是下面五个问题。
一、断点续传:中断以后不能从头再来
上传一个大文件,可能需要几十分钟,甚至几个小时。
在这个过程中,任何环节都可能中断:
单请求上传一旦中断,整个文件通常只能重新上传。
断点续传并不是继续一个已经断开的 HTTP 请求,而是重新连接后,只上传服务端缺少的分片。
1. 先把文件切成分片
假设文件大小为 30GB,每个分片大小为 16MB:
文件大小:30GB分片大小:16MB分片数量:约 1920 个
每个分片都拥有固定编号:
chunk 0chunk 1chunk 2chunk 3...chunk 1919
上传前,客户端先向 PHP 服务创建一个上传任务:
POST /api/uploadsContent-Type: application/json
请求参数:
{ ”file_name”: ”demo.mp4”, ”file_size”: 32212254720, ”file_hash”: ”完整文件的 SHA-256”, ”chunk_size”: 16777216, ”total_chunks”: 1920}
服务端返回一个唯一的 upload_id:
{ ”upload_id”: ”upload_20260728_001”, ”status”: ”uploading”, ”uploaded_chunks”: []}
之后,客户端通过 upload_id 上传每个分片:
PUT /api/uploads/upload_20260728_001/chunks/0PUT /api/uploads/upload_20260728_001/chunks/1PUT /api/uploads/upload_20260728_001/chunks/2
2. 恢复时查询服务端状态
假设上传到一半时网络中断。
客户端重新进入页面后,首先查询任务状态:
GET /api/uploads/upload_20260728_001
服务端返回:
{ ”upload_id”: ”upload_20260728_001”, ”status”: ”uploading”, ”uploaded_chunks”: [0, 1, 2, 3, 5, 6], ”missing_chunks”: [4, 7, 8, 9]}
客户端只需要补传缺失的分片:
chunk 4chunk 7chunk 8chunk 9
3. 不能只记录最后一个分片编号
有些实现只保存一个字段:
这种方式只适用于严格顺序上传。
但为了提高上传速度,客户端一般会并发上传多个分片,完成顺序可能是:
此时:
并不能证明 0 到 7 的所有分片都已经上传成功。
正确方式是为每个分片单独保存记录:
CREATE TABLE upload_chunk( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, upload_id VARCHAR(64) NOT NULL, chunk_index INT UNSIGNED NOT NULL, chunk_size BIGINT UNSIGNED NOT NULL, chunk_hash CHAR(64) NOT NULL, storage_path VARCHAR(500) NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_upload_chunk ( upload_id, chunk_index ));
断点续传必须以服务端记录为准。客户端记录可以用于优化体验,但不能作为最终可信状态。
二、并发控制:分片可以并发,状态不能失控
为了提高速度,客户端通常会同时上传多个分片。
例如,并发数设置为 4:
请求 1:上传 chunk 100请求 2:上传 chunk 101请求 3:上传 chunk 102请求 4:上传 chunk 103
并发上传可以提高带宽利用率,但也会带来数据一致性问题。
1. 同一个分片可能被同时上传
由于网络超时、客户端重试或者页面重复操作,同一个分片可能同时收到两个请求:
请求 A:上传 chunk 100请求 B:上传 chunk 100
如果两个请求直接写入同一个文件:
file_put_contents( ”/data/chunks/{$uploadId}/100.part”, $chunkData);
可能出现:
因此,每个请求应该先写入独立的临时文件:
$temporaryPath = sprintf( '/data/chunks/%s/%d_%s.tmp', $uploadId, $chunkIndex, bin2hex(random_bytes(8)));$finalPath = sprintf( '/data/chunks/%s/%d.part', $uploadId, $chunkIndex);
临时文件写入完成后,再执行:
校验分片大小 ↓校验分片哈希 ↓写入数据库记录 ↓原子重命名为正式分片
2. 上传进度必须原子更新
假设当前已上传 100MB,两个 16MB 分片同时完成。
两个请求可能同时读取到:
然后分别计算:
最终数据库可能得到 116MB,而不是正确的 132MB。
错误写法如下:
$task = findUploadTask($uploadId);$task->uploaded_bytes = $task->uploaded_bytes + $chunkSize;$task->save();
应该使用数据库原子更新:
UPDATE upload_taskSET uploaded_bytes = uploaded_bytes + :chunk_size, updated_at = NOW()WHERE id = :upload_id;
但这里还有一个问题:
原子加法只能解决并发覆盖,不能解决重复分片导致的重复累加。
更加可靠的流程是:
开启事务 ↓插入分片记录 ↓通过唯一索引判断是否重复 ↓只有首次插入成功才增加 uploaded_bytes ↓提交事务
3. 上传和合并不能同时发生
还可能出现这样的情况:
请求 A:正在写入最后一个分片请求 B:开始合并整个文件
如果最后一个分片还没有写完,合并程序就可能读取到不完整的数据。
因此,上传任务必须有明确的状态机:
initialized ↓uploading ↓merging ↓verifying ↓completed
异常状态可以包括:
开始合并时,使用条件更新抢占合并资格:
UPDATE upload_taskSET status = 'merging', updated_at = NOW()WHERE id = :upload_id AND status = 'uploading';
只有数据库影响行数为 1 的请求,才有资格执行合并。
其他重复请求直接返回:
{ ”upload_id”: ”upload_20260728_001”, ”status”: ”merging”}
4. 多台服务器不能同时合并
在多实例部署环境中,同一个完成请求可能被分发到不同服务器:
服务器 A:开始合并 upload_001服务器 B:也开始合并 upload_001
如果没有并发控制,可能发生:
可以使用:
数据库条件更新数据库行锁Redis 分布式锁消息队列单消费者对象存储原生分片合并
更推荐把合并改成异步任务:
客户端调用完成接口 ↓数据库状态改为 merging ↓投递合并消息 ↓后台 Worker 执行合并
分片上传可以高并发,但同一个上传任务的“合并操作”必须保证只能执行一次。
三、重复分片:客户端重试必须幂等
大文件上传过程中,分片重试不可避免。
一个常见场景是:
客户端上传 chunk 100 ↓服务端保存成功 ↓服务端返回响应 ↓响应在网络中丢失 ↓客户端认为上传失败 ↓再次上传 chunk 100
第一次请求在服务端已经成功,只是客户端没有收到响应。
如果第二次请求又重新保存、重新统计,就会产生重复数据。
1. 分片接口必须具备幂等性
同一个分片上传一次或上传多次,最终结果必须一致。
分片的天然幂等键是:
数据库中建立唯一索引:
UNIQUE KEY uk_upload_chunk ( upload_id, chunk_index)
收到分片后,按照以下逻辑处理:
分片不存在: 保存分片 校验大小和哈希 插入分片记录 返回上传成功分片已存在,并且哈希一致: 不重复写入 直接返回成功分片已存在,但哈希不同: 拒绝覆盖 返回冲突
对于重复但内容一致的分片,可以返回:
{ ”chunk_index”: 100, ”status”: ”already_uploaded”}
对于编号相同但内容不同的分片,可以返回:
HTTP/1.1 409 ConflictContent-Type: application/json
{ ”chunk_index”: 100, ”error”: ”chunk_hash_conflict”}
2. 不能只判断文件是否存在
下面这种逻辑并不可靠:
if (file_exists($chunkPath)) { return [ 'status' => 'already_uploaded' ];}
因为已经存在的文件可能是:
正确判断应该同时检查:
数据库记录是否存在分片状态是否为 uploaded磁盘文件是否存在实际文件大小是否一致实际文件哈希是否一致
3. 分片先写临时文件
PHP 接收原始分片流时,可以这样处理:
$input = fopen('php://input', 'rb');if ($input === false) { throw new RuntimeException('无法读取请求体');}$temporaryPath = sprintf( '/data/chunks/%s/%d_%s.tmp', $uploadId, $chunkIndex, bin2hex(random_bytes(8)));$output = fopen($temporaryPath, 'xb');if ($output === false) { fclose($input); throw new RuntimeException('无法创建临时分片');}$writtenBytes = stream_copy_to_stream( $input, $output);fclose($input);fclose($output);
写入完成后校验大小:
if ($writtenBytes !== $expectedChunkSize) { @unlink($temporaryPath); throw new RuntimeException('分片大小不一致');}
再校验哈希:
$actualHash = hash_file( 'sha256', $temporaryPath);if ( $actualHash === false || !hash_equals($expectedHash, $actualHash)) { @unlink($temporaryPath); throw new RuntimeException('分片哈希校验失败');}
校验成功后,再处理数据库幂等和正式文件切换。
不要让重复请求直接覆盖一个已经校验成功的正式分片。
四、合并校验:分片齐全,不代表文件正确
所有分片上传完成后,服务端需要把分片按顺序合并成最终文件。
最简单的实现可能是:
foreach ($chunks as $chunkPath) { file_put_contents( $targetPath, file_get_contents($chunkPath), FILE_APPEND );}
这种写法对于 30GB 文件存在明显问题。
file_get_contents() 会把整个分片读入 PHP 内存。
如果分片较大或者多个任务同时合并,很容易造成内存占用过高。
而且:
能够把所有分片拼接起来,不代表最终文件一定正确。
1. 合并前校验分片完整性
正式合并前,至少需要检查:
分片总数是否正确分片编号是否连续是否存在重复分片分片文件是否真实存在每个分片大小是否正确每个分片哈希是否正确最后一个分片大小是否合理
例如任务声明共有 1000 个分片,那么编号必须完整覆盖:
不能只统计数据库记录数量:
SELECT COUNT(*)FROM upload_chunkWHERE upload_id = :upload_id AND status = 'uploaded';
因为“数量为 1000”不一定代表编号真的连续。
应该逐个检查:
for ($index = 0; $index < $totalChunks; $index++) { if (!isset($uploadedChunks[$index])) { throw new RuntimeException( ”缺少分片:{$index}” ); }}
2. 使用流式方式合并
正确的合并方式是逐个打开分片,通过流复制到目标文件:
$mergingPath = $targetPath . '.merging';$output = fopen($mergingPath, 'wb');if ($output === false) { throw new RuntimeException('无法创建合并文件');}for ($index = 0; $index < $totalChunks; $index++) { $chunkPath = getChunkPath( $uploadId, $index ); $input = fopen($chunkPath, 'rb'); if ($input === false) { fclose($output); throw new RuntimeException( ”无法读取分片:{$index}” ); } $copiedBytes = stream_copy_to_stream( $input, $output ); fclose($input); if ($copiedBytes === false) { fclose($output); throw new RuntimeException( ”复制分片失败:{$index}” ); }}fflush($output);fclose($output);
这里不会一次性把整个分片或整个文件加载进内存。
3. 不能直接写正式文件
合并时不要直接生成:
应该先生成临时文件:
只有全部分片合并并校验成功后,才能切换为正式文件:
if (!rename($mergingPath, $targetPath)) { throw new RuntimeException( '正式文件切换失败' );}
在同一个文件系统中,rename() 通常可以完成原子切换,避免其他程序读取到半成品。
4. 校验最终文件大小
合并完成后,首先检查文件大小:
$actualSize = filesize($mergingPath);if ($actualSize === false) { throw new RuntimeException( '无法读取最终文件大小' );}if ($actualSize !== $expectedSize) { throw new RuntimeException( '最终文件大小校验失败' );}
但是只校验大小还不够。
两个内容不同的文件,可能拥有完全相同的大小。
5. 校验完整文件哈希
客户端创建任务时,应提交完整文件的 SHA-256:
{ ”file_hash”: ”完整文件的 SHA-256”}
服务端合并后重新计算:
$actualHash = hash_file( 'sha256', $mergingPath);if ($actualHash === false) { throw new RuntimeException( '无法计算最终文件哈希' );}if (!hash_equals($expectedHash, $actualHash)) { throw new RuntimeException( '最终文件哈希校验失败' );}
完整文件哈希可以发现:
分片缺失分片重复分片顺序错误分片内容损坏合并过程异常磁盘写入异常
6. 合并过程中同步计算哈希
30GB 文件合并完成后,再读取一遍计算 SHA-256,会额外产生一次完整磁盘读取。
可以在合并时同步计算哈希:
$hashContext = hash_init('sha256');$output = fopen($mergingPath, 'wb');for ($index = 0; $index < $totalChunks; $index++) { $chunkPath = getChunkPath( $uploadId, $index ); $input = fopen($chunkPath, 'rb'); if ($input === false) { fclose($output); throw new RuntimeException( ”无法读取分片:{$index}” ); } while (!feof($input)) { $buffer = fread( $input, 1024 * 1024 ); if ($buffer === false) { fclose($input); fclose($output); throw new RuntimeException( ”读取分片失败:{$index}” ); } if ($buffer === '') { continue; } $written = fwrite( $output, $buffer ); if ($written === false) { fclose($input); fclose($output); throw new RuntimeException( '写入合并文件失败' ); } hash_update( $hashContext, $buffer ); } fclose($input);}fflush($output);fclose($output);$actualHash = hash_final( $hashContext);
这样可以在一次读取中同时完成:
五、失败恢复:任何一步中断后都能继续
30GB 文件上传通常持续很长时间。
因此,系统不能假设整个流程一定能顺利执行完。
可能出现:
PHP 进程被杀死合并 Worker 崩溃服务器重启容器重新调度数据库连接中断磁盘空间不足Redis 锁过期合并到一半程序退出文件已经完成但状态没有更新状态已经更新但分片没有清理
大文件上传系统必须假设:任何一个步骤,都可能执行到一半时失败。
1. 上传状态必须持久化
不能只把上传状态保存在 PHP Session、Redis 临时变量或进程内存中。
上传任务表至少需要记录:
CREATE TABLE upload_task ( id VARCHAR(64) PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, original_name VARCHAR(255) NOT NULL, expected_size BIGINT UNSIGNED NOT NULL, expected_hash CHAR(64) NOT NULL, chunk_size BIGINT UNSIGNED NOT NULL, total_chunks INT UNSIGNED NOT NULL, uploaded_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL, target_path VARCHAR(500) DEFAULT NULL, retry_count INT UNSIGNED NOT NULL DEFAULT 0, last_error VARCHAR(1000) DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, expires_at DATETIME NOT NULL);
即使服务器重启,也可以根据数据库状态恢复任务。
2. 合并任务必须可重复执行
假设合并到第 800 个分片时,Worker 突然退出。
磁盘上可能留下:
恢复时有两种方案。
方案一:删除临时文件,重新合并。
if (is_file($mergingPath)) { unlink($mergingPath);}dispatchMergeJob($uploadId);
这种方案实现简单、可靠性高。
缺点是需要重新读取已经合并过的分片。
方案二:记录合并进度。
merged_chunk_index = 799merged_bytes = 13421772800
恢复后从第 800 个分片继续。
但这种方式必须保证:
数据库进度和临时文件大小一致上一个分片已经完整写入恢复时不会重复追加临时文件内容没有损坏哈希计算状态可以恢复
实现复杂度会明显上升。
大多数业务更适合失败后重新合并,优先保证正确性,而不是追求合并过程本身的断点续传。
3. 识别长时间停留的异常任务
正常状态应该按顺序变化:
uploading ↓merging ↓verifying ↓completed
如果任务长时间停留在:
例如 30 分钟没有更新时间,说明任务可能已经异常。
后台恢复程序可以查询:
SELECT idFROM upload_taskWHERE status IN ('merging', 'verifying') AND updated_at < NOW() - INTERVAL 30 MINUTE;
发现异常任务后,执行:
检查最终文件是否存在检查临时合并文件是否存在检查文件大小和哈希检查当前是否仍有 Worker 执行重新投递合并任务或者标记为 failed
4. 完成操作必须幂等
一种典型故障是:
文件已经合并成功 ↓正式文件已经生成 ↓更新数据库前服务崩溃
此时数据库可能仍然是:
但正式文件已经存在。
恢复程序不能直接重新合并,而应该先检查:
正式文件是否存在正式文件大小是否正确正式文件哈希是否正确
如果全部正确,可以直接恢复任务状态:
UPDATE upload_taskSET status = 'completed', updated_at = NOW()WHERE id = :upload_id AND status IN ('merging', 'verifying');
另一种情况是:
数据库已经变成 completed ↓服务在删除分片前崩溃
这时不应该重新合并,只需要继续执行分片清理。
所以,整个完成流程应该拆成多个可重复执行的步骤:
生成临时合并文件 ↓校验文件大小和哈希 ↓切换为正式文件 ↓更新任务状态 ↓清理临时分片
每一步都应该具备幂等性。
5. 不要用长事务包住文件合并
错误设计:
开启数据库事务 ↓合并 30GB 文件 ↓计算完整哈希 ↓更新任务状态 ↓提交事务
30GB 文件合并可能持续几分钟。
长时间占用数据库事务会造成:
数据库锁长期占用连接资源无法释放并发请求被阻塞Undo Log 持续增长事务超时主从延迟增加
正确做法是只对状态切换使用短事务。
先抢占合并资格:
BEGIN;UPDATE upload_taskSET status = 'merging', updated_at = NOW()WHERE id = :upload_id AND status = 'uploading';COMMIT;
然后在事务外执行文件合并。
合并成功后,再用一个短事务更新状态:
BEGIN;UPDATE upload_taskSET status = 'completed', target_path = :target_path, updated_at = NOW()WHERE id = :upload_id AND status IN ('merging', 'verifying');COMMIT;
6. 失败任务需要自动重试
任务表中可以保存:
retry_countlast_errornext_retry_at
重试策略例如:
第一次失败:1 分钟后重试第二次失败:5 分钟后重试第三次失败:30 分钟后重试超过最大次数:标记为 failed
失败原因应该明确记录:
missing_chunkchunk_hash_mismatchfile_hash_mismatchdisk_fullpermission_deniedmerge_timeoutstorage_unavailable
这样才能支持: