前阵子有位做内部管理后台的朋友问我:网页端能不能用jQuery实现大文件分片上传?最好直接给份能跑的源码。他说自己系统的视频上传一直卡在超时失败,网上搜到的分片上传示例大多绑定了Vue或React的生态,塞进依赖混乱的老项目里根本没法落地。这个问题我有过类似的经历,前后端联调、并发、断点续传、合并校验,每一步都有各自的坑。所以这篇文章就把完整的实现思路和源码拆解开,从原理到前后端代码再到排障经验一次讲透,适合还在维护传统页面、需要上传视频/安装包/数据备份文件的场景。1. 为什么必须分片:大文件上传失败背后的三个元凶先说个大前提:分片上传不是炫技,是被实际场景逼出来的。很多人一开始都以为放一个input控件,再拿form直接POST就行。等文件传到一半浏览器转圈、请求失败、用户来问“怎么回事”,才意识到大文件上传和普通表单提交不是一回事。分片方案本质上把一次“长时间运行的大体积传输”,拆成很多次“短平快的小体积传输”,从而绕开下面三个核心问题。1.1 超时:连接不可能永远为你保持一个文件从浏览器到服务器,中间往往会经过本机网络、公司网关、反向代理、应用服务器等多层设备。每一层都可能配置HTTP超时时间。比如有些Nginx环境的proxy_read_timeout设置为60秒,如果文件传输过程中始终没有完整请求帧结束,中间设备可能在超时后主动断开连接。HTTP请求并不是一个“我传到了才算完”的通道,对很多代理设备来说,它就是一个需要在规定时间内完成的请求。一次传500MB需要几分钟,期间如果没有网络活动,连接被判定为闲置,就容易被切断。用户看到的表象就是进度条卡住或者直接失败。分片之后,每个分片请求通常几秒到几十秒就能结束,可以大幅降低被中断的概率。1.2 服务器默认限制:一个请求体只能装这么多服务端对单个请求体大小的限制非常普遍,而且默认值往往低得惊人。PHP环境常见配置是upload_max_filesize=2M,post_max_size=8M;Nginx一层还有client_max_body_size,不同发行版的默认值差异很大,有的甚至只有1M。也就是说,哪怕你写了一行代码,试图一次POST一个500MB文件,请求会在到达业务代码之前就被基础软件拒绝。这些限制不是不能改,但在很多生产环境里,改配置需要走变更流程、协调运维,万一改完影响其它业务,还容易引来麻烦。分片上传天然规避了这个问题:每个分片只有几百KB到几MB,怎么传都撞不上这些硬限制,改动配置的压力就小很多。1.3 重传成本:止损是分片方案最大的价值大文件上传的失败率并不低,在弱网和无线网络环境下尤其明显。如果一次上传到80%失败,整个文件只能重新来。更气人的是第二次可能又卡在95%,整个团队都被耗在这件事上。分片之后,每个分片是独立的上传单元,失败时只需要重传这一片;配合并发策略和失败重试,整体上传效率也能提高。可以说,分片最大的价值不是“快”,而是“可控”,让系统面对网络抖动时有足够的余地去补救。2. 动手前先想清楚:分片参数与接口协议设计前端的实现离不开三个参数和一个清晰接口协议。先把这些定下来,再写代码,后面才不会改来改去。很多人一上来就写切片循环,结果分片大小、并发数、服务端目录结构全凭感觉定,联调时才发现两边规则对不上,返工成本非常高。2.1 三个关键参数:分片大小、并发数、失败重试分片大小是最容易被纠结的参数。我建议按网络状况动态调整:内网或宽带环境用2MB到5MB,公网弱网环境用512KB到1MB。分片越小,单次请求耗时越短,失败重试的代价越低,但请求总数会暴涨,HTTP握手和表单解析的开销占比变大;分片越大,请求数少,单次传输时间变长,超时概率上升。并发数通常控制在3到5个。并发太低,几百个分片串行上传会很慢;并发太高,服务端瞬间涌入大量分片写入请求,磁盘IO和临时文件管理压力会明显上升,稍不注意还会出现合并竞态问题。失败重试一般给3次就够了,重试间隔用1秒、2秒、4秒的指数退避,避免连续重试压垮服务端。参数参考表:场景分片大小建议并发数说明内网/有线宽带4MB-8MB3-4带宽稳定,片大可减少请求数普通Wi-Fi2MB3均衡出错与重传成本4G/5G信号一般512KB-1MB2-3避免单片传输时间过长弱网/高延时256KB-512KB2以大量小分片换取低失败率2.2 文件唯一标识:为什么一定要先算MD5分片上传需要一个全局唯一的文件标识,用来组织所有分片。单纯用文件名加文件大小并不可靠,同名不同内容的文件很常见。所以我选择在前端用SparkMD5按块读取文件,计算出完整文件的MD5值作为fileHash。这个值会贯穿上传和合并的整个流程。如果让服务端算MD5,就必须先把整个文件传上去,等于没有解决大文件传输的问题;在前端算的好处是上传前就能知道文件身份,为后面做秒传、断点续传铺路。计算MD5时不要一次把整个文件读进内存,而是用FileReader按分片大小逐块读取,边读边喂给SparkMD5的ArrayBuffer实例,大文件也不会把浏览器内存打爆。2.3 接口协议设计:上传和合并两个接口就够前端和服务端只需要约定两个接口。第一个是分片上传接口,接收文件块和元数据,把分片落盘;第二个是合并接口,在所有分片都上传成功后由前端主动调用,服务端按顺序拼接成完整文件。接口方法主要参数说明/uploadPOSTfile、fileHash、chunkIndex、totalChunks、chunkSize每个分片一个请求/mergePOSTfileHash、fileName、totalChunks所有分片上传完成后调用这里有个设计要点:不要在每次upload请求里顺带判断“所有分片是否已经到齐”。如果并发上传时最后一片刚落盘、另一个请求就扫到了“好像全了”,合并时机把握不准,很容易吞掉尾部数据。把合并操作交给一个独立接口,前端确保分片全部成功后再调用merge,时序就干净了。2.4 服务端存储结构:fileHash目录加chunkIndex文件服务端接收分片时,在临时目录下按fileHash建子目录,分片文件命名为0.part、1.part、2.part,一直到totalChunks-1。所有分片都落在同一个目录里,合并时按索引顺序读取拼接即可。fileHash由于是32位十六进制字符串,天然不会带路径符号,正好可以安全地用作目录名。文件最终合并后存放在上传目录,再清理掉这个临时目录。3. 前端jQuery源码逐段拆解:切片、上传、并发池与进度跑到这里才算进入正题。整段实现可以分成五个模块:页面结构、MD5计算、切片、单片上传、并发池控制。我会先把每个关键点讲清楚,再给一份可以直接复制运行的完整页面源码。3.1 页面结构:一个文件框、一个按钮、一个进度条页面尽量精简,只需要一个file输入框、一个开始上传按钮、一个progress元素和一段文本提示。引入jquery.min.js和spark-md5.min.js,两个都放本地路径就行。注意,老项目里如果已经引入过jQuery,就以项目里现有版本为准,建议至少是jQuery 3.x,老版本对FormData的支持和Deferred的Promise化都有隐患。3.2 计算文件MD5:Promise包一层异步读取点击文件框选择文件后,先不急着切片,第一步是计算整个文件的MD5。这个函数用FileReader按CHUNK_SIZE分块读取文件,每读完一块就把ArrayBuffer追加进SparkMD5实例。整个计算过程是异步的,我用Promise把回调包起来,后面用.then获取fileHash。关键点在于:FileReader.readAsArrayBuffer的入参是一个Blob切片,而不是整个文件,这保证了内存占用始终可控。对大文件来说,这一步可能要跑几秒甚至十几秒,界面上应该先给用户一个“正在计算MD5”的提示,避免误以为页面卡死了。3.3 切片逻辑:Blob.slice的边界要算准分片数量用Math.ceil(file.size / CHUNK_SIZE)向上取整。每个分片的起始位置是i * CHUNK_SIZE,结束位置是Math.min((i + 1) * CHUNK_SIZE, file.size),保证最后一个分片不会越界。切片后得到一个Blob数组,每个Blob就是一个独立的上传单元。这里最常见的错误是手滑把结束位置写成(i + 1) * CHUNK_SIZE,结果最后一个分片拿到一个空Blob,合并出来的文件会多出一段空数据或者直接损坏。边界计算虽然简单,但每次都要拿小文件先验证一遍两端大小。3.4 单片上传:$.ajax配合FormData的两个隐藏开关上传单个分片就是把分片Blob塞进FormData,连同fileHash、chunkIndex、totalChunks一起POST到/upload接口。jQuery的$.ajax在这里必须设置processData: false和contentType: false,否则jQuery会把FormData序列化成普通字符串,服务端$_FILES里什么都收不到。timeout建议设60秒左右,弱网环境下单片传输不应该拖到无限期。失败重试用一个带次数限制的attempt函数:每失败一次,间隔递增后重新发;重试3次还失败,就reject掉整个上传任务。重试时必须复用同一个FormData实例,不要每次重新new,避免文件字段丢失。3.5 并发池与失败重试:串行太慢,全并发太野全部串行上传虽然简单,几百个分片会很慢;全部一次并发发出,可能瞬间打满服务端IO。所以我用了一个固定并发数的队列:维护待上传分片索引的queue,同时最多保持CONCURRENCY个请求在飞,每当一个分片成功,就从队列里取出下一个继续补位。直到成功分片数等于totalChunks,调用merge接口。失败情况下,只要有一个分片把重试次数用尽,我就把整个任务置为stopped,已经发出的分片即使返回成功也不再触发merge。前端提示“上传失败”,避免用户看到进度条到了100%却在合并时才发现文件缺块。3.6 完整源码:复制到空HTML文件即可运行以下代码是前面所有模块的完整组合,使用时把jquery.min.js和spark-md5.min.js放到对应的js目录下,再把上传地址改成自己项目的路径。这段代码在普通PC浏览器、移动端主流浏览器上都验证过,没有依赖任何框架。<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>jQuery 大文件分片上传</title> </head> <body> <input type="file" id="fileInput"> <button id="uploadBtn" disabled>开始上传</button> <progress id="progressBar" value="0" max="100"></progress> <span id="progressText">0%</span> <script src="js/jquery.min.js"></script> <script src="js/spark-md5.min.js"></script> <script> $(function () { var CHUNK_SIZE = 2 * 1024 * 1024; var CONCURRENCY = 3; var MAX_RETRY = 3; var selectedFile = null; var fileHash = ''; var totalChunks = 0; var chunkBlobs = []; function updateProgress(successCount) { var percent = Math.round(successCount / totalChunks * 100); $('#progressBar').attr('value', percent); $('#progressText').text(percent + '%'); } function calcFileMD5(file) { return new Promise(function (resolve, reject) { var spark = new SparkMD5.ArrayBuffer(); var reader = new FileReader(); var offset = 0; function readNext() { var slice = file.slice(offset, offset + CHUNK_SIZE); reader.readAsArrayBuffer(slice); } reader.onload = function (e) { spark.append(e.target.result); offset += CHUNK_SIZE; if (offset < file.size) { readNext(); } else { resolve(spark.end()); } }; reader.onerror = function () { reject('文件读取失败,无法计算MD5'); }; readNext(); }); } function uploadOneChunk(blob, index) { var fd = new FormData(); fd.append('file', blob, selectedFile.name); fd.append('fileHash', fileHash); fd.append('chunkIndex', index); fd.append('totalChunks', totalChunks); fd.append('chunkSize', CHUNK_SIZE); return new Promise(function (resolve, reject) { function attempt(timesLeft) { $.ajax({ url: '/upload', type: 'POST', data: fd, processData: false, contentType: false, timeout: 60000, dataType: 'json' }).done(function (res) { if (res && res.status === 'ok') { resolve(); } else { tryAgain(); } }).fail(function (xhr, status) { console.warn('分片', index, '失败:', status); tryAgain(); }); function tryAgain() { if (timesLeft > 0) { setTimeout(function () { attempt(timesLeft - 1); }, 1000 * (MAX_RETRY - timesLeft + 1)); } else { reject(new Error('分片 ' + index + ' 重试次数用尽')); } } } attempt(MAX_RETRY); }); } function startUpload() { var queue = []; for (var i = 0; i < totalChunks; i++) { queue.push(i); } var successCount = 0; var active = 0; var stopped = false; function fetchNext() { if (stopped) return; if (queue.length === 0) return; var index = queue.shift(); active++; uploadOneChunk(chunkBlobs[index], index) .then(function () { active--; successCount++; updateProgress(successCount); if (successCount === totalChunks) { notifyMerge(); return; } fetchNext(); }) .catch(function (err) { active--; stopped = true; $('#progressText').text('上传失败:' + err.message); }); } for (var j = 0; j < Math.min(CONCURRENCY, totalChunks); j++) { fetchNext(); } } function notifyMerge() { var fd = new FormData(); fd.append('fileHash', fileHash); fd.append('fileName', selectedFile.name); fd.append('totalChunks', totalChunks); $.ajax({ url: '/merge', type: 'POST', data: fd, processData: false, contentType: false, timeout: 120000, dataType: 'json' }).done(function (res) { if (res && res.status === 'complete') { $('#progressText').text('上传完成:' + res.path); } else { $('#progressText').text('合并失败,请稍后重试'); } }).fail(function () { $('#progressText').text('合并接口请求失败'); }); } $('#fileInput').on('change', function () { var file = this.files[0]; if (!file || !file.size) return; selectedFile = file; $('#uploadBtn').prop('disabled', true); $('#progressText').text('正在计算MD5,文件较大请耐心等待...'); calcFileMD5(file).then(function (hash) { fileHash = hash; totalChunks = Math.ceil(file.size / CHUNK_SIZE); chunkBlobs = []; for (var i = 0; i < totalChunks; i++) { chunkBlobs.push(file.slice(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, file.size))); } $('#uploadBtn').prop('disabled', false); $('#progressText').text('切片完成,共 ' + totalChunks + ' 片,每片 ' + Math.round(CHUNK_SIZE / 1024) + 'KB'); }).catch(function (err) { $('#progressText').text(err); }); }); $('#uploadBtn').on('click', function () { if (!selectedFile || !fileHash) return; $(this).prop('disabled', true); startUpload(); }); }); </script> </body> </html>使用这段代码时有几个容易忽略的细节。一是进度条更新逻辑计算的是“成功分片数/总分片数”,它和文件字节级的真实进度略有偏差,但每个分片大小是固定的,按片数算已经完全够用。二是如果用户在上传过程中重新选择了文件,要把selectedFile、fileHash、chunkBlobs全部重置,不然新旧文件的分片会串到一起,这个坑我在实际项目里碰到过一次,表现就是合并出来的文件内容混着两个文件的数据。三是按钮的disabled状态一定要在失败重试用完后再放开,否则用户会重复点击,导致同一个分片被并发上传多次。另外还要提一句浏览器兼容性:旧版iOS Safari对Blob.slice的边界处理有历史问题,虽然现在遇到老设备的概率不高,但如果业务覆盖范围包含老款iPhone或旧版浏览器,建议先拿一个小文件在目标设备上跑一遍,确认切片后每个分片的大小都符合预期,再放心部署。4. 服务端配合:PHP接收分片与合并文件的实现前端把分片传上去后,服务端必须按同样的规则接住。我这里统一用PHP做示例,因为传统后台项目里PHP和jQuery的组合最常见;如果服务端是Java、Node.js或其它语言,逻辑完全一样,只是接口写法不同。4.1 上传接口upload.php:落盘分片上传接口的核心动作只有两个:校验参数,然后把临时文件移动到分片目录。第一步是收下fileHash、chunkIndex、totalChunks三个字段,注意fileHash一定要做白名单过滤,只允许十六进制字符,避免有人把目录分隔符塞进来;chunkIndex和totalChunks用intval转成整数。第二步确认$_FILES['file']存在,再move_uploaded_file到目标路径。<?php $fileHash = isset($_POST['fileHash']) ? preg_replace('/[^a-f0-9]/', '', $_POST['fileHash']) : ''; $chunkIndex = isset($_POST['chunkIndex']) ? intval($_POST['chunkIndex']) : -1; $totalChunks = isset($_POST['totalChunks']) ? intval($_POST['totalChunks']) : 0; if ($fileHash === '' || $chunkIndex < 0 || $totalChunks <= 0) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'params invalid']); exit; } if (!isset($_FILES['file'])) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'file field missing']); exit; } $chunkDir = __DIR__ . '/chunks/' . $fileHash; if (!is_dir($chunkDir)) { mkdir($chunkDir, 0777, true); } $chunkFile = $chunkDir . '/' . $chunkIndex . '.part'; if (!move_uploaded_file($_FILES['file']['tmp_name'], $chunkFile)) { http_response_code(500); echo json_encode(['status' => 'error', 'message' => 'move failed']); exit; } echo json_encode(['status' => 'ok', 'chunkIndex' => $chunkIndex]);这里我特意先把fileHash过滤成纯十六进制,再拼进路径,就杜绝了路径穿越。目录权限给了0777是图省事,生产环境建议按运行用户隔离好,不要所有文件都777。4.2 合并接口merge.php:按序拼装成完整文件前端所有分片成功后会调用/merge接口。服务端先检查这个fileHash对应的临时目录里是否所有分片都存在,任何一片缺失就返回错误状态,让前端知道哪些块没传成。然后按索引从0到totalChunks-1依次读取分片,写入目标文件。合并时我不建议用file_get_contents一次把整个分片读进内存,虽然单片通常只有几MB,但并发量大时PHP进程的内存会非常紧张。改用fopen分片、循环fread和fwrite,流式搬运更稳妥。<?php $fileHash = isset($_POST['fileHash']) ? preg_replace('/[^a-f0-9]/', '', $_POST['fileHash']) : ''; $fileName = isset($_POST['fileName']) ? $_POST['fileName'] : ''; $totalChunks = isset($_POST['totalChunks']) ? intval($_POST['totalChunks']) : 0; if ($fileHash === '' || $totalChunks <= 0) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'params invalid']); exit; } $chunkDir = __DIR__ . '/chunks/' . $fileHash; for ($i = 0; $i < $totalChunks; $i++) { if (!file_exists($chunkDir . '/' . $i . '.part')) { http_response_code(409); echo json_encode(['status' => 'error', 'message' => 'chunk missing: ' . $i]); exit; } } $safeName = basename($fileName); $finalPath = __DIR__ . '/uploads/' . $fileHash . '_' . $safeName; $fp = fopen($finalPath, 'wb'); if (!$fp) { http_response_code(500); echo json_encode(['status' => 'error', 'message' => 'cannot create final file']); exit; } for ($i = 0; $i < $totalChunks; $i++) { $in = fopen($chunkDir . '/' . $i . '.part', 'rb'); while (!feof($in)) { fwrite($fp, fread($in, 1024 * 1024)); } fclose($in); } fclose($fp); foreach (glob($chunkDir . '/*.part') as $f) { unlink($f); } rmdir($chunkDir); echo json_encode(['status' => 'complete', 'path' => basename($finalPath)]);这段代码里有两处值得重视。一是$fileName必须用basename过滤,否则用户上传一个带路径的文件名,拼接出的finalPath就可以写入任意目录。二是合并完成后立刻清理分片临时目录,防止同一fileHash重复上传时旧分片残留造成误判;如果要做断点续传,清理时机要改成“确认服务端不再需要旧分片”之后。4.3 php.ini与WebServer的配置注意事项前端可以看到一片2MB的分片,服务端如果php.ini里upload_max_filesize还是默认的2M,一样会失败。建议在调试阶段先用phpinfo()确认这三个配置:upload_max_filesize、post_max_size、max_execution_time。我的经验值是:upload_max_filesize设20M,post_max_size设30M。post_max_size必须大于单片大小加表单字段,差得太小容易出现“刚好卡在边界”的诡异问题;max_execution_time在CLI或调试环境可以设为0,在PHP-FPM下建议设300秒以上,避免合并大文件时被掐断。如果前面还有Nginx,同步确认client_max_body_size至少大于分片大小,不然请求会在Web层就返回413。4.4 换成Node.js服务端时的关键调整如果是Node.js技术栈,思路完全一样,但要注意中间件选择。接收multipart分片时,用busboy或multer这类中间件解析,不能直接把原文件存内存。文件合并时按chunkIndex顺序写入同一个可写流,逻辑和PHP版本没有本质区别。唯一需要留神的是Node.js默认的请求体大小限制,比如Express默认会拒绝较大的请求体,要对上传路由单独放开,否则分片再小也进不来。5. 实测中的四个坑与完整排查链路这套方案跑不跑得通,大概率就卡在这几个地方。每个坑我都按实际排查的顺序来写,而不是直接扔答案,这样出问题时你能自己顺着链路找原因。5.1 合并后文件损坏,不知道是哪一步错了现象是前端明明显示上传完成,合并出来的视频拖进播放器却打不开,PDF打开提示损坏。排查时先不要怀疑前端,从落盘数据看起。第一步,在服务端上传接口里临时记录每个分片的字节数,对照前端期望的大小。2MB分片,前面99片都应该是2097152字节,最后一片等于file.size取模的结果(如果整除就是2097152)。如果某一片大小明显不对,多半是Blob.slice的结束位置写错了。第二步,检查合并循环是否真的严格按索引顺序读取。这里最容易翻车的是异步回调里的循环变量:如果代码里用for循环遍历分片索引,然后在某个异步回调里引用索引变量,等回调真正执行时,索引可能已经变成了totalChunks,结果每片都指向同一个分片文件。我在PHP版本里用同步for循环,天然没有这个问题,但如果你用异步服务端,务必把当前索引拷贝到局部变量。第三步,合并完成后用原始文件和服务端最终文件的MD5做对比。MD5一致才能确认整个链路没有缺块或错序。5.2 $_FILES里收不到文件,请求被jQuery改了格式这是jQuery上传分片时最经典的坑。打开浏览器Network面板,点击分片请求,如果看到Content-Type是application/x-www-form-urlencoded,请求体是一行key=value字符串,说明jQuery把FormData当普通表单序列化了。服务端$_FILES自然为空,只有$_POST里那几个普通字段还在。原因就一句话:$.ajax的data是FormData对象时,必须关闭jQuery默认的序列化逻辑,也就是指定processData: false和contentType: false。如果用了老的jQuery版本,建议直接升到3.x,老版本对FormData的处理有很多历史遗留问题。核对这两项后,刷新页面重传,Network里Content-Type会变成multipart/form-data; boundary=...,$_FILES就能正常收到了。5.3 单片只有2MB,PHP却说超过上传限制现象是浏览器报错或者服务端返回“The uploaded file exceeds upload_max_filesize”,但单片明明才2MB。原因通常是php.ini的upload_max_filesize是1M或2M,恰好卡在边界附近;另一种情况是post_max_size设置只比分片大一点点,表单字段一多就超限。排查顺序是先用phpinfo()看实际生效值,注意PHP-FPM和CLI用的php.ini可能不是同一个文件。修改后要重启PHP-FPM,不是重启Nginx。如果修改了php.ini但没生效,检查opcache和PHP-FPM的配置加载路径。Web层同样用413状态码排查:如果是Nginx返回的413,问题在client_max_body_size,而不是PHP。5.4 服务端误判分片齐全,提前合并这个坑隐蔽一些。之前我在一个版本里图省事,在upload.php的最后检查“目录里分片数量等于totalChunks就立即合并”。表面看没问题,实际并发上传时,最后一片move_uploaded_file刚写完,另一个上传请求扫描目录发现分片全了,立刻触发合并。结果合并时最后一片可能还没来得及完整写入,或者被正在写入的请求覆盖,最终文件少一块。排查链路是这样:复现时给每个请求打印时间戳,看合并请求是否早于最后一个分片的上传返回;或者直接给upload里的合并逻辑加一个“发现全部到齐后延迟几秒再合”的临时方案,能复现但不可靠。根治方法就是我一开始说的:把合并独立成一个接口,只有当前端所有分片都拿到成功响应后,才发merge请求。这个时序上的竞态,从设计层面就避免掉。再补充一个细节:断点续传场景下,服务端实现“检查已上传分片列表”的接口时,不要用“扫描目录文件数量等于totalChunks”来判断文件整体是否存在,因为残留的旧分片会干扰判断。要记录每个fileHash的元数据,或者通过merge接口的结果状态来判断。6. 进阶扩展:从分片上传到断点续传与秒传标题问的是源码,但拿到源码能跑只是第一步。把这套方案接进真实业务时,大概率还会遇到断点续传和秒传的需求,我展开讲一下实现路径。6.1 断点续传:记住已经传成功的是哪些分片断点续传的思路很简单:上传前先摸清哪些分片已经存在于服务端,只传缺失的部分。前端把每个成功分片的索引存进localStorage,key用fileHash加文件大小生成。下次再选同一个文件时,先算MD5,然后到localStorage里找记录,已经成功的分片直接跳过。localStorage方案有个限制:它只对同一台设备同一个浏览器生效,用户换个电脑就失效。要真正跨设备续传,服务端要提供一个查询接口,比如GET /check?fileHash=xxx,服务端扫描分片目录,返回已存在的分片索引列表。前端拿到列表后只上传缺失部分,上传完还是走merge。这套改动在现有代码上只需要增加一个查询步骤,上传函数基本不用动。6.2 秒传:让fileHash直接换一个“已完成”秒传的底层逻辑其实更简单。服务端维护一张文件哈希表,记录每个fileHash对应的最终文件路径。前端算完MD5后,先调用查询接口,如果服务端发现这个fileHash已经存在完整文件,直接返回complete,前端显示“秒传完成”。用户不需要真的传一遍文件。这也正是我坚持在浏览器端算MD5的原因:秒传必须在上传动作发生前就知道文件的唯一标识。如果服务端才知道哈希,那个文件已经完整到达服务器了,秒传就没有意义。文件哈希表的维护可以在merge成功后插入一条记录,也可以用数据库唯一索引避免重复存文件。6.3 弱网下的参数调整:实测结论比理论更靠谱关于分片大小,我在不同网络环境里做过对比,结论是:在局域网和稳定公网里,5MB分片和1MB分片的整体成功率差别不大,5MB网络开销低,反而更快;一旦进入弱网,越大越容易超时。所以我的建议是不要写死2MB,而是根据文件大小和网络状态选参数。文件大小稳定网络建议弱网建议100MB以下5MB分片,并发31MB分片,并发2100MB-1GB2MB分片,并发4512KB分片,并发21GB以上1MB分片,并发3256KB-512KB分片,并发2另外可以做一层动态调整:统计最近几个分片的平均传输耗时,如果单片耗时超过比如20秒,就自动把分片大小降一档;如果连续多片都很快,再升一档。这个逻辑用在前端就是一个实时调整CHUNK_SIZE的阈值判断,配合现有并发池,代码改动不大,体验提升很明显。最后分享一个实操心得:很多人拿到这套代码后第一件事是调并发数,想跑满带宽。我的建议是先保持3个并发跑一遍完整流程,确认合并文件MD5一致,再逐步往上加。并发数高了以后,最容易暴露的不是前端代码,而是服务端临时目录的IO竞争和php.ini的限制。分片上传从来不是“前端把文件切开就行”这么简单,前后端的分片规则、目录结构、合并时机、失败重试必须完全对得上,任何一个环节不对,都会在合并后文件损坏时暴露出来。先把基础链路跑稳,再考虑断点续传和秒传,这条路是我验证过最省心的顺序。
前阵子有位做内部管理后台的朋友问我:网页端能不能用jQuery实现大文件分片上传?最好直接给份能跑的源码。他说自己系统的视频上传一直卡在超时失败,网上搜到的分片上传示例大多绑定了Vue或React的生态,塞进依赖混乱的老项目里根本没法落地。这个问题我有过类似的经历,前后端联调、并发、断点续传、合并校验,每一步都有各自的坑。所以这篇文章就把完整的实现思路和源码拆解开,从原理到前后端代码再到排障经验一次讲透,适合还在维护传统页面、需要上传视频/安装包/数据备份文件的场景。1. 为什么必须分片:大文件上传失败背后的三个元凶先说个大前提:分片上传不是炫技,是被实际场景逼出来的。很多人一开始都以为放一个input控件,再拿form直接POST就行。等文件传到一半浏览器转圈、请求失败、用户来问“怎么回事”,才意识到大文件上传和普通表单提交不是一回事。分片方案本质上把一次“长时间运行的大体积传输”,拆成很多次“短平快的小体积传输”,从而绕开下面三个核心问题。1.1 超时:连接不可能永远为你保持一个文件从浏览器到服务器,中间往往会经过本机网络、公司网关、反向代理、应用服务器等多层设备。每一层都可能配置HTTP超时时间。比如有些Nginx环境的proxy_read_timeout设置为60秒,如果文件传输过程中始终没有完整请求帧结束,中间设备可能在超时后主动断开连接。HTTP请求并不是一个“我传到了才算完”的通道,对很多代理设备来说,它就是一个需要在规定时间内完成的请求。一次传500MB需要几分钟,期间如果没有网络活动,连接被判定为闲置,就容易被切断。用户看到的表象就是进度条卡住或者直接失败。分片之后,每个分片请求通常几秒到几十秒就能结束,可以大幅降低被中断的概率。1.2 服务器默认限制:一个请求体只能装这么多服务端对单个请求体大小的限制非常普遍,而且默认值往往低得惊人。PHP环境常见配置是upload_max_filesize=2M,post_max_size=8M;Nginx一层还有client_max_body_size,不同发行版的默认值差异很大,有的甚至只有1M。也就是说,哪怕你写了一行代码,试图一次POST一个500MB文件,请求会在到达业务代码之前就被基础软件拒绝。这些限制不是不能改,但在很多生产环境里,改配置需要走变更流程、协调运维,万一改完影响其它业务,还容易引来麻烦。分片上传天然规避了这个问题:每个分片只有几百KB到几MB,怎么传都撞不上这些硬限制,改动配置的压力就小很多。1.3 重传成本:止损是分片方案最大的价值大文件上传的失败率并不低,在弱网和无线网络环境下尤其明显。如果一次上传到80%失败,整个文件只能重新来。更气人的是第二次可能又卡在95%,整个团队都被耗在这件事上。分片之后,每个分片是独立的上传单元,失败时只需要重传这一片;配合并发策略和失败重试,整体上传效率也能提高。可以说,分片最大的价值不是“快”,而是“可控”,让系统面对网络抖动时有足够的余地去补救。2. 动手前先想清楚:分片参数与接口协议设计前端的实现离不开三个参数和一个清晰接口协议。先把这些定下来,再写代码,后面才不会改来改去。很多人一上来就写切片循环,结果分片大小、并发数、服务端目录结构全凭感觉定,联调时才发现两边规则对不上,返工成本非常高。2.1 三个关键参数:分片大小、并发数、失败重试分片大小是最容易被纠结的参数。我建议按网络状况动态调整:内网或宽带环境用2MB到5MB,公网弱网环境用512KB到1MB。分片越小,单次请求耗时越短,失败重试的代价越低,但请求总数会暴涨,HTTP握手和表单解析的开销占比变大;分片越大,请求数少,单次传输时间变长,超时概率上升。并发数通常控制在3到5个。并发太低,几百个分片串行上传会很慢;并发太高,服务端瞬间涌入大量分片写入请求,磁盘IO和临时文件管理压力会明显上升,稍不注意还会出现合并竞态问题。失败重试一般给3次就够了,重试间隔用1秒、2秒、4秒的指数退避,避免连续重试压垮服务端。参数参考表:场景分片大小建议并发数说明内网/有线宽带4MB-8MB3-4带宽稳定,片大可减少请求数普通Wi-Fi2MB3均衡出错与重传成本4G/5G信号一般512KB-1MB2-3避免单片传输时间过长弱网/高延时256KB-512KB2以大量小分片换取低失败率2.2 文件唯一标识:为什么一定要先算MD5分片上传需要一个全局唯一的文件标识,用来组织所有分片。单纯用文件名加文件大小并不可靠,同名不同内容的文件很常见。所以我选择在前端用SparkMD5按块读取文件,计算出完整文件的MD5值作为fileHash。这个值会贯穿上传和合并的整个流程。如果让服务端算MD5,就必须先把整个文件传上去,等于没有解决大文件传输的问题;在前端算的好处是上传前就能知道文件身份,为后面做秒传、断点续传铺路。计算MD5时不要一次把整个文件读进内存,而是用FileReader按分片大小逐块读取,边读边喂给SparkMD5的ArrayBuffer实例,大文件也不会把浏览器内存打爆。2.3 接口协议设计:上传和合并两个接口就够前端和服务端只需要约定两个接口。第一个是分片上传接口,接收文件块和元数据,把分片落盘;第二个是合并接口,在所有分片都上传成功后由前端主动调用,服务端按顺序拼接成完整文件。接口方法主要参数说明/uploadPOSTfile、fileHash、chunkIndex、totalChunks、chunkSize每个分片一个请求/mergePOSTfileHash、fileName、totalChunks所有分片上传完成后调用这里有个设计要点:不要在每次upload请求里顺带判断“所有分片是否已经到齐”。如果并发上传时最后一片刚落盘、另一个请求就扫到了“好像全了”,合并时机把握不准,很容易吞掉尾部数据。把合并操作交给一个独立接口,前端确保分片全部成功后再调用merge,时序就干净了。2.4 服务端存储结构:fileHash目录加chunkIndex文件服务端接收分片时,在临时目录下按fileHash建子目录,分片文件命名为0.part、1.part、2.part,一直到totalChunks-1。所有分片都落在同一个目录里,合并时按索引顺序读取拼接即可。fileHash由于是32位十六进制字符串,天然不会带路径符号,正好可以安全地用作目录名。文件最终合并后存放在上传目录,再清理掉这个临时目录。3. 前端jQuery源码逐段拆解:切片、上传、并发池与进度跑到这里才算进入正题。整段实现可以分成五个模块:页面结构、MD5计算、切片、单片上传、并发池控制。我会先把每个关键点讲清楚,再给一份可以直接复制运行的完整页面源码。3.1 页面结构:一个文件框、一个按钮、一个进度条页面尽量精简,只需要一个file输入框、一个开始上传按钮、一个progress元素和一段文本提示。引入jquery.min.js和spark-md5.min.js,两个都放本地路径就行。注意,老项目里如果已经引入过jQuery,就以项目里现有版本为准,建议至少是jQuery 3.x,老版本对FormData的支持和Deferred的Promise化都有隐患。3.2 计算文件MD5:Promise包一层异步读取点击文件框选择文件后,先不急着切片,第一步是计算整个文件的MD5。这个函数用FileReader按CHUNK_SIZE分块读取文件,每读完一块就把ArrayBuffer追加进SparkMD5实例。整个计算过程是异步的,我用Promise把回调包起来,后面用.then获取fileHash。关键点在于:FileReader.readAsArrayBuffer的入参是一个Blob切片,而不是整个文件,这保证了内存占用始终可控。对大文件来说,这一步可能要跑几秒甚至十几秒,界面上应该先给用户一个“正在计算MD5”的提示,避免误以为页面卡死了。3.3 切片逻辑:Blob.slice的边界要算准分片数量用Math.ceil(file.size / CHUNK_SIZE)向上取整。每个分片的起始位置是i * CHUNK_SIZE,结束位置是Math.min((i + 1) * CHUNK_SIZE, file.size),保证最后一个分片不会越界。切片后得到一个Blob数组,每个Blob就是一个独立的上传单元。这里最常见的错误是手滑把结束位置写成(i + 1) * CHUNK_SIZE,结果最后一个分片拿到一个空Blob,合并出来的文件会多出一段空数据或者直接损坏。边界计算虽然简单,但每次都要拿小文件先验证一遍两端大小。3.4 单片上传:$.ajax配合FormData的两个隐藏开关上传单个分片就是把分片Blob塞进FormData,连同fileHash、chunkIndex、totalChunks一起POST到/upload接口。jQuery的$.ajax在这里必须设置processData: false和contentType: false,否则jQuery会把FormData序列化成普通字符串,服务端$_FILES里什么都收不到。timeout建议设60秒左右,弱网环境下单片传输不应该拖到无限期。失败重试用一个带次数限制的attempt函数:每失败一次,间隔递增后重新发;重试3次还失败,就reject掉整个上传任务。重试时必须复用同一个FormData实例,不要每次重新new,避免文件字段丢失。3.5 并发池与失败重试:串行太慢,全并发太野全部串行上传虽然简单,几百个分片会很慢;全部一次并发发出,可能瞬间打满服务端IO。所以我用了一个固定并发数的队列:维护待上传分片索引的queue,同时最多保持CONCURRENCY个请求在飞,每当一个分片成功,就从队列里取出下一个继续补位。直到成功分片数等于totalChunks,调用merge接口。失败情况下,只要有一个分片把重试次数用尽,我就把整个任务置为stopped,已经发出的分片即使返回成功也不再触发merge。前端提示“上传失败”,避免用户看到进度条到了100%却在合并时才发现文件缺块。3.6 完整源码:复制到空HTML文件即可运行以下代码是前面所有模块的完整组合,使用时把jquery.min.js和spark-md5.min.js放到对应的js目录下,再把上传地址改成自己项目的路径。这段代码在普通PC浏览器、移动端主流浏览器上都验证过,没有依赖任何框架。<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>jQuery 大文件分片上传</title> </head> <body> <input type="file" id="fileInput"> <button id="uploadBtn" disabled>开始上传</button> <progress id="progressBar" value="0" max="100"></progress> <span id="progressText">0%</span> <script src="js/jquery.min.js"></script> <script src="js/spark-md5.min.js"></script> <script> $(function () { var CHUNK_SIZE = 2 * 1024 * 1024; var CONCURRENCY = 3; var MAX_RETRY = 3; var selectedFile = null; var fileHash = ''; var totalChunks = 0; var chunkBlobs = []; function updateProgress(successCount) { var percent = Math.round(successCount / totalChunks * 100); $('#progressBar').attr('value', percent); $('#progressText').text(percent + '%'); } function calcFileMD5(file) { return new Promise(function (resolve, reject) { var spark = new SparkMD5.ArrayBuffer(); var reader = new FileReader(); var offset = 0; function readNext() { var slice = file.slice(offset, offset + CHUNK_SIZE); reader.readAsArrayBuffer(slice); } reader.onload = function (e) { spark.append(e.target.result); offset += CHUNK_SIZE; if (offset < file.size) { readNext(); } else { resolve(spark.end()); } }; reader.onerror = function () { reject('文件读取失败,无法计算MD5'); }; readNext(); }); } function uploadOneChunk(blob, index) { var fd = new FormData(); fd.append('file', blob, selectedFile.name); fd.append('fileHash', fileHash); fd.append('chunkIndex', index); fd.append('totalChunks', totalChunks); fd.append('chunkSize', CHUNK_SIZE); return new Promise(function (resolve, reject) { function attempt(timesLeft) { $.ajax({ url: '/upload', type: 'POST', data: fd, processData: false, contentType: false, timeout: 60000, dataType: 'json' }).done(function (res) { if (res && res.status === 'ok') { resolve(); } else { tryAgain(); } }).fail(function (xhr, status) { console.warn('分片', index, '失败:', status); tryAgain(); }); function tryAgain() { if (timesLeft > 0) { setTimeout(function () { attempt(timesLeft - 1); }, 1000 * (MAX_RETRY - timesLeft + 1)); } else { reject(new Error('分片 ' + index + ' 重试次数用尽')); } } } attempt(MAX_RETRY); }); } function startUpload() { var queue = []; for (var i = 0; i < totalChunks; i++) { queue.push(i); } var successCount = 0; var active = 0; var stopped = false; function fetchNext() { if (stopped) return; if (queue.length === 0) return; var index = queue.shift(); active++; uploadOneChunk(chunkBlobs[index], index) .then(function () { active--; successCount++; updateProgress(successCount); if (successCount === totalChunks) { notifyMerge(); return; } fetchNext(); }) .catch(function (err) { active--; stopped = true; $('#progressText').text('上传失败:' + err.message); }); } for (var j = 0; j < Math.min(CONCURRENCY, totalChunks); j++) { fetchNext(); } } function notifyMerge() { var fd = new FormData(); fd.append('fileHash', fileHash); fd.append('fileName', selectedFile.name); fd.append('totalChunks', totalChunks); $.ajax({ url: '/merge', type: 'POST', data: fd, processData: false, contentType: false, timeout: 120000, dataType: 'json' }).done(function (res) { if (res && res.status === 'complete') { $('#progressText').text('上传完成:' + res.path); } else { $('#progressText').text('合并失败,请稍后重试'); } }).fail(function () { $('#progressText').text('合并接口请求失败'); }); } $('#fileInput').on('change', function () { var file = this.files[0]; if (!file || !file.size) return; selectedFile = file; $('#uploadBtn').prop('disabled', true); $('#progressText').text('正在计算MD5,文件较大请耐心等待...'); calcFileMD5(file).then(function (hash) { fileHash = hash; totalChunks = Math.ceil(file.size / CHUNK_SIZE); chunkBlobs = []; for (var i = 0; i < totalChunks; i++) { chunkBlobs.push(file.slice(i * CHUNK_SIZE, Math.min((i + 1) * CHUNK_SIZE, file.size))); } $('#uploadBtn').prop('disabled', false); $('#progressText').text('切片完成,共 ' + totalChunks + ' 片,每片 ' + Math.round(CHUNK_SIZE / 1024) + 'KB'); }).catch(function (err) { $('#progressText').text(err); }); }); $('#uploadBtn').on('click', function () { if (!selectedFile || !fileHash) return; $(this).prop('disabled', true); startUpload(); }); }); </script> </body> </html>使用这段代码时有几个容易忽略的细节。一是进度条更新逻辑计算的是“成功分片数/总分片数”,它和文件字节级的真实进度略有偏差,但每个分片大小是固定的,按片数算已经完全够用。二是如果用户在上传过程中重新选择了文件,要把selectedFile、fileHash、chunkBlobs全部重置,不然新旧文件的分片会串到一起,这个坑我在实际项目里碰到过一次,表现就是合并出来的文件内容混着两个文件的数据。三是按钮的disabled状态一定要在失败重试用完后再放开,否则用户会重复点击,导致同一个分片被并发上传多次。另外还要提一句浏览器兼容性:旧版iOS Safari对Blob.slice的边界处理有历史问题,虽然现在遇到老设备的概率不高,但如果业务覆盖范围包含老款iPhone或旧版浏览器,建议先拿一个小文件在目标设备上跑一遍,确认切片后每个分片的大小都符合预期,再放心部署。4. 服务端配合:PHP接收分片与合并文件的实现前端把分片传上去后,服务端必须按同样的规则接住。我这里统一用PHP做示例,因为传统后台项目里PHP和jQuery的组合最常见;如果服务端是Java、Node.js或其它语言,逻辑完全一样,只是接口写法不同。4.1 上传接口upload.php:落盘分片上传接口的核心动作只有两个:校验参数,然后把临时文件移动到分片目录。第一步是收下fileHash、chunkIndex、totalChunks三个字段,注意fileHash一定要做白名单过滤,只允许十六进制字符,避免有人把目录分隔符塞进来;chunkIndex和totalChunks用intval转成整数。第二步确认$_FILES['file']存在,再move_uploaded_file到目标路径。<?php $fileHash = isset($_POST['fileHash']) ? preg_replace('/[^a-f0-9]/', '', $_POST['fileHash']) : ''; $chunkIndex = isset($_POST['chunkIndex']) ? intval($_POST['chunkIndex']) : -1; $totalChunks = isset($_POST['totalChunks']) ? intval($_POST['totalChunks']) : 0; if ($fileHash === '' || $chunkIndex < 0 || $totalChunks <= 0) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'params invalid']); exit; } if (!isset($_FILES['file'])) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'file field missing']); exit; } $chunkDir = __DIR__ . '/chunks/' . $fileHash; if (!is_dir($chunkDir)) { mkdir($chunkDir, 0777, true); } $chunkFile = $chunkDir . '/' . $chunkIndex . '.part'; if (!move_uploaded_file($_FILES['file']['tmp_name'], $chunkFile)) { http_response_code(500); echo json_encode(['status' => 'error', 'message' => 'move failed']); exit; } echo json_encode(['status' => 'ok', 'chunkIndex' => $chunkIndex]);这里我特意先把fileHash过滤成纯十六进制,再拼进路径,就杜绝了路径穿越。目录权限给了0777是图省事,生产环境建议按运行用户隔离好,不要所有文件都777。4.2 合并接口merge.php:按序拼装成完整文件前端所有分片成功后会调用/merge接口。服务端先检查这个fileHash对应的临时目录里是否所有分片都存在,任何一片缺失就返回错误状态,让前端知道哪些块没传成。然后按索引从0到totalChunks-1依次读取分片,写入目标文件。合并时我不建议用file_get_contents一次把整个分片读进内存,虽然单片通常只有几MB,但并发量大时PHP进程的内存会非常紧张。改用fopen分片、循环fread和fwrite,流式搬运更稳妥。<?php $fileHash = isset($_POST['fileHash']) ? preg_replace('/[^a-f0-9]/', '', $_POST['fileHash']) : ''; $fileName = isset($_POST['fileName']) ? $_POST['fileName'] : ''; $totalChunks = isset($_POST['totalChunks']) ? intval($_POST['totalChunks']) : 0; if ($fileHash === '' || $totalChunks <= 0) { http_response_code(400); echo json_encode(['status' => 'error', 'message' => 'params invalid']); exit; } $chunkDir = __DIR__ . '/chunks/' . $fileHash; for ($i = 0; $i < $totalChunks; $i++) { if (!file_exists($chunkDir . '/' . $i . '.part')) { http_response_code(409); echo json_encode(['status' => 'error', 'message' => 'chunk missing: ' . $i]); exit; } } $safeName = basename($fileName); $finalPath = __DIR__ . '/uploads/' . $fileHash . '_' . $safeName; $fp = fopen($finalPath, 'wb'); if (!$fp) { http_response_code(500); echo json_encode(['status' => 'error', 'message' => 'cannot create final file']); exit; } for ($i = 0; $i < $totalChunks; $i++) { $in = fopen($chunkDir . '/' . $i . '.part', 'rb'); while (!feof($in)) { fwrite($fp, fread($in, 1024 * 1024)); } fclose($in); } fclose($fp); foreach (glob($chunkDir . '/*.part') as $f) { unlink($f); } rmdir($chunkDir); echo json_encode(['status' => 'complete', 'path' => basename($finalPath)]);这段代码里有两处值得重视。一是$fileName必须用basename过滤,否则用户上传一个带路径的文件名,拼接出的finalPath就可以写入任意目录。二是合并完成后立刻清理分片临时目录,防止同一fileHash重复上传时旧分片残留造成误判;如果要做断点续传,清理时机要改成“确认服务端不再需要旧分片”之后。4.3 php.ini与WebServer的配置注意事项前端可以看到一片2MB的分片,服务端如果php.ini里upload_max_filesize还是默认的2M,一样会失败。建议在调试阶段先用phpinfo()确认这三个配置:upload_max_filesize、post_max_size、max_execution_time。我的经验值是:upload_max_filesize设20M,post_max_size设30M。post_max_size必须大于单片大小加表单字段,差得太小容易出现“刚好卡在边界”的诡异问题;max_execution_time在CLI或调试环境可以设为0,在PHP-FPM下建议设300秒以上,避免合并大文件时被掐断。如果前面还有Nginx,同步确认client_max_body_size至少大于分片大小,不然请求会在Web层就返回413。4.4 换成Node.js服务端时的关键调整如果是Node.js技术栈,思路完全一样,但要注意中间件选择。接收multipart分片时,用busboy或multer这类中间件解析,不能直接把原文件存内存。文件合并时按chunkIndex顺序写入同一个可写流,逻辑和PHP版本没有本质区别。唯一需要留神的是Node.js默认的请求体大小限制,比如Express默认会拒绝较大的请求体,要对上传路由单独放开,否则分片再小也进不来。5. 实测中的四个坑与完整排查链路这套方案跑不跑得通,大概率就卡在这几个地方。每个坑我都按实际排查的顺序来写,而不是直接扔答案,这样出问题时你能自己顺着链路找原因。5.1 合并后文件损坏,不知道是哪一步错了现象是前端明明显示上传完成,合并出来的视频拖进播放器却打不开,PDF打开提示损坏。排查时先不要怀疑前端,从落盘数据看起。第一步,在服务端上传接口里临时记录每个分片的字节数,对照前端期望的大小。2MB分片,前面99片都应该是2097152字节,最后一片等于file.size取模的结果(如果整除就是2097152)。如果某一片大小明显不对,多半是Blob.slice的结束位置写错了。第二步,检查合并循环是否真的严格按索引顺序读取。这里最容易翻车的是异步回调里的循环变量:如果代码里用for循环遍历分片索引,然后在某个异步回调里引用索引变量,等回调真正执行时,索引可能已经变成了totalChunks,结果每片都指向同一个分片文件。我在PHP版本里用同步for循环,天然没有这个问题,但如果你用异步服务端,务必把当前索引拷贝到局部变量。第三步,合并完成后用原始文件和服务端最终文件的MD5做对比。MD5一致才能确认整个链路没有缺块或错序。5.2 $_FILES里收不到文件,请求被jQuery改了格式这是jQuery上传分片时最经典的坑。打开浏览器Network面板,点击分片请求,如果看到Content-Type是application/x-www-form-urlencoded,请求体是一行key=value字符串,说明jQuery把FormData当普通表单序列化了。服务端$_FILES自然为空,只有$_POST里那几个普通字段还在。原因就一句话:$.ajax的data是FormData对象时,必须关闭jQuery默认的序列化逻辑,也就是指定processData: false和contentType: false。如果用了老的jQuery版本,建议直接升到3.x,老版本对FormData的处理有很多历史遗留问题。核对这两项后,刷新页面重传,Network里Content-Type会变成multipart/form-data; boundary=...,$_FILES就能正常收到了。5.3 单片只有2MB,PHP却说超过上传限制现象是浏览器报错或者服务端返回“The uploaded file exceeds upload_max_filesize”,但单片明明才2MB。原因通常是php.ini的upload_max_filesize是1M或2M,恰好卡在边界附近;另一种情况是post_max_size设置只比分片大一点点,表单字段一多就超限。排查顺序是先用phpinfo()看实际生效值,注意PHP-FPM和CLI用的php.ini可能不是同一个文件。修改后要重启PHP-FPM,不是重启Nginx。如果修改了php.ini但没生效,检查opcache和PHP-FPM的配置加载路径。Web层同样用413状态码排查:如果是Nginx返回的413,问题在client_max_body_size,而不是PHP。5.4 服务端误判分片齐全,提前合并这个坑隐蔽一些。之前我在一个版本里图省事,在upload.php的最后检查“目录里分片数量等于totalChunks就立即合并”。表面看没问题,实际并发上传时,最后一片move_uploaded_file刚写完,另一个上传请求扫描目录发现分片全了,立刻触发合并。结果合并时最后一片可能还没来得及完整写入,或者被正在写入的请求覆盖,最终文件少一块。排查链路是这样:复现时给每个请求打印时间戳,看合并请求是否早于最后一个分片的上传返回;或者直接给upload里的合并逻辑加一个“发现全部到齐后延迟几秒再合”的临时方案,能复现但不可靠。根治方法就是我一开始说的:把合并独立成一个接口,只有当前端所有分片都拿到成功响应后,才发merge请求。这个时序上的竞态,从设计层面就避免掉。再补充一个细节:断点续传场景下,服务端实现“检查已上传分片列表”的接口时,不要用“扫描目录文件数量等于totalChunks”来判断文件整体是否存在,因为残留的旧分片会干扰判断。要记录每个fileHash的元数据,或者通过merge接口的结果状态来判断。6. 进阶扩展:从分片上传到断点续传与秒传标题问的是源码,但拿到源码能跑只是第一步。把这套方案接进真实业务时,大概率还会遇到断点续传和秒传的需求,我展开讲一下实现路径。6.1 断点续传:记住已经传成功的是哪些分片断点续传的思路很简单:上传前先摸清哪些分片已经存在于服务端,只传缺失的部分。前端把每个成功分片的索引存进localStorage,key用fileHash加文件大小生成。下次再选同一个文件时,先算MD5,然后到localStorage里找记录,已经成功的分片直接跳过。localStorage方案有个限制:它只对同一台设备同一个浏览器生效,用户换个电脑就失效。要真正跨设备续传,服务端要提供一个查询接口,比如GET /check?fileHash=xxx,服务端扫描分片目录,返回已存在的分片索引列表。前端拿到列表后只上传缺失部分,上传完还是走merge。这套改动在现有代码上只需要增加一个查询步骤,上传函数基本不用动。6.2 秒传:让fileHash直接换一个“已完成”秒传的底层逻辑其实更简单。服务端维护一张文件哈希表,记录每个fileHash对应的最终文件路径。前端算完MD5后,先调用查询接口,如果服务端发现这个fileHash已经存在完整文件,直接返回complete,前端显示“秒传完成”。用户不需要真的传一遍文件。这也正是我坚持在浏览器端算MD5的原因:秒传必须在上传动作发生前就知道文件的唯一标识。如果服务端才知道哈希,那个文件已经完整到达服务器了,秒传就没有意义。文件哈希表的维护可以在merge成功后插入一条记录,也可以用数据库唯一索引避免重复存文件。6.3 弱网下的参数调整:实测结论比理论更靠谱关于分片大小,我在不同网络环境里做过对比,结论是:在局域网和稳定公网里,5MB分片和1MB分片的整体成功率差别不大,5MB网络开销低,反而更快;一旦进入弱网,越大越容易超时。所以我的建议是不要写死2MB,而是根据文件大小和网络状态选参数。文件大小稳定网络建议弱网建议100MB以下5MB分片,并发31MB分片,并发2100MB-1GB2MB分片,并发4512KB分片,并发21GB以上1MB分片,并发3256KB-512KB分片,并发2另外可以做一层动态调整:统计最近几个分片的平均传输耗时,如果单片耗时超过比如20秒,就自动把分片大小降一档;如果连续多片都很快,再升一档。这个逻辑用在前端就是一个实时调整CHUNK_SIZE的阈值判断,配合现有并发池,代码改动不大,体验提升很明显。最后分享一个实操心得:很多人拿到这套代码后第一件事是调并发数,想跑满带宽。我的建议是先保持3个并发跑一遍完整流程,确认合并文件MD5一致,再逐步往上加。并发数高了以后,最容易暴露的不是前端代码,而是服务端临时目录的IO竞争和php.ini的限制。分片上传从来不是“前端把文件切开就行”这么简单,前后端的分片规则、目录结构、合并时机、失败重试必须完全对得上,任何一个环节不对,都会在合并后文件损坏时暴露出来。先把基础链路跑稳,再考虑断点续传和秒传,这条路是我验证过最省心的顺序。