news 2026/9/24 19:28:33

HTML+PHP实现超大视频分片秒传与断点续传:保险理赔勘查实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HTML+PHP实现超大视频分片秒传与断点续传:保险理赔勘查实战

理赔勘查视频的上传,我猜做过保险行业系统的朋友都有过这种体验:勘查员在外面拍了一段十几分钟的事故现场视频,文件动辄几百MB甚至上GB,回到车里用4G网络传回公司,结果传到80%断了,又得从头再来。定损等着看视频,领导催进度,勘查员急得骂人,技术这边只能干瞪眼。

这个场景催生了一个很典型的Web开发需求:在浏览器端实现超大视频文件的上传,要快、要稳、还要能在网络抖动时续传。而保险行业内部系统往往并不是什么高精尖的架构,很多还是经典的HTML+PHP组合,这就带来一个问题:这套技术栈能不能搞定超大文件分片上传?

答案是肯定的,而且实施下来体验相当不错。这篇文章就围绕“保险行业如何用HTML+PHP实现理赔勘查视频的超大文件分片秒传”这个主题,把整体设计思路、前端切片、后端合并、断点续传、秒传判断这些环节逐个拆开讲,包含可以直接参考的代码思路和踩坑记录,希望能给正在做类似系统的同学一些实在的帮助。

1. 场景分析与方案选型:为什么理赔勘查视频必须分片上传

1.1 理赔勘查视频的真实痛点

保险理赔的勘查环节,尤其是车险、农险、财产险,现场情况复杂,勘查员需要用手机或执法记录仪拍摄视频。这类视频的共同点就是:单文件体积大、时长相对长,而且拍摄环境往往是事故现场、田间地头、地下车库这些网络条件不稳定的地方。

传统的表单文件上传,就是把整个文件塞进HTTP请求体里一次性提交,这种方案在几MB的图片场景下没什么问题。但视频文件一旦超过几百MB,问题就集中爆发了,最典型的三个表现是:

  • 上传超时。PHP默认的max_execution_time是30秒,就算你调到300秒,一个500MB的文件在普通上行带宽下也未必传得完,更不用说移动网络的上行带宽本来就被运营商限得比较厉害。
  • 请求体过大被服务端拒绝。post_max_sizeupload_max_filesize如果没调大,文件直接就被PHP拒收了,前端拿到的就是500错误或者空白响应。
  • 断线后一切归零。移动网络在移动中切换基站是常态,一个TCP连接说断就断,断了之后整包重传,之前传了半小时的数据全白费。

我当时去一个分支机构的理赔部门调研,勘查员跟我吐槽说,最怕的就是拍了重要现场视频传不回去,只能把手机拿回公司连Wi-Fi再试,而Wi-Fi传输大文件同样会出问题。这个痛点其实本质上不是带宽问题,而是传输可靠性问题,分片上传正好切中要害。

1.2 分片上传、秒传、断点续传的技术拆解

“分片秒传”这个词听起来高大上,实际拆开就是三个能力组合在一起:

分片上传是基础。前端用Blob.prototype.slice方法把大文件切成若干个小块,比如每片5MB,然后逐个上传。这样做的好处很多:单个请求体变小,服务端处理压力分散;某个分片失败只需要重传这个分片,不用整包重来;多个分片还可以并发上传,充分利用带宽。

秒传则是用户体验层面的优化。核心原理是:文件内容本身是确定的,同一段视频不管谁传,只要内容一样,文件哈希(比如MD5、SHA-1)就一样。前端先算出整个文件的哈希值,发给后端查一下“这个文件是不是已经存在了”,如果已存在就直接返回“上传成功”,一秒都不用等。表面上是“秒传”,实际上后端根本没有接收任何文件数据,只是返回了一个已存在的记录。

断点续传是分片上传的自然延伸。因为文件被切成了多个独立分片,后端可以记录每个分片的上传状态。前端再次发起上传时,先从后端拿一份“已上传分片清单”,只上传缺失的部分,就能做到断点续传。

这三个能力叠加,用户感知就是:选择文件后很快提示上传成功,或者中途断了再点一次继续上传。这恰好覆盖了理赔勘查视频传输的全部核心诉求。

1.3 技术栈选型:HTML+PHP到底够不够用

可能有人会有疑问,现在前端框架满天飞,后端又有Go、Java,为什么要选HTML+PHP?

这里要结合保险行业的实际情况看。保险公司的核心业务系统往往建设年头比较久,很多内部系统还是PHP开发的,或者用了很多年PHP维护得很稳定。让理赔系统单独引入一套Java微服务或者Go服务,从运维、部署、人员技能储备来看都不现实。反而是PHP这套,运维熟、开发熟、部署简单,往现有系统里加几个接口非常自然。

