
分块上传这个需求只要是做过文件系统的后端基本都绕不开。我最早遇到是在做一个管理后台要支持上传几百MB的安装包和培训视频用户传着传着进度条就卡死刷新页面又得从头再来后台还被撑爆过内存。后来我把上传模块彻底重构成一个可扩展的分块上传组件才把这个问题按下去。这篇文章就把这套组件的链路拆解、扩展边界和工程细节一次说清楚适合正在改造成分块上传的后端、全栈同学也适合那些“明明接了OSS但还是要自己写服务端合并逻辑”的团队参考。分块上传组件扩展开发核心不是“把文件切碎”这个动作而是你要在组件里留好哪些扩展点以及在并发上传、校验、合并、清理这些环节上把好关。很多博客讲分块上传只讲前端怎么切片、后端怎么收实际上服务端组件的设计难度远高于客户端。你面对的是一堆乱序到达的分片、超时重传、磁盘写满、合并时OOM、还有和各种存储后端对接的问题。1. 分块上传组件要解决的根本问题链路拆解与原始需求1.1 大文件直传的三大痛点先说结论在大文件上传场景里分块不是一种“优化方案”而是基本前提。要是你的系统还允许用户通过一个普通的POST请求直接上传1GB的文件那你会连续踩中三个坑。第一是连接超时。单个HTTP请求从发起到响应中间要经过浏览器/App、网关、负载均衡、应用服务器任何一个节点都可能有超时限制。文件越大传输时间越长被打断的概率指数上升。而且一旦超时服务端可能已经收了一半的数据客户端却不知道只能整个重来。这个失败成本在几十GB的文件上几乎是不可接受的。第二是内存占用。Spring MVC默认的multipart解析会把上传数据先写入内存或临时文件。线程池里同时来几个大文件内存直接飙高。很多人配置了spring.servlet.multipart.max-file-size以为限制住大小就安全了其实那只是拒绝了超限请求体面地拒绝不代表你能处理大文件。第三是重试成本。没有分块就没有“局部重试”的概念。断了就是全断重传就是全传。网络环境越差用户体感越糟糕。分块之后失败只需要重传那一块成功块可以复用。分块上传最核心的价值就在这里把一个不可控的长任务拆成一组可控的短任务。每个分片的大小和数量都可以规划服务端可以边收边合并客户端可以并行加速失败时只重试受影响的分片。而“断点续传”也自然就有了——只要服务端能告诉客户端“哪些分片已经收到”客户端就能跳过这些分片继续传。1.2 分块上传的完整链路抛开具体的前端框架服务端分块上传组件至少要支持这样四条核心链路。第一步是初始化上传。客户端先请求一个init接口带上文件名、文件大小、预计分片大小等信息。服务端生成一个uploadId把文件元数据记录下来同时算出总的分片数返回给客户端。这个uploadId是整个上传任务的身份标识后面所有分片请求都要带着它。public class UploadInitResponse { private String uploadId; // 全局唯一 private long chunkSize; // 建议分片大小字节 private long totalChunks; // 总分片数 private ListInteger uploadedChunkIndexes; // 已收到的分片用于断点续传 }第二步是分片上传。客户端按约定的分片大小把文件切块每个请求携带uploadId、chunkIndex、chunkData。服务端收到后先做参数校验再把文件块写入临时存储更新该任务的接收进度。这一步是并发发生的所以服务端必须处理分片乱序、重复、缺失的情况。第三步是合并。客户端在确认所有分片都成功后调用complete接口。服务端按分片序号把临时文件拼接成完整文件再执行整体校验比如MD5比对最终把文件移动到正式存储位置更新任务状态。第四步是可选的回调。如果业务需要在合并完成后触发一个回调事件比如更新数据库、推送消息、通知审批流等。这个回调不应该同步阻塞在合并线程里不然大文件合并会拖垮整个请求链路。1.3 断点续传、秒传与分块的边界很多人在讨论里把分块上传、断点续传、秒传混在一起说其实它们是不同层次的能力。分块上传是传输机制解决的是“怎么把一个大文件安全地传完”。断点续传依赖分块机制解决的是“失败后从哪继续”核心是服务端要能响应queryUploadedChunks这类查询告诉客户端哪些分片已存在。秒传则是另一条路客户端先用spark-md5之类工具算整个文件的指纹服务端查一下库里有没有相同指纹的文件。有的话直接把这个文件关联到当前用户返回成功根本不用传。秒传和分块没有必然关系但一个成熟的上传组件通常会把这两个能力都收进去因为它们的判断时机都在“真正上传之前”。理解了这三者的边界你再去设计组件就会清醒很多。分块组件不一定要做秒传但一定要把分片的接收、查询、合并接口设计好否则后面想加断点续传会很痛苦。2. 扩展开发第一步把变化点抽象成组件边界2.1 固定流程与变化点分离扩展开发最容易犯的错误是把所有可能性都塞进一个类然后在方法里写满if (storageType OSS)。这样做确实能撑过第一次迭代但等你要接第二个存储、换校验算法、加文件命名规则的时候这个类就会膨胀到不可维护。正确的思路是先把上传组件的固定流程定死再把变化点抽象成接口。固定流程是稳定的初始化任务 - 接收分片 - 校验分片 - 记录进度 - 合并文件 - 触发回调。这个流程对所有存储后端都一样。变化点却不稳定存储后端本地磁盘、MinIO、OSS、NAS、分片校验算法无校验、CRC32、MD5、文件命名策略按日期、按业务ID、带随机串、回调方式同步、MQ、Webhook。这两类东西必须分开。固定流程用模板方法模式收敛在一个核心执行器中变化点通过接口注入。后面扩展时大多数情况下你只是新增一个接口实现类而不是去修改核心流程的代码。2.2 核心扩展接口设计我实际沉淀下来最小的必要接口有四个。第一个是ChunkStorage负责分片临时存储和合并时的数据读取。这是最关键的一个扩展点因为不同环境的存储能力差别非常大。public interface ChunkStorage { void saveChunk(String uploadId, int chunkIndex, InputStream inputStream, long size) throws IOException; InputStream openChunk(String uploadId, int chunkIndex) throws IOException; ListInteger listUploadedChunks(String uploadId) throws IOException; void deleteTask(String uploadId) throws IOException; }本地磁盘实现就是按目录写文件OSS实现就是分片上传到临时PrefixMinIO实现类似。组件核心只依赖接口不关心具体写在哪。第二个是ChecksumStrategy定义分片校验逻辑。接收分片时客户端通常会带一个分片MD5服务端需要重新计算并比对。这个策略可以分场景切换内网传输可靠可能干脆跳过校验省CPU公网传输则必须校验。第三个是FileNamingStrategy定义合并后的正式文件命名规则。有的业务希望不暴露原始文件名有的希望保留原名的同时加日期前缀有的希望按业务系统传过来的bizId命名。设计成接口后这些都能通过配置切换。第四个是UploadTaskRepository定义上传任务的元数据存取。任务状态、已接收分片列表、文件大小、创建时间这些信息可以放数据库也可以放Redis也可以只放内存单机、允许重启丢失的场景。但接口定义是稳定的这样组件就不关心底层是MySQL还是Redis。这四个接口定义好组件的核心流程代码就基本不用动了。扩展开发的核心工作变成了“新增实现类 配置绑定”而不是“到处打补丁”。2.3 上传任务的状态机与组件内部通信既然叫组件就不能只是一个“工具类”它内部还应该有一套清晰的任务状态变化逻辑。我习惯用一个最小状态机来约束上传任务的生命周期。任务状态至少要有INIT、UPLOADING、MERGING、SUCCESS、FAILED这五个。分片接收和合并两个操作都要更新状态但要避免直接互相调Service。一个比较干净的做法是事件驱动接口层把事件发布出去状态机监听并流转。比如chunkReceived事件发生后状态机判断如果所有分片都已接收就把任务状态从UPLOADING变更为MERGING并触发合并逻辑。mergeDone事件再触发SUCCESS和后续回调。这个状态机的好处是当你要加“取消上传”“人工重新触发合并”这些操作时只需要新增对应的事件和状态转移规则而不是去改分片接收和合并的核心代码。我见过不少失败的上传组件问题都不是单点功能缺失而是状态散落在各个业务逻辑里。分片上传一个方法里直接改库合并另一个方法里再改库中间的状态完全靠猜。组件化改造的第一步其实不是接口抽象而是先把状态机画清楚。状态机清楚了接口边界自然就清楚了。3. 并发上传与分片合并的工程细节3.1 客户端并发窗口与连接池配置分片上传允许并发但不代表可以让客户端一次性把所有分片全发出去。我见过有同事把1000个分片同时发起上传的结果网关连接数直接被打满连普通接口都开始超时。一般我会建议客户端把并发窗口控制在3到5个分片。这个数字对大多数场景足够既充分利用带宽又不会把服务端连接池冲垮。而且分片上传的瓶颈通常在带宽和磁盘IO不在并发数。并发数拉满收益是边际递减的风险却是线性上升的。服务端这边如果用的是RestTemplate或HttpClient访问存储后端连接池参数一定要调。默认的HttpClient连接池很小并发上传分片时大概率会遇到连接排队。我常用的配置是setMaxTotal(200)、setDefaultMaxPerRoute(50)同时把连接建立超时设为3秒读取超时根据分片大小设为60秒到120秒。这里的逻辑很简单分片大小是固定的读取时间长了大概率是网络卡住或对端处理出问题设一个合理的上限能快速失败给重试留出时间。3.2 服务端分片接收与幂等处理并发上传必然带来重复请求。客户端超时重试、用户网络抖动导致的半包重发都会让同一个chunkIndex的片段到达服务端多次。设计组件时这个过程必须幂等。服务端接收分片的常规做法是根据uploadId和chunkIndex生成一个临时文件路径比如/tmp/upload/{uploadId}/{chunkIndex}.part。每次请求到达直接覆盖写这个文件。从效果上看后到的请求确实会覆盖先到的请求但这里有个隐藏问题并发写同一个分片文件会导致写一半的文件被另一个线程截断最终存下来的分片是损坏的。我处理的办法是按uploadId加细粒度锁保证同一个分片的写操作串行化不同分片之间可以并行。private final ConcurrentHashMapString, Object taskLocks new ConcurrentHashMap(); private Object getTaskLock(String uploadId) { return taskLocks.computeIfAbsent(uploadId, k - new Object()); } public void saveChunk(String uploadId, int chunkIndex, InputStream in, long size) { synchronized (getTaskLock(uploadId)) { // 写临时文件并校验size与md5 } }细粒度锁比给整个任务加锁要高效得多。单个分片的写操作很快锁冲突很小但如果没有锁并发环境下分片文件基本是必损坏的。另外服务端每次接收分片时还可以做一个轻量校验客户端上传时会传一个partMd5服务端写入临时文件后计算实际MD5做比对。不一致的要么是传输损坏要么是客户端算错了直接返回失败让客户端重传这个分片。这个校验成本不高但能挡住大量“合并后才发现的静默损坏”。3.3 合并策略与存储顺序所有分片接收完成后进入合并阶段。合并最容易出的问题是内存溢出有人图省事把所有分片一次性读入内存再Files.write拼成一个byte[]。这种写法在100MB以下的文件可能没问题上了GB必然会出问题。正确的合并姿势是流式拼接让数据从磁盘到磁盘直接流动不要进堆内存。Java实现里效率最高的是FileChannel.transferTo或transferFromtry (FileChannel out FileChannel.open(targetPath, WRITE, CREATE)) { for (int i 0; i totalChunks; i) { Path partPath taskDir.resolve(i .part); try (FileChannel in FileChannel.open(partPath, READ)) { long position 0; long size in.size(); while (position size) { position in.transferTo(position, size - position, out); } } } }transferTo在Linux上会走sendfile系统调用数据在操作系统内核层直接搬运用户态几乎没有内存拷贝。合并几十GB的文件也不会有OOM风险。如果存储后端支持分片合并API比如OSS的completeMultipartUpload那就连这个循环都省了直接把分片信息列表交给存储后端去合并速度会更快。还有一点合并完成后临时目录里的分片文件记得清理。如果把清理步骤漏了短期可能只是磁盘多了几个文件长期就是inode爆炸。那个uploadId对应的临时目录是必须由组件负责打扫的。4. 参数调优与真实踩坑记录4.1 分片大小怎么选分片大小没有绝对标准但我实测下来大体可以参考下面这张表分片大小适用场景优点缺点128KB ~ 512KB弱网、移动端失败重试代价小请求数过多元数据开销大1MB ~ 4MB常规Web端均衡最推荐无8MB ~ 16MB内网、对象存储直传请求数少吞吐高单分片失败重传代价较大如果你是做通用上传组件给个默认值2MB基本不会出大错同时允许业务方在初始化上传时自定义分片大小。这里有个容易被忽略的点分片大小一旦在初始化时确定客户端在后续上传过程中就不能改变否则已经上传成功的那批分片就全部作废了。所以客户端切分逻辑要和初始化接口的参数严格保持一致前后端最好都用同一个配置来源。4.2 超时重试与网关层的隐藏坑分片上传最容易踩的坑其实不在应用层而在网关和负载均衡层。Nginx默认的client_max_body_size只有1MB。如果分片大小是2MB客户端每传一个分片Nginx都会直接返回413而应用日志里根本看不到任何请求。我排查过好几个“上传突然全失败”的问题最后都是部署环境里某个Nginx配置没同步。更新这个配置还不够改完必须nginx -t并reload不然不会生效。另一个坑是proxy_read_timeout。默认的60秒对于大分片来说是不够的尤其当分片大小被你调大到8MB而客户端上行带宽只有1MB/s时物理上就需要8秒以上但Nginx到后端的读取超时可能先触发。建议把上传路径的proxy_read_timeout设置到300秒以上同时对网关层的内存缓冲也要注意。一些网关默认会把请求体缓冲到内存来一个大分片就直接占掉几十MB内存并发稍微高一点就会把网关搞崩。我常用的调优方案是上传相关的路径单独配置一份Nginx Location不跟普通接口共用超时参数。一个上传接口和一个查询接口的超时时间本来就不该一样全都用默认值必然出事。应用层的超时设置也不能缺。请求分片上传接口时必须设置读超时而不是只设连接超时。连接超时只是解决“连不上”的问题分片传了一半卡住只能靠读超时兜底。配合客户端重试就能保证单个分片失败后被快速重新上传。4.3 临时文件清理与任务生命周期最后必须说临时文件。分片上传过程中临时文件是必然存在的但如果不管理好生命周期这些临时文件就会变成磁盘上的“僵尸”。两个基本手段第一个是合并完成后立即删除分片临时目录这是组件内主动清理第二个是定时任务兜底扫描所有上传任务对超过一定时间仍处于UPLOADING状态的任务做过期处理。用户传了一半退出、网络断了、浏览器关了都有可能让任务卡在中间态定时清理能把磁盘空间释放掉。我常用的过期策略是任务创建后30分钟内没有新的分片上传事件进来就判定为僵尸任务清理临时文件并标记FAILED。这个策略要注意边界比如大文件断点续传场景下用户可能确实会隔几分钟再传下一批分片所以过期时间不能太短。按业务实际调整我这个30分钟在大多数ToB场景是够的。还有磁盘水位的保护。写分片临时文件前可以先查一下磁盘剩余空间低于阈值就直接拒绝新的分片接收。不做这个保护一个被遗忘的僵尸任务就能在几个小时里把磁盘写满搞挂整个服务。4.4 前端配合与前后端约定组件扩展开发不止后端的事前端配合方式同样要纳入设计。至少在分片拼接规则上前后端必须对齐。我个人踩过最痛的坑是前端用一个中间件做分片后端也实现了分片接口但两边的chunkIndex一个从0开始一个从1开始导致合并出来的文件总是缺一小块。这类问题排查起来非常绕因为分片本身都能上传成功只有最后拼出来的文件打不开。比较好的做法是在初始化接口返回的元数据里明确包含chunkIndexStart字段和分片大小前端直接按这个约定执行而不是各自写死规则。同理进度展示也建议按分片数来算而不是按字节数。网络抖动时按字节的进度条会忽前忽后按分片数的进度条虽然跳跃但最终能到100%用户体感反而稳定。前端如果要支持断点续传还需要在上传前查询已接收的分片列表。前端拿到这些序号后就可以直接跳过已经传过的分片从未传的分片开始。这个查询接口的响应要尽量轻量因为客户端在每次页面加载后都可能触发一次。5. 写在最后分块上传组件做起来不难难的是把边界画清楚。流动的状态、稳定的接口、可控的并发、可靠的重试——这几个点抓住了组件基本就立住了。我个人的体会是不要一开始就追求对接各种存储后端和复杂的断点续传。先把“初始化、传分片、合并、删除临时文件”这条最小闭环跑通再逐步把存储适配、校验策略、秒传能力作为扩展点接进去。这样组件核心代码会一直保持稳定而新需求进来时你只需要关心新增哪个实现、注册哪段配置而不是动不动就把上传核心逻辑改一遍。顺带说一句如果你正在面试或者给组里做技术分享讲分块上传组件时别总盯着前端怎么切片能把任务状态机、并发锁、分片幂等、合并流的边界讲清楚才真正体现了你对这个组件的掌控力。