前段时间做信创改造,好几个同行都在问同一个问题:配置再高、网络再好的环境里,为什么文件夹上传还是动不动报错?仔细一看,多数系统还在用最原始的multipart/form-data一次性上传。大文件传一半断了就从头再来,小文件上千个就一个个发请求,服务器一边接收二进制流一边写库,压力全堆在单线程上。标题里“扩展HTTP协议实现文件夹多线程分片秒传”这句话,其实是这类项目里绕不开的共性需求。这篇内容我会把整个方案拆开讲:为什么必须扩展HTTP协议而不是换一种协议,分片上传的接口和状态机怎么设计,多线程并发需要注意哪些细节,秒传到底怎么触发,最后再分享一批我在信创环境下实测踩过的坑和排查方法。适合正在做系统信创适配的Java后端开发、需要给业务系统加文件传输能力的技术负责人,以及想从“能传”做到“传得快、断点续传、批量秒传”的同学参考。1. 先想清楚:为什么要为HTTP协议做“扩展”很多人一听到“扩展HTTP协议”,第一反应是“HTTP不是现成的吗?直接传不就行了”。真做起来会发现,HTTP本身只定义了请求和响应的格式,并没有定义“一个上传任务的完整生命周期”。1.1 传统上传方式在信创环境下的三个痛点首先是缺少任务标识。传统上传接口收到一个POST请求,读取完body里的二进制流就落盘,整个过程是一次性的。如果网络抖动、连接中断,服务器端可能已经写了半个文件,但客户端不知道服务器收到了多少,只能整份重传。在信创环境里,网络设备、国产操作系统驱动的吞吐能力和稳定性通常不如原生态环境,这个问题会被放大。其次是文件夹语义缺失。文件夹不是HTTP协议里的概念,一个文件夹可能包含几百个子目录和几千个文件,如果前端逐个文件调用上传接口,会产生大量短连接和数据库写操作,并发一高,网关、中间件、国产数据库最先扛不住。第三是“秒传”无从谈起。秒传本质上不是传得快,而是“不传”。它依赖服务端对已有文件的指纹识别,传统上传接口没有给客户端提供“先问一下文件在不在”的通道,所以每次都是白传一遍。1.2 扩展HTTP协议的正确姿势:复用语义,不要发明协议我们的目标不是自定义一套像FTP那样独立的传输协议,而是在HTTP框架内扩展出“分片上传”“断点续传”“快速去重”这些能力。实现上不是去改HTTP协议栈,而是约定一组接口路径、自定义Header、状态码和状态机,让客户端和服务端对一个“上传任务”有一致的理解。这样做的好处很明显:信创环境里的国产中间件、安全网关、负载均衡设备对HTTP的兼容性最成熟,你不需要为自定义协议做额外的防火墙放行或网关适配。我们只需要依赖Servlet或Spring MVC这类标准Web能力,把业务语义通过Header和路径表达出来。1.3 整体方案的形态我最终落地的是一个“客户端SDK + 服务端REST接口”的结构:客户端SDK以Java库形式提供,负责遍历文件夹、计算文件指纹、并发上传分片、断点续传、秒传判断。服务端提供统一的上传任务管理接口,负责创建任务、接收分片、校验摘要、合并文件、记录指纹。存储层使用文件系统目录保存最终文件和临时分片,用数据库记录任务、分片和指纹信息。这套结构不依赖任何特定前端框架,第三方业务系统只需要集成SDK,就能获得完整的上传能力。对于信创环境下常见的“老系统改造、存量数据迁移”场景,这是最务实的方案。2. 分片上传协议设计:接口定义与字段规划2.1 客户端与服务端的交互流程整个上传过程不是一次请求就完事,而是拆成四个阶段,我习惯用“创建任务 → 上传分片 → 完成合并 → 秒传跳过”来描述:客户端生成文件清单,包括每个文件的相对路径、大小、修改时间和指纹,先请求“校验接口”。服务端对清单做对账,返回哪些文件已经存在、哪些文件需要上传。对需要上传的大文件,客户端调用“创建任务接口”获取uploadId,然后并发调用“分片上传接口”。所有分片传完后,客户端调用“完成接口”,服务端合并分片并更新指纹表。这四个阶段用接口来表达,就是下面这组REST接口。2.2 关键接口设计下面这套接口我实际项目里跑通过,你可以直接照搬骨架:接口说明POST /api/upload/check批量校验文件清单,入参是文件相对路径、大小、哈希值,返回已存在和需上传统计POST /api/upload/init初始化上传任务,入参是文件路径、大小、总分片数,返回uploadId和有效时间POST /api/upload/part上传单个分片,请求体是分片二进制数据,自定义Header携带uploadId和分片索引POST /api/upload/complete通知服务端所有分片已上传,传uploadId、文件总大小和总哈希,服务端执行合并DELETE /api/upload/{uploadId}取消任务并清理残留分片,失败重试场景必须提供分片上传接口的请求头信息可以这样设计:Header名称示例作用X-Upload-Ida1b2c3d4-...上传任务唯一标识X-Chunk-Index0分片序号,从0开始X-Chunk-Size8388608分片字节数X-Chunk-CRC321a2b3c4d分片数据CRC32校验值,服务端接收后复核为什么用自定义Header而不是把参数全放在URL Query里?因为URL会经过国产中间件、WAF、网关等多层组件,超长Query被截断的案例我见过不止一次。Header携带元数据,Body只放二进制流,这样上传接口的签名计算也方便,日志里不会把二进制内容打出来。2.3 服务端状态存储设计光有接口不够,还需要在数据库里记录任务状态。我用了三张表:上传任务表,记录每个分片上传任务的整体状态:CREATE TABLE upload_task ( upload_id VARCHAR(40) PRIMARY KEY, relative_path VARCHAR(1024) NOT NULL, file_size BIGINT NOT NULL, chunk_total INT NOT NULL, chunk_size INT NOT NULL, status VARCHAR(20) NOT NULL, file_hash VARCHAR(64), create_time DATETIME NOT NULL, finish_time DATETIME );分片信息表,记录每个分片是否上传成功和校验值:CREATE TABLE upload_chunk ( id BIGINT AUTO_INCREMENT PRIMARY KEY, upload_id VARCHAR(40) NOT NULL, chunk_index INT NOT NULL, chunk_size INT NOT NULL, crc32 VARCHAR(16) NOT NULL, status VARCHAR(20) NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_upload_chunk (upload_id, chunk_index) );文件指纹表,用于秒传判断:CREATE TABLE file_fingerprint ( file_id BIGINT AUTO_INCREMENT PRIMARY KEY, file_hash VARCHAR(64) NOT NULL, file_size BIGINT NOT NULL, store_path VARCHAR(1024) NOT NULL, create_time DATETIME NOT NULL, UNIQUE KEY uk_hash_size (file_hash, file_size) );状态机要收敛,不要搞出乱七八糟的中间状态。任务状态只有CREATED、UPLOADING、COMPLETED、FAILED四种;分片状态只有PENDING、RECEIVED、VERIFIED三种。所有状态流转必须由服务端控制,客户端不允许直接改状态。3. 多线程分片上传的实现细节3.1 客户端:文件清单、指纹计算与并发模型客户端一开始先把文件夹整个递归扫描一遍,生成统一的文件清单。这里有一个容易忽略的点:相对路径的分隔符,Windows环境是反斜杠,Linux是正斜杠,清单里必须统一转成/,否则服务端合并后路径错乱。指纹计算不要和上传抢同一批线程。我建议单独用一个后台线程池做“扫描+计算”,主上传线程池只负责传输。否则大文件算SHA-256时会卡住界面或者拖慢已经开始的分片上传。以Java实现为例,核心逻辑可以这样组织:ExecutorService scanExecutor = Executors.newSingleThreadExecutor(); ExecutorService uploadExecutor = Executors.newFixedThreadPool(8); List<FileItem> fileItems = scanExecutor.submit(() -> scanDirectory(rootPath)).get(); for (FileItem item : fileItems) { CompletableFuture .supplyAsync(() -> computeFingerprint(item), scanExecutor) .thenAcceptAsync(fp -> submitUploadTasks(item, fp), uploadExecutor); }这里我踩过一个坑:假如一个文件夹里有1000个文件,一次性把它们全提交给线程池,会在短时间内发起大量HTTP请求,服务端压力瞬间飙升。正确做法是做一个带并发上限的信号量或队列,让同时处理的任务数保持在一个稳定值。3.2 分片大小怎么选分片大小直接决定上传效果。分片太小,比如256KB,分片数量巨大,HTTP请求头和数据库写入反而成为瓶颈;分片太大,比如64MB,单分片传输时间过长,失败重试的成本也大。我常用的是8MB分片,4MB到16MB之间都属于合理区间。选型时参考三个因素:文件大小分布:如果大文件多,分片可以取大一点,减少分片总数。网络带宽:假设带宽20Mbps,一个8MB分片耗时约3秒多,用户在界面能看到稳定进度;如果带宽只有2Mbps,建议把分片降到2MB。服务器临时磁盘写入速度和数据库性能:分片越大,单次读写的稳定性和并发压力越容易控制。分片数量也不是越多越好。一个8MB分片的大文件有128个分片,并发8线程大约16轮请求完成。如果分片缩到1MB,分片数变成1024个,数据库分片记录表会多出近千行,对国产数据库的写入压力是成倍增加的。3.3 服务端:分片落盘与合并的并发安全服务端接收分片接口,需要先把二进制流写到临时目录。临时目录结构我建议按uploadId隔离:/tmpfs/upload_tmp/{uploadId}/ chunk_00000000.tmp chunk_00000001.tmp ...分片写入是并发进行的,不同分片写不同文件,天然无冲突。合并时唯一需要注意的坑是:不能依赖文件到达顺序,必须先扫描目录,再按分片序号排序,最后顺序拼接。拼接时每读完一个分片,校验CRC32,不一致就报错并把错误分片索引返回给客户端,客户端只重传失败分片即可。合并核心思路如下:public void mergeChunks(String uploadId, int chunkTotal) throws IOException { List<Path> chunkPaths = listChunkFiles(uploadId); chunkPaths.sort(Comparator.comparingInt(this::extractChunkIndex)); try (OutputStream out = Files.newOutputStream(finalPath)) { for (int i = 0; i < chunkTotal; i++) { Path chunkPath = getChunkPath(uploadId, i); String crc32 = computeChunkCrc32(chunkPath); if (!crc32.equals(expectedCrcMap.get(i))) { throw new IOException("chunk " + i + " crc mismatch"); } Files.copy(chunkPath, out); } } deleteTmpDir(uploadId); }这里要注意,合并和删除临时目录必须保证原子性。我先合并到一个“合并中”的目标文件,合并完成后再原子重命名为最终文件,最后清理临时目录。不要在合并过程中读最终路径,否则其他请求可能读到不完整的文件。3.4 网络异常下的断点续传与重试策略分片上传最大的优势就是想重传谁就重传谁。客户端每次启动时先调用“查询任务状态”接口,把服务端已经收到的分片索引列表拉回来,然后只提交缺失的分片。这个逻辑实现成本很低,但价值极高。重试策略上我建议分三层:单分片请求内部做2次快速重试;分片级别失败后记录失败索引,本批次全部结束后统一重试;整个任务失败后允许从最近一次进度继续。不要做无限重试,每层都要设置最大次数,否则并发高峰时客户端会像打地鼠一样不停重发,把服务端拖垮。还有一个经验:单线程慢速重试比多线程疯狂重试更可靠。比如某个分片连续失败5次,继续让它在8个线程里反复重试,可能把网络栈打满,反而拖累别的分片。我把失败3次以上的分片放入一个“慢速重试队列”,用单个线程每2秒重试一次,实测成功率更高。4. 秒传的正确打开方式:指纹去重与文件夹级秒传4.1 文件指纹的选择与计算位置秒传的前提是能找到“同一个文件”。文件指纹我用“大小 + SHA-256”,而不是只靠快速哈希(比如CRC32或MD5)。SHA-256计算大文件比较耗时,但它能在几乎可忽略的概率下保证不误判。文件系统层面同一个文件可能改个文件名又传一遍,只靠路径判断完全无效,必须内容级别去重。指纹计算放在客户端还是服务端,我的建议是放在客户端。服务端只收到清单后做哈希比对,这能避免大量数据先传上来再做无用的哈希计算。不过客户端算完哈希后,服务端在合并完成后还要再算一次最终文件的SHA-256,用来复核,防止客户端上报哈希错误导致后续秒传误命中。4.2 文件夹级秒传:先上报清单再对账文件夹秒传是整个功能里体验提升最明显的一部分。核心思路是“先对账,后传输”:客户端扫描整个目录生成清单后,先把清单提交给check接口,服务端对每个文件按“大小+SHA-256”查指纹表,把已存在的文件列表返回给客户端,同时返回对应的存储路径。客户端拿到这份“已存在名单”,就把这些文件标记为“秒传成功”,不再进入分片上传流程。为了提升对账效率,我建议把check接口设计成批量提交,一次最多500个文件。数量太多时请求体太大,网关会拒绝;数量太少时请求往返次数太多,反而没有效率。4.3 秒传与分片的关系要注意秒传和分片不是互斥关系,而是串行关系:一个文件先查指纹,命中则秒传;未命中且文件大于分片阈值,才分片上传;未命中且文件小于分片阈值,直接整文件上传。这里有一个容易搞错的逻辑:如果只对“整个文件”做了秒传,那么某个文件在历史版本里没有,改了个后缀名又上传,就不会命中。更可靠的方案是服务端合并完文件后,先按内容哈希入库,再把该文件索引到任务表中。这样下次任何路径上传同一个内容都能秒传。我遇到过一些系统追求“快速秒传”,用MD5或者文件大小单条件作为秒传依据,结果出现哈希碰撞或者不同文件相同大小导致误秒传。在信创环境的数据迁移场景里,误秒传比慢传更可怕,它会让用户认为文件上传了,实际内容却是错的。所以宁可多花一点计算时间,也要使用强哈希。5. 信创环境落地中的典型适配问题标题里的“信创环境”四个字,决定了这个方案不仅要把通用上传功能写对,还要在国产化软硬件组合上跑稳。这一部分如果你不做,交付到客户现场基本都会出问题。5.1 国产操作系统网络参数与并发限制第一个坑是文件描述符限制。国产Linux系统默认ulimit -n经常只有1024,而8线程并发上传再加上本身业务连接、数据库连接、日志文件句柄,很容易瞬间打满。你会在客户端看到大量socket连接异常,甚至底层抛出“Too many open files”。上线前要把上限调高,同时把中间件启动脚本里的ulimit也一并设置。TCP keepalive参数也值得注意。分片传输间隙长,连接一旦被中间设备回收,客户端还在复用旧连接就会报连接重置。我给Java的HttpClient连接管理器设置了空闲连接存活时间,同时开启系统层面TCP keepalive,这样空闲连接能被复用而不是被白白销毁。5.2 国产化中间件与JDK的兼容性检查服务端应用大多是部署在国产中间件上的传统Java Web应用,这些中间件很多基于Tomcat或Jetty二次开发,Servlet版本上限大多是3.0或3.1。如果你用了Servlet 4.0的HTTP/2能力,或者用了Jakarta命名空间,部署阶段就会直接失败。我建议代码保持Servlet 3.1兼容,不要迷信新特性。JDK方面,国产化JDK大多基于OpenJDK 8维护,有些环境还在用8u192这种较老版本。如果客户端SDK想用JDK 11才有的java.net.http.HttpClient,在老环境就要换回Apache HttpClient。我的SDK最终以Java 8为基线,用Apache HttpClient 4.5.x,这样兼容面最大。还有一个隐藏问题:国产中间件默认临时目录经常是系统的/tmp,而信创系统中的/tmp可能是只读或会被定时清理。分片临时文件被清理后,合并时就会报“No such file or directory”。我后来把临时目录配置到应用工作目录下的独立目录,并挂载在独立分区,这个问题才彻底解决。5.3 国产化数据库:分片记录表的高频写入与SQL方言适配分片上传是个高写入场景:每个分片都要更新状态,合并后还要写指纹表。用国产数据库时要特别关注驱动和SQL方言的差异。最常见的问题是主键生成策略。国产数据库对自增主键的支持不如传统MySQL一致,部分场景需要显式使用序列。我建议分片信息表直接用upload_id + chunk_index作为唯一键,减少对自增主键的依赖。事务控制上,上传分片接口不要包一个大事务。每接收一个分片,单独更新一行记录即可。并发写入同一张表时,如果表设计缺少索引,行锁竞争会很严重。我给upload_chunk表建了组合索引,并控制单连接内的事务提交频率,实测每秒能处理的入库记录数提升了不少。5.4 文件存储:本地磁盘与对象存储的路径规划信创环境里文件存储方式多种多样。有的单位用本地磁盘做NAS挂载,有的用国产对象存储做归档,两种方式的路径规划逻辑差异很大。本地磁盘的话,目录权限需要给到中间件运行用户可读写,特别要注意是否启用了SELinux,它可能会拦截中间件写文件。对象存储则要考虑的更多是“分片文件是否先落临时盘再转存到对象存储”,以及路径里能不能带中文文件名。中文文件名是重灾区。不同OS层面的编码处理不一致,HTTP Header里带中文容易乱码,落盘时也容易目录乱码。我统一的做法是:存储路径里用URLEncoder.encode编码后的文件名,数据库里存原始路径,对外展示时再解码。这虽然牺牲了一点可读性,但避免了一堆编码问题。6. 常见问题与排查技巧实录这套方案从开发到上线,我前后踩了不下十个坑,其中几个是排查了很久才定位的。整理成一个速查表,供你抄作业:现象原因排查方法解决办法并发上传时报“Too many open files”系统或进程文件描述符上限过低执行ulimit -n查看上限调高上限,并在中间件启动脚本同步设置分片全部上传成功,合并时提示找不到分片文件临时目录被清理,或多实例部署时请求分发到不同节点查看合并节点临时目录中是否有chunk_*.tmp文件临时目录固定在应用工作目录,多实例用共享存储或按实例隔离目录合并后整个文件MD5和源文件不一致客户端或服务端CRC校验缺失,某个分片写入不完整对每个分片重新计算CRC32,对比上传值服务端接收分片时严格校验CRC32,校验通过才置为VERIFIED秒传判断出错,文件内容对不上使用了弱哈希或单字段判断检查指纹表对应的校验算法改为“大小+SHA-256”双重校验,合并后服务端必须复核上传几百个分片后数据库锁等待超时高频更新同一张表未用批量事务或缺少索引查看慢SQL和锁等待日志按upload_id, chunk_index建索引,将连续分片状态更新合并为批量提交客户端重连后进度丢失未记录已成功分片索引查询服务端upload_chunk状态客户端启动时拉取已接收分片列表,跳过已上传分片大并发上传导致网关请求被拒单机并发线程数设置过高看网关日志中连接数、超时数客户端线程池限制在8以内,网关超时时间调大除了表里这些,我特别想提醒一个容易被忽略的点:多实例部署时,如果服务端部署了两个节点,而分片临时文件存在本地磁盘,那么客户端第一次上传的分片在节点A,第二次重试的分片可能被分发到节点B,合并时必然缺分片。最简单可靠的方案是先让上传接口绑定固定实例,或者把节点配置成无状态、临时目录放到共享存储。否则排查半天最后发现是负载均衡策略导致的问题,心态会崩。另外,客户端SDK要把每次传输的请求日志留全。我在SDK里加了一个开关,开启后每个分片的uploadId、分片索引、耗时、返回码都会输出到单独日志文件。上线初期这个日志开关帮我快速定位了大量问题,稳定运行后可以关闭,以免日志刷得太快。最后再说一点我的实际体会这个功能上线后,最直观的效果不是“技术多炫”,而是业务方终于不再抱怨上传了。原来连续传上百个文件要盯着进度条,现在一个文件夹拖进去,命中秒传的文件瞬间跳过,剩余文件多线程分片并发传,中途断网重连还能续传,整体体验从“不敢传大文件”变成“随便传”。我个人做这类方案的经验是:先把协议边界定义清楚,再写实现。很多上传功能写着写着乱掉,就是因为没有把创建任务、分片上传、完成合并这三个阶段分开来理解。协议定清楚后,客户端、服务端、数据库、异常处理就都有了明确的锚点,后面再适配信创环境的各种细节,都是水到渠成的事。如果后续你还需要更进一步,可以考虑把文件指纹做成公共缓存服务,让多个应用系统共用一套秒传数据库;也可以给SDK加上任务优先级和流量控制,在有大量存量数据需要迁移的场景里按批次分发。这个方向做到后面,就不再只是一个上传工具,而是分发平台了。
前段时间做信创改造,好几个同行都在问同一个问题:配置再高、网络再好的环境里,为什么文件夹上传还是动不动报错?仔细一看,多数系统还在用最原始的multipart/form-data一次性上传。大文件传一半断了就从头再来,小文件上千个就一个个发请求,服务器一边接收二进制流一边写库,压力全堆在单线程上。标题里“扩展HTTP协议实现文件夹多线程分片秒传”这句话,其实是这类项目里绕不开的共性需求。这篇内容我会把整个方案拆开讲:为什么必须扩展HTTP协议而不是换一种协议,分片上传的接口和状态机怎么设计,多线程并发需要注意哪些细节,秒传到底怎么触发,最后再分享一批我在信创环境下实测踩过的坑和排查方法。适合正在做系统信创适配的Java后端开发、需要给业务系统加文件传输能力的技术负责人,以及想从“能传”做到“传得快、断点续传、批量秒传”的同学参考。1. 先想清楚:为什么要为HTTP协议做“扩展”很多人一听到“扩展HTTP协议”,第一反应是“HTTP不是现成的吗?直接传不就行了”。真做起来会发现,HTTP本身只定义了请求和响应的格式,并没有定义“一个上传任务的完整生命周期”。1.1 传统上传方式在信创环境下的三个痛点首先是缺少任务标识。传统上传接口收到一个POST请求,读取完body里的二进制流就落盘,整个过程是一次性的。如果网络抖动、连接中断,服务器端可能已经写了半个文件,但客户端不知道服务器收到了多少,只能整份重传。在信创环境里,网络设备、国产操作系统驱动的吞吐能力和稳定性通常不如原生态环境,这个问题会被放大。其次是文件夹语义缺失。文件夹不是HTTP协议里的概念,一个文件夹可能包含几百个子目录和几千个文件,如果前端逐个文件调用上传接口,会产生大量短连接和数据库写操作,并发一高,网关、中间件、国产数据库最先扛不住。第三是“秒传”无从谈起。秒传本质上不是传得快,而是“不传”。它依赖服务端对已有文件的指纹识别,传统上传接口没有给客户端提供“先问一下文件在不在”的通道,所以每次都是白传一遍。1.2 扩展HTTP协议的正确姿势:复用语义,不要发明协议我们的目标不是自定义一套像FTP那样独立的传输协议,而是在HTTP框架内扩展出“分片上传”“断点续传”“快速去重”这些能力。实现上不是去改HTTP协议栈,而是约定一组接口路径、自定义Header、状态码和状态机,让客户端和服务端对一个“上传任务”有一致的理解。这样做的好处很明显:信创环境里的国产中间件、安全网关、负载均衡设备对HTTP的兼容性最成熟,你不需要为自定义协议做额外的防火墙放行或网关适配。我们只需要依赖Servlet或Spring MVC这类标准Web能力,把业务语义通过Header和路径表达出来。1.3 整体方案的形态我最终落地的是一个“客户端SDK + 服务端REST接口”的结构:客户端SDK以Java库形式提供,负责遍历文件夹、计算文件指纹、并发上传分片、断点续传、秒传判断。服务端提供统一的上传任务管理接口,负责创建任务、接收分片、校验摘要、合并文件、记录指纹。存储层使用文件系统目录保存最终文件和临时分片,用数据库记录任务、分片和指纹信息。这套结构不依赖任何特定前端框架,第三方业务系统只需要集成SDK,就能获得完整的上传能力。对于信创环境下常见的“老系统改造、存量数据迁移”场景,这是最务实的方案。2. 分片上传协议设计:接口定义与字段规划2.1 客户端与服务端的交互流程整个上传过程不是一次请求就完事,而是拆成四个阶段,我习惯用“创建任务 → 上传分片 → 完成合并 → 秒传跳过”来描述:客户端生成文件清单,包括每个文件的相对路径、大小、修改时间和指纹,先请求“校验接口”。服务端对清单做对账,返回哪些文件已经存在、哪些文件需要上传。对需要上传的大文件,客户端调用“创建任务接口”获取uploadId,然后并发调用“分片上传接口”。所有分片传完后,客户端调用“完成接口”,服务端合并分片并更新指纹表。这四个阶段用接口来表达,就是下面这组REST接口。2.2 关键接口设计下面这套接口我实际项目里跑通过,你可以直接照搬骨架:接口说明POST /api/upload/check批量校验文件清单,入参是文件相对路径、大小、哈希值,返回已存在和需上传统计POST /api/upload/init初始化上传任务,入参是文件路径、大小、总分片数,返回uploadId和有效时间POST /api/upload/part上传单个分片,请求体是分片二进制数据,自定义Header携带uploadId和分片索引POST /api/upload/complete通知服务端所有分片已上传,传uploadId、文件总大小和总哈希,服务端执行合并DELETE /api/upload/{uploadId}取消任务并清理残留分片,失败重试场景必须提供分片上传接口的请求头信息可以这样设计:Header名称示例作用X-Upload-Ida1b2c3d4-...上传任务唯一标识X-Chunk-Index0分片序号,从0开始X-Chunk-Size8388608分片字节数X-Chunk-CRC321a2b3c4d分片数据CRC32校验值,服务端接收后复核为什么用自定义Header而不是把参数全放在URL Query里?因为URL会经过国产中间件、WAF、网关等多层组件,超长Query被截断的案例我见过不止一次。Header携带元数据,Body只放二进制流,这样上传接口的签名计算也方便,日志里不会把二进制内容打出来。2.3 服务端状态存储设计光有接口不够,还需要在数据库里记录任务状态。我用了三张表:上传任务表,记录每个分片上传任务的整体状态:CREATE TABLE upload_task ( upload_id VARCHAR(40) PRIMARY KEY, relative_path VARCHAR(1024) NOT NULL, file_size BIGINT NOT NULL, chunk_total INT NOT NULL, chunk_size INT NOT NULL, status VARCHAR(20) NOT NULL, file_hash VARCHAR(64), create_time DATETIME NOT NULL, finish_time DATETIME );分片信息表,记录每个分片是否上传成功和校验值:CREATE TABLE upload_chunk ( id BIGINT AUTO_INCREMENT PRIMARY KEY, upload_id VARCHAR(40) NOT NULL, chunk_index INT NOT NULL, chunk_size INT NOT NULL, crc32 VARCHAR(16) NOT NULL, status VARCHAR(20) NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_upload_chunk (upload_id, chunk_index) );文件指纹表,用于秒传判断:CREATE TABLE file_fingerprint ( file_id BIGINT AUTO_INCREMENT PRIMARY KEY, file_hash VARCHAR(64) NOT NULL, file_size BIGINT NOT NULL, store_path VARCHAR(1024) NOT NULL, create_time DATETIME NOT NULL, UNIQUE KEY uk_hash_size (file_hash, file_size) );状态机要收敛,不要搞出乱七八糟的中间状态。任务状态只有CREATED、UPLOADING、COMPLETED、FAILED四种;分片状态只有PENDING、RECEIVED、VERIFIED三种。所有状态流转必须由服务端控制,客户端不允许直接改状态。3. 多线程分片上传的实现细节3.1 客户端:文件清单、指纹计算与并发模型客户端一开始先把文件夹整个递归扫描一遍,生成统一的文件清单。这里有一个容易忽略的点:相对路径的分隔符,Windows环境是反斜杠,Linux是正斜杠,清单里必须统一转成/,否则服务端合并后路径错乱。指纹计算不要和上传抢同一批线程。我建议单独用一个后台线程池做“扫描+计算”,主上传线程池只负责传输。否则大文件算SHA-256时会卡住界面或者拖慢已经开始的分片上传。以Java实现为例,核心逻辑可以这样组织:ExecutorService scanExecutor = Executors.newSingleThreadExecutor(); ExecutorService uploadExecutor = Executors.newFixedThreadPool(8); List<FileItem> fileItems = scanExecutor.submit(() -> scanDirectory(rootPath)).get(); for (FileItem item : fileItems) { CompletableFuture .supplyAsync(() -> computeFingerprint(item), scanExecutor) .thenAcceptAsync(fp -> submitUploadTasks(item, fp), uploadExecutor); }这里我踩过一个坑:假如一个文件夹里有1000个文件,一次性把它们全提交给线程池,会在短时间内发起大量HTTP请求,服务端压力瞬间飙升。正确做法是做一个带并发上限的信号量或队列,让同时处理的任务数保持在一个稳定值。3.2 分片大小怎么选分片大小直接决定上传效果。分片太小,比如256KB,分片数量巨大,HTTP请求头和数据库写入反而成为瓶颈;分片太大,比如64MB,单分片传输时间过长,失败重试的成本也大。我常用的是8MB分片,4MB到16MB之间都属于合理区间。选型时参考三个因素:文件大小分布:如果大文件多,分片可以取大一点,减少分片总数。网络带宽:假设带宽20Mbps,一个8MB分片耗时约3秒多,用户在界面能看到稳定进度;如果带宽只有2Mbps,建议把分片降到2MB。服务器临时磁盘写入速度和数据库性能:分片越大,单次读写的稳定性和并发压力越容易控制。分片数量也不是越多越好。一个8MB分片的大文件有128个分片,并发8线程大约16轮请求完成。如果分片缩到1MB,分片数变成1024个,数据库分片记录表会多出近千行,对国产数据库的写入压力是成倍增加的。3.3 服务端:分片落盘与合并的并发安全服务端接收分片接口,需要先把二进制流写到临时目录。临时目录结构我建议按uploadId隔离:/tmpfs/upload_tmp/{uploadId}/ chunk_00000000.tmp chunk_00000001.tmp ...分片写入是并发进行的,不同分片写不同文件,天然无冲突。合并时唯一需要注意的坑是:不能依赖文件到达顺序,必须先扫描目录,再按分片序号排序,最后顺序拼接。拼接时每读完一个分片,校验CRC32,不一致就报错并把错误分片索引返回给客户端,客户端只重传失败分片即可。合并核心思路如下:public void mergeChunks(String uploadId, int chunkTotal) throws IOException { List<Path> chunkPaths = listChunkFiles(uploadId); chunkPaths.sort(Comparator.comparingInt(this::extractChunkIndex)); try (OutputStream out = Files.newOutputStream(finalPath)) { for (int i = 0; i < chunkTotal; i++) { Path chunkPath = getChunkPath(uploadId, i); String crc32 = computeChunkCrc32(chunkPath); if (!crc32.equals(expectedCrcMap.get(i))) { throw new IOException("chunk " + i + " crc mismatch"); } Files.copy(chunkPath, out); } } deleteTmpDir(uploadId); }这里要注意,合并和删除临时目录必须保证原子性。我先合并到一个“合并中”的目标文件,合并完成后再原子重命名为最终文件,最后清理临时目录。不要在合并过程中读最终路径,否则其他请求可能读到不完整的文件。3.4 网络异常下的断点续传与重试策略分片上传最大的优势就是想重传谁就重传谁。客户端每次启动时先调用“查询任务状态”接口,把服务端已经收到的分片索引列表拉回来,然后只提交缺失的分片。这个逻辑实现成本很低,但价值极高。重试策略上我建议分三层:单分片请求内部做2次快速重试;分片级别失败后记录失败索引,本批次全部结束后统一重试;整个任务失败后允许从最近一次进度继续。不要做无限重试,每层都要设置最大次数,否则并发高峰时客户端会像打地鼠一样不停重发,把服务端拖垮。还有一个经验:单线程慢速重试比多线程疯狂重试更可靠。比如某个分片连续失败5次,继续让它在8个线程里反复重试,可能把网络栈打满,反而拖累别的分片。我把失败3次以上的分片放入一个“慢速重试队列”,用单个线程每2秒重试一次,实测成功率更高。4. 秒传的正确打开方式:指纹去重与文件夹级秒传4.1 文件指纹的选择与计算位置秒传的前提是能找到“同一个文件”。文件指纹我用“大小 + SHA-256”,而不是只靠快速哈希(比如CRC32或MD5)。SHA-256计算大文件比较耗时,但它能在几乎可忽略的概率下保证不误判。文件系统层面同一个文件可能改个文件名又传一遍,只靠路径判断完全无效,必须内容级别去重。指纹计算放在客户端还是服务端,我的建议是放在客户端。服务端只收到清单后做哈希比对,这能避免大量数据先传上来再做无用的哈希计算。不过客户端算完哈希后,服务端在合并完成后还要再算一次最终文件的SHA-256,用来复核,防止客户端上报哈希错误导致后续秒传误命中。4.2 文件夹级秒传:先上报清单再对账文件夹秒传是整个功能里体验提升最明显的一部分。核心思路是“先对账,后传输”:客户端扫描整个目录生成清单后,先把清单提交给check接口,服务端对每个文件按“大小+SHA-256”查指纹表,把已存在的文件列表返回给客户端,同时返回对应的存储路径。客户端拿到这份“已存在名单”,就把这些文件标记为“秒传成功”,不再进入分片上传流程。为了提升对账效率,我建议把check接口设计成批量提交,一次最多500个文件。数量太多时请求体太大,网关会拒绝;数量太少时请求往返次数太多,反而没有效率。4.3 秒传与分片的关系要注意秒传和分片不是互斥关系,而是串行关系:一个文件先查指纹,命中则秒传;未命中且文件大于分片阈值,才分片上传;未命中且文件小于分片阈值,直接整文件上传。这里有一个容易搞错的逻辑:如果只对“整个文件”做了秒传,那么某个文件在历史版本里没有,改了个后缀名又上传,就不会命中。更可靠的方案是服务端合并完文件后,先按内容哈希入库,再把该文件索引到任务表中。这样下次任何路径上传同一个内容都能秒传。我遇到过一些系统追求“快速秒传”,用MD5或者文件大小单条件作为秒传依据,结果出现哈希碰撞或者不同文件相同大小导致误秒传。在信创环境的数据迁移场景里,误秒传比慢传更可怕,它会让用户认为文件上传了,实际内容却是错的。所以宁可多花一点计算时间,也要使用强哈希。5. 信创环境落地中的典型适配问题标题里的“信创环境”四个字,决定了这个方案不仅要把通用上传功能写对,还要在国产化软硬件组合上跑稳。这一部分如果你不做,交付到客户现场基本都会出问题。5.1 国产操作系统网络参数与并发限制第一个坑是文件描述符限制。国产Linux系统默认ulimit -n经常只有1024,而8线程并发上传再加上本身业务连接、数据库连接、日志文件句柄,很容易瞬间打满。你会在客户端看到大量socket连接异常,甚至底层抛出“Too many open files”。上线前要把上限调高,同时把中间件启动脚本里的ulimit也一并设置。TCP keepalive参数也值得注意。分片传输间隙长,连接一旦被中间设备回收,客户端还在复用旧连接就会报连接重置。我给Java的HttpClient连接管理器设置了空闲连接存活时间,同时开启系统层面TCP keepalive,这样空闲连接能被复用而不是被白白销毁。5.2 国产化中间件与JDK的兼容性检查服务端应用大多是部署在国产中间件上的传统Java Web应用,这些中间件很多基于Tomcat或Jetty二次开发,Servlet版本上限大多是3.0或3.1。如果你用了Servlet 4.0的HTTP/2能力,或者用了Jakarta命名空间,部署阶段就会直接失败。我建议代码保持Servlet 3.1兼容,不要迷信新特性。JDK方面,国产化JDK大多基于OpenJDK 8维护,有些环境还在用8u192这种较老版本。如果客户端SDK想用JDK 11才有的java.net.http.HttpClient,在老环境就要换回Apache HttpClient。我的SDK最终以Java 8为基线,用Apache HttpClient 4.5.x,这样兼容面最大。还有一个隐藏问题:国产中间件默认临时目录经常是系统的/tmp,而信创系统中的/tmp可能是只读或会被定时清理。分片临时文件被清理后,合并时就会报“No such file or directory”。我后来把临时目录配置到应用工作目录下的独立目录,并挂载在独立分区,这个问题才彻底解决。5.3 国产化数据库:分片记录表的高频写入与SQL方言适配分片上传是个高写入场景:每个分片都要更新状态,合并后还要写指纹表。用国产数据库时要特别关注驱动和SQL方言的差异。最常见的问题是主键生成策略。国产数据库对自增主键的支持不如传统MySQL一致,部分场景需要显式使用序列。我建议分片信息表直接用upload_id + chunk_index作为唯一键,减少对自增主键的依赖。事务控制上,上传分片接口不要包一个大事务。每接收一个分片,单独更新一行记录即可。并发写入同一张表时,如果表设计缺少索引,行锁竞争会很严重。我给upload_chunk表建了组合索引,并控制单连接内的事务提交频率,实测每秒能处理的入库记录数提升了不少。5.4 文件存储:本地磁盘与对象存储的路径规划信创环境里文件存储方式多种多样。有的单位用本地磁盘做NAS挂载,有的用国产对象存储做归档,两种方式的路径规划逻辑差异很大。本地磁盘的话,目录权限需要给到中间件运行用户可读写,特别要注意是否启用了SELinux,它可能会拦截中间件写文件。对象存储则要考虑的更多是“分片文件是否先落临时盘再转存到对象存储”,以及路径里能不能带中文文件名。中文文件名是重灾区。不同OS层面的编码处理不一致,HTTP Header里带中文容易乱码,落盘时也容易目录乱码。我统一的做法是:存储路径里用URLEncoder.encode编码后的文件名,数据库里存原始路径,对外展示时再解码。这虽然牺牲了一点可读性,但避免了一堆编码问题。6. 常见问题与排查技巧实录这套方案从开发到上线,我前后踩了不下十个坑,其中几个是排查了很久才定位的。整理成一个速查表,供你抄作业:现象原因排查方法解决办法并发上传时报“Too many open files”系统或进程文件描述符上限过低执行ulimit -n查看上限调高上限,并在中间件启动脚本同步设置分片全部上传成功,合并时提示找不到分片文件临时目录被清理,或多实例部署时请求分发到不同节点查看合并节点临时目录中是否有chunk_*.tmp文件临时目录固定在应用工作目录,多实例用共享存储或按实例隔离目录合并后整个文件MD5和源文件不一致客户端或服务端CRC校验缺失,某个分片写入不完整对每个分片重新计算CRC32,对比上传值服务端接收分片时严格校验CRC32,校验通过才置为VERIFIED秒传判断出错,文件内容对不上使用了弱哈希或单字段判断检查指纹表对应的校验算法改为“大小+SHA-256”双重校验,合并后服务端必须复核上传几百个分片后数据库锁等待超时高频更新同一张表未用批量事务或缺少索引查看慢SQL和锁等待日志按upload_id, chunk_index建索引,将连续分片状态更新合并为批量提交客户端重连后进度丢失未记录已成功分片索引查询服务端upload_chunk状态客户端启动时拉取已接收分片列表,跳过已上传分片大并发上传导致网关请求被拒单机并发线程数设置过高看网关日志中连接数、超时数客户端线程池限制在8以内,网关超时时间调大除了表里这些,我特别想提醒一个容易被忽略的点:多实例部署时,如果服务端部署了两个节点,而分片临时文件存在本地磁盘,那么客户端第一次上传的分片在节点A,第二次重试的分片可能被分发到节点B,合并时必然缺分片。最简单可靠的方案是先让上传接口绑定固定实例,或者把节点配置成无状态、临时目录放到共享存储。否则排查半天最后发现是负载均衡策略导致的问题,心态会崩。另外,客户端SDK要把每次传输的请求日志留全。我在SDK里加了一个开关,开启后每个分片的uploadId、分片索引、耗时、返回码都会输出到单独日志文件。上线初期这个日志开关帮我快速定位了大量问题,稳定运行后可以关闭,以免日志刷得太快。最后再说一点我的实际体会这个功能上线后,最直观的效果不是“技术多炫”,而是业务方终于不再抱怨上传了。原来连续传上百个文件要盯着进度条,现在一个文件夹拖进去,命中秒传的文件瞬间跳过,剩余文件多线程分片并发传,中途断网重连还能续传,整体体验从“不敢传大文件”变成“随便传”。我个人做这类方案的经验是:先把协议边界定义清楚,再写实现。很多上传功能写着写着乱掉,就是因为没有把创建任务、分片上传、完成合并这三个阶段分开来理解。协议定清楚后,客户端、服务端、数据库、异常处理就都有了明确的锚点,后面再适配信创环境的各种细节,都是水到渠成的事。如果后续你还需要更进一步,可以考虑把文件指纹做成公共缓存服务,让多个应用系统共用一套秒传数据库;也可以给SDK加上任务优先级和流量控制,在有大量存量数据需要迁移的场景里按批次分发。这个方向做到后面,就不再只是一个上传工具,而是分发平台了。