前端用原生HTML+JavaScript也是出于同样的考虑。理赔勘查员用的手机和平板型号五花八门,内部系统面向的浏览器环境没法统一要求,原生HTML5的文件API在所有现代浏览器上都支持,不需要引入重量级框架,也方便在现有页面里直接嵌入。而且HTML5标准里提供的FileBlobXMLHttpRequest这些接口完全能支撑分片上传,不需要任何第三方库。

当然,我承认这种做法有一定的工程牺牲,比如并发切片的状态管理、上传进度展示这些都得自己写,不像有些现成组件拿来即用。但在保险行业这种重稳定、轻创新的系统环境里,用最朴素的工具解决最实际的问题,反而是最优解。

看到这里你可能已经明白:方案选型从来不是选最新最炫的,而是选你的团队能稳定维护的、你的服务器能从容运行的、你的用户操作起来不会出错的。HTML+PHP的组合在分片上传这个场景下,完全合格。

2. 核心实现原理:分片、秒传、断点续传底层逻辑

2.1 分片是如何切的:Blob.slice与File对象

前端拿到用户选择的文件,其实就是一个File对象。File继承自Blob,而Blob上有一个slice方法,可以从一个大文件中切出指定字节范围的一部分,返回一个新的Blob对象。

举个例子,一个100MB的视频文件,如果定义每个分片5MB,那么一共要切20片。第1片的字节范围是0到5MB(左闭右开:[0, 5MB)),第2片是[5MB, 10MB),依次类推。前端代码大致是这样:

const file = fileInput.files[0]; const chunkSize = 5 * 1024 * 1024; // 5MB const totalChunks = Math.ceil(file.size / chunkSize); for (let i = 0; i < totalChunks; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize, file.size); const blob = file.slice(start, end); // 这个blob就是第i个分片,可以放进FormData里上传 }

这里有个容易忽略的细节:file.slice切出来的是原文件的“引用式切片”,并没有真正复制底层数据,也不会重新编码视频,所以切分过程非常快,基本不消耗内存。正因为这个特性,才能在浏览器端轻松处理超大文件,否则光切分这个动作就可能把手机内存撑爆。

另外一个关键点:由于切片只是原文件字节流的视图,只要原文件没有被修改,同一文件的切片结果就是确定的。这个特性为后面的秒传和断点续传提供了底层的确定性保障。

2.2 秒传的钥匙:文件哈希与索引表

秒传的实现依赖文件哈希,这里需要明确一点:秒传核心并不是“传得快”,而是“不用传”。后端需要一种方法来识别“这个文件之前已经传过了”,哈希就是这种指纹。

原理是:内容相同的文件,其哈希值必然相同。视频文件哪怕只修改了一个字节,哈希值也会完全不同。所以前端可以先计算整个文件的哈希值,上传之前先发一个“探测请求”到后端,后端拿着这个哈希查一下自己的记录表,如果发现同样哈希的文件已经存在,就直接返回“该文件已上传成功”,前端连分片都不用发,页面上看起来就是秒传。

理想情况下,应该用MD5、SHA-1这类算法给整个文件计算一个唯一的哈希。但这里有个很现实的问题:超大文件的完整哈希计算,在浏览器端并不快。一个1GB的视频文件,用JavaScript计算MD5,在普通手机上可能要几十秒甚至几分钟,这个等待本身就是一种糟糕的体验,也违背了“秒传”的初衷。

针对这个问题,业界有几种不同的应对策略。轻量方案是只取文件的首部、中部、尾部各一段字节,计算抽样哈希,牺牲一定的唯一性换取速度;重型方案是用Web Worker后台线程配合增量哈希算法,边读文件边计算,不阻塞UI,体验好但代码复杂度高;还有一种变通方案是不用哈希秒传,而是用“文件名+文件大小+最后修改时间”作为文件标识,速度极快但可靠性差一点。

我个人的建议是:对于理赔勘查视频这个场景,可以采用“抽样哈希为主、文件大小辅助验证”的折中方案。不需要做到绝对唯一,因为相同大小相同抽样哈希的冲突概率极低,而换来的是在手机上也是瞬间出结果。后面我会给出具体实现。

2.3 断点续传的数据库设计与状态机

断点续传需要后端记录“这个文件传了哪些分片、还缺哪些分片”,这个记录表是整个断点续传功能的基石。我在设计这个表的时候,没有把分片状态塞进文件表里,而是单独建了一张分片表,结构大致如下:

字段名类型说明
idbigint 主键自增分片记录唯一标识
file_idvarchar(64)业务侧的文件标识,我用的是前端生成的uploadId
chunk_indexint分片序号,从0开始
chunk_hashvarchar(64)分片内容哈希,用于校验完整性
chunk_sizebigint分片字节数
statustinyint0-等待 1-已上传 2-已合并
created_atdatetime创建时间
updated_atdatetime更新时间

而文件主表则负责记录整个上传任务的全局状态,字段比这个复杂一些,核心是这几个:

