信创环境下Java分块上传性能优化:从并发到IO的全面调优
2026/9/20 16:16:11 网站建设 项目流程

1. 信创环境对 Java 上传链路的真实影响:先搞清楚瓶颈在哪一层

1.1 信创环境到底“变”了什么:CPU、操作系统、JDK、中间件的四层差异

很多 Java 开发者对“信创”的第一感觉是“换了个操作系统和 JDK,代码应该一样跑”。这句话在功能层面基本成立,但在性能层面完全不成立。我调过不少上传服务,真正让我意识到这件事的是一次金融项目迁移:保险理赔系统要上传几十 MB 到几百 MB 的理赔视频证据,原来跑在 x86 + 标准 OpenJDK 上,并发 20 时各项指标都正常;换到鲲鹏架构的服务器、麒麟操作系统、毕昇 JDK 之后,同一套代码、同一个并发量,分块上传的平均耗时直接翻倍,上传成功率还掉了几个百分点。

当时团队的第一反应是“代码有 bug”,但代码一行没改。后来我们把链路一层层拆开,才定位到四个层面的差异上。

第一层是 CPU 指令集。信创环境的 CPU 可能是鲲鹏、飞腾这类 ARM 架构,可能是海光、兆芯这类 x86 兼容架构,也可能是龙芯的 LoongArch。Java 字节码最终要通过 JIT 编译成机器码,而 JIT 编译器在不同架构上的成熟度并不一样。AArch64 后端的优化在最近几个 JDK 版本里进步明显,但相比 x86 仍然有差距。这不是说 ARM 一定慢,而是如果你在 x86 上调优出来的 GC 参数、线程数、缓冲大小,直接搬到 ARM 上,不一定有同样效果。

第二层是操作系统。银河麒麟、统信 UOS、欧拉这些系统虽然都是 Linux 内核衍生,但内核版本、默认参数、TCP 协议栈行为都有差异。之前遇到过上传服务在某个环境上并发到一定量就大量超时,查了一圈发现是内核net.core.somaxconn默认值太小,accept 队列直接满了,连接被内核丢掉。这种问题在标准 CentOS 上也会遇到,但信创环境因为版本碎片化严重,更容易踩中。

第三层是 JDK 实现。毕昇 JDK、龙井、腾讯 Kona 这些虽然基于 OpenJDK 上游,但不同版本对应不同的 Java 版本基线,GC 实现和默认参数也有差异。比如某个环境里的毕昇 JDK 8 默认还是 Parallel GC,如果你在 x86 上习惯用默认参数,到了这个环境里可能就变成另一个 GC 策略。

第四层是中间件和基础软件。Web 容器可能从 Tomcat 换成东方通、宝兰德,数据库可能换成达梦、人大金仓。这些替换不光是名字变了,连接池行为、锁粒度、事务隔离级别都可能不同。金融场景里分块上传过程中需要频繁记录分块状态,这个状态存储的读写性能直接影响整体并发。

所以,信创环境下的性能优化,首先要做的是“承认环境变了,不能用旧经验直接套”。你现在做的事情不是“换台机器重新部署”,而是“在新环境里重新认识整个上传链路”。

1.2 金融视频上传场景的典型链路与瓶颈预判

金融行业的视频上传,场景比普通互联网应用更复杂。常见的有保险理赔的事故视频证据、银行远程面签的录像、证券开户的双录视频、反洗钱调查的证据文件。这些文件的共同点是:单文件大、有合规留存要求、通常需要加密传输、上传过程需要可审计。

典型的链路是这样:移动端或桌面端 Java SDK 先把视频切成多个分块,通过 HTTPS 上传到接入层(Nginx 或负载均衡),再转发到 Spring Boot 应用服务。应用服务把每个分块写入临时存储,记录分块状态,等全部分块到齐后触发合并,合并完成再把完整文件转存到对象存储或文件服务器,最后回写业务状态。

这条链路里,真正的性能瓶颈往往不在某个单一的环节,而是三个地方的叠加:

一是网络传输。视频文件大,分块多,客户端出口带宽、RTT、丢包率都会影响吞吐。金融场景里很多用户走的是公司内网或专线,网络条件比公网好,但也不能假定无限带宽。

