拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

分片上传与秒传实战:基于HTML5 File API的PHP大文件方案

分片上传与秒传实战:基于HTML5 File API的PHP大文件方案

1. 理赔勘查视频为什么让常规上传方案当场翻车

1.1 单文件体积超出想象:从手机拍摄参数反推

先说说我为什么盯上这个场景。我在保险科技公司做过核赔系统的后端,最头疼的不是核赔规则,也不是保单接口,而是查勘员手机里那段该死的视频。查勘员出险后去现场,围着车拍一圈,再拍点发动机、底盘、刹车痕迹,随手就是十几分钟甚至半小时的视频。现在手机默认都开1080P甚至4K,1080P的码率按8Mbps算,一小时大概是3.6GB,半小时就是1.8GB。要是查勘员忘了分段录制,一条视频轻松奔着2GB往上走。

这种文件拿去传服务器,谁做谁知道痛。传统网页上传就是一个<input type="file">加上表单提交,PHP那套$_FILES一把梭。但真拿2GB的文件这么干,大概率会碰到三个问题:一是HTTP连接维持不了那么久,中间任意一次网络抖动就断开;二是Nginx、PHP-FPM对请求体大小和执行时间都有默认限制,文件稍微大点直接被拒;三是即便服务器勉强收完,中间断了一次,对不起,前面传的几GB全部作废,从头再来。

理赔这种场景比普通文件传输更苛刻。查勘员在事故现场拍的视频属于证据材料,保险公司要拿它做核赔依据,上传失败一次意味着定损周期拉长一天,客户投诉就多一封。所以这不是“大文件上传”那种通用需求,而是“极不稳定网络环境下,超大视频必须尽可能一次成功”的强需求。

1.2 弱网与断线场景下的“全量重传”死局

查勘员出现场的网络环境根本不归你管。地下车库、郊区道路、山区信号死角,4G经常掉到一格,偶尔还能切回3G。高端手机还能撑一会Wi-Fi,但稳定性也就是那么回事。在这种网络上,一个2GB的请求体一次性POST过去,成功率无限趋近于零。

我印象很深的一次是某合作修理厂的查勘员,在客户地下室车库拍了一条2.4GB的视频,用手机流量传了一个多小时,传到了87%,停车场卷帘门一关,4G切成了3G,连接断裂,直接回到0%。他那天的表情我现在都记得。后来我们统计过,传统上传方式在弱网环境下的大文件成功率只有百分之十几,这还是在加了各种超时补偿之后的数字。

“全量重传”这种设计在小型文件上无所谓,最多浪费几秒钟,但上了GB级别,就变成一种灾难。解决办法只有一个方向:把大象切碎了过河,每次只搬一小块,搬失败的成本极小,搬完的块不会被丢掉。

1.3 分片上传解决的三个核心问题

分片上传本质上是在说三件事:断点续传、并发加速、失败隔离。

先看断点续传。大文件切成一堆几十KB到几十MB的分片,每个分片独立请求、独立确认。传输过程中哪一片断了,只重传这一片,已经成功上传的分片继续保留。上传任务可以被中断,也可以从任何时间点恢复,不需要重头再来。

再看并发加速。网络传输有个特点,单条TCP连接不一定能跑满带宽,特别是在有丢包和延迟的环境下,一条连接往往会受限。把文件切成多个分片,用3到6个并发连接同时传,能在同等网络条件下把利用率提上去。尤其对4G弱网,并发分片比单连接稳定得多。

最后是失败隔离。如果整个文件是一次性请求,任何一秒的网络抖动都可能毁掉整个任务。但分片之后,某一两片失败只影响那几MB的数据,重试成本低到可以忽略。分片上传的容错能力,就是做保险行业这种恶劣场景上传系统的地基。

2. HTML5 File API 分片的前端设计:切多大、并发几个、怎么续传

2.1 分片大小不是凭感觉定的

前端负责用HTML5的File对象把视频切成小块,这是整个方案的起点。但很多初学者上来就问:分片切多大合适?这个不能拍脑袋。

分片太小,比如256KB,1GB文件要切成4096片,每片都发一个HTTP请求,请求头和响应头的开销占比急剧上升,服务器的并发压力也大。分片太大,比如64MB一片,虽然请求数少了,但弱网下单片传输时间长,中途断掉,重传代价变大。

根据我们的实践,一般建议在4MB到16MB之间选。这里有几层考量:

  • 单片大小要小于PHP的upload_max_filesize和Nginx的client_max_body_size,否则会被拦截。
  • 单片传输时间尽量控制在3到10秒以内,方便做失败重试。
  • 分片总数尽量控制在200到1000片之间,既不会让服务端文件数爆炸,也不会让合并逻辑慢到不可接受。