字段名类型说明
idbigint 主键自增文件记录
upload_idvarchar(64)前端生成的唯一上传会话ID
file_hashvarchar(64)文件抽样哈希(秒传判断用)
file_namevarchar(255)文件名
file_sizebigint文件总字节数
total_chunksint总分片数
uploaded_chunksint已上传分片数
statustinyint1-上传中 2-上传完成 3-已合并 4-失败
created_atdatetime创建时间

这两张表配合,整个上传任务就变成了一个状态机:前端每次上传分片时带上传upload_id,后端实时更新uploaded_chunks计数;前端在分片全部上传完后,调用“合并接口”;后端检查到所有分片已就位,执行合并,并把状态改成“已合并”;如果中间断了,前端重新进来时调用“查询状态接口”,后端把status没标记为“已上传”的分片序号列表返回给前端,前端只补传这些分片即可。

这个状态机设计是断点续传的核心,uploaded_chunks计数只是表面信息,真正决定续传范围的是分片表里那些status=0或者记录不存在的分片序号。

3. 前端实现:HTML+JavaScript完成上传页面四件套

3.1 页面基础结构与文件选择

前端的页面结构不需要多花哨,一个文件选择控件、一个上传按钮、一个进度条、一个状态提示区,足够了。保险行业的勘查员年龄跨度大,操作界面尽量简单直观,不要搞一堆花里胡哨的交互。

下面是基础HTML结构,我实际项目里就是从这个结构开始改的:

