ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

信创环境下Java分块上传性能优化:从并发到IO的全面调优

信创环境下Java分块上传性能优化:从并发到IO的全面调优 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()) ); ListFutureChunkResult futures new ArrayList(); for (ChunkInfo chunk : chunks) { futures.add(executor.submit(() - uploadChunk(chunk))); } for (FutureChunkResult 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 更合适。ListCompletableFutureChunkResult futures chunks.stream() .map(chunk - CompletableFuture.supplyAsync( () - uploadChunk(chunk), uploadExecutor )) .collect(Collectors.toList()); CompletableFutureVoid 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()) { ListFutureChunkResult futures chunks.stream() .map(chunk - executor.submit(() - uploadChunk(chunk))) .toList(); for (FutureChunkResult 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 的连接池配置中三个参数最重要setMaxTotal、setDefaultMaxPerRoute、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 握手与国密场景下的会话复用金融行业强制 HTTPSTLS 握手的开销不能忽略。一个典型的 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 超时矩阵不要一套参数走天下超时参数设得太短会导致正常上传被误杀设得太长会导致问题请求长期占用连接和线程。金融视频上传里分块大小不同、网络环境不同超时参数不能统一。我一般用这套基准连接超时connectTimeout3 到 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:MaxDirectMemorySize512mGC 策略方面在国产 ARM 架构的 JDK 8 环境下我用 G1 而不是默认的 Parallel GC 时确实遇到过停顿变高的情况。原因之一是 ARM 平台的硬件对大页支持不同G1 的 RememberSet 扫描在跨代引用多的情况下开销更大。分块上传场景的特点是创建大量短生命周期的缓冲对象然后迅速变为垃圾。这种模式在 Parallel GC 下反而更高效因为并行垃圾回收的吞吐量更高。如果追求低延迟可以继续用 G1但需要调整目标停顿时间-XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:G1HeapRegionSize16m实际操作中我建议在信创环境做一次“GC 策略对照实验”同一套压测脚本分别用 Parallel、G1、ZGC如果 JDK 版本支持跑一次记录 P99 延迟和吞吐。不要想当然地照搬网上“G1 肯定比 Parallel 好”的说法得用数据说话。6. 金融场景并发上传的可靠性设计并发越大越要防错6.1 分块状态机与幂等设计并发度越高出现重复请求、乱序请求、部分失败的概率就越大。金融场景里这些异常不能靠“重传一次碰碰运气”来解决必须设计显式的状态机。分块上传的状态机字段一般包括fileId文件唯一标识、chunkIndex分块序号、status状态、uploadTime、checksum校验值。状态至少要有待上传、已上传、已校验、已合并。幂等性的核心是唯一约束。服务端表设计里用(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拿到锁才允许创建合并任务。二是合并过程中有新分块上传到达。如果分块不全时就触发了合并合并结果必然不完整。状态机的设计必须保证合并动作只在statusCOMPLETE的分块集合上执行并且合并期间禁止新的分块写入。三是合并任务的超时与失败重试。合并任务要记录开始时间、结束时间、结果。超时未完成的任务由后台巡检线程扫描并重新调度。重试时不能简单重复执行合并逻辑要先把已经写了一半的目标文件清理掉再从头合并否则会出现文件内容拼接错误。金融场景还要额外考虑“完成后非常即时的回调”。合并完成后需要通知业务系统“该视频已经可以用于审核/归档”。这个回调消息建议写入消息队列而不是同步调用业务接口否则合并线程会被业务系统的处理速度拖住影响后续文件的合并。7. 实测优化记录一个 100MB 视频从 40 秒到 12 秒的调整过程7.1 测试环境我先交代一下实测环境方便你对照自己的系统来理解。服务端是 2 路鲲鹏 920 处理器64 核操作系统是银河麒麟 V10 SP1JDK 用的是毕昇 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设为 16maxTotal设为 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 调到 400accept-count从 100 调到 500第三是把 JVM 参数从默认的 Parallel GC 调整为 G1目标停顿时间 200ms同时设置了 512MB 的直接内存上限最后把状态记录从同步写数据库改为批量异步刷新降低单分块的写入延迟。调整后单文件平均上传耗时降到 12 秒P99 从 31 秒降到 16 秒Tomcat 线程池峰值占用率从 95% 降到 70% 左右。整体优化效果从最初串行 40 秒到最终并发 连接池 服务端 IO 优化的 12 秒提升约 3.3 倍。7.5 最终参数推荐与可复制经验把三轮优化后的最终参数整理成一张表方便你直接参考配置项参数客户端并发度16上限实际按带宽测拐点分块大小5MBHttpClient maxPerRoute16HttpClient maxTotal64Keep-Alive30 秒连接超时5 秒响应超时60 秒约单块耗时的 2-3 倍Tomcat max-threads400Tomcat accept-count500临时目录独立磁盘按 fileId/日期分目录JVM 堆4GB -Xmx4g -Xms4gGCG1-XX:MaxGCPauseMillis200直接内存-XX:MaxDirectMemorySize512m这套参数只适用于我当时的测试环境。你实际部署时网络带宽、磁盘性能、CPU 型号都不一样参数肯定要做调整。但优化的方法和顺序是可复制的先基线测试再找并发拐点然后检查连接池和超时最后调服务端 IO 与 JVM。每一步都基于数据不要一步到位改一堆参数否则出问题的时候根本不知道是哪一步引起的。我在实际项目里踩过不少坑比如一开始把并发度调到 64结果服务端线程池先扛不住P99 飙升到 60 多秒又比如把超时时间设成 10 秒导致公网用户正常上传稍微慢一点就被误杀重试反而加重了服务端压力。这些问题如果你也遇到了按这个节奏一步步排查基本都能定位到具体环节。
返回列表