二是应用服务的连接处理能力。Spring Boot 默认的 Tomcat 线程池、接受连接数、多部分上传的解析配置,任何一个不合理都会成为瓶颈。很多团队只调客户端并发,服务端线程池没有跟着调,结果客户端开 20 个并发,服务端线程池只有 50 个线程,每个分块又占用一个线程好几秒,线程池直接打满。

三是临时存储的磁盘 IO。分块上传的核心动作是“接一块写一块”。所有分块都要先落到临时目录,合并时再读出来拼成一个文件。如果临时目录放在系统盘,或者磁盘本身 IOPS 不够,并发一高,写盘延迟就会拖慢整个上传过程。

金融场景还有一层特殊性:为了满足审计要求,上传过程往往会记录更详细的操作日志,每个分块的上传、校验、合并都要落库或写入消息队列。这个额外的状态写入开销,在并发量上去之后会放大。很多团队优化上传性能时只盯着带宽和线程池,忽略了状态记录本身也是一笔不小的 IO 开销。

1.3 先做环境基线测试,再谈并发调优

我见过太多项目一上来就改并发参数,改了半天效果不明显,最后发现是基线没测准。信创环境迁移后的第一步,不是改代码,而是先给当前环境做一个性能基线,把各环节的“原始能力”摸清楚。

建议至少测四组数据:

  • 网络吞吐:在客户端机器上用iperf3或者其他工具测试到服务端的实际 TCP 吞吐,记下来。这决定了并发度计算的上限。
  • 单分块上传耗时:写一个简单的 Java 客户端,单连接上传一个固定大小的分块,测多次取平均值。这里面包含了 RTT、TLS 握手、服务端处理、写盘的时间。
  • TLS 握手耗时:如果走 HTTPS,统计从建立 TCP 连接到 TLS 握手完成的耗时,特别是启用国密算法之后的耗时。金融环境下这一步可能比想象中慢很多。
  • 磁盘写入速度:在服务端临时目录用dd或 Java 程序测试顺序写入速度,至少测 10 个文件并发写的情况。

把这四组数据记下来,后面的并发度计算、超时设置、线程池大小、GC 参数调整都有据可依,而不是靠猜。

2. 分块大小与并发度怎么定:这个公式值得贴在工位上

2.1 分块上传并发模型的收益边界

先说明一个很多人忽略的前提:分块并发上传并不是并发度越高越好。TCP 连接之间会争抢带宽,当并发连接数超过网络的饱和点之后,再增加并发只会增加 RTT、加重丢包重传,整体吞吐反而下降。

理解这个问题的关键是“单连接吞吐”和“总吞吐”的区别。单个 TCP 连接上传一个大分块时,吞吐量取决于带宽时延积(BDP)和分块大小。如果分块太小,网络还没来得及把带宽跑满,这个块已经发完了,连接建立的开销就相对变高。如果分块太大,单连接传输时间变长,一旦中间出现丢包或抖动,整个块都要重传。

所以分块并发上传的收益边界是:在带宽尚未饱和的前提下,让足够多的连接同时传输,使总吞吐逼近链路带宽的上限。当总吞吐不再随并发数增加而增加时,就到了收益边界,再往上加并发就是负收益。

实际操作中,这个边界可以通过简单的压测找出来。从 1 个并发开始,依次测 4、8、16、32,记录每次的总吞吐和成功率。吞吐量从快速上升到明显停滞甚至下降的那个拐点,就是边界。后续所有参数都围绕这个拐点设置。

2.2 并发度计算的三个输入参数:带宽、RTT、单块上传耗时

用公式说话。假设:

  • 分块大小是chunkSize字节
  • 单连接上传一个分块的耗时是t秒(从发起请求到收到响应)
  • 目标总吞吐是targetThroughput字节/秒

那么所需的并发度N大约是:

N = targetThroughput × t / chunkSize

这个公式的本质是:单个连接每秒能上传chunkSize / t字节,要达到目标吞吐,就需要N个连接同时工作。

