在处理大文件下载时,传统方式容易出现下载失败、内存溢出或无法续传等问题。本文通过“按字节范围”动态分片的方式,详细讲解如何用最简单的前后端方案实现一个稳定、高效、可断点续传的大文件下载功能,前端无依赖、后端零改动成本,真正做到“简单好用、拿来即用”。
🎯 一、为什么要使用分片下载?
❌ 传统下载问题
传统的单次下载存在如下问题:
浏览器无法承载过大的文件 Blob(通常几百 MB 就容易内存崩溃)
网络波动或中断会导致重新下载整个文件
用户体验差,文件未下载完之前无法操作
✅ 分片下载优势
分片下载(Byte Range Requests)能够:
⬇️ 边下载边使用(适合媒体类文件)
🧩 失败重试某一片(无需整体重新下载)
🛠️ 支持断点续传
🧠 更易并发优化
🧠 二、设计思路:如何划分文件分片?
关键点是:
📦 “按照字节范围分片”,而不是把文件拆成多个独立小文件。
比如:一个 100MB 的文件,我们可以每次请求 1MB 字节:
Range: bytes=0-1048575 // 第1片
Range: bytes=1048576-2097151 // 第2片
……
每片通过 HTTP 的 Range 头请求后端返回指定范围的字节流
前端将每片 Blob 拼接成最终的 Blob 并触发下载
这就像“按需索取”,无需服务器提前分片存储,非常灵活高效。
请求头
Header示例描述
Range
bytes=0-1048575
指定下载的字节范围,格式
Accept
*/*
可选,客户端可接受的媒体类型列表
响应
200 OK
描述:请求未包含 Range,返回完整文件内容。
响应头:
NameValue
Content-Type
application/octet-stream
Content-Disposition
`attachment; filename="{encoded filename}"
Content-Length
{fileSize}(完整文件大小,字节)
响应体:完整文件的二进制流
206 Partial Content
描述:请求包含合法 Range,返回指定字节区间。
响应头:
NameValue
Content-Type
application/octet-stream
Content-Disposition
`attachment; filename="{encoded filename}"
Accept-Ranges
bytes
Content-Length
{end - start + 1}(本次返回字节数)
Content-Range
bytes {start}-{end}/{fileSize}
响应体:指定区间的字节流
404 Not Found
描述:文件不存在
状态码:404
416 Range Not Satisfiable
描述:请求的 Range 超出文件总长度
状态码:416
响应头:
Content-Range: bytes */{fileSize} (指示有效总长度)
响应体:可选空或错误描述
🧱 三、后端实现(Java 示例)
核心目标:支持 Range 请求 + 正确响应头设置 + 支持跨域
控制层:FileController
/**
* 文件控制器
*
* @author demo
*/
@RequiredArgsConstructor
@RestController
@RequestMapping ("file")
@CrossOrigin
public class FileController {
@RequestMapping(value = "packageFile", method = RequestMethod.GET)
@ApiOperation(value = "打包文件", notes = "打包文件")
@ApiImplicitParams({
@ApiImplicitParam(name = "filePath", value = "文件路径", required = true, dataType = "String")
})
public void packageMaterialFile(@Valid @NotEmpty(message = "文件路径filePath不允许为空") String filePath,
HttpServletResponse response, HttpServletRequest request) throws IOException {
FileSliceUtil.fileSlice(response, request,filePath);
}
}
分片下载工具类:FileSliceUtil
/**
* 文件分片工具类
*
* @author demo
* @date 2025/4/21 14:24
*/
@Slf4j
public class FileSliceUtil {
/**
* 文件分片
*
* @param filePath 文件路径
*/
public static void fileSlice(HttpServletResponse response, HttpServletRequest request, String filePath) throws IOException {
File file = new File(filePath);
if (!file.exists()) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
// 生成MD5摘要
String md5Digest = DigestUtils.md5DigestAsHex(Files.readAllBytes(file.toPath()));
String filename = file.getName();
long fileLength = file.length();
String rangeHeader = request.getHeader("Range");
long start = 0, end = fileLength - 1;
// 处理Range请求头
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
String[] ranges = rangeHeader.substring("bytes=".length()).split("-");
try {
start = Long.parseLong(ranges[0]);
if (ranges.length > 1) {
end = Long.parseLong(ranges[1]);
}
} catch (NumberFormatException e) {
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return;
}
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
} else {
response.setStatus(HttpServletResponse.SC_OK);
}
long contentLength = end - start + 1;
// 跨域暴露响应头
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Expose-Headers", "Content-Length, Content-Range, ETag");
response.setHeader("Content-Type", "application/octet-stream");
response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, "UTF-8"));
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("Content-Length", String.valueOf(contentLength));
response.setHeader("ETag", md5Digest);
response.setHeader("Content-Range", String.format("bytes %d-%d/%d", start, end, fileLength));
// RandomAccessFile 对文件进行随机读取
try (RandomAccessFile raf = new RandomAccessFile(file, "r");
OutputStream os = response.getOutputStream()) {
raf.seek(start);
byte[] buffer = new byte[8192];
long bytesToRead = contentLength;
while (bytesToRead > 0) {
int len = raf.read(buffer, 0, (int) Math.min(buffer.length, bytesToRead));
if (len == -1) break;
os.write(buffer, 0, len);
bytesToRead -= len;
}
os.flush();
}
}
}
💻 四、前端实现(jQuery/AJAX 分片请求)
function downloadFileInChunks(url, fileName, chunkSize = 1024 * 1024 * 100) {
let downloadedSize = 0;
let totalSize = 0;
const chunks = [];
// 第一步:获取总大小
$.ajax({
url: url,
type: 'GET',
headers: {
'Range': 'bytes=0-' + chunkSize
},
xhrFields: {
responseType: 'blob'
},
success: function (data, status, xhr) {
const contentRange = xhr.getResponseHeader('Content-Range');
if (!contentRange) {
alert("服务端未返回 Content-Range,无法支持分片下载");
return;
}
totalSize = parseInt(contentRange.split('/')[1], 10);
console.log('总文件大小:' + totalSize);
downloadNextChunk();
}
});
// 第二步:循环下载每个分片
function downloadNextChunk() {
if (downloadedSize >= totalSize) {
mergeChunks();
return;
}
const start = downloadedSize;
const end = Math.min(start + chunkSize - 1, totalSize - 1);
$.ajax({
url: url,
type: 'GET',
headers: {
'Range': `bytes=${start}-${end}`
},
xhrFields: {
responseType: 'blob'
},
success: function (data, status, xhr) {
chunks.push(data);
downloadedSize += data.size;
console.log(`已下载 ${downloadedSize}/${totalSize}`);
downloadNextChunk(); // 下载下一个分片
},
error: function () {
alert('分片下载失败,请检查网络或服务端 Range 支持');
}
});
}
// 第三步:合并 Blob 并下载
function mergeChunks() {
const blob = new Blob(chunks);
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
console.log('下载完成');
}
}
⚠️ 五、调用样例