先说个真实经历:前年给一个后台管理系统做微服务改造,单体应用里文件上传一直没啥问题,拆成SpringCloud之后,100M的压缩包直接传不上去了。一开始以为代码写错了,排查了一下午,最后才发现是网关超时加多实例Session不共享在捣鬼。这个事让我彻底意识到,百M级别大文件在SpringCloud架构里根本不是"上传接口怎么写"的问题,而是网关、服务实例、存储、前端一整条链路的事。这篇就把我在实际项目里折腾出来的方案完整拆开讲清楚,包括分片、校验、Redis状态记录、合并策略,以及各种常规文档里不会写的坑。
1. 百兆文件在微服务里难搞,难在网关和会话状态
1.1 网关超时:第一道隐形拦路虎
很多团队把服务拆成SpringCloud架构以后,第一个撞上的问题就是网关超时。单体应用时代,请求直接打到Tomcat,文件上传走的是Servlet容器那套,只要maxPostSize配得够大,前端把整个文件扔过来就行。但微服务架构里多了网关层,SpringCloud Gateway默认的response-timeout只有30秒左右,一个大文件在网络状况一般的情况下,传个几分钟很正常,网关直接给你一个504,前端一脸懵。
有人会说,那把网关超时调大不就行了?理论上可以,但问题在于网关层级的超时不是唯一的限制。服务注册中心(比如Nacos或者Eureka)、负载均衡策略、底层Tomcat的connection-timeout,还有中间可能穿插的Feign调用,每一层都有自己默认超时时间。你只调网关,不调下游服务,文件传到一半,下游服务自己先断了。调全部?融断器(Sentinel或Hystrix)又会跳出来,频繁触发熔断,整个服务链路直接瘫痪。所以第一个认知要扭转过来:百M文件传输不是调一个参数就能过去的,得从结构上规避"一次性传输整包文件"这个思路。
1.2 多实例下的状态不一致问题
SpringCloud架构下服务基本都会做多实例部署,两个副本后面挂一个负载均衡。传统上传方式如果依赖Session保存上传过程中的临时状态,问题就来了:客户端A把文件分片1发给实例1,分片2发给实例2,两个实例各存各的,临时状态不共享,合并的时候凑不齐。
有人觉得可以用Sticky Session(会话粘滞),让同一个用户请求始终打到一个实例上。可行,但运维成本高,而且SpringCloud生态里,负载均衡层随时可能因为服务上下线把会话打散。更优雅的做法是把"过程状态"从本地内存迁出去,存到Redis这类共享存储里。这样不管哪个实例收到分片,读到的都是一份完整的上传进度,天然支持多实例横向扩展。这个思路也是后面方案的核心前提。
1.3 SpringCloud技术栈带来的额外约束
除了网关和会话,SpringCloud生态还有几个对传输大文件不太友好的默认配置。比如Spring Cloud Gateway底层基于Netty,Netty的maxInitialLineLength、maxHeaderSize这些参数都是有默认上限的,文件分片如果走HTTP头传元信息,头一大直接报错。再比如前端走API网关上传文件时,如果用了multipart/form-data格式,网关默认的请求体大小限制也会拦截大请求。
这些限制表面上都是"配置文件加两行"的事,但如果你的目标是支持几百M甚至几G的文件,全链路放开缓冲区大小是非常危险的,网络慢的时候大量请求堆积在网关上,直接把网关内存打爆。所以大文件方案在设计之初就应该从"整包上传"切换到"分块传输",这是传统单体应用升级到SpringCloud之后必须要接受的一个现实。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 断点续传的本质:分片、指纹、状态记录与重组成一个闭环
2.1 断点续传到底断的是什么
从用户视角看,断点续传是"这次传了一半,下次继续传,不用重新开始"。但从技术视角看,我们要解决的是三个独立的问题:
- 网络连接中断怎么恢复?
- 服务端内存/磁盘里的临时数据怎么跨请求保留?
- 怎么确认恢复后拼接出来的文件是完整且正确的?
传统的整包上传法之所以难做断点续传,是因为它把文件内容、进度信息、元数据全部绑在一次请求里,请求一断,所有状态全部丢失。分片上传的本质是把这个"一次性请求"拆成多次幂等请求,每一次请求只负责文件中的一个片段,服务端通过对片段逐个确认来累计进度。这时候断点续传的"断"就不再是断整个请求,而是断某一个分片,恢复成本从"重传几百M"下降为"重传一个几M的分片"。
2.2 MD5指纹与秒传的基础逻辑
分片之后,文件本体被打散存储,但我们需要一个全局标识来告诉服务端"这个文件是谁"。最常用的方案是计算整个文件的MD5(也可以按分片算每个分片的MD5)。前端在正式上传前先读取文件,算出完整文件的MD5,把这个值作为上传任务的ID。这样做有额外两个好处:
- 服务端可以用MD5做去重判断,如果这个文件之前已经上传过且合并完成,直接返回"秒传"成功,不需要重新走一遍上传流程。
- 合并时可以对每个分片做MD5校验,哪个分片在传输过程中损坏了,就单独重传那个分片,不用全部作废。
需要提醒的是,百M级别的文件在浏览器里用JavaScript计算MD5,如果直接在主线程跑,页面会卡死。常规做法是放到Web Worker里异步算,或者使用SparkMD5这类库的增量计算能力,边读文件边算,避免一次性把文件读入内存。
2.3 分片大小的选择依据(百M级别)
分片大小没有统一标准,要根据文件典型大小、网络状况和服务端处理能力综合选。我给一个百M级别场景的参考:分片大小建议5M到10M之间。
- 如果文件是100M,按10M一个分片就是10片,重传成本可控;
- 分片太小(比如1M),分片数量暴增,每个分片都要走一次网络请求和Redis状态更新,网关压力大,MySQL或Redis的写放大也很明显;
- 分片太大(比如50M),一旦某个分片传输失败,重传代价接近重新上传,断点续传的意义就弱了。
实际项目里我会让前端快速探测网速,网速好就取10M,网速差就降到5M,这样能动态平衡请求数量和重传成本。
2.4 上传进度的两种记录方案
进度记录是这个方案里的"断点"本体。用Redis存上传状态是最直接的手段,用Hash结构记录每个分片的上传状态(0未上传,1已上传),同时记录分片大小、总分片数、文件MD5等信息。Redis的过期时间要设置成比前端超时重置时间长,否则前端还在传,后端Redis先过期了,白传。
另一种方案是服务端在本地磁盘上为每个上传任务建目录,每收一个分片就把分片落盘,通过文件系统里的分片文件个数来判断进度(比如已落盘分片数 / 总分片数 = 进度)。这种方式的好处是不依赖中间件,单体应用里很常见,但在SpringCloud多实例部署下会有问题——两个实例同时收到不同分片,分片落在不同机器的磁盘上,合并时找不到完整的文件。所以纯本地磁盘方案只适合单实例场景,多实例场景必须依赖Redis(或共享文件系统)。
3. 端到端方案设计:前端Worker分片、网关透传、文件服务兜底
3.1 前端为什么用Web Worker
前端在这个方案里的作用不只是"选文件然后upload",它要负责分片、计算MD5、并发上传多个分片,以及断线后自动续传。分片本身可以用File.slice()实现,但是MD5计算和分片读取是CPU密集和IO密集操作,放到主线程会让页面滚动、点击全部卡住。Web Worker就是为了解决这个问题。
我把前端的处理拆成三个模块:
FileHasher模块:在Worker里流式读取文件,计算整体MD5和每个分片的MD5;UploadScheduler模块:负责按顺序/并发发送分片,维护重试队列;ResumeManager模块:监听断网事件(offline)和失败响应,定期调用服务端状态接口,找出缺失分片并补传。
实测下来,100M文件用Web Worker计算MD5,在普通笔记本上1-2秒内完成,主线程无感知。如果放在主线程,页面至少白屏三秒以上。
3.2 网关层需要怎么配合
SpringCloud Gateway默认对请求体大小有限制,同时对路由也有超时设置。对于文件分片上传接口,我做了三件事:
- 关闭该路由的请求体大小限制,让分片请求可以顺利穿过网关;
- 在网关的
application.yml里单独给/upload/**路径配置更长的超时时间,而不是全局调大; - 网关层只透传分片数据,不做任何缓存和二次解析,避免Netty在内存里拼装大对象。
对应的Gateway配置大致是这样的:
yaml复制spring:
cloud:
gateway:
httpclient:
connect-timeout: 10000
response-timeout: 60s
routes:
- id: file-upload-route
uri: lb://file-service
predicates:
- Path=/api/file/**
filters:
- name: RequestSize
args:
maxSize: 15000000
RequestSize过滤器实际上还是会限制请求体大小,我这里给的是15M,略大于前端10M分片加一些表单字段的buffer。注意这个值不能设成无限制,网关需要一定的保护能力,防止有人绕过前端直接传超大Body把网关打崩。
3.3 文件服务的接口规划
文件服务(我这里叫file-service)核心就四个接口:
| 接口 | 作用 | 入参要点 |
|---|---|---|
POST /api/file/init |
创建上传任务,返回isExist标记 | 文件名、总大小、MD5、分片大小 |
POST /api/file/upload |
接收单个分片,落盘并更新Redis | 文件MD5、分片序号、分片数据 |
GET /api/file/status |
查询已上传分片列表 | 文件MD5 |
POST /api/file/merge |
触发合并,返回最终文件URL | 文件MD5 |
接口设计上有一个关键点:/api/file/init的返回值要包含uploadedChunks这个数组,告诉前端哪些分片已经上传过了。这样前端只要拿着文件MD5去查一次,就知道断点在哪里,不需要前端自己维护一个状态文件。这个设计在刷新页面、换浏览器、换电脑之后依然有效。
4. 服务端实现:Redis记录分片状态、磁盘暂存、合并校验
4.1 创建上传任务(init接口)
init接口的逻辑很简单,但它是整个断点续传的"注册中心"。代码大致如下:
java复制@PostMapping("/init")
public Result<InitVO> init(@RequestBody FileInitDTO dto) {
String md5 = dto.getMd5();
String redisKey = buildChunkKey(md5);
// 检查Redis里是否已经有这个上传任务
if (redisTemplate.hasKey(redisKey)) {
// 检查是否已经合并完成
String mergedFlag = redisTemplate.opsForValue().get(buildMergedKey(md5));
if (mergedFlag != null) {
return Result.ok(InitVO.alreadyExists(fileUrlMap.get(md5)));
}
// 返回已上传分片列表,实现断点续传
Set<Object> uploaded = redisTemplate.opsForHash().keys(redisKey);
return Result.ok(InitVO.resume(uploaded.stream().map(o -> Integer.parseInt(o.toString())).collect(Collectors.toList())));
}
// 创建新的分片记录,初始化所有分片状态为0
Map<String, Integer> chunkMap = new HashMap<>();
int totalChunks = (int) Math.ceil((double) dto.getFileSize() / dto.getChunkSize());
for (int i = 0; i < totalChunks; i++) {
chunkMap.put(String.valueOf(i), 0);
}
redisTemplate.opsForHash().putAll(redisKey, chunkMap);
redisTemplate.expire(redisKey, Duration.ofHours(24));
return Result.ok(InitVO.create(totalChunks));
}
我把每个分片的状态存在Hash里,field是分片序号,value是0或1。这样查询"哪些分片缺失"就变成了hvals之后筛选状态为0的字段,非常快。
4.2 分片上传接口与幂等设计
upload接口要处理的核心问题是并发和重复。前端为了提高效率,通常会让多个分片并发上传,服务端接收的顺序是乱的,而且同一个分片可能因为网络超时被前端重试,导致重复落盘。
为了处理这两个问题,我做了两件事:
- 每个分片保存到磁盘时,以
{md5}/{chunkIndex}.part命名,天然避免不同分片互相覆盖; - 接收分片后,先判断Redis里对应分片状态是不是已经是1,如果是,直接返回成功,不再重复写磁盘。
java复制@PostMapping("/upload")
public Result<Void> upload(@RequestParam("md5") String md5,
@RequestParam("chunkIndex") Integer chunkIndex,
@RequestParam("totalChunks") Integer totalChunks,
@RequestPart("file") MultipartFile file) throws IOException {
String redisKey = buildChunkKey(md5);
String status = (String) redisTemplate.opsForHash().get(redisKey, String.valueOf(chunkIndex));
if ("1".equals(status)) {
// 幂等:该分片已经上传过,直接返回
return Result.ok();
}
String dir = uploadDir + File.separator + md5;
File dirFile = new File(dir);
if (!dirFile.exists()) {
dirFile.mkdirs();
}
file.transferTo(new File(dir, chunkIndex + ".part"));
// 写Redis标记分片完成
redisTemplate.opsForHash().put(redisKey, String.valueOf(chunkIndex), "1");
return Result.ok();
}
有人会问:为什么不先写Redis再落盘?因为如果落盘失败但Redis已经标记完成,前端就会跳过这个分片不重传,最终合并时缺片,整个文件作废。所以顺序必须是"先落盘成功,再标记Redis"。
4.3 合并接口与完整性校验
当前端发现所有分片都已上传完成后,调用merge接口。合并就是把md5目录下的所有.part文件按序号拼接成一个完整文件。这一步的IO操作与校验逻辑是整个服务的重量级环节。
java复制@PostMapping("/merge")
public Result<String> merge(@RequestParam("md5") String md5) throws IOException, NoSuchAlgorithmException {
String redisKey = buildChunkKey(md5);
Map<Object, Object> chunkMap = redisTemplate.opsForHash().entries(redisKey);
// 1. 检查所有分片是否都上传完成
int totalChunks = chunkMap.size();
for (Object v : chunkMap.values()) {
if (!"1".equals(v.toString())) {
return Result.fail("存在未上传的分片,无法合并");
}
}
// 2. 按序号读取所有分片,写入输出文件
String dir = uploadDir + File.separator + md5;
File outputFile = new File(uploadDir + File.separator + md5 + ".dat");
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
for (int i = 0; i < totalChunks; i++) {
File partFile = new File(dir, i + ".part");
if (!partFile.exists()) {
return Result.fail("分片缺失:" + i);
}
Files.copy(partFile.toPath(), fos);
}
}
// 3. 校验合并后的文件MD5
String fileMd5 = calculateMd5(outputFile);
if (!md5.equalsIgnoreCase(fileMd5)) {
outputFile.delete();
return Result.fail("MD5校验失败,请重新上传");
}
// 4. 清理临时文件
deleteDirectory(new File(dir));
redisTemplate.opsForValue().set(buildMergedKey(md5), "1", Duration.ofDays(7));
String fileUrl = "/files/" + md5 + ".dat";
fileUrlMap.put(md5, fileUrl);
return Result.ok(fileUrl);
}
这里的MD5校验是安全的底线。网络传输、磁盘写入都可能产生静默损坏,如果不清不楚就把一个损坏的文件合并好返回给用户,后面排查问题的成本远比重传高。
4.4 下载场景的断点续传(HTTP Range)
上传讲完了,其实下载场景同样会遇到"网络断了要重头下"的问题。百M级别的大文件下载,浏览器或者HTTP客户端可以通过Range请求头实现断点续传,服务端要做的是正确处理这个头。
Spring的ResourceHttpMessageConverter本身就支持Range请求,但如果你自定义了接口返回文件流,就需要注意自己处理。一个更简单的方式是把文件服务做成静态资源映射,Spring Boot里这样配置:
java复制@Configuration
public class StaticResourceConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/files/**")
.addResourceLocations("file:" + uploadDir + "/");
}
}
这样/files/{md5}.dat就支持HTTP Range断点下载了,配合Nginx的proxy_pass或者云OSS的离线下载功能,整个下载链路也具备断点续传能力。
5. 衍生问题:限流、大文件导出和面试怎么答
5.1 网关限流与文件上传的冲突
热搜词里有"eureka+gateway的springcloud如何限流",这个在文件上传场景里特别微妙。网关层的限流(比如基于Sentinel或RequestRateLimiter)通常按IP、用户、接口维度统计QPS,但文件上传的QPS本来就低,真正高的是分片请求数。一个前端把100M文件分成10个分片,瞬间发出10个并发请求,如果网关对这个路径做了严格的每秒限流,比如QPS=5,那么一半分片会被拒掉,前端必须重试,轻微影响效率,严重的会相互触发雪崩。
我的经验是:文件上传接口应当走独立的网关限流规则,QPS阈值放宽(比如按文件服务实例数乘以10),但要限制单请求大小和单文件总大小。限流不应作用在"请求次数"上,而应该作用在"单位时间内累计接收的字节数"上。这个能用Sentinel的Hot Param限流做,参数为内部生成的限流标识(如用户ID+文件MD5),每收到一个分片就累加流量,超过预设阈值直接拒绝后续分片。
5.2 大文件导出的另一种"断点续传"思路
很多业务系统的"大文件导出"场景,比如导出报表、导出日志,本质是服务端生成一个百M甚至更大的Excel或CSV,然后返回给前端下载。这个过程如果同步生成,用户等30秒以上必然超时。我把断点续传的思路反过来用在导出上:先异步生成文件并分片存储,生成过程中通过SSE或轮询将进度推给前端,文件生成完毕后再走/files/**的Range下载。
这样做的好处是两条链路都稳定:生成环节不怕超时,因为它是异步任务;下载环节支持断点续传,因为Range请求天然支持。SpringCloud生态里的消息队列可以把"任务分发"与"实际生成"解耦,也不需要在事务里做长耗时的IO操作。
5.3 SpringCloud面试题的高分答法
这类话题在"springcloud面试题"里频繁出现。如果被问到"SpringCloud如何处理百M大文件上传",别一上来就背分片算法,真正有区分度的回答是:先讲清楚单体到微服务架构的变化带来了哪些问题,再讲解决方案如何适配SpringCloud的组件特性。
我一般会这么组织回答:
- 先说结论:断点续传 = 分片 + 状态标记 + 合并校验;
- 然后讲为什么SpringCloud场景下不能沿用单体方案——网关超时、多实例状态不一致、内存压力;
- 再提具体落地:网关层放宽限流和超时,文件服务用Redis记录分片State,分片落盘后标记,合并时校验MD5;
- 最后补充:限流要按流量维度而非QPS维度,下载走Range,文件落地目录建议挂在共享存储上,否则多实例还是没法合并。
这套回答把"会做"和"懂架构"都体现出来了,比单纯背"chunk-size建议5M"要有说服力得多。
6. 实测中的坑与调优参数
6.1 网关默认缓冲导致的内存暴涨
这个坑是我第二轮优化才发现的。当时网关超时也调了,分片也能穿过网关,但在大并发测试时网关内存飙升到了90%。查了半天发现是Spring Cloud Gateway的Netty对multipart/form-data请求默认会有一个DataBuffer缓冲,多个分片同时到达时,每个连接都在占用内存,积少成多直接把堆打满。
解决办法有两个:
- 在网关过滤器里尽早把文件流透传给下游,不要做聚合解析;
- 如果网关必须解析Body(比如要做权限校验),那就要显式地限制缓冲,超过阈值的请求直接拒绝。
实际项目里我把网关对/api/file/upload的请求做特殊处理:保留必要的Header放行,Body原样转发。权限校验改用Header里的Token信息,不在网关层读文件内容。
6.2 分片并发上传导致的乱序与覆盖
前端并发上传分片时,后端的落盘顺序是无法确定的。如果我在合并时按文件名前缀排序再拼接,遇到2.part、10.part这种序号,字符串排序会把10排在2前面,合并出来的文件内容就乱了。这个问题的标准解法是用数字序号填充,比如00000.part、00010.part,这样字典序和数字序一致。我在代码里始终用0填充到固定长度,比如总共有20个分片,那么分片序号统一格式化为%05d。
6.3 Redis记录分片进度时的并发一致性
多个分片请求同时到达时,opsForHash().put本身是原子的,所以单字段的更新不会有并发问题。但如果我用"先读后写"的逻辑做状态判断,比如先hget看看是不是1,再决定要不要写文件,在高并发下就可能出现两个线程同时读到了0,同时写文件,导致磁盘IO浪费。解决方法是把这个判断改成基于文件系统的幂等设计,Redis里的状态只作为一个快速查询的缓存,真正的校验以磁盘文件是否存在为准。这样即使并发操作重复执行,也只是磁盘写入被覆盖,结果是一致的,合并时不会缺分片。
还有一种情况是在判断"所有分片是否完成"的时候,如果刚好有分片正在落盘,Redis标记还没写,merge接口就会被调用,导致误判失败。我在merge接口里加了二次文件系统检查:先扫描磁盘上的.part文件数量,和总分片数比对,通过后再进入拼接循环,把判断冗余控制在5秒重试窗口内。
6.4 断电/进程崩溃后的恢复策略
服务端进程突然崩溃,所有分片都在磁盘上,但Redis的Key可能在24小时后过期。如果前端迟迟没有续传,Redis Key过期删除了,下次init时服务端认为这是一个全新文件,分片目录里却又残留着旧分片。我处理这个问题的办法是:init接口在创建新任务前,先检查磁盘目录是否存在且包含有效分片,如果有,就自动"继承"这些分片并重建Redis Hash。这就相当于服务端的自我修复能力,不依赖前端做额外的恢复逻辑。
6.5 磁盘IO与临时文件清理
大文件上传场景下,磁盘压力比想象中要大。每个分片到达后都要做一次transferTo,合并时又要做一次全量读取,磁盘瞬时吞吐可能冲到几百M每秒。我在生产环境里把上传临时目录和最终文件存储目录分开挂载,临时目录用SSD,合并完成后把最终文件移动到普通机械盘或云盘上。这样可以避免合并时的IO风暴影响其他业务。
临时文件的清理我也踩过坑。之前用@Scheduled每天凌晨扫一遍上传目录,删除超过24小时没有更新的分片文件。但有一次大促活动文件量大,凌晨清理任务和正在进行的合并任务撞上,好几批文件删除到了未完成的临时文件,导致用户上传中断。后来我改成"只清理Redis中已标记过期并且目录修改时间超过2小时的目录",并增加了一个五分钟的延迟时间,确保清理动作不会和活跃合并任务重叠。
6.6 几个可以直接抄走的参数
最后整理一份我实测下来相对稳定的参数建议,不同业务可以在此基础上调整:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| 分片大小 | 5M-10M | 百M文件在浏览器和网关层都比较均衡 |
| 最大并发分片数 | 3-5 | 并发太高会导致网关内存和磁盘IO双高 |
| Redis分片状态过期时间 | 24小时 | 和前端续传窗口匹配 |
| 网关单请求体上限 | 分片大小+5M | 留出表单字段开销 |
| 网关路由超时 | 60s | 分片请求单体完成不会超过这个时间 |
| 合并后文件保留时间 | 7天 | 根据业务需要调整,过期自动删除 |
| 临时目录空间 | 预估最大并发文件数 * 文件大小 * 1.5 | 留出合并时双份IO的余量 |
这些数值不能照抄每家公司,毕竟带宽、实例规格、文件平均大小都不一样。但如果你的场景就是"SpringCloud架构下百M级文件",这套组合拳跑下来基本够用。
最后再分享一个小技巧:断点续传做完了不要直接上线,一定要做一次"拔网线测试"。浏览器DevTools里把网络模式改成离线,传一半再恢复,看前端能不能自动补传;服务端也要手杀一次进程,重启后继续传,确认状态能从磁盘和Redis里恢复。这两个测试通过,这个方案才算真正立住了。
