做保险理赔查勘的同事可能都有过这种经历:事故现场拍了好几个G的勘查视频,回到公司发现上传一直卡在99%,或者传了几分钟网络突然断开,又得从头再来。这类视频文件动辄几百MB甚至上GB,在基层网络环境下想一次性POST上去,基本等于赌运气。我自己接过一个保险系统的项目,需求就是在纯内网Web端做一个“分片秒传”功能,前端就是HTML+JavaScript,后端用的是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的请求体限制;二是支持断点续传,哪一块没传成功就重传哪一块,而不是整文件重来。
第二件是秒传。前端先算出这个文件的唯一指纹(通常使用文件大小加抽样哈希),把它提交给服务端查询。如果服务端发现这个文件之前已经上传过,那就直接返回“已有该文件”,前端连分片都不用传了,用户体验就是瞬间完成,所以叫“秒传”。在保险理赔场景里,同一个案件会被多次操作、复制重命名、换设备重复上传,秒传能省下大量带宽和重复存储。
这两件事合在一起,才是完整意义上的“HTML+PHP分片秒传”方案。它解决的不仅仅是技术上传问题,更是理赔环节里查勘员的工作效率问题。查勘员不用在事故现场干等着传视频,也不用被“上次传到90%又断了”折腾得心态崩掉。
2. 方案选型与关键参数设计
2.1 为什么坚持HTML+PHP
在这个项目里,我第一轮就把前端框架和后端语言的方案过了一遍。前端可以用Vue、React,有现成的上传组件;后端可以换成Java、Go,性能和并发都更强。但最后我还是选择了原生HTML+JavaScript配合PHP,原因很实际。
保险公司的内网系统往往历史包袱重,现有的理赔管理平台就是一个PHP项目,团队对PHP的维护经验也最丰富。引入一个新的Java微服务或者Go服务,意味着要额外维护一套部署链路、日志采集、监控告警,对于一个小团队的驻场开发项目来说,性价比太低。前端也一样,公司里面的业务系统兼容性要求高,一些查勘员用的还是老旧的Windows电脑和旧版浏览器,原生HTML+JavaScript没有构建步骤,不依赖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. 前端实现:HTML+JavaScript逐片上传
3.1 页面结构与文件切片核心逻辑
前端页面不需要很复杂,一个<input type="file">选择文件,一个进度条,一个上传结果展示区域就够用。核心在JavaScript的逻辑部分。
先看HTML骨架。
<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="UTF-8"> <title>理赔勘查视频上传</title> </head> <body> <h2>理赔勘查视频上传</h2> <input type="file" id="videoFile" accept="video/*"> <div> <button id="uploadBtn">开始上传</button> </div> <div id="progress">等待选择文件...</div> <script src="upload.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..." }前端拿到exist=true就直接提示秒传成功,不用再走上传流程。如果exist=false,就把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 Large | nginx的client_max_body_size没调大 | 设置为大于分片大小(20m) |
| 上传分片时报500,PHP返回“save chunk failed” | PHP临时目录不可写,或upload_tmp_dir磁盘已满 | 检查PHP临时目录权限,清理磁盘空间 |
| 上传分片时报Unexpected token或EOF | PHP的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分区占满。我建议定期处理已归档案件的文件,把超过半年且理赔流程已完结的视频转存到冷存储,保持热数据目录容量可控。别让分片功能本身成了故障源头。
这个系统上线后,查勘员的工作流有了明显变化。以前传一个大视频,人得守在电脑前面盯着进度条,现在选完文件就可以去处理下一个案件,系统全部在后台自动跑完,秒传的情况更是点一下按钮就直接结束。我在这个项目里最深的体会是,分片上传的技术本身不神秘,真正拉高完成度的永远是那些配置文件、命名规则、并发控制和失败重试这些边角细节。把这些边角打磨干净,用户体验自然就上来了。