<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>理赔勘查视频上传</title> <style> .upload-panel { max-width: 640px; margin: 40px auto; padding: 24px; border: 1px solid #ddd; border-radius: 8px; } .progress-wrap { height: 20px; background: #f0f0f0; border-radius: 10px; margin: 16px 0; overflow: hidden; } .progress-bar { height: 100%; width: 0; background: #2e7d32; transition: width 0.3s; } .status-text { font-size: 14px; color: #666; } .btn { padding: 8px 16px; } </style> </head> <body> <div class="upload-panel"> <h3>理赔勘查视频上传</h3> <input type="file" id="videoFile" accept="video/*"> <button id="uploadBtn" class="btn">开始上传</button> <div class="progress-wrap"> <div class="progress-bar" id="progressBar"></div> </div> <div class="status-text" id="statusText">等待选择文件</div> </div> <script src="upload.js"></script> </body> </html>

这里要注意几个细节:accept="video/*"只是浏览器层面的文件类型过滤提示,并不能阻止用户选择其他类型文件,后端必须再次校验文件类型,这个安全习惯一定要养成;id命名规范要清晰,方便后面JavaScript操作DOM;CSS里给进度条加了过渡动画,让进度变化看起来平滑一些,减少用户焦虑感。

3.2 切片逻辑与并发控制

文件选择后,先做基础校验,然后初始化上传任务。切片的代码本身不复杂,复杂的是怎么管理多个分片的并发上传。如果一次把所有分片都发出去,几十个请求同时打到服务器,带宽和服务器压力都吃不住;如果一个个串行上传,大文件的耗时又太长。所以需要一个并发控制的机制。

简单有效的做法是维护一个并发任务池,固定并发数。我这边实测的经验是:

网络环境推荐并发数单分片大小
移动4G/5G弱网2-32MB-4MB
普通Wi-Fi3-55MB
办公内网/高带宽5-85MB-10MB

不建议并发数设置过高,因为移动网络下并发请求过多反而会互相抢带宽,导致每个请求都变慢,整体吞吐率反而下降。而且服务端PHP默认的并发处理能力也有限,Nginx的worker_connections、PHP-FPM的pm.max_children都有上限,分片太碎太多会把后端连接池打满。

我用的并发控制思路是一个简单的“调度器”:

const file = document.getElementById('videoFile').files[0]; const chunkSize = 5 * 1024 * 1024; const totalChunks = Math.ceil(file.size / chunkSize); const uploadId = generateUploadId(); // 可以用文件名+时间戳+随机数生成 async function uploadWithConcurrency(concurrency = 3) { let current = 0; const tasks = []; for (let i = 0; i < totalChunks; i++) { tasks.push(i); } async function worker() { while (tasks.length > 0) { const chunkIndex = tasks.shift(); await uploadChunk(chunkIndex); } } const workers = []; for (let i = 0; i < concurrency; i++) { workers.push(worker()); } await Promise.all(workers); }

这段代码的核心就是维护一个待上传分片队列,启动N个worker并发消费队列。每个worker上传完一个分片后再取下一个,直到队列清空。这种方式不同于“一次把所有分片发出去”,它能精确控制同时在线的请求数,服务器压力是可控的。

实际上传的时候还需要处理异常重试。我给uploadChunk加了一个简单的重试机制:

async function uploadChunk(chunkIndex, retries = 3) { const start = chunkIndex * chunkSize; const end = Math.min(start + chunkSize, file.size); const blob = file.slice(start, end); const formData = new FormData(); formData.append('file', blob, `chunk-${chunkIndex}`); formData.append('uploadId', uploadId); formData.append('chunkIndex', chunkIndex); formData.append('totalChunks', totalChunks); formData.append('fileName', file.name); formData.append('fileSize', file.size); for (let attempt = 1; attempt <= retries; attempt++) { try { const resp = await fetch('upload.php', { method: 'POST', body: formData }); if (resp.ok) { return; } } catch (e) { // 网络异常,记录日志 } // 指数退避等待后重试 await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); } throw new Error(`分片 ${chunkIndex} 上传失败`); }

这里面的指数退避等待很重要。如果网络已经出了问题,立即重试大概率还是失败,等1秒、2秒、4秒逐渐递增,给网络恢复留出时间。而且重试次数上限3次就够了,再多的重试会占用用户时间,不如尽早报错让用户手动续传。

3.3 哈希计算的性能优化:增量摘要与Web Worker

前面提到秒传需要计算文件哈希,但对于超大视频文件,一次性读完整文件计算哈希,在移动端是不现实的。这里有两种提速思路可以并行使用。

第一种是Web Worker后台计算。把文件Blob传递给Worker线程,Worker里使用FileReader分块读取文件内容,每读一块就累加计算哈希,这样页面UI不会卡顿,用户可以继续操作或观看进度提示。

第二种是抽样哈希。不必读完整文件,只读文件开头64KB、中间64KB、结尾64KB,合成约192KB的样本数据,对这个样本计算MD5。这个哈希虽然不是全文件哈希,但它结合了文件不同位置的字节信息,冲突概率极低。对于理赔勘查视频来说,两个不同视频在这三个位置都恰好完全相同几乎不可能。

Web Worker的代码大致是这样的:

// worker.js self.onmessage = async function(e) { const { file, sampleSize } = e.data; const blobSlices = []; // 开头64KB blobSlices.push(file.slice(0, sampleSize)); // 中间64KB(如果文件足够大) if (file.size > sampleSize * 3) { const mid = Math.floor(file.size / 2); blobSlices.push(file.slice(mid, mid + sampleSize)); } // 末尾64KB blobSlices.push(file.slice(file.size - sampleSize, file.size)); // 合并成一个Blob后读取 const combined = new Blob(blobSlices); const arrayBuffer = await combined.arrayBuffer(); const hash = await computeMd5(arrayBuffer); // 使用第三方库或Web Crypto self.postMessage({ hash }); };

主线程里只需要两行代码就能调用:

const worker = new Worker('worker.js'); worker.onmessage = (e) => { fileHash = e.data.hash; // 接下来用fileHash去探测是否秒传 }; worker.postMessage({ file, sampleSize: 64 * 1024 });

这种“Worker+抽样”的组合方案,在1GB的视频上实测大概1-2秒就能算出哈希,用户体感接近瞬间完成,秒传的体验就建立在这个基础上。

3.4 进度展示与状态管理

进度条不能光显示一个百分比,对于分片上传来说,要区分“当前正在传哪一片”和“整体完成了多少”,否则用户体验很迷茫。我给进度条做了两层信息:

function updateProgress(completedChunks, totalChunks) { const percent = Math.round(completedChunks / totalChunks * 100); progressBar.style.width = percent + '%'; statusText.textContent = `正在上传 ${completedChunks}/${totalChunks} 个分片,已完成 ${percent}%`; }

状态管理方面,需要在内存中维护一个Map来记录每个分片是否上传成功。前面提到的断点续传,前端拿到后端返回的“已上传分片列表”,其实就可以直接初始化这个Map,然后只把没传过的分片塞进任务队列。

我踩过的坑是:为了简单,最初把分片状态放在一个普通数组里,用push记录已传分片,后来发现某些分片重试成功后被记录了两次,导致计数不准确。这里用Set或者Map按分片索引存储更稳妥:

const uploadedChunks = new Set(); // 存储已成功上传的分片索引 // 续传初始化 serverUploadedChunks.forEach(index => uploadedChunks.add(index));

4. 后端实现:PHP接收分片、合并与秒传判断

4.1 接口设计与参数约定

前端和后端之间必须有清晰稳定的接口约定。我设计的三个核心接口分别是:探测秒传、上传分片、合并文件。另外还有查询状态接口用于断点续传。

接口设计的原则是简洁清晰,每个接口只做一件事。前端通过uploadId来标识一个上传任务,后端靠这个ID来归档分片、记录状态。参数中必须包含的信息:

探测接口check.php

  • file_hash:文件的抽样哈希
  • file_size:文件总字节数
  • file_name:文件名

返回JSON格式:如果哈希存在且文件大小一致,则返回{"code": 0, "exists": true, "file_url": "..."},前端收到这个就直接提示上传完成;否则返回{"code": 0, "exists": false}

上传分片接口upload.php

  • upload_id:上传任务ID
  • chunk_index:分片序号
  • total_chunks:总分片数
  • file_name:文件名
  • file_size:文件大小
  • chunk_hash:分片的MD5
  • file:分片文件本身

这里有个隐含的技巧:total_chunksfile_size不必每个分片都传,但传了之后后端可以做校验,防止前端逻辑出错导致分片错乱。

合并接口merge.php

  • upload_id:上传任务ID
  • file_name:文件名

返回合并后的文件访问路径和最终的URL。

查询接口status.php

  • upload_id:上传任务ID

返回该任务已上传的分片序号列表,用于断点续传。

4.2 分片接收与临时目录管理

PHP接收分片和接收普通上传文件的代码差别不大,但是有两点必须注意:一是分片文件的落盘位置,二是不完整分片的清理策略。

接收分片的PHP代码:

<?php // upload.php $uploadId = $_POST['upload_id'] ?? ''; $chunkIndex = (int)($_POST['chunk_index'] ?? 0); $totalChunks = (int)($_POST['total_chunks'] ?? 0); $fileName = $_POST['file_name'] ?? ''; $fileSize = (int)($_POST['file_size'] ?? 0); $chunkHash = $_POST['chunk_hash'] ?? ''; if (empty($uploadId) || empty($fileName)) { jsonResponse(['code' => 400, 'msg' => '参数错误']); return; } $uploadDir = '/data/uploads/tmp/' . $uploadId . '/'; if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } $tmpFile = $_FILES['file']['tmp_name']; $targetFile = $uploadDir . sprintf('%05d.chunk', $chunkIndex); // 校验分片大小是否符合预期(可选),并校验哈希 $md5 = md5_file($tmpFile); if ($chunkHash !== '' && strtolower($md5) !== strtolower($chunkHash)) { jsonResponse(['code' => 400, 'msg' => '分片校验失败']); return; } if (!move_uploaded_file($tmpFile, $targetFile)) { jsonResponse(['code' => 500, 'msg' => '分片保存失败']); return; } // 更新分片表状态 markChunkUploaded($uploadId, $chunkIndex, $md5); // 返回当前已上传分片数 jsonResponse(['code' => 0, 'msg' => 'ok', 'uploadedChunks' => countUploadedChunks($uploadId)]);

临时目录的管理非常关键。/data/uploads/tmp/{uploadId}/这个目录下,每个分片以%05d.chunk命名,比如00000.chunk00001.chunk。文件名里序号补零的好处是,后续合并时按文件名排序就能得到正确的分片顺序。

还有一个很容易被忽略的问题:PHP进程对上传文件的行为。move_uploaded_file是PHP处理上传文件的标准方式,它不仅能移动文件,还会检查文件是否是有效的上传文件,安全性比直接用rename好很多。千万不要图省事直接用file_put_contents$_FILES['file']['tmp_name']配合,那样可能会引入本地文件包含之类的安全隐患。

同一uploadId多次上传相同分片时,直接覆盖旧文件即可。这种幂等性设计支持了“重试”和“断点续传”:前端可能因为超时重发同一个分片,后端不会因为它已存在就报错,而是正常覆盖并返回成功。

4.3 文件合并的两种方式与选择

分片全部到位后,进入合并阶段。PHP合并分片文件有两种实现方式,各有适用场景。

第一种是流式合并,逐片读取内容写入目标文件:

<?php // merge.php 核心逻辑 $uploadId = $_POST['upload_id'] ?? ''; $fileName = $_POST['file_name'] ?? ''; $tmpDir = '/data/uploads/tmp/' . $uploadId . '/'; $finalDir = '/data/uploads/final/' . date('Y/m/d') . '/'; if (!is_dir($finalDir)) { mkdir($finalDir, 0755, true); } $finalPath = $finalDir . uniqid() . '_' . $fileName; $finalHandle = fopen($finalPath, 'wb'); $chunkCount = getUploadedChunkCount($uploadId); $totalChunks = (int)getTotalChunksByUploadId($uploadId); if ($chunkCount < $totalChunks) { jsonResponse(['code' => 400, 'msg' => '还有分片未上传']); return; } for ($i = 0; $i < $totalChunks; $i++) { $chunkFile = $tmpDir . sprintf('%05d.chunk', $i); if (!file_exists($chunkFile)) { fclose($finalHandle); unlink($finalPath); jsonResponse(['code' => 500, 'msg' => "分片 $i 缺失"]); return; } $chunkHandle = fopen($chunkFile, 'rb'); stream_copy_to_stream($chunkHandle, $finalHandle); fclose($chunkHandle); unlink($chunkFile); // 合并完删除分片 } fclose($finalHandle); // 清理临时目录 rmdir($tmpDir); jsonResponse(['code' => 0, 'msg' => 'ok', 'fileUrl' => '/uploads/final/...']);

stream_copy_to_stream是PHP里流式复制的高效函数,内部有缓冲,比循环fread+fwrite效率高一个档次。这里我同时做了两件事:合并文件和删除已合并的分片,这样磁盘空间不会因为临时文件堆积而爆掉。

第二种是Shell命令合并,用系统级命令快速拼接:

$cmd = "cat " . $tmpDir . "*.chunk > " . escapeshellarg($finalPath); exec($cmd, $output, $returnCode);

这个方案更快,因为cat命令是C语言实现的,文件拼接速度快。但要注意几个坑:*.chunk的通配符排序依赖文件名,所以前面说的补零命名就很重要,否则5.chunk会排在20.chunk后面;exec函数在很多PHP环境里被禁用了,得确认服务器是允许的;而且用系统命令处理用户输入的文件名,必须做好转义,防止命令注入。

实际项目中,我建议默认用”流式合并“,只有确认了服务器环境不会禁用exec、并且文件量特别大时,才考虑用Shell方案。

4.4 秒传与去重逻辑的实现

秒传的判断很简单,就是查表:

<?php // check.php $fileHash = $_POST['file_hash'] ?? ''; $fileSize = (int)($_POST['file_size'] ?? 0); if (empty($fileHash)) { jsonResponse(['code' => 400, 'msg' => '缺少文件哈希']); return; } // 查文件表 $stmt = $pdo->prepare("SELECT id, file_url FROM upload_files WHERE file_hash = ? AND file_size = ? LIMIT 1"); $stmt->execute([$fileHash, $fileSize]); $row = $stmt->fetch(); if ($row) { // 如果业务需要,可以为当前用户生成一条关联记录 jsonResponse(['code' => 0, 'exists' => true, 'fileUrl' => $row['file_url']]); } else { jsonResponse(['code' => 0, 'exists' => false]); }

这里有个值得讨论的细节:秒传之后,虽然物理文件没有重复存储,但业务上需要给“当前这次上传”生成一条独立的记录。因为同一个视频可能在多个理赔案件中被引用,如果只复用物理文件而逻辑记录也共用一条,后续理赔单关联视频时就会出问题。

正确的做法是物理文件唯一(只存储一份),逻辑记录仍然为每次上传生成一条。文件表里保存file_hashfile_url,业务表里关联file_url或者文件记录的ID。这样既节省了存储空间,又不影响理赔案件的独立性。

我是强烈建议做物理文件去重的。保险行业的视频资料动辄几百GB,重复存储就是巨大的成本浪费。哈希秒传不仅提升了用户体验,还顺带解决了存储重复的问题,属于一石二鸟。

5. 实操中的高并发与稳定性处理

5.1 Nginx/Apache/PHP相关参数配置

分片上传虽然每个分片不大,但并发请求数量会比较多。如果不调整服务器默认配置,很可能出现“分片传到一半服务器罢工”的情况。

PHP侧的三个关键参数必须调整:

; php.ini memory_limit = 256M upload_max_filesize = 20M post_max_size = 25M

upload_max_filesize要略大于单个分片的大小,比如分片是5MB,这个值至少设成20M,留出余量;post_max_size必须大于upload_max_filesize,因为POST请求里除了文件外还有其他表单字段,如果post_max_size等于upload_max_filesize,刚好压线也容易出问题。

max_execution_time建议设大一些,如300秒,因为大分片上传到服务器后还要写磁盘,极端情况下处理时间会超过默认30秒。

Nginx侧也要配合:

client_max_body_size 25m; client_body_timeout 60s; proxy_read_timeout 300s;

client_max_body_size必须大于PHP的post_max_size,否则Nginx层就会直接拒绝请求,PHP代码根本执行不到。这个参数位置比较隐蔽,我遇到过好几次排查半天才发现是Nginx先挡了。

PHP-FPM的并发能力也要关注:

; php-fpm.conf pm.max_children = 50 pm.start_servers = 10 pm.max_requests = 500

max_children决定了PHP-FPM同时能处理的请求数。如果前端并发分片数是5,同时有10个用户在上传,就会有50个分片请求并发到达PHP-FPM,再加上业务系统的其他请求,max_children设小了就会排队等待,表现为上传速度变慢甚至超时。

5.2 并发分片的顺序错乱与切片校验

分片并发上传会带来一个自然的问题:分片到达后端的顺序不是按照序号来的。第5片可能比第2片先到达,这在分片存储阶段没问题,因为文件名里有序号,各写各的互不干扰。但到了合并阶段就需要注意了,合并前必须确认所有分片都存在,而且要严格按序号顺序拼接。

我还在分片上传时增加了分片哈希校验。前端对每个分片计算MD5,放在chunk_hash字段里一起提交,后端接收后用md5_file计算收到的分片文件哈希,不一致直接拒绝。这是为了排查网络传输中数据损坏的问题。虽然TCP协议本身有校验,但在弱网环境下,TCP校验失败会导致重传,一般不会把损坏数据交给应用层,但我仍然建议加这道校验,因为有一次线上问题就是运营商网络设备返回了错误数据,别问我怎么知道的。

合并完成后,最好对整个文件做一次大小校验:

// 合并后检查大小是否等于前端声明的大小 $finalSize = filesize($finalPath); if ($finalSize !== $fileSize) { // 大小不一致,说明合并过程有分片缺失或文件损坏 unlink($finalPath); jsonResponse(['code' => 500, 'msg' => '文件大小校验失败']); }

这个校验成本极低,但能拦截大部分合并错误。

5.3 异步合并与任务队列

同步合并在小文件场景下没问题,但如果是几GB的视频文件,PHP的同步合并可能会执行几秒甚至几十秒,HTTP请求一直挂着,前端体验很差,也容易因为网关超时导致请求中断。

更稳妥的做法是引入异步合并。后端上传分片的接口只负责接收和记录,当检测到“最后一个分片已上传”时,把合并任务放进一个消息队列(比如Redis列表、RabbitMQ),然后立即返回给前端“分片已全部上传,合并中”。由后台的常驻Worker进程(可以用CLI模式运行PHP脚本,或者用系统定时任务轮询)去执行合并操作。

异步合并的PHP Worker大致逻辑:

<?php // worker.php 命令行模式运行 while (true) { $uploadId = $redis->lpop('merge_queue'); if (!$uploadId) { sleep(2); continue; } mergeFile($uploadId); }

前端在“合并中”状态下轮询查询状态接口,等到文件状态变为“已合并”后再显示成功。这个方案比同步合并可靠得多,而且把耗时的合并操作从Web请求链路里摘出去了,Web服务器的进程不会被长时间占用。

对于保险行业这种对稳定性要求高的场景,我强烈建议用异步合并。勘查员手机断网了页面刷新了,合并任务还在后台正常跑,用户体验和系统稳定性都好了不少。

6. 常见问题与排查技巧实录

6.1 典型问题速查表

把这些年做分片上传遇到的高频问题整理成了一张速查表,遇到问题可以先对照排查:

现象可能原因排查与解决
上传到一半报413错误Nginxclient_max_body_size或PHPpost_max_size过小检查两边配置,Nginx限制要大于PHP限制
所有分片都显示上传成功,但合并后文件打不开分片顺序错乱确认分片文件名是否按序号补零,合并时严格按序拼接
手机网络下上传速度极慢并发数过高,分片之间互相抢带宽降低并发数,弱网下用2-3并发
断点续传后文件上传成功但大小不对分片遗漏或重复检查续传逻辑,用Set去重记录已传分片;合并后校验文件大小
秒传判断不命中抽样哈希冲突或哈希计算方式不一致确认前端每次计算的哈希逻辑一致,大小字段也参与比对
PHP报“File upload error”upload_tmp_dir不可写或磁盘空间不足检查PHP临时目录权限,用df -h查看磁盘剩余空间
分片上传接口偶发500PHP-FPMmax_children耗尽调大max_children,同时检查是否有慢查询或死锁
视频合并后中间有一段黑屏某个分片上传数据损坏启用分片MD5校验,损坏则重传该分片

6.2 几个让我印象深刻的线上问题

第一个印象深刻的坑是“Nginx层413”,当时分片大小设的10MB,PHP配置也调好了,但用户上传总是失败。排查半天,发现Nginx配置里client_max_body_size还保持默认的1M,Nginx直接拒绝了大请求。这个配置层级很隐蔽,改完PHP配置往往就把Nginx忘了。

第二个坑是“临时目录被系统清理”,部署在海外云服务器上时,发现已经上传的分片偶尔会莫名其妙丢失。后来发现是系统的tmpwatch或者cron清理任务把/tmp下超过一定时间的文件自动删掉了。所以分片临时目录一定不能放在系统/tmp下,要放在独立的业务目录里,并设置合理的目录权限。

第三个坑是“PHP-FPM连接数被打满”,上线后发现上传高峰期,整个业务系统的接口都变慢了,包括查询保单这种轻量接口。监控发现是分片上传请求把PHP-FPM的Worker全部占满了,其他请求排队等待。后来把分片上传单独部署了一套PHP-FPM池,并且把上传接口的并发和业务接口隔离,问题才解决。这也提醒我:分片上传这种资源密集型请求,最好和普通业务请求做资源隔离。

第四个坑是前端“重试风暴”,弱网环境下单个分片上传失败后,如果前端立即无限重试,会一直占用网络连接,导致其他分片饿死。加上指数退避和最多3次重试限制后,整体成功率反而大幅提升。

6.3 一个值得借鉴的降级策略

再分享一个应急处置的思路。有一次核心上传服务所在机房网络异常,分片上传成功率只有50%左右,大量勘查员反馈无法上传视频。因为不能强行要求用户等待,我们在前端加了一个降级策略:当检测到连续3个分片上传失败后,自动切换到“串行小分片模式”,把单分片从5MB降为1MB,并发数从4降为1,这样虽然慢一些,但成功率很高。

这个降级策略的核心逻辑是:弱网环境下,小分片和低并发反而更容易成功,因为单个请求占用网络的时间短、占用连接少,不容易触发运营商层面的限速或阻断。跑了一小时,失败率从50%降到了不到5%,虽然传输速度慢了,但至少能把视频传回去,比起传不回去强太多了。

写在最后:一些个人经验

这套方案在理赔勘查视频上传场景里跑了一年多,前后承载了几万个视频文件的上传,总数据量有几十TB,最大的单个视频超过3GB,都能稳定完成传输。回想起来,最初也走过弯路,比如一开始试图用Flash上传组件解决大文件传输,在现在这个时代既不安全也不兼容;后来切到原生HTML5分片上传,代码虽然多一些,但胜在踏实可控。

我给正在规划类似系统的朋友几点实在的建议:

第一,分片大小和并发数一定要根据真实网络环境测试确定,不要拍脑袋定参数。你可以在测试环境模拟弱网,用不同分片大小跑一遍,看哪个组合的整体成功率最高。

第二,服务端一定要做幂等设计。分片重传、断点续传、重复提交,这些操作在真实的弱网环境里是常态,不是异常,接口必须能自然容忍。

第三,日志要打全。哪个用户、哪个uploadId、哪天传了哪个分片、花了多长时间、失败了几次,这些信息在排查疑难问题时就是救命稻草。我见过太多线上问题因为日志缺失而无从查起。

分片秒传不是什么高深技术,但它确实是大文件传输场景里工程价值最高的方案。如果你也在保险、医疗、教育这类传统行业里做上传功能,希望这篇文章能给你一些帮助,少踩几个我踩过的坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 19:27:34

抖音批量下载总失败?三层容错架构实战解析

1. 从一次批量抓取崩溃说起&#xff1a;为什么单线程下载在抖音场景下必然翻车做过内容归档的人大概都有过这种体验&#xff1a;脚本跑得好好的&#xff0c;前几十条视频顺利落盘&#xff0c;突然某一条卡住不动&#xff0c;整个队列跟着僵死&#xff1b;或者跑到一半网络抖了一…

作者头像 李华
网站建设 2026/9/24 19:26:32

2026护网行动蓝队实战手册:从攻击面收敛到应急响应全流程

每年到了这个季节&#xff0c;做安全的同行群里都会冒出来同一个问题&#xff1a;2026护网行动时间到底定了没有&#xff0c;我们的备战排期要不要再提前。说实话&#xff0c;官方从来不会提前甩一个具体日期出来&#xff0c;真正经历过多次护网的老蓝队都知道&#xff0c;与其…

作者头像 李华
网站建设 2026/9/24 19:26:31

Windows防休眠实战:NoSleep与SetThreadExecutionState详解

1. 从一次深夜渲染中断说起&#xff1a;Windows意外休眠到底卡在哪 凌晨两点&#xff0c;我盯着屏幕上一根进度条走到87%然后整台机器黑掉&#xff0c;风扇声消失&#xff0c;硬盘灯熄灭——不是断电&#xff0c;是系统自己进了睡眠。第二天早上打开事件查看器&#xff0c;一条…

作者头像 李华
网站建设 2026/9/24 19:26:29

内外网文件安全传输选型指南:从U盘到网闸的全面对比

在内外网隔离这件事上&#xff0c;我见过太多企业一边花钱买安全设备&#xff0c;一边靠U盘和网盘在网间"走私"数据。排行榜和厂商白皮书看了不少&#xff0c;真到落地时才发现&#xff0c;网上那些所谓的"权威排行"&#xff0c;大多是把产品手册换了个排版…

作者头像 李华
网站建设 2026/9/24 19:25:27

快速微调LLaMA实战:从LoRA选型到权重合并部署

简介&#xff1a;面向大模型应用开发者的LLaMA快速微调实战项目&#xff0c;聚焦如何利用预训练语言模型完成问答、文本生成、机器翻译等具体任务的二次训练。内容覆盖环境设置、数据准备、模型加载、微调配置、模型训练、验证与测试、保存部署的完整链路&#xff0c;源码内置训…

作者头像 李华
网站建设 2026/9/24 19:24:08

SQL注入常见方式系统汇总:从原理到渗透测试实战

从事这行久了你会发现&#xff0c;很多在外面吹得天花乱坠的所谓“高危漏洞”&#xff0c;真正上了授权测试的项目清单&#xff0c;翻来覆去就那么几个。SQL注入常年稳居前三&#xff0c;几乎每一次外网渗透测试、每一次内网横向评估&#xff0c;都得跟它打照面。原因很简单&am…

作者头像 李华