我自己一般选8MB,也就是8 * 1024 * 1024字节。1080P半小时的视频约1.8GB,切成230片左右,请求数合理,单片几秒传完,重试成本也低。如果你主要跑在信号极差、上行带宽只有1Mbps甚至更低的环境,建议降到4MB,否则一片要传一分钟,失败概率会高很多。

2.2 文件切片与并发上传的实现框架

HTML5的File对象带一个slice()方法,用法和数组的slice类似,指定起始字节和结束字节,返回一个Blob对象。这个Blob可以直接放进FormData发给服务器。这就是“分片”的底层能力,不需要装任何第三方库。

一个可用的前端结构大概是这样的:

const CHUNK_SIZE = 8 * 1024 * 1024; // 8MB一片 function createChunks(file) { const chunks = []; let start = 0; while (start < file.size) { const end = Math.min(start + CHUNK_SIZE, file.size); chunks.push(file.slice(start, end)); start = end; } return chunks; }

切好片之后,要做的事是并发上传。同时开几个XMLHttpRequest或fetch,把每一片的数据POST到后端。注意不要一下子把所有分片全部发出去,那样网络会先拥塞,然后集体失败。稳妥的做法是控制并发数,比如同时最多跑3到5个。

我用一个简单的计数器来控制并发:

async function uploadChunk(chunk, fileMd5, chunkIndex, totalChunks) { const formData = new FormData(); formData.append('file', chunk, 'video.mp4'); formData.append('fileMd5', fileMd5); formData.append('chunkIndex', chunkIndex); formData.append('totalChunks', totalChunks); const res = await fetch('/api/upload/chunk', { method: 'POST', body: formData }); return res.json(); } async function uploadWithConcurrency(chunks, fileMd5, concurrency = 4) { let index = 0; const workers = Array.from({ length: concurrency }, async () => { while (index < chunks.length) { const current = index++; try { await uploadChunk(chunks[current], fileMd5, current, chunks.length); } catch (e) { // 记录失败分片,稍后重试 failedIndices.add(current); } } }); await Promise.all(workers); }

实际项目里还要加进度汇总。进度条不能直接用某一个分片的onprogress来算,那会跳来跳去。推荐的做法是把“已成功上传的分片数量”除以“总分片数量”作为总体进度,再乘以100%。这个口径不依赖单片的瞬时速度,用户看着心里踏实。

2.3 断点续传为什么不能只依赖 localStorage

前端做断点续传最简单的方式是:每成功一片,就把这个分片索引存到localStorage里,刷新页面后读取,跳过已传的索引。这个思路在小文件场景下够用,但在理赔勘查这种长任务里有个致命问题——本地存储只属于当前浏览器、当前设备。

查勘员手机浏览器清理一次缓存,或者换一台手机登录,本地的断点记录就全没了。保险公司的内勤和查勘员不可能保证永远同一台设备。所以真正可靠的断点续传必须以服务端为准:前端上传前先问一下后端“这个文件的哪些分片已经传过了”,后端查临时目录或数据库返回一个索引列表,前端只传输缺失的片。

这个查询接口在上传任务开始前调用一次即可,也可以在用户手动点击“继续上传”时调用。前端本地记录的定位是“减少一次服务端查询的辅助缓存”,而不是唯一依据。谁把断点状态完全放在前端,谁早晚要出事故。

3. PHP后端接收分片:请求接口、临时文件与最终合并的实现

3.1 分片上传接口的基本形态与校验策略

后端的核心工作就两件:收分片、合并分片。我用PHP来实现,这里的思路与语言无关,Java、Go、Node都能平移。

先定义分片上传的接口约定。前端每次POST包含四个关键字段:

字段含义示例
fileMd5整个文件的MD5摘要,用来标识同一次上传任务a3f2b1...
chunkIndex当前分片的序号,从0开始45
totalChunks全部分片数量230
file分片二进制内容video.mp4.part45

PHP接口接收到请求后,第一步不是急着写文件,而是做合法性校验:

$fileMd5 = $_POST['fileMd5'] ?? ''; $chunkIndex = (int)($_POST['chunkIndex'] ?? -1); $totalChunks = (int)($_POST['totalChunks'] ?? 0); if ( !preg_match('/^[a-f0-9]{32}$/', $fileMd5) || $chunkIndex < 0 || $totalChunks <= 0 || $chunkIndex >= $totalChunks ) { http_response_code(400); echo json_encode(['code' => 400, 'message' => 'invalid params']); exit; }

这里要注意两点。第一点是fileMd5必须做格式校验,只能允许32位十六进制字符,不能图省事直接拿它拼路径,否则等于给路径注入开了一扇门。第二点是必须校验chunkIndex和totalChunks的数值关系,防止有人上传一个超出范围的索引,把合并逻辑打乱。

分片文件本身要放到临时目录。我的做法是按fileMd5建一个子目录,所有分片都放进这个目录。为什么用MD5做目录名?因为不同用户可能同名文件同时上传,如果直接拼文件名,会互相覆盖。而MD5本身是文件内容的摘要,内容相同则目录相同,天然做了聚合。

$chunkDir = '/data/upload_tmp/' . $fileMd5 . '/'; if (!is_dir($chunkDir)) { mkdir($chunkDir, 0755, true); } $chunkPath = $chunkDir . $chunkIndex . '.part'; move_uploaded_file($_FILES['file']['tmp_name'], $chunkPath);

这里用的move_uploaded_file是PHP处理上传文件的标准函数,比copy或rename更安全,它会校验文件是否真的来自HTTP POST。分片落盘之后,可以顺手记一个Redis标记,把当前分片索引标记为已完成:

$redis->sAdd('upload:' . $fileMd5, $chunkIndex);

这个标记在断点续传查询时非常关键,后端能直接在Redis里查出“哪些分片已经传过”,然后返回前端。

3.2 合并大文件必须用流式读写,别让内存爆炸

当所有分片都传完之后,前端会调用一个“合并”接口,或者后端在检测到最后一个分片到位时自动触发合并。合并的逻辑是把0.part、1.part、2.part……按顺序拼接成一个完整的视频文件。

新手最容易犯的错误是用file_get_contents把分片整个读进内存,再拼起来:

// 错误示范:文件一大,内存立刻爆掉 for ($i = 0; $i < $totalChunks; $i++) { $content = file_get_contents($chunkDir . $i . '.part'); file_put_contents($finalPath, $content, FILE_APPEND); }

这个写法在分片是4MB、总共几百片时也会出问题。PHP默认内存限制通常只有128MB,一片8MB,你倒是没超,但多片同时缓存到内部缓冲里,分分钟超限。2GB的文件合并到一半,FPM直接宕掉。

正确的做法是流式合并。用fopen打开目标文件,然后循环用fread读源分片、用fwrite写入目标文件,每次只读一小块,比如2MB,内存占用保持恒定:

$target = fopen($finalPath, 'wb'); for ($i = 0; $i < $totalChunks; $i++) { $partPath = $chunkDir . $i . '.part'; if (!file_exists($partPath)) { throw new RuntimeException('missing chunk: ' . $i); } $source = fopen($partPath, 'rb'); while (!feof($source)) { $buffer = fread($source, 2 * 1024 * 1024); // 每次2MB fwrite($target, $buffer); } fclose($source); unlink($partPath); // 合并完一片就删一片,省磁盘 } fclose($target);

合并完成后,还要做一次完整性校验。最简单的方式是计算合并后的文件MD5,与前端上传时传的fileMd5对比。如果不一致,说明中间有分片丢失或者损坏,要触发整个上传任务重新走一轮。这个过程的时间开销要看文件大小,2GB的MD5计算大约需要几秒到十几秒,在可接受范围内。

3.3 为什么需要异步合并,以及常见的队列化方案

合并2GB的文件不是瞬间完成的事。即便用流式读写,整个过程也可能需要20秒甚至更久。如果合并逻辑跑在PHP-FPM的请求周期里,前端那个“合并请求”会一直挂着,等20多秒。问题来了:FPM的max_execution_time默认为30秒,nginx的fastcgi_read_timeout默认60秒,超了任何一个,连接就被掐断。

所以生产环境里最好不要同步合并。我的做法是:当最后一个分片到达后,后端只做一件事——把“需要合并”的消息丢进Redis队列,然后立即返回响应“分片已全部接收,正在合成”。真正耗时的合并逻辑交给后台CLI脚本处理:

// 接口层:只负责通知 $redis->lpush('video_merge_queue', json_encode([ 'fileMd5' => $fileMd5, 'totalChunks' => $totalChunks, 'finalPath' => $finalPath, 'originalName' => $originalName ])); echo json_encode(['code' => 200, 'message' => 'creating...']);

后台脚本用PHP CLI写一个常驻消费者:从队列里取出任务,执行合并,写入数据库记录。前端这时候可以通过轮询或WebSocket查询合并状态,一旦发现“合并完成”,就跳转到视频预览页。

这个异步化改造是我踩坑之后才做的。一开始图省事直接同步合并,上线第一周就收到“某些视频传完但一直不出现”的投诉,查下来全是FPM超时把合并进程杀掉了。切到异步队列之后,再也没出过这类问题。

4. 秒传机制没那么玄:指纹、状态接口与重传策略

4.1 秒传的三种实现路径

“秒传”这个词在分片上传语境下容易被人误读。它其实有三层含义,做的深度完全不一样。

第一层是文件级秒传。服务端已经存在同内容的文件,前端还没有真正上传数据,后端检测到MD5相同,直接返回上传成功,进度条瞬间到100%。这个设计最彻底,但前提是内容完全相同。这里特别说一下MD5风险:MD5在安全场景里不算强校验,但在文件去重这种“非对抗、只防误判”的场景下,碰撞概率可以接受。如果你特别较真,可以换成SHA-256,代价是计算开销更大。

第二层是分片级秒传。整个文件的对端不存在,但某几个分片之前传过——可能是上次中断留下的,也可能另一条完全相同的素材的中间几片。后端通过状态查询接口返回“已有的分片列表”,前端只传缺失分片,效率远超全量重传。

第三层是体验层。不追求真正做到“前面有缓存”,而是让用户从点击上传到看到进度条走动的时间尽量短。比如,一个大文件切完片后,前端立刻发出上一条请求,同时后端立刻确认并返回“继续”,用户几乎没有等待感,这也能被感知成“秒传”。

4.2 大文件指纹:全量哈希的代价与折中方案

既然要判断“文件是否已存在”,最严谨的办法是计算整个文件的MD5。前端用spark-md5这类库对文件做全量读取计算,但那种方式要把整个文件从头到尾读一遍。2GB文件读一遍加计算,便宜的电脑也要十几秒,贵的手机也要近十秒。更尴尬的是,这个计算期间用户看着进度条卡在0%,他的第一反应是“上传坏了”。体验并不好。

所以在保险理赔这种大视频场景,我推荐一个折中方案:先按分片上传,每片都有独立的MD5,服务端在合并结束后计算整个文件MD5。这样前端的“秒传判断”只在文件已经存在且服务端已有记录时才会发生,而大部分情况是用户登录后,系统主动检查“历史上是否有同一个理赔案件上传过相同视频”。有,则走秒传;没有,则直接进分片上传流程,不等前端算全量哈希。

前端如果确实想做文件级秒传,也可以采用抽样哈希法:选取文件开头、中间、结尾几段各几MB,计算拼接后的摘要,再加文件名和大小作为辅助判定。这个方案计算量极小,命中率在绝大多数场景够用。比如:

async function sampleHash(file) { const sampleSize = 4 * 1024 * 1024; const parts = []; // 开头4MB parts.push(file.slice(0, sampleSize)); // 中间4MB const mid = Math.floor(file.size / 2); parts.push(file.slice(mid, mid + sampleSize)); // 结尾4MB parts.push(file.slice(file.size - sampleSize, file.size)); // 计算拼接后的MD5,再带上文件大小 ... }

抽样哈希不是绝对精确,可能在极端情况下漏判或误判,所以它只能作为“秒传前置判断”,真正做完整性校验还得靠服务端合并后的全量校验。

4.3 从进度条角度设计“秒传”体验

理赔用户不是程序员,他们不看技术指标,只看“这东西到底传没传上去”。所以我特别在意进度条的呈现口径。

第一,进度条永远不能回退。哪怕某一片上传失败重试,前端已经渲染的进度也不该倒回去。显示卡住几秒可以接受,回退会让人立刻怀疑系统坏了。

第二,速度要平滑。网络忽快忽慢时,真实瞬时速度可能在5MB/s和500KB/s之间反复横跳。直接显示瞬时速度,用户会觉得系统抽风。我的做法是记录最近10秒内成功上传的总字节数,除以10,得到“滑动平均速度”,这样曲线平滑得多。

第三,提高“开始反馈”的速度。用户点完上传,至少要在一两秒内看到进度条被激活,哪怕是0.1%。如果前端必须等全量MD5算完,这个反馈会延迟很多。所以我把“秒传判断”放在“切片完成之后”而不是“切片之前”:切完片,立刻发送第一个分片并显示进度条,同时后台做抽样哈希秒传判断。绝大多数用户的感知就是“点完按钮,立刻看到进度在走”。

这个细节没人给你写进需求文档,但它是用户评价“你们平台好不好用”的一个隐藏指标。

5. 从Demo到生产:Nginx、PHP配置、临时目录与安全的那些坑

5.1 PHP/FPM/Nginx 的关键配置项对照表

分片上传落地的过程中,配置层面的坑比逻辑层面的坑多得多。逻辑你还能靠调试慢慢捋,配置错了就是线上事故,而且经常是“为什么我本地能跑,服务器不行”。

以8MB分片为例,关键配置如下:

配置项默认值推荐值说明
client_max_body_size(nginx)1M16m必须大于单片大小,否则分片POST会被nginx直接拒绝
upload_max_filesize(php.ini)2M16m必须大于单片大小
post_max_size(php.ini)8M20m必须大于单片大小加上表单字段的开销
max_execution_time(php.ini)3060只对单次分片接收有意义,合并交给CLI,不用设太大
max_file_uploads(php.ini)2020单片请求只传一个文件,保持默认即可

client_max_body_size是所有人第一眼都会踩的坑。默认只有1MB,你传8MB的分片,nginx直接返回413。upload_max_filesize同理。我把推荐值定为16m/20m,是为了给“表单字段长度”和“未来调大分片”留余量。

这里多说一句:不要为了省事把post_max_size设成500M或1G,那样等于回到了“大请求一把梭”的老路,nginx和PHP的防护形同虚设。

5.2 多实例与共享存储:分片临时目录的地狱坑

如果你们的后端只有一台服务器,临时目录随便放,没问题。但保险科技系统的架构一般不会那么寒酸,至少两台起,前面挂负载均衡。这时候就出现了一个经典问题:

用户先通过服务器A上传了第0到第50片,连接断了,重新发起请求,负载均衡把后面的51到100片送到了服务器B。临时分片目录是服务器本地的/data/upload_tmp/文件MD5/,结果A上的文件B看不到,B上的文件A也看不到。合并时无论哪台服务器发起,都会报“missing chunk”。

这个坑的排查也很折磨人:单独打一台服务器测,一切正常;一挂到LB下,随机性失败。我当时排查了两天才反应过来,问题是“临时目录没有共享”。

解决办法有三种:

  • 用NFS或CIFS把/data/upload_tmp挂载到所有PHP节点,保证所有分片落在一个共享文件系统上。成本低,但NFS性能一般,且单点故障。
  • 把分片直接上传到对象存储或云OSS,PHP只记录元数据。大厂主流做法,成本高一点,但可靠性和扩展性都好得多。
  • 临时目录保留在各节点本地,用Redis做分片索引,合并时某个节点通过内网从其他节点拉取缺失分片。复杂度最高,不推荐。

如果团队没有专门的运维支撑,我建议先用NFS共享目录快速解决。等到并发量上来了,再迁到对象存储不迟。

5.3 安全与合规:文件类型校验、目录权限与审计

最后必须说安全,这块出事就是大事。

第一,分片目录绝对不能放在Web可访问目录下。万一有人猜到路径,直接下载别人上传的fileMd5目录里的分片,就能拼出原始视频,涉及隐私泄漏。我的做法是放在/data/upload_tmp,完全独立在nginx的root之外。

第二,合并后的视频文件也要做类型校验。不能因为分片名以.part结尾就放松警惕。合并完成后,用finfo_file检查MIME类型,或者读取文件头判断是否为MP4、MOV等合法视频格式:

$finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $finalPath); finfo_close($finfo); $allowed = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'application/octet-stream']; if (!in_array($mime, $allowed, true)) { unlink($finalPath); throw new RuntimeException('invalid video type: ' . $mime); }

为什么不能只看扩展名?攻击者完全可以构造一个PHP一句话木马,命成video.mp4,再通过分片上传拼出来,如果服务器配置不当,直接变成webshell。虽然我们的分片接口只接收数据,但这道防线必须做。

第三,要记录审计日志。理赔视频关系保险赔付,属于业务证据链的一环,谁在什么时间上传了什么文件、用了多长时间、最终文件MD5和大小是多少,都应该写入日志。一旦后续发生理赔纠纷,这个日志是技术层面能拿出的最客观材料。

最后说点实在的

分片秒传这套方案,我在保险理赔项目里完整落地过。一开始也想过找现成的云服务直接接,但出于数据安全和私有化部署的要求,最终还是用HTML+PHP在自建服务器上实现了全套。现在查勘员传视频的失败率从原来的百分之八十几降到了个位数,系统上线后最大的感受就是:内勤不再因为“视频传不上来”给查勘员打电话了。

如果你们团队也要做类似的事,我建议不要一上来就上组件、上队列、上微服务。先用一个最简单的版本跑通“切片-上传-合并-预览”闭环,再一步步加并发控制、断点续传、秒传判断和异步合并。每一步都有自己的坑,提前踩一遍,总比上线了被人追着骂强。

返回列表