举个例子。某金融保险系统的理赔视频上传场景,客户端和服务端走的是企业内网,实测单连接上传 5MB 分块耗时 0.6 秒。链路带宽是 100Mbps(约 12.5MB/s),我们目标吞吐定在 8MB/s(留 30% 余量,避免带宽被打满影响业务系统):

N = 8MB/s × 0.6s / 5MB = 9.6

向上取整,并发度设在 8 到 10 比较合理。这里注意,千万不要把目标吞吐直接定成带宽上限。网络是共享的,服务端还有其他业务进程,客户端机器本身也有 IO 开销,压到极限只会导致延迟飙升和重传。

除了并发度,还要计算内存占用。客户端并发上传时,每个分块都会在内存里有一份缓冲。假设每个分块被分成读缓冲和写缓冲两部分,每部分 1MB,那么 10 个并发、5MB 分块的情况下,内存占用大约是 10 × 5MB × 2 = 100MB。这在现代服务器上不算大,但如果分块大小调到 32MB、并发调到 64,内存占用就会变成 4GB 以上,这时候就必须重新评估。

2.3 不同网络场景下的参数推荐矩阵

根据我的实际项目经验,不同网络环境下分块大小和并发度的推荐值差异很大,直接给一组保守但可靠的参数:

网络场景典型带宽RTT推荐分块大小推荐并发度说明
公网 4G/5G5-20Mbps20-80ms2MB4-8分块不要太大,移动网络丢包率较高
企业宽带50-100Mbps10-30ms4-8MB8-16最常见场景,参数适中
内网/专线500Mbps以上1-5ms8-16MB16-32大分块减少握手次数,并发可以放高
局域网热备1Gbps以上<1ms16-32MB8-16(取决于磁盘IO)瓶颈往往在磁盘,而不是网络

这些参数不是拍脑袋定的,背后逻辑是:分块大小的下限要覆盖“单连接传输时间远大于 RTT”,否则每个分块都在等网络往返,吞吐上不去;分块大小的上限受限于“内存缓冲”和“重传成本”,太大的分块一旦出错,重传代价很高。

3. 并发上传的三种 Java 实现方式对比与选型

3.1 线程池 + Future:最稳妥的基础盘

在信创环境里做金融项目,第一原则是稳定、可控、团队里每个人都看得懂。线程池 + Future 是三种方式里最保守也最好排查问题的。

核心思路是:按并发度设置一个固定线程池,把每个分块的上传任务提交进去,最后通过 Future 等待所有任务完成。

