ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

大文件分片秒传:HTML+PHP完整方案与踩坑记录

大文件分片秒传:HTML+PHP完整方案与踩坑记录 做保险理赔查勘的同事可能都有过这种经历事故现场拍了好几个G的勘查视频回到公司发现上传一直卡在99%或者传了几分钟网络突然断开又得从头再来。这类视频文件动辄几百MB甚至上GB在基层网络环境下想一次性POST上去基本等于赌运气。我自己接过一个保险系统的项目需求就是在纯内网Web端做一个“分片秒传”功能前端就是HTMLJavaScript后端用的是PHP不引入重量级组件。这篇文章把我当时的完整方案、参数计算逻辑和踩坑记录整理出来希望能给同样被大文件上传卡住的朋友一个可直接落地的参考。1. 需求拆解为什么保险理赔视频必须分片秒传1.1 理赔勘查视频的真实规模与传输痛点保险理赔勘查视频和普通的用户自拍视频完全是两个量级。查勘员到事故现场之后通常会用手机或专用执法记录仪绕着车辆拍一圈正前方、左前45度、右后45度、受损细节、底盘、内饰、行驶证驾驶证每个点位都要录一段1080P甚至4K的分辨率下一段十几秒的视频就是几十MB一个案件的全部素材汇总下来超过1GB非常常见。这些视频要回传到理赔系统由后端人工审核或者算法模型做定损分析。问题在于保险公司往往把这类系统部署在自有机房或内网环境办公网络的出口带宽并不宽裕。查勘员如果在外地偏远郊区移动网络信号不稳定一个1GB的文件走传统的HTTP文件上传中途任何一个抖动都可能断掉。断了就得从头传这种挫败感做过项目的人都懂。更麻烦的是PHP默认的post_max_size和upload_max_filesize一般只有8M、2M就算你改到100M、200M也会给Web服务器和PHP进程带来巨大的内存压力。一个大文件一次性POST上来PHP需要把整个请求体接收完再写盘期间如果nginx反向代理的client_max_body_size没调大直接被403或413挡回去查勘员根本传不上去。1.2 秒传的两种含义和核心价值“分片秒传”这个词在很多产品里被混着用但严格拆开其实是两件事。第一件是分片传输。把一个大文件用前端代码切成若干个小块逐块上传到服务器服务器全部收齐后再合并成一个完整文件。带来的直接好处有两个一是每次请求的体量小了不容易触发服务器和PHP的请求体限制二是支持断点续传哪一块没传成功就重传哪一块而不是整文件重来。第二件是秒传。前端先算出这个文件的唯一指纹通常使用文件大小加抽样哈希把它提交给服务端查询。如果服务端发现这个文件之前已经上传过那就直接返回“已有该文件”前端连分片都不用传了用户体验就是瞬间完成所以叫“秒传”。在保险理赔场景里同一个案件会被多次操作、复制重命名、换设备重复上传秒传能省下大量带宽和重复存储。这两件事合在一起才是完整意义上的“HTMLPHP分片秒传”方案。它解决的不仅仅是技术上传问题更是理赔环节里查勘员的工作效率问题。查勘员不用在事故现场干等着传视频也不用被“上次传到90%又断了”折腾得心态崩掉。2. 方案选型与关键参数设计2.1 为什么坚持HTMLPHP在这个项目里我第一轮就把前端框架和后端语言的方案过了一遍。前端可以用Vue、React有现成的上传组件后端可以换成Java、Go性能和并发都更强。但最后我还是选择了原生HTMLJavaScript配合PHP原因很实际。保险公司的内网系统往往历史包袱重现有的理赔管理平台就是一个PHP项目团队对PHP的维护经验也最丰富。引入一个新的Java微服务或者Go服务意味着要额外维护一套部署链路、日志采集、监控告警对于一个小团队的驻场开发项目来说性价比太低。前端也一样公司里面的业务系统兼容性要求高一些查勘员用的还是老旧的Windows电脑和旧版浏览器原生HTMLJavaScript没有构建步骤不依赖node_modules随便一个浏览器打开就能跑部署成本几乎为零。分片上传本身并不复杂核心其实就是HTML5的File对象和Blob.slice()方法这是浏览器原生能力。用原生代码实现反而更可控出现问题时不用去翻第三方组件的源码一行一行检查自己的逻辑就行。2.2 分片大小、并发数等参数如何定分片大小是整个方案里最关键的参数。我给出的建议是4MB到16MB之间实际项目里我最终选的是8MB。为什么是8MB可以从三个维度来看。第一是服务端限制。PHP的post_max_size和upload_max_filesize需要留出余量如果分片大小是8MB这两个配置至少要设置成20MB因为分片数据加上FormData里的其他字段文件名、分片索引、哈希值等会有少量额外开销加上网络传输分块配置太紧会被拒掉。第二是请求数量。一个1GB的文件如果用1MB分片就是1024个请求如果并发控制得不好队列会拖得很长如果用8MB分片就是128个请求性能和体验的平衡点比较理想。第三是网络容错。分片越小单个请求的失败重传代价越低但请求数量上去了握手和HTTP头的开销也会占比升高在弱网环境下反而容易被TCP拥塞控制影响。并发数同样要控制。PHP-FPM的进程池是固定数量的前端如果一次性并发十几个分片请求会把后端进程瞬间打满其他正常业务接口全部变慢。我在前端做了并发限制同时只允许3个分片请求在途这样既利用了带宽又不会打死PHP服务。2.3 前端秒传校验的思路秒传实现的关键是文件指纹。最严谨的做法是对整个文件做一次哈希计算比如MD5或者SHA-256但一个1GB的文件在全量计算哈希时非常耗时用户可能等了两三分钟还没开始上传体验就很差。实际项目中我采用了抽样哈希文件大小的组合方案。取文件头部1MB、中间1MB、尾部1MB拼在一起算一个SHA-256哈希再配合文件总字节数作为这个文件的唯一标识。为什么要取头中尾三段因为大多数视频文件的元数据集中在头部和尾部中间部分是压缩后的媒体流数据两个不同的视频如果在头中尾抽样后哈希一致再叠加文件大小一致基本可以认定为同一个文件。抽样哈希虽然不是100%严谨的完整性校验但作为秒传去重的依据已经足够。这里要特别注意crypto.subtle.digest可以在浏览器原生环境计算SHA-256但它要求页面运行在HTTPS或者localhost下。保险公司内网系统经常是HTTP访问这种情况可以改用SparkMD5这类纯JavaScript的哈希库虽然大文件抽样计算的耗时比原生接口稍高但稳定性和兼容性更稳妥。3. 前端实现HTMLJavaScript逐片上传3.1 页面结构与文件切片核心逻辑前端页面不需要很复杂一个input typefile选择文件一个进度条一个上传结果展示区域就够用。核心在JavaScript的逻辑部分。先看HTML骨架。!DOCTYPE html html langzh-cn head meta charsetUTF-8 title理赔勘查视频上传/title /head body h2理赔勘查视频上传/h2 input typefile idvideoFile acceptvideo/* div button iduploadBtn开始上传/button /div div idprogress等待选择文件.../div script srcupload.js/script /body /html文件选择后读取文件信息并计算分片。let file null; let fileId ; let chunkSize 8 * 1024 * 1024; // 8MB let totalChunks 0; document.getElementById(videoFile).addEventListener(change, function (e) { file e.target.files[0]; if (!file) return; totalChunks Math.ceil(file.size / chunkSize); document.getElementById(progress).textContent 文件大小: (file.size / 1024 / 1024).toFixed(2) MB分片数: totalChunks; });Blob.slice(start, end)是切片的核心方法返回一个Blob子集。整个文件的切片逻辑其实只有两行代码但真正的业务复杂度在于切完片之后怎么约束上传顺序、怎么处理失败重传、怎么和服务器确认每一片都到位。3.2 断点续传、并发控制与进度展示断点续传的实现思路是上传之前先问一下服务器“这个文件之前传过没有传了哪些分片”服务器返回一个已上传分片索引的数组前端循环的时候直接跳过这些索引只补传缺失的部分。秒传校验和断点查询可以合并成一个接口后端返回一个JSON结构类似这样{ exist: false, uploaded_chunks: [0, 1, 2, 5, 6], file_id: a3f2b8c1... }前端拿到existtrue就直接提示秒传成功不用再走上传流程。如果existfalse就把uploaded_chunks转换成Set在切片循环中跳过这些索引实现断点续传。并发控制我用了一个简单的任务队列模式。先把所有需要上传的分片索引放进一个数组然后同时启动3个工作协程每个协程不断从队列里取任务执行直到队列为空。这样实现干净不会出现某个飞快的分片传完了后面还要干等的浪费。async function uploadWithConcurrency(pendingTasks, concurrency 3) { let index 0; const worker async () { while (index pendingTasks.length) { const task pendingTasks[index]; let retry 0; while (retry 3) { try { await uploadChunk(task); break; } catch (err) { retry; if (retry 3) { throw new Error(分片上传失败: task.chunkIndex); } await sleep(1000 * retry); } } } }; await Promise.all(Array.from({ length: concurrency }, worker)); }关于上传进度的展示这里有一个容易被新手忽略的细节使用fetch做上传时拿不到上传进度事件只有等待响应。想要实时进度条必须使用XMLHttpRequest监听xhr.upload.onprogress事件。在分片上传场景里我会在每个分片上传期间把该分片对应的字节数累加到“已上传字节数”上配合文件总字节数算出整体进度效果比单独的请求级进度更精准。4. 后端实现PHP接收、索引与合并4.1 分片接收接口与临时文件管理后端的职责分成四块接收分片、记录索引、合并文件、清理临时数据。我建议把接收和合并拆成两个接口职责更清晰也方便单独调试。分片接收接口upload.php核心逻辑?php // upload.php 接收单个分片 $fileId $_POST[file_id] ?? ; $chunkIndex (int)($_POST[chunk_index] ?? 0); $chunkTotal (int)($_POST[chunk_total] ?? 0); $fileName $_POST[file_name] ?? ; $fileSize (int)($_POST[file_size] ?? 0); $tmpDir /data/upload_tmp/; if (!is_dir($tmpDir)) { mkdir($tmpDir, 0755, true); } // 安全校验fileId只允许字母数字和下划线防止路径穿越 if (!preg_match(/^[a-zA-Z0-9_]$/, $fileId)) { http_response_code(400); exit(invalid file_id); } // 保存分片 $partFile $tmpDir . $fileId . _ . str_pad($chunkIndex, 5, 0, STR_PAD_LEFT) . .part; if (!move_uploaded_file($_FILES[chunk][tmp_name], $partFile)) { http_response_code(500); exit(save chunk failed); } // 记录已上传分片索引用于断点续传 file_put_contents( $tmpDir . $fileId . .index, $chunkIndex . \n, FILE_APPEND | LOCK_EX ); echo json_encode([code 0, chunk_index $chunkIndex]);这里有几个关键点要重点说明。第一个是分片文件的命名规范。我用$fileId _ 5位补零的索引 .part比如a3f2b8c1_00007.part。为什么要补零因为如果索引是1、2、10这种长度不一的名字在合并时按字符串排序会排成1、10、2的顺序合并出来的文件就错乱了。统一补零到5位字符串排序就等于数字排序合并时直接按文件名排序读取即可逻辑简单又可靠。第二个是move_uploaded_file。PHP接收上传文件后文件先保存在PHP临时目录里必须用这个函数移动到自己的目录否则请求结束临时文件就被自动清除了。要注意确保upload_tmp_dir有足够的磁盘空间一般建议和上传暂存目录放在同一个磁盘分区避免跨分区复制导致性能下降。第三个是索引文件的写入方式。我用FILE_APPEND | LOCK_EX追加写每条记录一个分片索引。断点续传查询时把索引文件逐行读出来转成数组就是已传分片列表。这种文件型存储虽然看起来简陋但在单机部署的上传服务里完全够用胜在零依赖、无死锁、好排查。4.2 合并、校验与清理当前端把所有分片都上传成功后调用merge.php触发合并。合并的流程是读取索引文件按顺序把所有分片拼接成完整文件校验大小是否等于前端上报的文件大小最后删除临时分片和索引。?php // merge.php 合并分片 $fileId $_POST[file_id] ?? ; $fileName $_POST[file_name] ?? ; $fileSize (int)($_POST[file_size] ?? 0); $tmpDir /data/upload_tmp/; $finalDir /data/upload/; $finalFile $finalDir . $fileId . _ . basename($fileName); if (!preg_match(/^[a-zA-Z0-9_]$/, $fileId)) { http_response_code(400); exit(invalid file_id); } $indexFile $tmpDir . $fileId . .index; if (!is_file($indexFile)) { http_response_code(400); exit(index not found); } // 读取已上传分片索引 $uploadedChunks array_map(intval, file($indexFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)); sort($uploadedChunks); // 按索引顺序拼接分片 $fp fopen($finalFile, wb); foreach ($uploadedChunks as $chunkIndex) { $partFile $tmpDir . $fileId . _ . str_pad($chunkIndex, 5, 0, STR_PAD_LEFT) . .part; if (!is_file($partFile)) { fclose($fp); http_response_code(500); exit(missing chunk: . $chunkIndex); } fwrite($fp, file_get_contents($partFile)); } fclose($fp); // 校验最终文件大小 if (filesize($finalFile) ! $fileSize) { unlink($finalFile); http_response_code(500); exit(file size mismatch); } // 合并成功清理所有分片和索引 foreach ($uploadedChunks as $chunkIndex) { $partFile $tmpDir . $fileId . _ . str_pad($chunkIndex, 5, 0, STR_PAD_LEFT) . .part; if (is_file($partFile)) unlink($partFile); } unlink($indexFile); echo json_encode([code 0, file $finalFile]);合并时用的是fopen(..., wb)加fwrite的方式而不是直接file_put_contents追加。这有一个好处如果在合并过程中某个分片缺失可以立即终止并返回错误不会留下残缺的预览文件让业务侧误以为上传成功。文件大小校验只能证明“字节数一致”不能100%证明文件内容没损坏。严格一些的做法是对合并后的完整文件再做一次全量哈希对比前端计算整文件的MD5或者SHA-256后端合并完成后同样计算一次比对。但上面说了全量哈希在大文件上很耗时这就要看业务对证据链的严谨程度要求了。保险理赔视频是证据材料我在这个项目里最后加了一层抽样哈希的最终校验合并后取头中尾三段重新计算哈希与前台上报值比对兼顾了速度和可靠性。5. 上线前的配置清单与常见问题5.1 环境配置与安全加固如果直接把上面的代码丢到生产环境大概率会踩到一堆配置坑。PHP和Web服务器的各项限制必须提前调好。; php.ini 关键配置 memory_limit 256M post_max_size 20M upload_max_filesize 20M max_execution_time 0nginx的client_max_body_size也要同步调大。虽然单个分片只有8MB但加上multipart/form-data的编码额外开销HTTP请求体可能到8.2MB左右nginx默认的1MB会直接把这个请求挡下来。我建议设置成20MB留足余量。server { # 这个限制要大于分片大小 client_max_body_size 20m; }安全方面保险行业对数据合规要求很高有几个隐患必须堵住。第一是上传目录禁止执行PHP。如果用户传一个伪装成视频的PHP文件再直接访问上传目录的URL就可能触发远程代码执行。在nginx里对上传目录单独做限制location /data/upload/ { location ~ \.php$ { deny all; } }更好的做法是把上传目录放在Web根目录之外通过PHP脚本去读取和输出文件从根上杜绝直接URL访问。理赔系统里视频要回放预览我建议单独写一个play.php做鉴权后再输出视频流不要直接把上传目录暴露出去。第二是文件名处理。分片合并后的最终文件名如果直接用用户上传的原始文件名会存在路径穿越和特殊字符注入的风险。我的做法是最终文件名统一用$fileId _ 案件号 扩展名的格式原始文件名只用来提取扩展名而且扩展名要做白名单校验只允许mp4、mov、avi、m4v这些视频格式。这样即使文件名里带着../../或者%00之类的恶意内容也不会影响到保存路径。第三是接口鉴权。理赔系统的上传接口必须走登录态校验不能裸奔在公网上。如果沿用老的PHP项目就直接使用现有的Session鉴权逻辑在上传和合并接口入口处加上登录校验。另外要给上传接口加一个简单的频率限制防止有人写脚本恶意灌入大量垃圾分片把磁盘打满。5.2 高频问题排查速查表实际运行中遇到最多的问题我把它们整理成一张速查表方便各位直接对照处理。现象可能原因处理方法上传分片时报413 Request Entity Too Largenginx的client_max_body_size没调大设置为大于分片大小20m上传分片时报500PHP返回“save chunk failed”PHP临时目录不可写或upload_tmp_dir磁盘已满检查PHP临时目录权限清理磁盘空间上传分片时报Unexpected token或EOFPHP的post_max_size或upload_max_filesize小于分片大小调整php.ini重启PHP-FPM合并后视频播放到中间卡住分片顺序错乱确认分片文件名补零合并前sort(SORT_NUMERIC)秒传永远不生效每次都重新传文件头中尾抽样哈希算法不一致或fileId生成规则前后端不一致核对前后端fileId的计算规则统一抽样区间多分片并发上传部分分片丢失并发数过高导致PHP-FPM进程池耗尽前端限制并发数2-3个调高php-fpm的pm.max_children合并时报missing chunk某个分片上传失败但前端没重传成功检查前端失败重试逻辑合并前先比对索引完整性内网HTTP环境crypto.subtle报错crypto.subtle只在HTTPS或localhost下可用改用SparkMD5等第三方哈希库上传速度极慢分片大小太小导致请求数量过多在弱网下适当调大到16MB减少请求往返次数排查这类问题有一个通用的思路先看是不是配置问题再看是不是代码问题最后才怀疑网络问题。配置问题的特征是最直接——报错信息里会提到413、500、之类的HTTP状态码跟着状态码去查对应服务器的限制就行。代码问题往往表现得更随机比如某个分片偶发失败、合并后文件大小对不上这时候可以先打开PHP的display_errors和error_log看后端日志里有没有明确报错再配合前端开发者工具里Network面板逐条检查分片请求的响应体。最后一个容易忽视的坑是磁盘空间。理赔勘查视频动辄一个案件好几个GB生产环境的磁盘如果没提前规划好上线运行一两个月就能把/data分区占满。我建议定期处理已归档案件的文件把超过半年且理赔流程已完结的视频转存到冷存储保持热数据目录容量可控。别让分片功能本身成了故障源头。这个系统上线后查勘员的工作流有了明显变化。以前传一个大视频人得守在电脑前面盯着进度条现在选完文件就可以去处理下一个案件系统全部在后台自动跑完秒传的情况更是点一下按钮就直接结束。我在这个项目里最深的体会是分片上传的技术本身不神秘真正拉高完成度的永远是那些配置文件、命名规则、并发控制和失败重试这些边角细节。把这些边角打磨干净用户体验自然就上来了。
返回列表