做后端这些年,往对象存储传大文件这件事几乎避不开。前阵子接了个需求:一批单个大小在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、或者是网络环境不够稳定的大文件场景。
选型时有几条路线,各有取舍:
| 方案 | 适用场景 | 优势 | 注意点 |
|---|---|---|---|
| 普通putObject | 100MB以下小文件 | 实现简单、请求次数少 | 不支持断点续传,超过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> <groupId>software.amazon.awssdk</groupId> <artifactId>s3</artifactId> <version>2.25.0</version> </dependency> <dependency> <groupId>software.amazon.awssdk</groupId> <artifactId>apache-client</artifactId> <version>2.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:连接池最大连接数,必须大于你配置的并发线程数,否则线程拿不到连接会一直等。我曾经把并发数设到32,maxConnections还是默认的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); List<CompletedPart> 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分钟 |
| 手动分段 | 64MB | 4 | 约9分30秒 | 稳定,重试0次 |
| 手动分段 | 32MB | 8 | 约6分50秒 | 接近带宽上限,重试0次 |
| 手动分段 | 16MB | 16 | 约7分10秒 | 请求数变多,S3 API开销开始显现 |
| 手动分段 | 8MB | 32 | 约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读取时会有一段默认缓冲。分片设得过大(比如512MB),JVM的内存压力会明显上升,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,少了3,complete时同样会报错。所以异常处理必须严谨,确认所有分片全部成功后,再执行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,要么给桶配置生命周期规则,定期清理“未完成上传”的对象。两种方式建议都用,代码清理保即时性,生命周期规则做兜底。
最后归纳一下我这次上线沉淀下来的推荐起始参数,可以直接抄作业:
| 文件大小 | 分片大小 | 并发数 | 连接池maxConnections |
|---|---|---|---|
| 100MB ~ 1GB | 16MB | 8 | 20 |
| 1GB ~ 10GB | 32MB | 8~16 | 50 |
| 10GB以上 | 64MB | 16 | 100 |
这些参数不是死规则,每换一个网络环境,都建议先用1GB测试文件压一遍,记录不同组合的耗时再定生产值。这次优化给我的最大体会是:上传性能优化不是无脑加线程数,瓶颈在带宽时加并发有效,瓶颈在本地磁盘或S3 API配额时加并发只会添乱。分段上传这套机制本身不复杂,复杂的是把分片大小、并发数、连接池、超时和重试策略调成一套匹配当前环境的组合。参数定下来之后,再把异常路径和断点续传补齐,这个功能才算真正能扛住生产流量。