Spring Boot 与前端分片上传、断点续传实战
文件上传是前端岗常考的「长时异步」场景:进度、暂停、重试、断点续传都要讲清楚。我自己在 Demo 里用 React 写了分片状态机,生产环境再对接 Spring Boot 或 OSS。这篇把前后端怎么串起来写全,面试按「方案对比 → 状态机 → 分片原理 → 前后端代码 → 生产取舍」讲即可。
题目
- 文件上传有哪些实现方式?大文件为什么用分片?
- 上传状态机怎么设计?
- 分片上传和断点续传的原理?刷新页面能续吗?
- 失败怎么重试?暂停/取消怎么处理?
- Spring Boot 后端怎么接?前端怎么调?
参考答案(口述要点)
- 小文件:
multipart/form-data一次传完,XHRupload.onprogress看进度- 大文件:分片上传——
File.slice()切片,逐片传,单片失败只重传该片- 断点续传:每片成功后持久化 下一片 index(localStorage + 服务端记录已传 parts)
- 状态机:
idle → pending → uploading ⇄ paused → success/error/cancelled,UI 只订阅回调- Spring Boot:
init拿 uploadId → 逐片POST /chunk→complete合并;或生产直传 OSS- 重试:单片粒度 + 递增退避;401/400 不重试,5xx/超时重试
方案对比:什么时候用什么
| 方式 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| form 提交 | 最简单 | 无进度、会刷页 | 几乎不用 |
| 单请求 multipart | 实现快 | 大文件易超时、失败整文件重传 | 小图、Excel |
| XHR / fetch 单文件 | 有进度、可 AbortController 取消 | 仍是一口气传完 | 10MB 以内 |
| 分片 + 断点续传 | 大文件、弱网、可暂停 | 协议复杂,前后端都要设计 | 视频、大包、移动端 |
| 直传 OSS / S3 | 不占业务带宽 | STS 签名、安全策略 | 生产首选 |
面试可以说:「我们业务上传素材最大 100MB,移动端 4G 多,所以做了分片;小头像仍走普通接口。」
整体流程(前后端串联)
前端:状态机 + 分片核心
上传是长时异步任务,状态集中管理,UI 通过 onProgress / onStatusChange 订阅,别在组件里散落 if。
7 种状态与转移
idle → pending → uploading ⇄ paused
↓
success / error / cancelled| status | UI 建议 | 可用操作 |
|---|---|---|
| idle | 待上传 | 选文件、开始 |
| uploading | 进度条 | 暂停、取消 |
| paused / error | 警告色 | 继续、重试、取消 |
| success | 完成 | — |
| cancelled | 已取消 | 重新选文件 |
分片循环(核心逻辑,与 Demo 一致)
// utils/chunkUpload.ts — 精简示意
export type UploadStatus =
| 'idle' | 'pending' | 'uploading' | 'paused'
| 'success' | 'error' | 'cancelled';
const STORAGE_PREFIX = 'upload_checkpoint_';
function generateFileId(file: File): string {
return `${file.name}_${file.size}_${file.lastModified}`;
}
function getCheckpoint(fileId: string): number {
const raw = localStorage.getItem(STORAGE_PREFIX + fileId);
return raw ? parseInt(raw, 10) : 0;
}
function saveCheckpoint(fileId: string, chunkIndex: number) {
localStorage.setItem(STORAGE_PREFIX + fileId, String(chunkIndex));
}
export function createChunkUpload(file: File, options = {}) {
const {
chunkSize = 256 * 1024, // 256KB,移动端 4G 可再小
maxRetries = 3,
uploadChunk,
onProgress,
onStatusChange,
} = options;
const fileId = generateFileId(file);
const totalChunks = Math.ceil(file.size / chunkSize);
let currentChunk = getCheckpoint(fileId);
let loaded = currentChunk * chunkSize;
let paused = false;
let cancelled = false;
let status: UploadStatus = 'idle';
const setStatus = (s: UploadStatus) => {
status = s;
onStatusChange?.(s);
};
const emitProgress = () => onProgress?.({
loaded,
total: file.size,
percent: Math.round((loaded / file.size) * 100),
chunkIndex: currentChunk,
totalChunks,
});
const uploadLoop = async () => {
setStatus('uploading');
while (currentChunk < totalChunks) {
if (cancelled) { setStatus('cancelled'); return; }
while (paused) {
await new Promise(r => setTimeout(r, 100));
if (cancelled) return;
}
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end); // 零拷贝,不整文件读内存
// 单片重试 + 线性退避
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (!navigator.onLine) {
setStatus('paused');
throw new Error('网络断开,上传已暂停');
}
try {
await uploadChunk(chunk, currentChunk, totalChunks, fileId);
loaded = end;
currentChunk++;
saveCheckpoint(fileId, currentChunk);
emitProgress();
break;
} catch (err) {
if (attempt >= maxRetries) {
setStatus('error');
throw err;
}
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
}
}
}
localStorage.removeItem(STORAGE_PREFIX + fileId);
setStatus('success');
};
return {
start: async () => {
cancelled = false;
paused = false;
loaded = currentChunk * chunkSize;
emitProgress();
setStatus('pending');
await uploadLoop();
},
pause: () => { paused = true; setStatus('paused'); },
resume: () => {
paused = false;
if (status === 'paused' || status === 'error') {
uploadLoop().catch(() => {});
}
},
cancel: () => { cancelled = true; paused = false; setStatus('cancelled'); },
retry: () => uploadLoop(),
};
}React Hook 绑定 UI
// hooks/useChunkUpload.ts
export function useChunkUpload() {
const controllerRef = useRef<ReturnType<typeof createChunkUpload> | null>(null);
const [status, setStatus] = useState<UploadStatus>('idle');
const [progress, setProgress] = useState({ loaded: 0, total: 0, percent: 0 });
useEffect(() => () => controllerRef.current?.cancel(), []); // 卸载取消
const bindFile = (f: File) => {
controllerRef.current?.cancel();
controllerRef.current = createChunkUpload(f, {
onProgress: setProgress,
onStatusChange: setStatus,
uploadChunk: realUploadChunk, // 见下文对接 Spring Boot
});
};
return { status, progress, bindFile, start, pause, resume, cancel, retry };
}断点续传:刷新页面能续吗?
| 条件 | 仅前端 localStorage | 前后端完整方案 |
|---|---|---|
| 进度记录 | 有,记下一片 index | + 服务端 uploadId 与已传 parts |
| File 对象 | 刷新丢失,用户需重选同一文件 | 同上,或 IndexedDB 存 blob |
| 已传分片 | mock 无服务端则只信本地 | 服务端保留 chunk 文件,续传时跳过 |
fileId = name + size + lastModified 用来识别「是不是同一个文件」,重选文件后能从 checkpoint 接着传。
Spring Boot 后端实现
单体项目里常见做法:临时目录存分片,Redis 记元数据,完成后合并。接口拆四步:初始化 → 传片 → 查进度 → 合并。
1. 请求/响应 DTO
// UploadInitRequest.java
public record UploadInitRequest(
String fileName,
long fileSize,
String fileId, // 前端 generateFileId
int totalChunks
) {}
// UploadInitResponse.java
public record UploadInitResponse(
String uploadId,
List<Integer> uploadedChunks // 已存在的分片,供断点跳过
) {}
// UploadCompleteRequest.java
public record UploadCompleteRequest(String uploadId) {}
// UploadCompleteResponse.java
public record UploadCompleteResponse(String url, String fileName) {}2. 上传服务
@Service
public class ChunkUploadService {
private final String tempDir; // application.yml: upload.temp-dir
private final StringStorageService storage; // 合并后存本地/OSS
private final StringRedisTemplate redis;
private static final String META_KEY = "upload:meta:";
public UploadInitResponse init(UploadInitRequest req) {
String uploadId = UUID.randomUUID().toString().replace("-", "");
Path dir = Paths.get(tempDir, uploadId);
try {
Files.createDirectories(dir);
} catch (IOException e) {
throw new BizException("创建上传目录失败");
}
UploadMeta meta = new UploadMeta(
uploadId, req.fileName(), req.fileSize(),
req.fileId(), req.totalChunks()
);
redis.opsForValue().set(META_KEY + uploadId, toJson(meta), Duration.ofDays(1));
List<Integer> uploaded = listExistingChunks(dir);
return new UploadInitResponse(uploadId, uploaded);
}
public void saveChunk(String uploadId, int chunkIndex, MultipartFile chunk) {
UploadMeta meta = getMeta(uploadId);
Path chunkPath = Paths.get(tempDir, uploadId, "chunk-" + chunkIndex);
if (Files.exists(chunkPath)) {
return; // 幂等:已传过直接成功
}
try {
chunk.transferTo(chunkPath.toFile());
} catch (IOException e) {
throw new BizException("分片保存失败: " + chunkIndex);
}
}
public List<Integer> status(String uploadId) {
return listExistingChunks(Paths.get(tempDir, uploadId));
}
public UploadCompleteResponse complete(UploadCompleteRequest req) {
UploadMeta meta = getMeta(req.uploadId());
Path dir = Paths.get(tempDir, req.uploadId());
Path target = dir.resolve("merged-" + meta.fileName());
try (OutputStream out = Files.newOutputStream(target)) {
for (int i = 0; i < meta.totalChunks(); i++) {
Path part = dir.resolve("chunk-" + i);
if (!Files.exists(part)) {
throw new BizException("缺少分片: " + i);
}
Files.copy(part, out);
}
} catch (IOException e) {
throw new BizException("合并分片失败");
}
String url = storage.store(target.toFile(), meta.fileName());
cleanup(req.uploadId());
return new UploadCompleteResponse(url, meta.fileName());
}
private List<Integer> listExistingChunks(Path dir) {
if (!Files.exists(dir)) return List.of();
try (Stream<Path> stream = Files.list(dir)) {
return stream
.map(p -> p.getFileName().toString())
.filter(name -> name.startsWith("chunk-"))
.map(name -> Integer.parseInt(name.substring(6)))
.sorted()
.toList();
} catch (IOException e) {
return List.of();
}
}
private void cleanup(String uploadId) {
redis.delete(META_KEY + uploadId);
// 异步删临时目录
}
}3. Controller
@RestController
@RequestMapping("/api/upload")
public class ChunkUploadController {
private final ChunkUploadService uploadService;
@PostMapping("/init")
public UploadInitResponse init(@RequestBody @Valid UploadInitRequest req) {
return uploadService.init(req);
}
@PostMapping("/chunk")
public void uploadChunk(
@RequestParam String uploadId,
@RequestParam int chunkIndex,
@RequestParam("file") MultipartFile chunk
) {
uploadService.saveChunk(uploadId, chunkIndex, chunk);
}
@GetMapping("/status/{uploadId}")
public List<Integer> status(@PathVariable String uploadId) {
return uploadService.status(uploadId);
}
@PostMapping("/complete")
public UploadCompleteResponse complete(@RequestBody UploadCompleteRequest req) {
return uploadService.complete(req);
}
}4. 配置注意点
# application.yml
spring:
servlet:
multipart:
max-file-size: 10MB # 单片大小上限,略大于 chunkSize
max-request-size: 12MB
upload:
temp-dir: /data/upload-temp// 生产建议:登录校验、文件类型白名单、单用户并发上传限制
@PreAuthorize("isAuthenticated()")
@PostMapping("/chunk")
public void uploadChunk(...) { ... }合并大文件时,Files.copy 逐片追加即可;量特别大可以考虑 FileChannel.transferFrom 或上传完成后异步合并,接口先返回「处理中」。
前端对接 Spring Boot
把 createChunkUpload 里的 uploadChunk 从 mock 换成真实请求。init 只在任务开始时调一次,complete 在所有片传完后调。
// api/chunkUploadApi.ts
let cachedUploadId: string | null = null;
export async function initUpload(file: File, totalChunks: number) {
const fileId = `${file.name}_${file.size}_${file.lastModified}`;
const res = await fetch('/api/upload/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
fileSize: file.size,
fileId,
totalChunks,
}),
});
if (!res.ok) throw new Error('初始化上传失败');
const data = await res.json();
cachedUploadId = data.uploadId;
// 服务端已有分片时,同步本地 checkpoint(取较大值)
const serverMax = data.uploadedChunks?.length
? Math.max(...data.uploadedChunks) + 1
: 0;
const local = getCheckpoint(fileId);
const startAt = Math.max(serverMax, local);
saveCheckpoint(fileId, startAt);
return data;
}
export async function realUploadChunk(
chunk: Blob,
index: number,
_total: number,
_fileId: string,
) {
if (!cachedUploadId) throw new Error('uploadId 未初始化');
const form = new FormData();
form.append('uploadId', cachedUploadId);
form.append('chunkIndex', String(index));
form.append('file', chunk, `chunk-${index}`);
const res = await fetch('/api/upload/chunk', {
method: 'POST',
body: form,
// 不要手动设 Content-Type,让浏览器带 boundary
});
if (res.status === 401 || res.status === 400) {
throw new Error('鉴权或参数错误,请重新登录'); // 不重试
}
if (!res.ok) {
throw new Error(`分片 ${index + 1} 上传失败`);
}
}
export async function completeUpload() {
const res = await fetch('/api/upload/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId: cachedUploadId }),
});
if (!res.ok) throw new Error('合并失败');
return res.json();
}启动时串起来
const bindAndStart = async (file: File) => {
const totalChunks = Math.ceil(file.size / (256 * 1024));
await initUpload(file, totalChunks);
const controller = createChunkUpload(file, {
uploadChunk: realUploadChunk,
onProgress: setProgress,
onStatusChange: setStatus,
});
await controller.start();
const result = await completeUpload();
console.log('文件地址', result.url);
};fetch 想中途取消,额外维护 AbortController,在 cancel() 里 abort() 当前请求。
生产进阶:直传 OSS(推荐大流量)
业务服务器合并分片会占带宽和磁盘,生产更常见 STS 临时凭证 + 前端直传 OSS:
1. POST /api/upload/init → { uploadId, ossCredentials }
2. 每片 PUT OSS uploadPart → 返回 ETag
3. POST /api/upload/complete → { uploadId, parts: [{partNumber, etag}] }
4. OSS 服务端合并,返回最终 URLSpring Boot 只负责鉴权、记元数据、发 STS,不传文件流。前端分片状态机可原样复用,只换 uploadChunk 实现。
移动端与场景题
100MB 视频、弱网 H5 还要注意:
- 动态 chunkSize:WiFi 1MB,4G 256KB
- Page Visibility:切后台
pause(),回前台提示继续 - 省流模式:
navigator.connection?.saveData为 true 时暂停 - 禁止
file.arrayBuffer()一次读入全文件 - 断点:localStorage + 服务端 parts 双写,以服务端为准
// 与网络模块联动
useEffect(() => {
if (!navigator.onLine && status === 'uploading') {
pause();
Toast.show({ content: '网络断开,已自动暂停' });
}
}, [status]);延伸 / 易错点
| 点 | 说明 |
|---|---|
| 幂等 | 同一片重复传,服务端 chunk-n 已存在则直接 200 |
| 合并顺序 | 必须按 chunk-0,1,2… 顺序追加,不能乱序写 |
| 进度 | 分片方案用 loaded = end 自算;比单请求 onprogress 更可控 |
| 暂停 | flag 空转,当前片传完才停;不是杀线程 |
| 错误分类 | 5xx/超时重试;401/400 提示用户,别死循环 |
| 安全 | 校验扩展名、大小、用户配额;临时目录定期清理 |
写在最后
分片上传面试的核心就三句:切片零拷贝、状态机驱动 UI、断点记到片级。Demo 用 localStorage 就能讲清原理;跟面试官说生产时 Spring Boot 管 init/merge,或 STS 直传 OSS,显得你做过完整闭环。本地我按 mobile-image-demo 的 chunkUpload.ts 练过一遍,对接后端时主要改 uploadChunk 这一个注入点。