int concurrency = 8; ExecutorService executor = new ThreadPoolExecutor( concurrency, concurrency, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(chunks.size()) ); List<Future<ChunkResult>> futures = new ArrayList<>(); for (ChunkInfo chunk : chunks) { futures.add(executor.submit(() -> uploadChunk(chunk))); } for (Future<ChunkResult> future : futures) { ChunkResult result = future.get(90, TimeUnit.SECONDS); if (!result.isSuccess()) { // 记录失败分块,稍后统一重试 failedChunks.add(result.getChunkIndex()); } } executor.shutdown();

这里有几个细节要注意。Future 的get一定要设置超时,否则某个分块卡住时整个上传流程会一直挂着。金融场景的客户端常在用户设备上运行,不能允许线程无限等待。另外,executor.shutdown()之后要确认所有任务真正结束,最好用awaitTermination兜底。

线程池 + Future 最大的好处是“可观测”。堆栈信息清晰,哪里慢了、哪个线程卡住了,通过 jstack 一眼就能看到。这对于信创环境上线初期的排查非常重要。

3.2 CompletableFuture 编排分块任务:兼顾灵活与性能

如果分块上传的任务之间有依赖关系,比如某个分块必须先于另一个分块上传(实际上分块之间可以并行,但有些业务希望控制顺序或按组提交),CompletableFuture 更合适。

List<CompletableFuture<ChunkResult>> futures = chunks.stream() .map(chunk -> CompletableFuture.supplyAsync( () -> uploadChunk(chunk), uploadExecutor )) .collect(Collectors.toList()); CompletableFuture<Void> allDone = CompletableFuture.allOf( futures.toArray(new CompletableFuture[0]) ); // 带超时等待 try { allDone.get(120, TimeUnit.SECONDS); } catch (TimeoutException e) { // 主动取消尚未完成的分块请求 futures.forEach(f -> f.cancel(true)); throw new UploadTimeoutException("分块上传超时"); }

CompletableFuture 相比原始 Future 的好处是错误处理更灵活,可以针对失败分块单独做补偿。但代价是代码可读性下降,排查问题时不如显式的 Future 循环直观。在金融场景里,如果团队对 CompletableFuture 不熟,我宁愿用线程池 + Future。

3.3 JDK 21 虚拟线程:信创环境下的新变量

JDK 21 引入虚拟线程之后,并发编程模型的写法有了新选项。虚拟线程的核心价值是“以极低的线程成本支撑大量阻塞 IO 操作”,非常适合视频分块上传这种 IO 密集型场景。在 JDK 21 环境下,代码可以简化为:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<ChunkResult>> futures = chunks.stream() .map(chunk -> executor.submit(() -> uploadChunk(chunk))) .toList(); for (Future<ChunkResult> future : futures) { ChunkResult result = future.get(90, TimeUnit.SECONDS); // 同样处理失败分块 } }

看起来和线程池 + Future 差不多,区别在于底层不再消耗几千个平台线程,而是用轻量级虚拟线程。但要注意,信创环境里很多机构还在用 JDK 8 或 JDK 11,虚拟线程不可用。即便用了 JDK 21,也需要注意“固定 pinning”问题:当虚拟线程在 synchronized 块或 native 方法里阻塞时,会固定到载体线程上,导致并发能力下降。分块上传代码里如果大量使用同步锁,虚拟线程的优势会被抵消。

另外,毕昇 JDK、龙井等国产 JDK 对虚拟线程的支持版本各不相同,部署前一定要确认 JDK 版本和发行版是否完整支持 JDK 21 的虚拟线程功能,不能只看到java -version是 21 就以为没问题。

3.4 三种方式的对比结论

实现方式适合场景排查难度资源占用信创环境适配注意点
线程池 + Future绝大多数金融项目,团队基础好中等JDK 8 即可用,最稳妥
CompletableFuture有复杂编排、异步补偿需求的场景中等JDK 8+ 可用,注意语义
虚拟线程JDK 21+,连接数大、阻塞时间长的场景确认国产 JDK 的 21 版本支持度

我的建议是:如果团队还在 JDK 8/11 上,用线程池 + Future;如果能上 JDK 21 且已经过了虚拟线程的学习期,可以试用虚拟线程,但上线前一定要做压测对比。

4. HTTP 连接池与超时参数调优:并发性能的第二增长曲线

4.1 HTTP 客户端选型:JDK HttpClient、Apache HttpClient、OkHttp 怎么选

分块并发上传的性能,不只是“开多少个线程”决定的,HTTP 客户端的连接管理方式同样关键。很多人并发度调上去了,但每个分块请求都重新建立连接,大量的时间浪费在 TCP 三次握手和 TLS 握手上。

服务端 Java 应用最常见的选型是 Apache HttpClient 5.x,优势是连接池管理成熟,池大小、Keep-Alive、重试策略都可以细粒度控制。另一个可选方案是 JDK 11+ 自带的java.net.http.HttpClient,原生支持 HTTP/2,不需要额外引入依赖,但连接池的调优能力相对弱一些。OkHttp 在移动端和桌面客户端里用得更多,性能好,API 也简单,但服务端场景下没有那么普及。

实际项目里,如果客户端和服务端都是 Java,我会优先推荐 Apache HttpClient 5.x,因为它的连接池配置最直观,出问题也最容易排查。

4.2 连接池参数配置细节

Apache HttpClient 5.x 的连接池配置中,三个参数最重要:setMaxTotalsetDefaultMaxPerRoute、Keep-Alive 策略。

PoolingHttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create() .setMaxConnTotal(200) .setMaxConnPerRoute(50) .build(); CloseableHttpClient client = HttpClients.custom() .setConnectionManager(cm) .setKeepAliveStrategy((response, context) -> 30000) .setDefaultRequestConfig(RequestConfig.custom() .setConnectTimeout(Timeout.ofMilliseconds(5000)) .setResponseTimeout(Timeout.ofMilliseconds(60000)) .build()) .build();

这里setMaxConnPerRoute要大于等于客户端程序的并发度N。如果并发度是 10,单路由最大连接数只有 8,那么多出的 2 个请求会一直等连接释放,实际上是排队而不是并发。常见错误是把maxTotal设得很大、maxPerRoute设得很小,结果每个目标地址的连接数不够。

Keep-Alive 时间也很关键。HTTP/1.1 默认支持连接复用,但服务端的 Keep-Alive 超时时间如果比客户端短,连接会被服务端先关掉,客户端再发请求时就要重新握手。金融项目里如果经过 Nginx 转发,Nginx 的keepalive_timeout默认是 65 秒,客户端策略建议设置为 30 秒左右,比 Nginx 短一点,保证连接不会在服务端主动断开前被复用。

4.3 TLS 握手与国密场景下的会话复用

金融行业强制 HTTPS,TLS 握手的开销不能忽略。一个典型的 TLS 1.2 握手需要一次完整的往返,如果走双向证书验证,需要两次以上。信创环境下如果启用国密算法(SM2/SM3/SM4),握手的计算成本会比 RSA 更高。

优化手段主要有三个:

第一是开启 TLS 会话缓存和会话恢复。让客户端在同一个连接池连接上尽量复用 TLS 会话,避免每个新连接都重新握手。Apache HttpClient 的SSLConnectionSocketFactory配置里需要设置合适的会话缓存大小。

第二是升级到 TLS 1.3。TLS 1.3 握手只需要一次往返,相比 TLS 1.2 少了一次 RTT。虽然国密算法有自己的 SSL 协议实现,但标准 TLS 1.3 在非国密要求的场景下性能优势明显。如果业务上没有强制国密要求,尽量用 TLS 1.3。

第三是在信创环境的 JDK 里注意 JCE 提供方的实现。有些国密 JCE 提供方(如基于 Bouncy Castle 的实现)性能并不好,握手时证书链验证特别慢。如果遇到握手耗时过长,可以用压测工具单独测 TLS 握手 RTT,如果发现远高于标准 TLS,就要考虑更换 JCE 提供方或者调整会话缓存的效率。

4.4 超时矩阵:不要一套参数走天下

超时参数设得太短会导致正常上传被误杀,设得太长会导致问题请求长期占用连接和线程。金融视频上传里,分块大小不同、网络环境不同,超时参数不能统一。

我一般用这套基准:

  • 连接超时connectTimeout:3 到 10 秒。内网可以短一些,公网或移动网络放长到 10 秒。
  • 响应超时responseTimeout(等同于 socket/read timeout):单块预计耗时的 2 到 3 倍。如果单块 5MB 在目标网络下预计 2 秒传完,超时设 6 秒。
  • 写超时writeTimeout:通常比响应超时短,但不要低于连接超时。

如果客户端开了 10 个并发,每个分块都设了 60 秒的超时,而服务端卡住了,那么客户端会保持 10 个连接占用 60 秒,这段时间内新的上传请求只能在连接池里排队。所以超时时间直接决定连接池的“周转速度”,宁可设得偏短、通过重试来补偿,也不要设得过长导致雪崩。

5. 服务端分块接收与合并的 IO 优化:隐藏的瓶颈往往在磁盘

5.1 分块临时存储层设计

服务端接收分块时,第一个动作是“落盘”。这个动作看似简单,但在并发场景下很容易变成瓶颈。

分块临时存储有几个原则。第一,不要用系统盘,特别是/tmp。很多 Linux 发行版的/tmp是 tmpfs,写满会占用内存;如果不是 tmpfs,也会跟系统日志抢 IO。建议单独挂载一块高性能磁盘,专门用于分块临时文件。第二,按业务维度建目录,比如按上传日期或 fileId 的前几位分目录,避免单个目录下文件数量过多导致 inode 检索变慢。第三,评估磁盘空间容量。假设分块大小 8MB,并发度 20,需要同时暂存的分块总数大约是 20 到 40(考虑合并期间的积压),那么瞬时占用就是 20 × 8MB 到 40 × 8MB,约 160MB 到 320MB。如果业务高峰有 100 个文件同时上传,空间估算必须乘以业务并发数。

还有一个容易被忽略的问题:分块写入时的“刷盘策略”。Java 的FileOutputStream写入并不代表数据真正落到磁盘,可能还在页缓存里。金融场景如果要求严格的数据可靠性,需要调用FileChannel.force(true)刷盘。但注意,每次分块都刷盘会显著降低吞吐,因为fsync的代价很高。实际做法是:在分块接收阶段不每块都刷盘,只做write,等分块合并完成后再对最终文件刷盘。如果对可靠性要求极高,可以在每接收一个分块后只刷一次,但要接受性能损失。

5.2 合并策略:顺序读写的价值

分块上传的最后一步是合并。合并操作的性能要领是“顺序读写”。每个分块在临时目录里都是独立文件,合并时要按chunkIndex的顺序把这些小文件的内容拼到最终文件里。

我用FileChannel.transferTo或者常规的缓冲读取写入方案都可以。关键是缓冲大小不要设得太小,否则频繁的磁盘读写切换会拖慢速度。单次缓冲设置为 1MB 到 4MB 比较合理,读取一个分块、写入目标文件,然后立即处理下一个分块。

另外,合并过程不要用同步方式在接收请求的线程里做。大文件的合并可能持续几秒甚至几十秒,如果直接用 Tomcat 线程做,会占住线程池。正确的姿势是:收到“全部分块已上传”的请求后,返回“合并任务已提交”,由后台异步任务工厂执行合并,合并完成后通过回调或状态查询通知客户端。

5.3 JVM 参数与 GC 在国产 CPU 上的调整思路

服务端 JVM 参数对分块上传性能的影响主要集中在两个方面:堆内存分配和 GC 策略。

分块上传的重点内存消耗在“直接内存”(Direct Memory)而不是堆内存。如果用 NIO 读取分块、写入临时文件,文件通道和网络通道的缓冲会使用堆外内存。默认情况下 MaxDirectMemorySize 等于堆大小上限,但在某些信创环境的默认配置里可能偏小。如果看到OutOfMemoryError: Direct buffer memory的报错,就需要显式调大:

-XX:MaxDirectMemorySize=512m

GC 策略方面,在国产 ARM 架构的 JDK 8 环境下,我用 G1 而不是默认的 Parallel GC 时,确实遇到过停顿变高的情况。原因之一是 ARM 平台的硬件对大页支持不同,G1 的 RememberSet 扫描在跨代引用多的情况下开销更大。分块上传场景的特点是:创建大量短生命周期的缓冲对象,然后迅速变为垃圾。这种模式在 Parallel GC 下反而更高效,因为并行垃圾回收的吞吐量更高。

如果追求低延迟,可以继续用 G1,但需要调整目标停顿时间:

-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16m

实际操作中,我建议在信创环境做一次“GC 策略对照实验”:同一套压测脚本,分别用 Parallel、G1、ZGC(如果 JDK 版本支持)跑一次,记录 P99 延迟和吞吐。不要想当然地照搬网上“G1 肯定比 Parallel 好”的说法,得用数据说话。

6. 金融场景并发上传的可靠性设计:并发越大,越要防错

6.1 分块状态机与幂等设计

并发度越高,出现重复请求、乱序请求、部分失败的概率就越大。金融场景里,这些异常不能靠“重传一次碰碰运气”来解决,必须设计显式的状态机。

分块上传的状态机字段一般包括:fileId(文件唯一标识)、chunkIndex(分块序号)、status(状态)、uploadTimechecksum(校验值)。状态至少要有:待上传、已上传、已校验、已合并。

幂等性的核心是唯一约束。服务端表设计里,用(fileId, chunkIndex)做唯一键。客户端重复上传同一个分块时,服务端检查到该分块已存在并且校验一致,直接返回成功,不重复追加写入。否则如果客户端因为超时重试,而实际上第一次请求已经成功,就会造成分块文件被重复覆盖、数据错乱。

校验和也至关重要。金融场景不建议只算文件 MD5,因为大文件的整体校验在分块上传场景里要与每个分块的校验分开。建议每个分块上传时携带自身内容的 SHA-256(或 MD5),服务端接收后计算校验值,不一致就拒绝。不要小看这个计算,大视频文件的分块数量可能上百,服务端 CPU 占用在高峰期相当可观。如果对校验强度和性能都有要求,可以在 CPU 支持相关指令的前提下选择性能更好的校验算法实现,但一般推荐先用 SHA-256 压测一次,确认瓶颈后再决定。

6.2 重试风暴的源头与抑制策略

并发上传遇到网络波动时,最怕的不是单块失败,而是“所有分块同时失败、同时重试”。20 个并发分块,突然 20 个全部超时,客户端如果立即重试,服务端会在同一瞬间收到 20 个重复请求。如果重试也失败,再重试又是 20 个请求。这种重试风暴会把一个本来只是轻微网络抖动的问题,放大成服务端集群雪崩。

抑制策略有三层。第一层是限制重试次数,金融项目一般不超过 3 次。第二层是重试退避,采用指数退避加抖动:第一次重试等 1 秒,第二次等 2 到 4 秒,第三次等 5 到 8 秒,并加入随机抖动,避免所有客户端在同一时间点重试。第三层是客户端并发信号量:一旦某个分块失败进入重试阶段,就限制整体并发度,例如从 10 降到 4,防止重试请求打满连接池。

还有一个细节:重试时要带requestId(请求唯一标识),服务端根据这个 ID 识别同一次上传的重复请求,避免重复写文件、重复扣减配额。金融系统里的审计日志也要能追踪到每一次重试的轨迹,这不仅是技术需求,也是合规需求。

6.3 合并阶段的并发控制与一致性保证

全部分块上传完成后,服务端开始合并。这个阶段容易遇到的问题:

一是同一个fileId的合并任务被重复触发。客户端可能因为状态查询超时,又重新提交了一次“全部分块已就绪”的请求。服务端必须保证同一个文件的合并任务只执行一次。做法是给合并任务加 Redis 分布式锁或数据库锁,锁的 key 就是fileId,拿到锁才允许创建合并任务。

二是合并过程中有新分块上传到达。如果分块不全时就触发了合并,合并结果必然不完整。状态机的设计必须保证:合并动作只在status=COMPLETE的分块集合上执行,并且合并期间禁止新的分块写入。

三是合并任务的超时与失败重试。合并任务要记录开始时间、结束时间、结果。超时未完成的任务由后台巡检线程扫描并重新调度。重试时不能简单重复执行合并逻辑,要先把已经写了一半的目标文件清理掉,再从头合并,否则会出现文件内容拼接错误。

金融场景还要额外考虑“完成后非常即时的回调”。合并完成后需要通知业务系统“该视频已经可以用于审核/归档”。这个回调消息建议写入消息队列,而不是同步调用业务接口,否则合并线程会被业务系统的处理速度拖住,影响后续文件的合并。

7. 实测优化记录:一个 100MB 视频从 40 秒到 12 秒的调整过程

7.1 测试环境

我先交代一下实测环境,方便你对照自己的系统来理解。

服务端是 2 路鲲鹏 920 处理器,64 核,操作系统是银河麒麟 V10 SP1,JDK 用的是毕昇 JDK 8(与 OpenJDK 8 兼容的开源版本),Web 容器是内置 Tomcat 的 Spring Boot 2.7。客户端是一台同网段的虚拟机,8 核 16GB 内存,JDK 17,使用 Apache HttpClient 5.x,测试文件是 100MB 的短视频,分块大小 5MB,共 20 个分块。

测试工具是自研的 Java 并发脚本,模拟 10 个用户同时上传,统计总上传耗时、平均单块耗时、P99 耗时和服务端线程池占用率。

7.2 第一轮优化:并发度调整

初始配置是串行上传,也就是客户端一个接一个地传分块。100MB 文件分成 20 个 5MB 分块,串行上传总耗时约 40 秒。单看这个数字好像还行,但 10 个用户同时上传时,服务端队列就开始堆积,P99 耗时达到 65 秒,不可接受。

第一轮调整是从串行改为并发上传,并发度从 1 调到 16,分块大小不变。效果立竿见影,10 个用户同时上传时,单文件平均上传耗时从 40 秒降到 22 秒,吞吐量接近翻倍。接着我把并发度调到 32,发现总吞吐并没有继续上升,反而略有下降,P99 延迟从 28 秒上升到 31 秒。这说明当前网络和磁盘条件下,并发度的拐点大约在 16 左右。

这一轮验证了一个判断:并发度不是越大越好,先通过压测找到拐点,而不是盲目调参数。

7.3 第二轮优化:连接池与超时

第一轮优化后,我注意到一个现象:上传过程中,服务端每收到一个分块请求,客户端的 TCP 连接都是新建的。原来是初始代码里没有使用连接池,每个分块请求都执行了一次完整的 TCP 握手和 TLS 握手。

这轮改了两处:一是引入 Apache HttpClient 的PoolingHttpClientConnectionManagermaxPerRoute设为 16,maxTotal设为 64;二是开启 Keep-Alive,设置 30 秒的连接复用。同时把 TSL 会话缓存打开。

改完再压,单文件平均上传耗时从 22 秒降到 15 秒。提升主要来自握手的减少。因为分块只有 5MB,网络传输时间相对短,握手的开销占比就特别明显。

随后我把连接池的maxPerRoute从 16 提高到 32,想看看会不会继续提升,结果吞吐没变化,反而连接数太多导致服务端 accept 队列偶尔出现积压。于是又调回 16,并把 Nginx 的keepalive_timeout从 65 秒改为 30 秒,确保服务端和客户端对连接生命周期的认知一致。

7.4 第三轮优化:服务端 IO 与 JVM 参数

前两轮优化后,瓶颈从客户端转移到了服务端。压测时观察服务端监控,发现 Tomcat 线程池在高峰期被占满,一度有 40% 的请求在等待线程。排查发现,除了接收分块,服务端还在同步执行“分块落盘 + 记录状态到数据库 + 写审计日志”三条链路,每个分块的处理时间被拉长到 1.2 秒。

这一轮做了四件事:先把分块临时目录从系统盘迁移到单独挂载的高性能磁盘,写盘耗时从平均 18 毫秒降到 6 毫秒写一个 5MB 分块;再调整 Tomcat 线程池参数,max-threads从默认的 200 调到 400,accept-count从 100 调到 500;第三是把 JVM 参数从默认的 Parallel GC 调整为 G1,目标停顿时间 200ms,同时设置了 512MB 的直接内存上限;最后把状态记录从同步写数据库改为批量异步刷新,降低单分块的写入延迟。

调整后,单文件平均上传耗时降到 12 秒,P99 从 31 秒降到 16 秒,Tomcat 线程池峰值占用率从 95% 降到 70% 左右。整体优化效果:从最初串行 40 秒,到最终并发 + 连接池 + 服务端 IO 优化的 12 秒,提升约 3.3 倍。

7.5 最终参数推荐与可复制经验

把三轮优化后的最终参数整理成一张表,方便你直接参考:

配置项参数
客户端并发度16(上限,实际按带宽测拐点)
分块大小5MB
HttpClient maxPerRoute16
HttpClient maxTotal64
Keep-Alive30 秒
连接超时5 秒
响应超时60 秒(约单块耗时的 2-3 倍)
Tomcat max-threads400
Tomcat accept-count500
临时目录独立磁盘,按 fileId/日期分目录
JVM 堆4GB -Xmx4g -Xms4g
GCG1,-XX:MaxGCPauseMillis=200
直接内存-XX:MaxDirectMemorySize=512m

这套参数只适用于我当时的测试环境。你实际部署时,网络带宽、磁盘性能、CPU 型号都不一样,参数肯定要做调整。但优化的方法和顺序是可复制的:先基线测试,再找并发拐点,然后检查连接池和超时,最后调服务端 IO 与 JVM。每一步都基于数据,不要一步到位改一堆参数,否则出问题的时候根本不知道是哪一步引起的。

我在实际项目里踩过不少坑,比如一开始把并发度调到 64,结果服务端线程池先扛不住,P99 飙升到 60 多秒;又比如把超时时间设成 10 秒,导致公网用户正常上传稍微慢一点就被误杀,重试反而加重了服务端压力。这些问题如果你也遇到了,按这个节奏一步步排查,基本都能定位到具体环节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询