
做后端这些年往对象存储传大文件这件事几乎避不开。前阵子接了个需求一批单个大小在1GB到5GB不等的文件要传到AWS S3业务方给的时间窗口很紧。最开始我用最直观的putObject单连接上传结果大文件传到一半经常连接超时一断线就从头重传链路吞吐量惨不忍睹。后来切到Java SDK的分段上传配合多线程并发整体耗时砍掉了近一半才把问题真正解决。这篇文章把这次优化的完整过程复盘一遍重点讲清楚多线程和分段上传怎么配合、分片大小和并发数怎么算、现场踩过哪些坑以及最后沉淀下来的参数模板。正在用Java对接S3、遇到大文件上传慢或总超时的同学可以直接参考。1. 先理清方案分段上传配多线程为什么快1.1 普通putObject上传的瓶颈在哪AWS S3的普通上传接口PutObject本身有5GB上限而且本质上是单连接串行传输。你的文件多大数据就沿着这一条TCP连接一路搬带宽再高也只能用到一个连接的吞吐。更麻烦的是链路不稳的时候一个长时间的上传会话很容易被网络抖动或者负载均衡策略掐断而S3普通上传不支持从中间续传一旦失败只能整个文件重新传一遍。网络问题之外还有一个容易被忽略的点普通上传在SDK里通常要把整个文件读入请求体虽然可以用InputStream流式发但对大文件来说内存缓冲的压力和单次请求的超时风险都会成倍上升。也就是说文件越大用putObject的失败概率越高重试成本也越贵。打个比方单连接上传就像一个人扛着全部行李过河水流稍微急一点就被冲走行李还得重新收拾。分段上传就是先把行李拆成多个包裹搭建一条多车道通道多个搬运工同时搬即使某个包裹中途掉水里也只补搬那一个包裹。1.2 分段上传的适用边界与选型AWS S3的分段上传Multipart Upload先把对象拆成多个part分别上传最后再合并成一个完整对象。官方限制是单个part大小5MB到5GB最多支持10000个part因此最大对象可达5TB。这意味着分段上传天然适合超过100MB、或者是网络环境不够稳定的大文件场景。选型时有几条路线各有取舍方案适用场景优势注意点普通putObject100MB以下小文件实现简单、请求次数少不支持断点续传超过5GB不可用使用SDK的TransferManager想快速上手的场景自动完成分段和并发代码量最小分片大小、并发数的可调性有限手动调用Multipart API需要精细调优的生产环境分片大小、线程数、重试策略完全可控需要自己维护分片元数据、处理异常我这次选定的是第三种手动调用Multipart API。原因很直接线上文件大小跨度大、网络条件也不是一成不变TransferManager虽然方便但里面的默认参数不一定匹配当前业务出了问题你还得再学一套它的调度逻辑。自己实现反而能把线程池、分片大小、重试次数全部攥在手里。1.3 分段上传加多线程解决了什么问题分段上传本身只解决“能不能并发”的问题真正把并发跑起来要靠多线程。整个上传流程拆成三步先用CreateMultipartUpload拿到一个uploadId然后把文件按固定大小切段每个part用一个线程提交UploadPart等所有part都上传完成后再调用CompleteMultipartUpload把这些part合并成对象。多线程的价值在于每个线程独立走一条HTTP连接相当于同时打开多条数据传输通道单个连接的限制就被绕开了。只要本机磁盘读得过来、网络带宽还有余量整体吞吐可以线性往上走。这里要注意多线程只是手段目标是让瓶颈落在带宽上而不是CPU、文件IO或者S3的API调用配额上。所以在动手写代码之前先想清楚你当前卡在哪一层后面调参才有方向。2. 动手前的准备环境、依赖和客户端参数2.1 Maven依赖怎么配我用的是AWS SDK for Java 2.x。2.x版本相比1.x在API设计、连接管理和异步支持上都干净很多官方也把维护重心放在2.x上新项目直接用2.x。需要引入两个核心依赖s3和apache-client。dependency groupIdsoftware.amazon.awssdk/groupId artifactIds3/artifactId version2.25.0/version /dependency dependency groupIdsoftware.amazon.awssdk/groupId artifactIdapache-client/artifactId version2.25.0/version /dependency这里额外引入apache-client不是多余的。AWS SDK 2.x默认使用基于URLConnection的HTTP实现对于高并发场景连接池管理远不如Apache HttpClient灵活。分段上传的每个part都要独立发请求连接数会瞬间膨胀使用Apache HttpClient可以统一控制连接池大小、超时时间和连接复用策略对稳定性和性能都有帮助。2.2 S3Client初始化的关键参数S3Client在SDK中设计为线程安全的多线程共享同一个实例没有任何问题这点非常关键不要在每个线程里各建一个客户端。正确做法是创建一个全局单例所有分片上传任务共用它。构造时重点调这几个参数S3Client s3Client S3Client.builder() .region(Region.AP_SOUTHEAST_1) .credentialsProvider(DefaultCredentialsProvider.create()) .httpClientBuilder( ApacheHttpClient.builder() .maxConnections(200) .connectionTimeout(Duration.ofSeconds(10)) .socketTimeout(Duration.ofSeconds(60)) ) .build();maxConnections连接池最大连接数必须大于你配置的并发线程数否则线程拿不到连接会一直等。我曾经把并发数设到32maxConnections还是默认的50初期看着够用但同一时刻还有其他业务占用连接导致上传线程阻塞。稳妥做法是给连接池预留1.5倍到2倍的并发空间。connectionTimeout建立TCP连接的超时时间一般10秒左右合理。socketTimeout读超时时间这个参数对大文件上传尤其重要。分片越大单个请求把part传输完需要的时间越长读超时设太短会在网络稍有波动时误杀正常请求。我的经验是60秒起步弱网环境拉到120秒也不夸张。凭证方面默认的DefaultCredentialsProvider会按照系统属性、环境变量、配置文件、容器凭证等顺序自动查找本地开发可以用~/.aws/credentials里的AK/SK生产环境建议用IAM Role或STS临时凭证。注意临时凭证有有效期如果上传任务时间跨度较长要提前处理凭证刷新问题。2.3 权限和前置条件检查网络层面的坑往往是先于代码暴露的在写上传逻辑之前线段内的权限和网络连通性最好先验一遍。S3侧需要确认IAM策略包含以下权限不只是PutObject{ Effect: Allow, Action: [ s3:PutObject, s3:AbortMultipartUpload, s3:ListMultipartUploadParts, s3:ListBucketMultipartUploads ], Resource: arn:aws:s3:::your-bucket/* }很多人只配了s3:PutObject到completeMultipartUpload阶段直接报AccessDenied排查半天才发现权限不够。另外还要确认执行机器的网络到S3对应区域的延迟和实际上行带宽这一步使用aws s3 cp命令做个压测比写代码更直观也可以直接跑一个几十MB的小文件上传看看平均耗时。3. 核心代码落地完整的并发分段上传实现3.1 分片大小和线程数怎么估算在写代码前要先确定两个核心参数分片大小partSize和并发线程数concurrency。分片大小受S3规则约束最小5MB、最大5GB、单个文件最多10000片。正常情况下不需要真的卡着10000片上限去算通常从吞吐角度倒推。文件越大分片越应该大但太大又会带来两个问题一是单分片重试代价变高二是部分环境内存缓冲吃紧。我给一个比较保守的起始公式partSize max(5MB, ceil(fileSize / 10000) 向上对齐到MB) concurrency min(32, max(4, 可用带宽MB/s / 单线程预估吞吐))单线程预估吞吐和带宽以及网络质量有关。比如带宽100Mbps约12.5MB/s本地网络没有明显限速时单线程跑到2~3MB/s是常事那并发8到16都合理。如果你的可用带宽是千兆约125MB/s单线程能跑到10MB/s并发可能要到16甚至32才能打满。实际调参别一步到位先跑一个1GB测试文件固定分片32MB分别用4、8、16线程各测一遍耗时曲线从下降到变平的那一点就是当前环境的最优并发数。下面代码里我默认用32MB、8线程作为起始配置。3.2 三步式完整实现手动分段上传的核心是三步init、uploadPart、complete。下面这份代码适配了AWS SDK 2.x支持任意大小文件每个分片并发提交。import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.*; import java.io.InputStream; import java.nio.channels.Channels; import java.nio.channels.FileChannel; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class S3MultipartUploader { private final S3Client s3Client; private final int concurrency; public S3MultipartUploader(S3Client s3Client, int concurrency) { this.s3Client s3Client; this.concurrency concurrency; } public void upload(String bucket, String key, Path filePath) throws Exception { long fileSize Files.size(filePath); int partSize calculatePartSize(fileSize); // 第1步创建分段上传任务 CreateMultipartUploadResponse initResponse s3Client.createMultipartUpload( CreateMultipartUploadRequest.builder() .bucket(bucket) .key(key) .build()); String uploadId initResponse.uploadId(); System.out.println(init uploadId uploadId); int totalParts (int) Math.ceil((double) fileSize / partSize); ListCompletedPart completedParts new CopyOnWriteArrayList(); CountDownLatch latch new CountDownLatch(totalParts); AtomicInteger completedCount new AtomicInteger(0); ExecutorService executor Executors.newFixedThreadPool(concurrency); // 第2步并发上传每个分片 for (int partNumber 1; partNumber totalParts; partNumber) { final int partNum partNumber; final long start (long) (partNum - 1) * partSize; final long partLen Math.min(partSize, fileSize - start); executor.submit(() - { try { PartResult result uploadPart(bucket, key, uploadId, partNum, start, partLen, filePath); completedParts.add(result.completedPart); int done completedCount.incrementAndGet(); System.out.printf(part %d/%d done, etag%s, progress%.2f%%%n, done, totalParts, result.completedPart.eTag(), done * 100.0 / totalParts); } catch (Exception e) { System.err.println(upload part partNum failed: e.getMessage()); } finally { latch.countDown(); } }); } latch.await(2, TimeUnit.HOURS); executor.shutdown(); if (completedParts.size() ! totalParts) { s3Client.abortMultipartUpload(AbortMultipartUploadRequest.builder() .bucket(bucket).key(key).uploadId(uploadId).build()); throw new RuntimeException(部分分片上传失败任务已中止本次上传已取消); } // 第3步合并分片 completedParts.sort((a, b) - Integer.compare(a.partNumber(), b.partNumber())); CompleteMultipartUploadResponse completeResponse s3Client.completeMultipartUpload( CompleteMultipartUploadRequest.builder() .bucket(bucket) .key(key) .uploadId(uploadId) .multipartUpload(CompletedMultipartUpload.builder() .parts(completedParts) .build()) .build()); System.out.println(upload complete, location completeResponse.location()); } private PartResult uploadPart(String bucket, String key, String uploadId, int partNumber, long start, long partLen, Path filePath) { UploadPartRequest request UploadPartRequest.builder() .bucket(bucket) .key(key) .uploadId(uploadId) .partNumber(partNumber) .contentLength(partLen) .build(); // 每个线程打开独立的FileChannel从对应position开始读取 try (FileChannel channel FileChannel.open(filePath, StandardOpenOption.READ)) { channel.position(start); InputStream in Channels.newInputStream(channel); UploadPartResponse response s3Client.uploadPart(request, RequestBody.fromInputStream(in, partLen)); return new PartResult(CompletedPart.builder() .partNumber(partNumber) .eTag(response.eTag()) .build()); } catch (Exception e) { throw new RuntimeException(part partNumber upload failed, e); } } private int calculatePartSize(long fileSize) { long minSize 5L * 1024 * 1024; long calc fileSize / 10000; if (calc minSize) { return (int) minSize; } long mb (calc 1024 * 1024 - 1) / (1024 * 1024); return (int) (mb * 1024 * 1024); } private static class PartResult { final CompletedPart completedPart; PartResult(CompletedPart completedPart) { this.completedPart completedPart; } } public static void main(String[] args) throws Exception { S3Client client S3Client.builder() .region(Region.AP_SOUTHEAST_1) .credentialsProvider(DefaultCredentialsProvider.create()) .httpClientBuilder(ApacheHttpClient.builder() .maxConnections(100) .connectionTimeout(Duration.ofSeconds(10)) .socketTimeout(Duration.ofSeconds(60))) .build(); S3MultipartUploader uploader new S3MultipartUploader(client, 8); uploader.upload(my-bucket, dir/big-file.zip, Path.of(/data/big-file.zip)); } }实现上有几个细节值得说明。每个分片任务里我使用独立的FileChannel并position(start)定位而不是直接在主线程把整个文件读成byte[]这样无论文件多大内存占用都只有当前分片大小不会发生OOM。CopyOnWriteArrayList用来收集已完成的part它的写操作线程安全最后排序时再按partNumber升序排列这就规避了并发写入导致的列表乱序问题。注意S3对completeMultipartUpload有个隐晦的要求parts列表必须按partNumber升序且PartNumber从1开始连续中间缺失或重复都会报错。主线程用CountDownLatch等待所有分片任务结束这是多线程编程里最常见的协作模式。如果某个分片最终没有成功直接调用abortMultipartUpload清理本次上传产生的残留分片避免空耗存储费用。3.3 失败重试与进度跟踪的落地方式代码里的重试逻辑只是最基础的一层实际生产环境我建议至少做到两点。第一单分片请求失败时使用指数退避重试而不是直接抛异常。S3在大规模并发下偶尔返回500或503这类临时性错误重试通常就能解决。我一般给每个part做3次重试退避间隔取1秒、2秒、4秒简单实现如下private UploadPartResponse uploadPartWithRetry(UploadPartRequest request, InputStream in, long partLen) { int maxRetries 3; int attempt 0; while (true) { try { return s3Client.uploadPart(request, RequestBody.fromInputStream(in, partLen)); } catch (S3Exception e) { if (attempt maxRetries || e.statusCode() 500) { throw e; } attempt; try { Thread.sleep(1000L * (1 (attempt - 1))); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new RuntimeException(ie); } } } }第二进度跟踪不能只靠日志建议用一个AtomicInteger统计完成的分片数通过一个定时任务或监听接口把进度上报给业务侧。大文件上传通常要持续几分钟甚至几十分钟没有进度反馈用户很容易误判为卡死。上面代码里已经打印了完成百分比生产环境可以换成回调接口或写入数据库。4. 性能实测分片大小和线程数到底怎么选4.1 一组实测数据和参数选择逻辑做完代码实现后我拿一台测试机做了几组对比。文件是10GB的tar包带宽约100Mbps理论12.5MB/s机器磁盘为SSD网络延迟约15ms。结果如下方案分片大小并发数实测耗时备注普通putObject整文件1约25分钟中途断过一次重传实际接近40分钟手动分段64MB4约9分30秒稳定重试0次手动分段32MB8约6分50秒接近带宽上限重试0次手动分段16MB16约7分10秒请求数变多S3 API开销开始显现手动分段8MB32约8分20秒分片太碎请求数暴涨反而不划算注意这张表是单次环境的结果只用来展示趋势不同机器和网络环境差异很大。但从数据里能看出两个通用结论。一是并发确实有效从4线程到8线程吞吐提升了近30%这说明之前确实被单连接限制住了。二是分片大小和并发数要一起看8MB分片配32线程虽然并发拉到了最高但10000个part上限很快就触到天花板而且每个part都要独立构造HTTP请求S3服务端的API调用开销反而拖慢了整体速度。一般来说分片大小取32MB到128MB是性价比最高的区间。小文件可以放宽到16MB但不要低于8MB超大文件如50GB以上我建议64MB起步配合16线程左右既保证并发又控制请求数量。4.2 容易被忽略的性能陷阱第一个陷阱是S3Client没有全局复用。SDK文档明确说S3Client是线程安全的可以多线程共享。但很多人习惯在每次上传时S3Client.builder().build()这样每个分片可能都要重新建立连接池和TLS握手开销非常大。正确做法是当作Spring Bean或者单例持有整个应用生命周期复用。第二个陷阱是连接池配置与并发数不匹配前面提过maxConnections要留有余量。如果连接池只有50并发线程设到32看起来够但S3Client内部还有其他请求比如列出分片、刷新凭证也要占用连接实际会出现线程等待连接的情况表现就是总耗时上不去CPU和带宽都不高。第三个陷阱是分片大小计算没有考虑内存。虽然我们用FileChannel避免了整个文件进内存但SDK在上传时仍会在内部缓冲分片数据尤其是从InputStream读取时会有一段默认缓冲。分片设得过大比如512MBJVM的内存压力会明显上升GC变频繁之后又拖慢IO。所以分片选择本质上是网络吞吐、请求数、内存三者之间的平衡。还有一个很隐蔽的问题弱网环境下如果socketTimeout设得太短大分片传不完就被判定超时。这里的超时不是TCP连接超时而是“多久没收到数据”的读超时。网络延迟高加上分片大的时候即使数据传输正常也可能因为某段时间带宽波动触发超时重试。我把60秒设为基础值带宽低于5MB/s时建议直接拉到120秒。5. 坑与对策真实环境中的问题排查记录5.1 鉴权类错误AccessDenied和签名不匹配这类问题在上线初期最容易出现。AccessDenied通常不是AK/SK写错而是IAM策略缺少分段上传相关权限。之前我就遇到过PutObject权限配好了但业务代码跑到completeMultipartUpload时直接403排查半天才意识到还要加s3:CompleteMultipartUpload。在IAM控制台给权限时直接把这几个Action一起放进去s3:PutObject s3:CompleteMultipartUpload s3:AbortMultipartUpload s3:ListMultipartUploadPartsSignatureDoesNotMatch大部分情况是客户端机器时间偏差超过15分钟导致的AWS签名会对时间戳做校验系统时钟不准就会报这个错。解决方式是校准系统时间同步NTP。如果你用的是STS临时凭证还需要检查凭证是否过期上传任务太长跑到凭证失效也会出现签名类错误。顺带提一句不只是AWS国内对象存储的SDK也有类似的场景比如曾经有人遇到过上传图片后报401 token错误本质上都是凭证过期、bucket区域填错或者签名算法对不上这几类问题排查思路完全通用。5.2 并发上传后complete失败分片列表乱序或缺失分片上传完成后的CompletedPart列表如果不按partNumber升序排列completeMultipartUpload会直接报InvalidPartOrder。并发提交任务时完成顺序天然无序所以必须在提交给complete接口之前做一次排序。我代码里用的就是completedParts.sort按partNumber比较这个环节不能省。另一个容易忽略的是PartNumber必须从1开始且必须连续。比如文件总共5个分片如果你传了1、2、4、5少了3complete时同样会报错。所以异常处理必须严谨确认所有分片全部成功后再执行complete。5.3 内存溢出和文件句柄过多文件很大时如果代码里写成byte[] fileBytes Files.readAllBytes(path)再上传JVM堆内存直接爆掉。正确做法是流式读取也就是前文代码里每个线程独立打开FileChannel并定位到分片起始位置。连接和流都要用try-with-resources管理避免文件句柄泄漏。Linux下文件句柄数量有限我实际遇到过分片多、线程数大且流没关闭导致“Too many open files”的情况程序跑一段时间后上传请求批量失败。如果并发线程数很高建议确认一下系统文件句柄限制ulimit -n默认1024很可能不够用。32线程、每个分片持有1个文件流再加上连接池里的连接文件描述符很快就超了。生产环境把这个值调到65535甚至更高比较稳妥。5.4 断点续传和孤儿分片处理分段上传天然支持从断点继续因为每个part的上传是独立的。如果程序中途崩溃再次启动时可以调用ListParts接口根据返回的part列表跳过已完成分片只上传缺失部分。实际落地时我会先把uploadId持久化到数据库或临时文件这样重跑时能拿到之前的uploadId而不是重新init一次。还有一种情况是上传任务取消了但已经上传的一部分part还残留在S3桶里属于“孤儿分片”会一直占用存储费用。要么在代码里catch逻辑主动调用abortMultipartUpload要么给桶配置生命周期规则定期清理“未完成上传”的对象。两种方式建议都用代码清理保即时性生命周期规则做兜底。最后归纳一下我这次上线沉淀下来的推荐起始参数可以直接抄作业文件大小分片大小并发数连接池maxConnections100MB ~ 1GB16MB8201GB ~ 10GB32MB8~165010GB以上64MB16100这些参数不是死规则每换一个网络环境都建议先用1GB测试文件压一遍记录不同组合的耗时再定生产值。这次优化给我的最大体会是上传性能优化不是无脑加线程数瓶颈在带宽时加并发有效瓶颈在本地磁盘或S3 API配额时加并发只会添乱。分段上传这套机制本身不复杂复杂的是把分片大小、并发数、连接池、超时和重试策略调成一套匹配当前环境的组合。参数定下来之后再把异常路径和断点续传补齐这个功能才算真正能扛住生产流量。