Spring Boot 优雅处理大文件上传:分片 + 断点续传 + 秒传
引言
"产品经理上传一个 2GB 的安装包,进度条爬到 92% 时网络闪断,浏览器弹了个'上传失败'——一切从头再来。第三次传到 80% 时,Tomcat 那边先 OOM 了。"
这个事故里其实藏着三层问题:普通 multipart 上传把整个文件吃进服务器内存/临时目录,2GB 文件等于让每个请求都背着一颗内存炸弹;HTTP 是无状态的,传了 92% 断开,服务端对'已经收到多少'毫无记忆,重传只能从零开始;同一个安装包团队里十个人各传一遍,服务器接收了十遍完全相同的 2GB 字节。
对应的三个解法已经是行业标配:分片上传把大文件切成 5MB 小块逐个传,单次请求小、失败只需重传一块;断点续传用 Redis 记录已上传分片,重连后只传缺口;秒传在上传前先拿文件 MD5 问服务端"这个文件是不是已经有了",有就直接返回成功,一个字节都不用传。这篇文章给出从前端 File.slice 到后端合并落盘的完整实现:前端 TS + Axios 并发池,后端 Spring Boot + Redis 分片状态 + FileChannel 零内存合并,文末再讲生产环境演进到 OSS/S3 原生分片时的关键取舍——本地合并方案能让你彻底理解原理,而理解了"uploadId + 分片编号"这套模型,切换对象存储只是换一层 API。
一、全景:大文件为什么必须分片
1.1 普通上传在大文件下的四个死穴
浏览器一次性 POST multipart/form-data(2GB)
│
├─ 浏览器/反向代理层:nginx client_max_body_size 默认 1MB,直接 413
├─ Tomcat 层:maxSwallowSize / multipart 临时文件占满磁盘
├─ Spring 层:MultipartFile 全量接收,内存+临时目录双份开销
├─ 网络层:任何一次抖动 = 2GB 全部重传,用户传一晚上传不完
└─ 业务层:十个人传同一个包,服务器收十遍,带宽和磁盘全浪费
1.2 三功能的协作流程
① 前端计算文件 MD5(spark-md5 分片抽样/全量)
│
② GET /upload/check?fileMd5=xxx&size=2147483648
│ 服务端返回:
│ ├─ uploaded=true → ★ 秒传:直接完成,0 字节上传
│ ├─ uploaded=false, chunks=[3,7] → 断点续传:第3、7片已传,跳过
│ └─ uploaded=false, chunks=[] → 全新上传
│
③ 前端 File.slice 切片(如每片 5MB),并发上传缺失分片
│ POST /upload/chunk (fileMd5, chunkIndex, chunkTotal, blob)
│ 服务端:存临时分片 + Redis 记录该片已到
│
④ 全部分片到齐 → POST /upload/merge (fileMd5, fileName, chunkTotal)
│ 服务端:按编号顺序合并为最终文件、校验总 MD5/大小、登记秒传
│
⑤ 返回最终文件访问 URL
1.3 数据模型与存储布局
Redis 里记两类状态:
upload:chunk:{fileMd5} → Set,成员=已上传分片编号,如 {0,1,2,5}
upload:meta:{fileMd5} → Hash:fileName/size/chunkTotal/uploaderId/createTime
TTL 24 小时,过期后分片由清理任务回收
sec:file:{md5} → String,秒传命中表:md5 → 最终文件路径/URL
(持久化可落 MySQL,Redis 做缓存)
服务器磁盘布局:
/data/tmp-upload/{fileMd5}/
├── 0.part
├── 1.part
└── ...
/data/files/2026/10/03/{fileMd5}_installer.dmg ← 合并后的最终文件
用 fileMd5 当临时目录名的好处:天然隔离不同文件、天然支持同一用户重传、断点续传时目录还在就能继续。
二、前端:File.slice、MD5 与并发池
2.1 切片与 MD5 计算
浏览器端 MD5 用 spark-md5,为避免 2GB 文件一次性读入内存,分片读取、增量追加:
import SparkMD5 from 'spark-md5'
export const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB 一片
/** 分片读取计算整文件 MD5;大文件可在此加进度回调,避免主线程卡死建议放 Web Worker */
export function calcFileMd5(file: File, onProgress?: (p: number) => void): Promise<string> {
return new Promise((resolve, reject) => {
const chunks = Math.ceil(file.size / CHUNK_SIZE)
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
let current = 0
reader.onload = (e) => {
spark.append(e.target!.result as ArrayBuffer)
current++
onProgress?.(current / chunks)
if (current < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
reader.onerror = () => reject(reader.error)
function loadNext() {
const start = current * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)
reader.readAsArrayBuffer(file.slice(start, end))
}
loadNext()
})
}
export function sliceFile(file: File, index: number): Blob {
const start = index * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)
return file.slice(start, end)
}
生产建议:MD5 计算放进 Web Worker,2GB 文件在主线程算会长时间卡死页面;对超大文件(10GB+)业界常用"抽样 MD5"(首尾固定字节 + 中间抽样 + 文件大小)做秒传初判,命中后再走全量校验,平衡 CPU 与准确率。
2.2 开传前检查:一个接口同时承载秒传与续传
export interface CheckResult {
uploaded: boolean // true=秒传命中
uploadedChunks: number[] // 已上传的分片编号(断点续传缺口表)
url?: string
}
export async function checkUpload(fileMd5: string, size: number): Promise<CheckResult> {
const { data } = await axios.get<CheckResult>('/api/upload/check', {
params: { fileMd5, size },
})
return data
}
2.3 并发池:信号量模式,别用 Promise.race 野路子
分片串行传太慢,无限并发会把浏览器连接池打满(HTTP/1.1 同域名 6 条连接上限)。用一个固定大小的并发池(3~6 个),按"每片失败重试 3 次"调度:
export interface UploadCallbacks {
onProgress?: (loaded: number, total: number) => void
concurrency?: number
}
export async function uploadChunks(
file: File,
fileMd5: string,
chunkTotal: number,
uploadedChunks: Set<number>,
cb: UploadCallbacks = {},
): Promise<void> {
const concurrency = cb.concurrency ?? 4
const pending: number[] = []
for (let i = 0; i < chunkTotal; i++) {
if (!uploadedChunks.has(i)) pending.push(i)
}
let loadedBytes = uploadedChunks.size * CHUNK_SIZE
let cursor = 0
async function worker() {
while (cursor < pending.length) {
const index = pending[cursor++]
const blob = sliceFile(file, index)
await uploadOneChunkWithRetry(fileMd5, index, chunkTotal, blob, () => {
loadedBytes += blob.size
cb.onProgress?.(Math.min(loadedBytes, file.size), file.size)
})
}
}
await Promise.all(Array.from({ length: concurrency }, () => worker()))
}
async function uploadOneChunkWithRetry(
fileMd5: string, index: number, chunkTotal: number, blob: Blob,
onLoaded: () => void,
) {
const form = new FormData()
form.append('fileMd5', fileMd5)
form.append('chunkIndex', String(index))
form.append('chunkTotal', String(chunkTotal))
form.append('chunk', blob)
let lastErr: unknown
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await axios.post('/api/upload/chunk', form, {
// ★ 单片超时要短:5MB 内网/正常宽带几秒内必完成,卡住快速失败重试
timeout: 30_000,
onUploadProgress: (e) => { if (e.total != null && e.loaded >= e.total) onLoaded() },
})
return
} catch (err) {
lastErr = err
await new Promise((r) => setTimeout(r, 500 * attempt)) // 线性退避
}
}
throw new Error(`分片 ${index} 重试3次仍失败`, { cause: lastErr })
}
这里有个踩过的坑值得强调:不要用 Promise.race 去管理并发池——race 返回的是兑现值而不是 Promise 引用,删除任务时会删错对象,池大小逐渐失控。worker 游标模式(每个 worker 从共享游标抢任务)实现最简单、池大小恒定。
2.4 编排主流程
export async function uploadFile(file: File, cb: UploadCallbacks = {}) {
const chunkTotal = Math.ceil(file.size / CHUNK_SIZE)
// ① MD5(实际项目放 Worker;MD5 阶段是本地计算,不计入网络上传进度)
const fileMd5 = await calcFileMd5(file)
// ② 检查:秒传 or 续传
const check = await checkUpload(fileMd5, file.size)
if (check.uploaded) {
cb.onProgress?.(file.size, file.size)
return { url: check.url!, instant: true }
}
// ③ 传缺口分片
await uploadChunks(file, fileMd5, chunkTotal, new Set(check.uploadedChunks), cb)
// ④ 合并
const { data } = await axios.post('/api/upload/merge', null, {
params: { fileMd5, fileName: file.name, chunkTotal, size: file.size },
})
return { url: data.url, instant: false }
}
三、后端:配置、Redis 状态与异常响应
3.1 上传相关配置
spring:
servlet:
multipart:
max-file-size: 10MB # ★ 单片大小上限(略大于5MB分片),不是整个文件
max-request-size: 12MB
data:
redis:
host: redis-prod
timeout: 2000ms
upload:
tmp-dir: /data/tmp-upload # 分片临时目录,独立挂载大磁盘
final-dir: /data/files # 合并后文件目录
chunk-expire-hours: 24 # 未完成上传的分片保留时间
chunk-size: 5242880
server:
tomcat:
max-swallow-size: -1 # 关键:让 Tomcat 吞完异常请求体,避免连接重置干扰前端重试
注意 max-file-size 限制的是单个请求,分片模式下设为略大于单片即可——这正是大文件不撑爆服务端的关键:服务端在任何时刻都只持有一片 5MB。
3.2 Redis 状态服务
@Service
@Slf4j
public class UploadStateService {
private static final String CHUNK_KEY = "upload:chunk:";
private static final String META_KEY = "upload:meta:";
private static final String SEC_KEY = "sec:file:";
private static final Duration TTL = Duration.ofHours(24);
private final StringRedisTemplate redis;
public UploadStateService(StringRedisTemplate redis) {
this.redis = redis;
}
/** 记录一片到达:SADD + 刷新 TTL(每次上传都续期,活跃任务不过期) */
public void markChunkUploaded(String fileMd5, int chunkIndex, UploadMeta meta) {
redis.executePipelined(new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations operations) {
operations.opsForSet().add(CHUNK_KEY + fileMd5, String.valueOf(chunkIndex));
operations.expire(CHUNK_KEY + fileMd5, TTL);
operations.opsForHash().putAll(META_KEY + fileMd5, meta.toMap());
operations.expire(META_KEY + fileMd5, TTL);
return null;
}
});
}
/** 取已上传分片,供断点续传 */
public List<Integer> uploadedChunks(String fileMd5) {
Set<String> members = redis.opsForSet().members(CHUNK_KEY + fileMd5);
if (members == null || members.isEmpty()) {
return Collections.emptyList();
}
return members.stream().map(Integer::parseInt).sorted().collect(Collectors.toList());
}
public long uploadedCount(String fileMd5) {
Long size = redis.opsForSet().size(CHUNK_KEY + fileMd5);
return size == null ? 0 : size;
}
/** 合并完成后清理上传态 */
public void clearUploadState(String fileMd5) {
redis.delete(CHUNK_KEY + fileMd5, META_KEY + fileMd5);
}
/** 秒传表:命中返回文件访问路径 */
public String hitInstant(String fileMd5) {
return redis.opsForValue().get(SEC_KEY + fileMd5);
}
public void registerInstant(String fileMd5, String relativePath) {
// 秒传记录长期保存(配合数据库持久化,Redis 缓存即可)
redis.opsForValue().set(SEC_KEY + fileMd5, relativePath, Duration.ofDays(30));
}
}
@Data
@AllArgsConstructor
public class UploadMeta {
private String fileName;
private long size;
private int chunkTotal;
private String uploaderId;
public Map<String, String> toMap() {
Map<String, String> m = new HashMap<>();
m.put("fileName", fileName);
m.put("size", String.valueOf(size));
m.put("chunkTotal", String.valueOf(chunkTotal));
m.put("uploaderId", uploaderId);
m.put("createTime", String.valueOf(System.currentTimeMillis()));
return m;
}
}
四、后端:检查、分片接收与合并三个接口
4.1 检查接口:秒传与断点续传的统一入口
@RestController
@RequestMapping("/api/upload")
@Slf4j
public class FileUploadController {
private final UploadStateService stateService;
private final UploadProperties props;
@GetMapping("/check")
public R<CheckVO> check(@RequestParam String fileMd5,
@RequestParam(required = false) Long size) {
// ① 秒传:文件库里已经有完全相同的文件
String existPath = stateService.hitInstant(fileMd5);
if (existPath != null) {
log.info("秒传命中 md5={}", fileMd5);
return R.ok(CheckVO.instant(existPath));
}
// ② 断点续传:返回缺口表
List<Integer> uploaded = stateService.uploadedChunks(fileMd5);
return R.ok(CheckVO.resumable(uploaded));
}
}
@Data
public class CheckVO {
private boolean uploaded;
private List<Integer> uploadedChunks;
private String url;
public static CheckVO instant(String path) {
CheckVO vo = new CheckVO();
vo.uploaded = true;
vo.uploadedChunks = Collections.emptyList();
vo.url = "/files/" + path;
return vo;
}
public static CheckVO resumable(List<Integer> chunks) {
CheckVO vo = new CheckVO();
vo.uploaded = false;
vo.uploadedChunks = chunks;
return vo;
}
}
4.2 分片接收:落临时目录,幂等写
@PostMapping("/chunk")
public R<Void> uploadChunk(@RequestParam String fileMd5,
@RequestParam int chunkIndex,
@RequestParam int chunkTotal,
@RequestParam MultipartFile chunk,
@AuthenticationPrincipal LoginUser user) throws IOException {
// 基础校验:防越界、防伪造
if (chunkIndex < 0 || chunkIndex >= chunkTotal) {
throw new BizException(ErrorCode.BAD_REQUEST, "分片编号非法");
}
if (chunk.getSize() > props.getChunkSize() + 1024) {
throw new BizException(ErrorCode.BAD_REQUEST, "分片过大");
}
String safeMd5 = validateMd5(fileMd5); // 只允许十六进制字符,防路径穿越
Path dir = Paths.get(props.getTmpDir(), safeMd5);
Files.createDirectories(dir);
Path partFile = dir.resolve(chunkIndex + ".part");
// ★ 幂等:同一片重传直接覆盖写临时文件再原子 move,避免半片残留
Path tmp = dir.resolve(chunkIndex + ".part.tmp");
try (InputStream in = chunk.getInputStream()) {
Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING);
}
Files.move(tmp, partFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
stateService.markChunkUploaded(safeMd5, chunkIndex,
new UploadMeta("", 0L, chunkTotal, user.getUserId()));
return R.ok();
}
/** MD5 白名单校验:拼路径的参数永远不能直接来自用户输入 */
private String validateMd5(String md5) {
if (md5 == null || !md5.matches("^[a-fA-F0-9]{32}$")) {
throw new BizException(ErrorCode.BAD_REQUEST, "fileMd5 非法");
}
return md5.toLowerCase();
}
几个安全与可靠性的细节:
- 防路径穿越:
fileMd5直接拼目录,必须正则白名单(../../etc/passwd这类输入一律拒绝)。 - 先写 .tmp 再原子 move:写到一半进程重启/请求中断,留下的是 .tmp 残片而不是损坏的 .part;下次重传覆盖即可。
- 接口天然幂等:同编号分片重传结果一致,断点续传时前端无脑重传缺口也不会出错。
4.3 合并接口:FileChannel 零内存拼接
合并绝不能把 2GB 字节读到 byte[] 里(那就是事故里的 OOM)。用 NIO 的 transferTo/transferFrom 在内核态完成文件拼接:
@PostMapping("/merge")
public R<MergeVO> merge(@RequestParam String fileMd5,
@RequestParam String fileName,
@RequestParam int chunkTotal,
@RequestParam long size,
@AuthenticationPrincipal LoginUser user) throws IOException {
String safeMd5 = validateMd5(fileMd5);
Path dir = Paths.get(props.getTmpDir(), safeMd5);
// ① 分片完整性校验:数量、编号连续、无 .tmp 残片
List<Path> parts = collectAndVerifyParts(dir, chunkTotal, safeMd5);
// ② 合并到最终目录(按日期分桶,避免单目录文件过多)
String datePart = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
Path finalDir = Paths.get(props.getFinalDir(), datePart);
Files.createDirectories(finalDir);
String safeName = sanitizeFileName(fileName);
Path target = finalDir.resolve(safeMd5 + "_" + safeName);
Path merging = target.resolveSibling(target.getFileName() + ".merging");
long mergedBytes = mergeWithChannel(parts, merging);
// ③ 大小校验:字节数不符说明分片损坏/伪造,直接失败
if (mergedBytes != size) {
Files.deleteIfExists(merging);
log.warn("合并大小不符 md5={} expect={} actual={}", safeMd5, size, mergedBytes);
throw new BizException(ErrorCode.UPLOAD_BROKEN, "文件大小校验失败,请重新上传");
}
// ④ 整体 MD5 校验(流式,不进内存),通过后才原子转正
String actualMd5 = DigestUtils.md5DigestAsHex(Files.newInputStream(merging));
if (!safeMd5.equals(actualMd5)) {
Files.deleteIfExists(merging);
throw new BizException(ErrorCode.UPLOAD_BROKEN, "MD5 校验失败");
}
Files.move(merging, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
// ⑤ 登记秒传 + 清理临时分片和 Redis 状态
String relative = datePart + "/" + target.getFileName();
stateService.registerInstant(safeMd5, relative);
stateService.clearUploadState(safeMd5);
cleanPartsQuietly(dir);
return R.ok(new MergeVO("/files/" + relative));
}
private List<Path> collectAndVerifyParts(Path dir, int chunkTotal, String md5) throws IOException {
if (!Files.isDirectory(dir)) {
throw new BizException(ErrorCode.UPLOAD_NOT_FOUND, "分片不存在");
}
// Redis 计数快速判定,磁盘清单做最终事实校验(Redis 与磁盘可能短暂不一致)
long redisCount = stateService.uploadedCount(md5);
if (redisCount < chunkTotal) {
throw new BizException(ErrorCode.UPLOAD_INCOMPLETE,
"分片不完整,缺少 " + (chunkTotal - redisCount) + " 片");
}
List<Path> parts = new ArrayList<>(chunkTotal);
for (int i = 0; i < chunkTotal; i++) {
Path p = dir.resolve(i + ".part");
if (!Files.isRegularFile(p)) {
throw new BizException(ErrorCode.UPLOAD_INCOMPLETE, "缺少分片 " + i);
}
parts.add(p);
}
return parts;
}
/** ★ 核心:transferTo 利用操作系统 sendfile,字节不进 JVM 堆 */
private long mergeWithChannel(List<Path> parts, Path target) throws IOException {
long total = 0;
try (FileChannel out = FileChannel.open(target,
StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
for (Path part : parts) {
try (FileChannel in = FileChannel.open(part, StandardOpenOption.READ)) {
long transferred = 0;
while (transferred < in.size()) {
long n = in.transferTo(transferred, in.size() - transferred, out);
if (n <= 0) break;
transferred += n;
}
total += transferred;
}
}
}
return total;
}
两个工程细节:
.merging临时名 + 原子转正:合并过程中目标文件名不可见,其他请求不会读到半成品;校验通过才 move。- Redis 计数 + 磁盘清单双校验:Redis 说有 100 片但磁盘可能因清理/故障缺一片,以磁盘文件为准;Redis 的价值是快速返回缺口表,不是事实来源。
4.4 秒传的安全边界
秒传的信任链是"MD5 相同 → 文件相同",工程上要注意:
- MD5 只用于去重标识,不用于安全场景(防碰撞要用 SHA-256)。普通业务文件去重 MD5 足够;高安全要求的场景用 SHA-256。
- 秒传命中后仍要做权限校验:用户 B 不能因为猜中/得知某个 MD5 就拿到用户 A 私有文件的 URL。私有文件的秒传命中应返回"服务端已存,为你建立引用关系",URL 走带鉴权的临时签名,而不是返回公开地址。
- 合并后校验整文件 MD5 是秒传可信的前提——只有校验过的文件才能进
sec:file:表。
五、配套机制:过期清理、Nginx 与进度
5.1 半途而废的分片谁来收
用户传一半关了浏览器,Redis 的 TTL 只清状态,磁盘上的 .part 不会自己消失。用定时任务按目录扫:
@Component
@Slf4j
public class ChunkCleaner {
private final UploadProperties props;
@Scheduled(cron = "0 30 3 * * ?") // 每天凌晨 3:30
public void cleanExpired() {
Path root = Paths.get(props.getTmpDir());
if (!Files.isDirectory(root)) return;
Instant deadline = Instant.now().minus(props.getChunkExpireHours(), ChronoUnit.HOURS);
try (Stream<Path> dirs = Files.list(root)) {
dirs.filter(Files::isDirectory).forEach(dir -> {
try {
Instant lastModified = Files.getLastModifiedTime(dir).toInstant();
if (lastModified.isBefore(deadline)) {
deleteRecursively(dir);
log.info("清理过期分片目录 {}", dir);
}
} catch (IOException e) {
log.warn("清理失败 {}", dir, e);
}
});
} catch (IOException e) {
log.warn("扫描分片目录失败", e);
}
}
// deleteRecursively 用 Files.walk 逆序删除,略
}
5.2 Nginx 网关配置
大文件上传经过 Nginx 时,分片接口与最终访问要分开配置:
server {
listen 443 ssl;
client_max_body_size 12m; # 与单片大小匹配,不是2GB
client_body_buffer_size 1m;
proxy_request_buffering off; # ★ 关闭请求缓冲,边收边转发给应用
proxy_read_timeout 120s;
proxy_send_timeout 120s;
location /api/upload/ {
proxy_pass http://upload_cluster;
}
}
5.3 秒传率与可观测性
上传服务至少埋这几个指标:秒传命中率、断点续传率(触发缺口上传的会话占比)、单片 P99 耗时、合并 P99 耗时、合并失败率(MD5/大小不符)、临时目录磁盘占用。秒传命中率超过 40% 的团队(安装包、设计素材类场景),带宽账单通常能直接砍掉三分之一以上——这是三功能里 ROI 最高的一个。
六、生产演进:本地磁盘到 OSS/S3 原生分片
当应用走向多实例/容器化,本地磁盘方案会遇到新问题:分片散落在各应用节点上(除非做 sticky 路由或共享存储)、扩容时数据不迁移、没有 11 个 9 的持久性。这时不要自己写"分片传服务器→服务器再传 OSS→回读合并"的中转链路(真实踩坑:40MB 文件产生 80MB+ 传输量,耗时翻倍),直接用对象存储的原生 Multipart Upload 模型——它和本文的模型几乎一一对应:
| 本文本地方案 | OSS/S3 原生分片 |
|---|---|
| fileMd5 标识一次上传 | uploadId(initiate 后返回) |
| 0.part/1.part 写本地磁盘 | 前端直传 PUT Part,返回 ETag |
| Redis Set 记录已传分片 | ListParts 接口直接查询已传分片 |
| FileChannel 合并 | completeMultipartUpload(uploadId, ETag列表),服务端无回读 |
| 合并后 MD5 校验登记 | 回调/轮询确认对象存在后登记秒传 |
| 秒传表 | 服务端 MD5 索引,命中后走 CopyObject 或直接返回已有对象 |
标准流程:前端先请求后端拿 STS 临时凭证 + uploadId → 浏览器直传分片到 OSS(不占应用带宽)→ 完成时调后端 complete,后端用 ETag 列表收尾并登记。本文的 Redis 状态、秒传表、MD5 校验逻辑全部保留,只是把"分片落在哪里"从本地磁盘换成对象存储。 这也是为什么建议先用本地方案理解原理——核心模型(切片编号、缺口查询、最终合并、完整性校验)在任何存储后端都是同一套。
七、常见问题
7.1 分片大小怎么定?5MB 是铁律吗?
不是,5MB 是通用甜点值,按场景调:太小(如 100KB)请求数爆炸,HTTP 头部和 TLS 握手开销占比高,2GB 文件要 2 万次请求;太大(如 50MB)单片失败重传成本高、弱网下超时率上升。经验区间 2MB~10MB:内网/带宽好用 10MB 减少请求数,移动弱网用 2MB~3MB 提高单片成功率。OSS/S3 要求分片 ≥5MB(最后一片除外),最大 1 万片——这也是选片大小要过的硬约束(2GB/5MB=400 片,远低于上限)。
7.2 浏览器关了再打开,Redis 记录还在但分片文件被清理了怎么办?
Redis 状态与磁盘事实可能不一致,所以合并接口用"Redis 快速计数 + 磁盘最终校验"双层判定(4.3 节)。更稳妥的做法是清理任务同时删除 Redis 状态(按目录名即 fileMd5 反查删除 key),让两边一致;断点续传检查时如果发现 Redis 说有分片但磁盘抽样缺失,应返回空缺口表让前端全量重传。永远不要仅凭 Redis 就告诉前端"这一片不用传了"。
7.3 MD5 算 2GB 文件太慢,页面卡死怎么优化?
三层优化:① MD5 计算放 Web Worker,主线程零卡顿;② 用"抽样指纹"做秒传初判(文件名+大小+首尾各 1MB+中间 N 段的哈希),服务端命中抽样指纹后再要求关键场景做全量校验,未命中则全量上传,用低代价排除"大概率不同"的文件;③ 抽样指纹相同的文件服务端合并后仍做全量 MD5 终检,保证秒传表零误判。注意抽样指纹只用于性能优化,不能替代最终完整性校验。
7.4 合并接口超时或应用重启,文件会坏吗?
不会,靠三道保险:单片先写 .part.tmp 再原子 rename;合并写 .merging 临时名,MD5 和大小双校验通过后才原子转正为最终文件;重启后残留的 .tmp/.merging 由清理任务回收。前端合并请求失败后的正确动作是重试 merge,而不是重传分片——merge 是幂等的(目标存在且校验一致直接返回成功),分片文件还在就能反复合并。
7.5 多实例部署时分片落在不同机器上怎么办?
三种方案按规模选:① Nginx 对同一 fileMd5 做一致性 hash 路由(sticky),同一会话固定到一个实例,简单但实例宕机会话失效;② 分片直接放共享存储(NFS/OSS),应用节点无状态,最常见的过渡方案;③ 直接上 OSS/S3 原生分片直传(第六章),应用节点只做凭证和状态管理,是云上的终态方案。不建议自己做"跨节点搬分片",复杂度和故障面都不划算。
7.6 怎么防止恶意用户伪造分片刷磁盘?
入口约束:登录鉴权 + 单片大小上限 + chunkTotal 上限(防申请天文数字分片);为每个用户/租户统计"未完成上传"总占用并设配额,超限拒绝新任务;fileMd5 严格白名单防路径穿越;临时目录所在磁盘独立挂载并配使用率告警(85% 告警、95% 触发上传降级);清理任务 TTL 必须短于磁盘容量可承受的堆积周期。秒传接口也要防遍历:接口加频率限制,避免被用来探测"某个 MD5 文件是否存在"。
八、总结
全链路速查卡
【前端】
5MB 切片:file.slice(start,end)
MD5:spark-md5 分片增量追加,生产放 Web Worker
GET /check:uploaded=true 秒传结束;否则拿 uploadedChunks 缺口表
并发池:worker游标模式,并发3~6,单片超时30s,失败3次线性退避
POST /chunk × N(只传缺口)→ POST /merge
【后端 Redis】
upload:chunk:{md5} Set 已传编号(TTL 24h,每片续期)
upload:meta:{md5} Hash 文件名/大小/片数/上传者
sec:file:{md5} 秒传命中表(建议数据库持久化 + Redis 缓存)
【后端接口】
/check:秒传 > 断点续传缺口表
/chunk:MD5白名单校验 → 写 .part.tmp → 原子 move .part(幂等)
/merge:Redis计数+磁盘清单双校验
→ FileChannel.transferTo 内核态拼接(零堆内存)
→ 大小校验 + 流式整文件MD5 → 原子转正
→ 登记秒传 + 清理分片与状态
【运维】
multipart.max-file-size=10MB(单片不是整文件)
nginx client_max_body_size 12m + proxy_request_buffering off
Tomcat max-swallow-size=-1
分片目录独立挂载 + 定时清理过期目录(与Redis状态一起清)
终态:OSS/S3 原生 Multipart(uploadId+ETag+complete),拒绝中转双传
给团队的建议
| 项 | 建议 |
|---|---|
| 切片大小 | 默认 5MB,弱网调小、内网调大;受 OSS 5MB 下限和 1 万片上限约束 |
| 合并 | 永远用 FileChannel/对象存储 complete,禁止把整片读进 byte[] |
| 一致性 | 临时名 + 原子 move;Redis 只做加速查询,磁盘/对象存储才是事实源 |
| 安全 | fileMd5 白名单防穿越、私有文件秒传仍鉴权、配额与磁盘告警、清理 TTL |
| 重试语义 | 单片失败重单片;merge 失败重试 merge(幂等);不要整文件重传 |
| 架构演进 | 单机本地磁盘理解原理,多实例/容器直接上 OSS 原生分片,模型原样平移 |
| 指标 | 秒传命中率、续传率、单片/合并 P99、合并失败率、临时磁盘占用 |
一句话
大文件上传的三个功能本质上是在给"一个必然会失败的长过程"增加状态:普通 multipart 上传让 2GB 文件一次性穿过浏览器、Nginx、Tomcat 和 JVM 堆,任何一层内存不足或网络抖动都会导致全部归零,而分片上传用 File.slice 把它切成 5MB 的独立请求,服务端每次只接收一片,配合先写 .tmp 再原子 rename 让单片接收天然幂等,失败只重传一块而不是重传 2GB;断点续传的关键是给上传过程建立外部记忆——Redis 的 Set 按 fileMd5 记录已到分片编号、check 接口返回缺口表,前端 worker 并发池只传缺失部分,但要牢记 Redis 只是加速查询,合并时必须以磁盘上连续编号的 .part 清单做最终校验,并用统一 TTL 的清理任务让 Redis 状态和临时目录同生共死;秒传则是在传输发生之前用文件 MD5 问一句"这个字节集服务端是不是已经有了",命中就零字节完成,三功能里 ROI 最高但前提是合并后做过流式整文件 MD5 校验、且私有文件命中后仍然走鉴权;最后的合并必须用 FileChannel.transferTo 借内核 sendfile 完成拼接,字节不进 JVM 堆,配合 .merging 临时名和大小+MD5 双校验后的原子转正,彻底杜绝半成品可见。理解了"MD5 标识一次上传、分片编号提供随机访问、缺口查询实现续传、完整校验保证最终一致"这套模型,从本地磁盘演进到 OSS/S3 原生 Multipart 时就会发现 uploadId、ETag、ListParts、complete 只是同一套思想的对象存储方言——而真正决定成败的永远不是 API 选择,而是并发池别用 Promise.race、单片超时要短、merge 要幂等、磁盘配额要告警这些工程细节。
互动话题:你们的大文件上传最大支持到多少 GB?有没有遇到过 90% 断网重传、合并 OOM 或者多实例分片散落在不同机器上的问题?最终是自建分片还是直接上了 OSS 直传?秒传命中率最高的业务场景是什么?评论区聊聊你的方案和踩坑。
参考资料
- MDN:Blob.slice() 与 File API
- spark-md5(浏览器端增量 MD5 计算)
- Spring Boot Reference:File Upload(multipart 配置)
- Java NIO FileChannel.transferTo(零拷贝拼接)
- 阿里云 OSS:分片上传(Multipart Upload)
- AWS S3:Multipart upload 与分段限制(5MB / 10000 片)
- Nginx:client_max_body_size 与 proxy_request_buffering
标题:Spring Boot 优雅处理大文件上传:分片 + 断点续传 + 秒传
作者:jiangyi
地址:http://jiangyi.space/articles/2026/10/05/1791036626826.html
公众号:服务端技术精选
- 引言
- 一、全景:大文件为什么必须分片
- 1.1 普通上传在大文件下的四个死穴
- 1.2 三功能的协作流程
- 1.3 数据模型与存储布局
- 二、前端:File.slice、MD5 与并发池
- 2.1 切片与 MD5 计算
- 2.2 开传前检查:一个接口同时承载秒传与续传
- 2.3 并发池:信号量模式,别用 Promise.race 野路子
- 2.4 编排主流程
- 三、后端:配置、Redis 状态与异常响应
- 3.1 上传相关配置
- 3.2 Redis 状态服务
- 四、后端:检查、分片接收与合并三个接口
- 4.1 检查接口:秒传与断点续传的统一入口
- 4.2 分片接收:落临时目录,幂等写
- 4.3 合并接口:FileChannel 零内存拼接
- 4.4 秒传的安全边界
- 五、配套机制:过期清理、Nginx 与进度
- 5.1 半途而废的分片谁来收
- 5.2 Nginx 网关配置
- 5.3 秒传率与可观测性
- 六、生产演进:本地磁盘到 OSS/S3 原生分片
- 七、常见问题
- 7.1 分片大小怎么定?5MB 是铁律吗?
- 7.2 浏览器关了再打开,Redis 记录还在但分片文件被清理了怎么办?
- 7.3 MD5 算 2GB 文件太慢,页面卡死怎么优化?
- 7.4 合并接口超时或应用重启,文件会坏吗?
- 7.5 多实例部署时分片落在不同机器上怎么办?
- 7.6 怎么防止恶意用户伪造分片刷磁盘?
- 八、总结
- 全链路速查卡
- 给团队的建议
- 一句话
- 参考资料
评论