一、断点续传下载的核心原理

断点续传下载与上传类似,都依赖于HTTP协议的Range头字段,但实现方向相反:

  1. 客户端请求部分数据:下载时,客户端通过Range头指定需要获取的文件字节范围
  2. 服务器响应部分数据:服务器验证范围有效性后,返回206 Partial Content响应及对应字节范围的数据
  3. 客户端拼接文件:客户端将多次获取的文件片段按顺序拼接,最终形成完整文件
  4. 断点记录:客户端记录已下载的字节范围,支持从断点处继续下载

断点续传下载的核心优势:

  • 网络中断后无需重新下载整个文件
  • 支持暂停/继续下载,提升用户体验
  • 可实现多线程下载,提高下载速度
  • 节省网络带宽,尤其适合大文件场景

二、后端实现(Java)

1. 数据库设计扩展

在原有表结构基础上,我们需要添加下载记录相关表,用于跟踪用户的下载进度:

CREATE TABLE `file_download_record` (`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',`file_id` bigint NOT NULL COMMENT '关联file_info.id',`user_id` varchar(50) NOT NULL COMMENT '用户标识',`downloaded_size` bigint NOT NULL DEFAULT 0 COMMENT '已下载大小(字节)',`status` tinyint NOT NULL DEFAULT 0 COMMENT '下载状态(0:未完成,1:已完成)',`last_download_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,PRIMARY KEY (`id`),UNIQUE KEY `uk_user_file` (`user_id`,`file_id`) COMMENT '用户-文件唯一组合'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文件下载记录表';

2. 实体类扩展

FileDownloadRecord.java

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;import java.util.Date;@Data
@TableName("file_download_record")
public class FileDownloadRecord {@TableId(type = IdType.AUTO)private Long id;private Long fileId;private String userId;private Long downloadedSize;private Integer status; // 0:未完成,1:已完成private Date lastDownloadTime;
}

3. 下载服务实现

FileDownloadService.java

import cn.hutool.core.io.FileUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;@Service
public class FileDownloadService extends ServiceImpl<FileDownloadRecordMapper, FileDownloadRecord> {@Value("${file.storage-path}")private String storagePath;private final FileInfoMapper fileInfoMapper;public FileDownloadService(FileInfoMapper fileInfoMapper) {this.fileInfoMapper = fileInfoMapper;}/*** 获取文件下载记录*/public FileDownloadRecord getDownloadRecord(Long fileId, String userId) {return getOne(new LambdaQueryWrapper<FileDownloadRecord>().eq(FileDownloadRecord::getFileId, fileId).eq(FileDownloadRecord::getUserId, userId));}/*** 更新下载进度*/@Transactionalpublic void updateDownloadProgress(Long fileId, String userId, Long downloadedSize) {FileDownloadRecord record = getDownloadRecord(fileId, userId);FileInfo fileInfo = fileInfoMapper.selectById(fileId);if (record == null) {record = new FileDownloadRecord();record.setFileId(fileId);record.setUserId(userId);record.setDownloadedSize(downloadedSize);record.setStatus(0);save(record);} else {// 检查是否已完成int status = (downloadedSize >= fileInfo.getFileSize()) ? 1 : 0;record.setDownloadedSize(downloadedSize);record.setStatus(status);updateById(record);}}/*** 验证文件是否存在且可下载*/public FileInfo validateFile(Long fileId) {FileInfo fileInfo = fileInfoMapper.selectById(fileId);if (fileInfo == null) {throw new IllegalArgumentException("文件不存在");}if (fileInfo.getUploadStatus() != 1) {throw new IllegalStateException("文件尚未上传完成,无法下载");}// 验证文件实际存在Path filePath = Paths.get(fileInfo.getStoragePath());if (!Files.exists(filePath)) {throw new IllegalStateException("文件已损坏或被删除");}return fileInfo;}/*** 获取文件的部分内容*/public byte[] getFilePart(Long fileId, long start, long end) throws IOException {FileInfo fileInfo = validateFile(fileId);Path filePath = Paths.get(fileInfo.getStoragePath());// 确保end不超过文件大小long fileSize = fileInfo.getFileSize();end = Math.min(end, fileSize - 1);return Files.readAllBytes(filePath);// 注意:实际应用中应使用流传输,而非一次性读取到内存// 这里为简化示例使用了readAllBytes,大文件场景需修改}
}

4. 下载控制器实现

FileDownloadController.java

import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;@RestController
@RequestMapping("/api/file/download")
public class FileDownloadController {private final FileDownloadService downloadService;private final FileInfoMapper fileInfoMapper;public FileDownloadController(FileDownloadService downloadService, FileInfoMapper fileInfoMapper) {this.downloadService = downloadService;this.fileInfoMapper = fileInfoMapper;}/*** 获取文件下载信息(大小、名称等)*/@GetMapping("/info/{fileId}")public ResponseEntity<FileInfo> getFileInfo(@PathVariable Long fileId, @RequestParam String userId) {try {FileInfo fileInfo = downloadService.validateFile(fileId);// 检查是否有下载记录FileDownloadRecord record = downloadService.getDownloadRecord(fileId, userId);if (record != null) {// 如果已完成下载,直接返回完成状态if (record.getStatus() == 1) {fileInfo.setUploadStatus(1); // 复用此字段标识下载完成} else {// 设置已下载大小fileInfo.setFileSize(record.getDownloadedSize());}}return ResponseEntity.ok(fileInfo);} catch (Exception e) {return ResponseEntity.badRequest().body(null);}}/*** 断点续传下载*/@GetMapping("/{fileId}")public ResponseEntity<Resource> downloadFile(@PathVariable Long fileId,@RequestParam String userId,HttpServletRequest request) {try {FileInfo fileInfo = downloadService.validateFile(fileId);Path filePath = Paths.get(fileInfo.getStoragePath());Resource resource = new FileSystemResource(filePath);// 获取Range头信息String rangeHeader = request.getHeader(HttpHeaders.RANGE);if (rangeHeader == null || !rangeHeader.startsWith("bytes=")) {// 没有Range头,返回完整文件HttpHeaders headers = createHeaders(fileInfo, fileInfo.getFileSize(), 0, fileInfo.getFileSize() - 1);return new ResponseEntity<>(resource, headers, HttpStatus.OK);}// 解析Range头String[] rangeParts = rangeHeader.replace("bytes=", "").split("-");long start = Long.parseLong(rangeParts[0]);long end = rangeParts.length > 1 && !rangeParts[1].isEmpty() ? Long.parseLong(rangeParts[1]) : fileInfo.getFileSize() - 1;// 验证范围有效性if (start < 0 || end >= fileInfo.getFileSize() || start > end) {return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE).build();}// 更新下载进度(仅记录较大的进度更新,避免频繁数据库操作)downloadService.updateDownloadProgress(fileId, userId, end + 1);// 创建部分内容响应头HttpHeaders headers = createHeaders(fileInfo, fileInfo.getFileSize(), start, end);return new ResponseEntity<>(resource, headers, HttpStatus.PARTIAL_CONTENT);} catch (Exception e) {return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();}}/*** 创建下载响应头*/private HttpHeaders createHeaders(FileInfo fileInfo, long totalSize, long start, long end) {HttpHeaders headers = new HttpHeaders();headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileInfo.getFileName() + "\"");headers.add(HttpHeaders.CONTENT_TYPE, fileInfo.getFileType() != null ? fileInfo.getFileType() : "application/octet-stream");headers.add(HttpHeaders.ACCEPT_RANGES, "bytes");headers.add(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + totalSize);headers.setContentLength(end - start + 1);return headers;}
}

5. 大文件流式传输优化

对于大文件,我们需要使用流式传输而非一次性加载到内存,修改getFilePart方法:

/*** 获取文件的部分内容(流式传输优化)*/
public Resource getFileResource(Long fileId, long start, long end) {FileInfo fileInfo = validateFile(fileId);Path filePath = Paths.get(fileInfo.getStoragePath());return new FileSystemResource(filePath) {@Overridepublic InputStream getInputStream() throws IOException {// 使用SeekableByteChannel实现从指定位置读取SeekableByteChannel channel = Files.newByteChannel(filePath);channel.position(start);// 计算需要读取的字节数long size = end - start + 1;return Channels.newInputStream(new LimitedSeekableByteChannel(channel, size));}};
}// 自定义Channel实现,限制读取大小
private static class LimitedSeekableByteChannel implements SeekableByteChannel {private final SeekableByteChannel channel;private final long limit;private long position = 0;public LimitedSeekableByteChannel(SeekableByteChannel channel, long limit) {this.channel = channel;this.limit = limit;}@Overridepublic int read(ByteBuffer dst) throws IOException {if (position >= limit) {return -1;}// 限制读取的字节数不超过剩余字节int bytesToRead = Math.min(dst.remaining(), (int) (limit - position));ByteBuffer limitedBuffer = dst.slice();limitedBuffer.limit(bytesToRead);int bytesRead = channel.read(limitedBuffer);if (bytesRead > 0) {position += bytesRead;dst.position(dst.position() + bytesRead);}return bytesRead;}// 实现其他必要方法...@Override public int write(ByteBuffer src) throws IOException { return 0; }@Override public long position() throws IOException { return position; }@Override public SeekableByteChannel position(long newPosition) throws IOException { return this; }@Override public long size() throws IOException { return limit; }@Override public SeekableByteChannel truncate(long size) throws IOException { return this; }@Override public boolean isOpen() { return channel.isOpen(); }@Override public void close() throws IOException { channel.close(); }
}

三、前端实现(Vue 3)

1. 下载组件(FileDownloader.vue)

<template><div class="download-container"><el-button @click="startDownload" :loading="isDownloading"type="primary">{{ isDownloading ? '下载中' : '开始下载' }}</el-button><el-button @click="pauseDownload" :disabled="!isDownloading || isPaused"style="margin-left: 10px;">暂停</el-button><div v-if="showProgress" class="progress-info"><el-progress :percentage="progress" :stroke-width="4" style="margin: 10px 0;"></el-progress><p>已下载: {{ formatFileSize(downloadedSize) }} / {{ formatFileSize(totalSize) }}<span v-if="downloadSpeed">速度: {{ formatSpeed(downloadSpeed) }}</span></p></div></div>
</template><script setup>
import { ref, computed, onUnmounted } from 'vue';
import { ElMessage, ElProgress, ElButton } from 'element-plus';
import axios from 'axios';// 接收文件ID和用户ID作为参数
const props = defineProps({fileId: {type: Number,required: true},userId: {type: String,required: true}
});// 下载状态
const isDownloading = ref(false);
const isPaused = ref(false);
const progress = ref(0);
const downloadedSize = ref(0);
const totalSize = ref(0);
const fileName = ref('');
const showProgress = ref(false);
const downloadSpeed = ref(0);
const downloadUrl = ref('');
const abortController = ref(null);
const lastDownloadTime = ref(0);
const lastDownloadedSize = ref(0);// 格式化文件大小
const formatFileSize = (bytes) => {if (bytes < 1024) return bytes + ' B';if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
};// 格式化下载速度
const formatSpeed = (bytesPerSecond) => {if (bytesPerSecond < 1024) return bytesPerSecond + ' B/s';if (bytesPerSecond < 1024 * 1024) return (bytesPerSecond / 1024).toFixed(2) + ' KB/s';return (bytesPerSecond / (1024 * 1024)).toFixed(2) + ' MB/s';
};// 计算下载速度
const calculateSpeed = () => {if (!lastDownloadTime.value) {lastDownloadTime.value = Date.now();lastDownloadedSize.value = downloadedSize.value;return;}const now = Date.now();const timeDiff = (now - lastDownloadTime.value) / 1000; // 秒const sizeDiff = downloadedSize.value - lastDownloadedSize.value;if (timeDiff > 0) {downloadSpeed.value = Math.round(sizeDiff / timeDiff);}lastDownloadTime.value = now;lastDownloadedSize.value = downloadedSize.value;
};// 获取文件信息
const getFileInfo = async () => {try {const response = await axios.get(`/api/file/download/info/${props.fileId}`, {params: { userId: props.userId }});if (response.data) {fileName.value = response.data.fileName;totalSize.value = response.data.fileSize;// 如果有已下载记录if (response.data.uploadStatus === 1) {// 已完成下载progress.value = 100;downloadedSize.value = totalSize.value;ElMessage.success('文件已下载完成');return true;} else if (response.data.fileSize > 0) {// 有部分下载进度downloadedSize.value = response.data.fileSize;progress.value = Math.round((downloadedSize.value / totalSize.value) * 100);}}return false;} catch (error) {ElMessage.error('获取文件信息失败');console.error(error);return false;}
};// 开始下载
const startDownload = async () => {// 检查是否已完成const isCompleted = await getFileInfo();if (isCompleted) return;showProgress.value = true;isDownloading.value = true;isPaused.value = false;// 开始断点续传continueDownload();
};// 继续下载
const continueDownload = async () => {if (isPaused.value || !isDownloading.value) return;// 如果已经下载完成if (downloadedSize.value >= totalSize.value) {completeDownload();return;}// 创建AbortController用于取消请求abortController.value = new AbortController();try {// 计算请求的字节范围const start = downloadedSize.value;// 每次请求5MB数据const end = Math.min(start + 5 * 1024 * 1024 - 1, totalSize.value - 1);const response = await axios.get(`/api/file/download/${props.fileId}`, {params: { userId: props.userId },headers: {Range: `bytes=${start}-${end}`},responseType: 'blob',signal: abortController.value.signal,onDownloadProgress: (progressEvent) => {if (progressEvent.lengthComputable) {// 更新已下载大小const increment = progressEvent.loaded;downloadedSize.value += increment;progress.value = Math.round((downloadedSize.value / totalSize.value) * 100);// 计算下载速度calculateSpeed();}}});// 处理响应if (response.status === 200 || response.status === 206) {// 保存接收到的文件片段await saveFileChunk(response.data, start);// 继续下载下一部分continueDownload();}} catch (error) {if (error.name !== 'AbortError') {ElMessage.error('下载失败,将重试...');console.error(error);// 重试下载setTimeout(continueDownload, 3000);}}
};// 保存文件片段
const saveFileChunk = async (blob, start) => {// 实际应用中应使用FileSystem API或IndexedDB存储文件片段// 这里简化处理,实际项目需要实现客户端存储逻辑console.log(`保存文件片段: 从 ${start} 字节开始,大小 ${blob.size} 字节`);// 模拟保存延迟return new Promise(resolve => setTimeout(resolve, 100));
};// 完成下载
const completeDownload = () => {isDownloading.value = false;progress.value = 100;ElMessage.success(`文件 "${fileName.value}" 下载完成`);// 合并所有文件片段(实际应用中实现)mergeFileChunks();
};// 合并文件片段
const mergeFileChunks = () => {// 实现文件片段合并逻辑console.log(`合并所有文件片段,生成完整文件: ${fileName.value}`);
};// 暂停下载
const pauseDownload = () => {if (abortController.value) {abortController.value.abort();}isPaused.value = true;isDownloading.value = false;ElMessage.info('下载已暂停');
};// 组件卸载时取消下载
onUnmounted(() => {if (abortController.value) {abortController.value.abort();}
});
</script><style scoped>
.download-container {padding: 20px;
}.progress-info {margin-top: 15px;padding: 10px;border: 1px solid #e5e7eb;border-radius: 4px;
}
</style>

2. 客户端文件存储优化

对于前端存储大文件片段,推荐使用IndexedDB或FileSystem API:

// IndexedDB文件存储工具类
class FileStorage {constructor(dbName = 'FileDownloadDB', storeName = 'fileChunks') {this.dbName = dbName;this.storeName = storeName;this.db = null;// 初始化数据库this.init();}// 初始化数据库init() {return new Promise((resolve, reject) => {const request = indexedDB.open(this.dbName, 1);request.onupgradeneeded = (event) => {const db = event.target.result;if (!db.objectStoreNames.contains(this.storeName)) {// 创建存储对象db.createObjectStore(this.storeName, { keyPath: 'id' });}};request.onsuccess = (event) => {this.db = event.target.result;resolve();};request.onerror = (event) => {console.error('IndexedDB初始化失败:', event.target.error);reject(event.target.error);};});}// 保存文件片段saveChunk(fileId, chunkNumber, data, start, end) {return new Promise((resolve, reject) => {if (!this.db) {reject(new Error('数据库未初始化'));return;}const transaction = this.db.transaction([this.storeName], 'readwrite');const store = transaction.objectStore(this.storeName);const chunkId = `${fileId}_${chunkNumber}`;const request = store.put({id: chunkId,fileId,chunkNumber,data,start,end,timestamp: Date.now()});request.onsuccess = () => resolve();request.onerror = () => reject(request.error);});}// 获取文件片段getChunk(fileId, chunkNumber) {return new Promise((resolve, reject) => {if (!this.db) {reject(new Error('数据库未初始化'));return;}const transaction = this.db.transaction([this.storeName], 'readonly');const store = transaction.objectStore(this.storeName);const chunkId = `${fileId}_${chunkNumber}`;const request = store.get(chunkId);request.onsuccess = () => resolve(request.result);request.onerror = () => reject(request.error);});}// 获取文件的所有片段getAllChunks(fileId) {return new Promise((resolve, reject) => {if (!this.db) {reject(new Error('数据库未初始化'));return;}const transaction = this.db.transaction([this.storeName], 'readonly');const store = transaction.objectStore(this.storeName);const chunks = [];const request = store.openCursor();request.onsuccess = (event) => {const cursor = event.target.result;if (cursor) {if (cursor.value.fileId === fileId) {chunks.push(cursor.value);}cursor.continue();} else {// 按片段编号排序chunks.sort((a, b) => a.chunkNumber - b.chunkNumber);resolve(chunks);}};request.onerror = () => reject(request.error);});}// 删除文件的所有片段deleteAllChunks(fileId) {return new Promise((resolve, reject) => {if (!this.db) {reject(new Error('数据库未初始化'));return;}this.getAllChunks(fileId).then(chunks => {const transaction = this.db.transaction([this.storeName], 'readwrite');const store = transaction.objectStore(this.storeName);chunks.forEach(chunk => {store.delete(chunk.id);});transaction.oncomplete = () => resolve();transaction.onerror = () => reject(transaction.error);}).catch(reject);});}
}

四、断点续传下载的关键技术点

  1. HTTP Range请求处理

    • 客户端通过Range头指定请求的字节范围
    • 服务器通过Content-Range头返回实际响应的范围
    • 正确处理206 Partial Content状态码
  2. 下载进度跟踪

    • 后端记录用户的下载进度,支持跨会话恢复
    • 前端实时计算下载速度,提供友好的进度展示
    • 合理设置进度更新频率,避免频繁数据库操作
  3. 大文件处理优化

    • 使用流式传输,避免一次性加载大文件到内存
    • 实现连接超时自动重试机制
    • 支持并发下载多个片段,提高下载速度
  4. 客户端存储策略

    • 对于小文件可使用内存存储片段
    • 大文件推荐使用IndexedDB或FileSystem API
    • 实现片段过期清理机制,释放存储空间

五、安全性与性能考量

  1. 安全性措施

    • 实现文件下载权限验证,防止未授权访问
    • 对下载请求进行频率限制,防止恶意请求
    • 验证Range头的有效性,防止恶意范围请求
  2. 性能优化

    • 对热门文件实现缓存机制
    • 使用Nginx等Web服务器处理静态文件下载,减轻应用服务器负担
    • 实现下载带宽控制,避免单个用户占用过多资源
    • 定期清理过期的临时文件和下载记录