资讯动态

AWS S3大文件上传优化:从putObject到多线程分段上传实践

发布时间:2026/9/17 1:57:30 来源:尧图企业网站定制
如果你的项目里还有人在用putObject直接传几个 GB 的大文件我建议你把这篇文章转给他。前阵子我接手一个数据迁移工具要从内网把几千个大文件搬到 AWS S3最初版本就是最简单的单请求上传一个 1.2GB 的备份文件平均要跑 6 分多钟。后来我把上传逻辑改成 Java SDK 多线程分段上传同样的带宽和机器配置下耗时直接降到 2 分钟左右而且大大减少了失败重传的代价。这个提升不是玄学。S3 Multipart Upload 这个协议本身就是为大规模传输设计的Java SDK 里也有对应的上层封装和底层 API。这篇文章我就把整套思路、代码、参数推导和踩坑记录写清楚重点讲清楚多线程分段上传的线程模型、分片边界、并发度怎么选以及中断恢复和未完成任务清理。无论你是刚开始用 S3 的开发者还是已经在生产环境跑上传任务的工程师都应该能从这里拿到可直接落地的方案。1. 为什么大文件上传必须换掉 putObject单连接请求的三个真实瓶颈1.1 一个请求扛全量数据失败就得从头再来putObject在 S3 里本质是单次 HTTP PUT 请求把整个对象作为请求体发出去。小文件这么干没有任何问题文件一旦上了几百 MB问题就开始暴露。第一个问题是失败成本。单请求上传时只要网络抖动、客户端超时、服务端 5xx整个传输就断了。S3 的 PUT 请求没有断点续传能力重新上传意味着从头开始。我见过最极端的案例是有人传一个 20GB 的数据库备份已经跑了 90%结果办公室网络闪断前面全部白传。如果当初用分段上传只有最后一个分片需要重传最多损失几分钟。第二个问题是并发能力完全没利用上。S3 的单个请求即便在带宽充足的情况下也很难打满网络链路。尤其是跨地域上传、经过公网时请求要经历多次 TCP 握手、TLS 协商、服务端接收处理这些时间在总体耗时里占比不小。单连接传输意味着网络往返延迟被串行摊销数据一直在一个管道里流动稍微有点延迟波动就会拖累整体吞吐。第三个问题也是很多人忽略的S3 对单请求的大小虽然没有硬性上限但过大的请求会让客户端和服务端都需要大量缓冲区一旦中途失败重试的代价呈线性放大。1.2 网络吞吐不是只和带宽有关我在调优前先做了一次带宽测试内网到 S3 的上行带宽大约是 80Mbps换算一下就是 10MB/s 左右。按这个数字1.2GB 文件理论上 2 分钟就能传完但单线程putObject实际花了 6 分多钟说明带宽根本没用满。问题出在 TCP 的拥塞窗口和网络往返时间 RTT 上。每次请求都要经历“发数据包 - 等服务端 ACK - 再发下一批数据”如果 RTT 是 50ms那么单个 TCP 连接的吞吐上限受窗口大小限制很难跑满带宽。多线程分段上传的原理就是把一个大请求拆成多个独立的小请求并发发出等于同时开多条 TCP 连接让网络窗口可以并行扩展这才是性能提升的核心来源。所以不要一上来就盲目调大 JVM 内存或加 CPU先看你的网络拓扑和 RTT。只有并发请求数量足够多让链路里始终有充足的在途数据带宽才能真正被吃满。1.3 S3 分段上传原理服务器端的“拼图游戏”Multipart Upload 的流程可以分成四步CreateMultipartUpload先向 S3 申请一个uploadId这次上传任务有了唯一标识。UploadPart将文件拆成多个分片每个分片用独立的 PUT 请求上传携带相同的uploadId和不同的partNumber。ListParts查询已上传的分片用于校验和断点恢复。CompleteMultipartUpload所有分片传完后带着完整的分片编号和 ETag 列表提交合并S3 服务端负责把分片组合成最终对象。S3 对分片有两个硬性限制每个任务最多 10000 个分片除最后一个分片外每个分片最小 5MB。这两个限制直接影响分片大小怎么选下一节展开讲。2. 动手前先想清楚的两件事分片边界与线程模型2.1 分片数上限与最小尺寸的硬规则很多人写分段上传代码时直接把分片大小设成 8MB、16MB从来不问为什么。这里其实隐藏着一个边界问题S3 最多允许 10000 个分片如果文件特别大固定 8MB 分片会直接把任务搞崩。举个例子1TB 文件用 8MB 分片分片数量是1TB / 8MB 131072远超 10000 个的上限UploadPart到一半就会报错。所以分片大小的选择必须动态计算而不是拍脑袋定死long fileSize Files.size(file); long partSize 8L * 1024 * 1024; // 默认 8MB // 如果按当前分片大小算出的分片数超过 10000就需要调大分片 if (fileSize / partSize 10000) { partSize (long) Math.ceil((double) fileSize / 10000); // 向上对齐到 1MB避免出现太多奇奇怪怪的边界 partSize (partSize (1024L * 1024L - 1)) / (1024L * 1024L) * (1024L * 1024L); } // 分片不能小于 5MB最后一个分片除外这里全局设一个下限更稳妥 if (partSize 5L * 1024 * 1024) { partSize 5L * 1024 * 1024; }这段代码的意图很明确默认 8MB 优先遇到超大文件自动放大分片确保分片数不超过平台限制。实际项目里我一般会把这个计算逻辑单独抽成一个方法因为文件大小来源可能是Content-Length、本地文件、流式输入统一处理能减少很多边缘问题。2.2 根据文件大小与带宽反推分片大小分片大小不是越大越好也不是越小越好。理论上一个分片在网络上传输的时间应该远大于 RTT这样传输过程中网络才不会被频繁的请求确认打断。如果带宽是 10MB/s、RTT 是 50ms那么网络管道容量大约是10MB/s * 0.05s 512KB也就是说分片大于 512KB 后单请求的传输时间就大于 RTT 了可以较为充分地利用带宽。但 512KB 这个值低于 S3 的 5MB 最小分片限制所以实际生产中 5MB 到 16MB 是常见区间。我在大部分项目中默认选择 8MB原因有三个8MB 足够大于常见网络环境的 BDP单个分片传输时间约 0.8 秒请求往返开销占比很低。8MB 的分片在失败重传时损失的上传数据量可控。分片数量适中CompleteMultipartUpload时提交的元数据体积也不会太大。如果文件达到几十 GB 甚至 TB 级再考虑 32MB 或 64MB 分片。大分片能减少分片数量同时降低ListParts和CompleteMultipartUpload的请求压力。2.3 并发度别拍脑袋有界队列与线程数估算并发度不是越大越好这一点我后面会用实测数据证明。先给一个经验范围对于 50Mbps 到 200Mbps 的带宽环境并发线程数设在 8 到 16 通常能跑出不错的效果到了 1Gbps 带宽可以尝试 16 到 32。这里的“并发线程数”指的是同时进行的UploadPart请求数不是 JVM 总线程数。另一个容易被忽视的问题是任务提交方式。很多人用ExecutorService CompletableFuture一次性把所有分片任务丢进线程池这种方式在分片数量少的时候没问题但遇到大文件会产生上千个 Future 对象而且线程池的阻塞队列会积压大量任务内存和 GC 压力都不小。更稳的做法是“限流提交”用一个信号量或固定大小的阻塞队列保证同时在飞的分片数不超过配置的上限。我习惯用Semaphore每次提交前先acquire()上传完成后release()这样任务队列再长也不会失控。int concurrency 8; Semaphore inflight new Semaphore(concurrency); for (int partNumber 1; partNumber partCount; partNumber) { inflight.acquire(); CompletableFuture.supplyAsync(() - uploadOnePart(...), executor) .whenComplete((part, ex) - inflight.release()); }这种模式和“不限制提交数量只限制同时执行数量”的最大区别是内存占用可控。文件越大优势越明显。3. 实战一TransferManager最省事的并发分段上传姿势3.1 基于 S3AsyncClient 构建 TransferManagerAWS SDK for Java 2.x 里提供了一个高层封装S3TransferManager使用它不需要自己处理分片、并发和重试是最快的上手方式。它内部基于S3AsyncClient构建所以先要有一个异步客户端。S3AsyncClient s3AsyncClient S3AsyncClient.builder() .region(Region.AP_SOUTHEAST_1) .credentialsProvider(DefaultCredentialsProvider.create()) .build(); S3TransferManager transferManager S3TransferManager.builder() .s3Client(s3AsyncClient) .build();这里不需要在代码里写死 AccessKey。生产环境建议通过环境变量、AWS Profile 或 IAM Role 提供凭证避免密钥泄露。3.2 上传单文件和监听进度上传单个文件的核心代码非常简洁Upload upload transferManager.upload(builder - builder .putObjectRequest(req - req .bucket(my-bucket) .key(backup/data.zip)) .source(Paths.get(/data/backup/data.zip))); // 阻塞等待完成 upload.completionFuture().join();传输完成之后你可以拿到结果CompletedUpload completed upload.completionFuture().get(10, TimeUnit.MINUTES); ResponseBytesGetObjectResponse obj s3AsyncClient.getObject(...).join();这个封装会自动判断文件大小一旦超过阈值就切成 Multipart Upload并且内部默认开启并发上传和自动重试对多数场景已经足够。如果只是想把本机一个大文件传到 S3不需要关心具体分片细节用S3TransferManager是最省心的选择。它还支持上传整个目录用transferManager.uploadDirectory(...)就可以批量上传一个目录下的所有文件。3.3 何时不该用 TransferManagerTransferManager 虽然方便但有一个问题它对外暴露的调优参数有限你想精确控制分片大小、在线并发数、每批提交多少个分片它不一定都能满足。尤其是上传任务需要和业务状态绑定比如把每个分片的上传结果持久化到数据库以便断点续传这时候高层封装反而碍事。我自己遇到过的情况是一个任务需要把不同来源的文件合并成一个大对象分片之间还有依赖关系TransferManager 的“传入一个文件路径”模型根本不适用。所以如果你的需求只是普通大文件上传用 TransferManager 没问题一旦需要精细控制和自定义恢复逻辑就老实走低层 API也就是下一节的内容。4. 实战二手写 UploadPart把并发参数攥在自己手里4.1 第一步CreateMultipartUpload 拿到 uploadId低层 API 操作分四步。首先创建分段上传任务S3Client s3Client S3Client.builder() .region(Region.AP_SOUTHEAST_1) .credentialsProvider(DefaultCredentialsProvider.create()) .build(); long fileSize Files.size(filePath); long partSize calculatePartSize(fileSize); int partCount (int) Math.ceil((double) fileSize / partSize); CreateMultipartUploadResponse initResp s3Client.createMultipartUpload(r - r .bucket(bucket) .key(key)); String uploadId initResp.uploadId();uploadId是整个分段上传任务的身份证后面所有UploadPart和CompleteMultipartUpload都要带它。这里要注意一点uploadId是有有效期的如果长时间不完成任务会一直占用服务端资源所以后面必须提到清理策略。4.2 第二步并发上传分片的核心代码由于 SDK 2.x 的RequestBody没有直接的“从文件 offset 读取指定长度”的方法我们需要自己构造一个限长输入流。我封装了一个简单的LimitedInputStream内部基于RandomAccessFile定位到分片起始位置static class LimitedInputStream extends InputStream { private final RandomAccessFile raf; private long remaining; LimitedInputStream(RandomAccessFile raf, long size) { this.raf raf; this.remaining size; } Override public int read() throws IOException { if (remaining 0) return -1; remaining--; return raf.read(); } Override public int read(byte[] b, int off, int len) throws IOException { if (remaining 0) return -1; int n (int) Math.min(len, remaining); int r raf.read(b, off, n); if (r 0) return -1; remaining - r; return r; } Override public void close() throws IOException { raf.close(); } }然后并发上传每个分片并把返回的 ETag 收集起来int concurrency 8; ExecutorService executor Executors.newFixedThreadPool(concurrency); ListCompletableFutureCompletedPart futures new ArrayList(); for (int partNumber 1; partNumber partCount; partNumber) { long offset (long) (partNumber - 1) * partSize; long size Math.min(partSize, fileSize - offset); final int part partNumber; CompletableFutureCompletedPart future CompletableFuture.supplyAsync(() - { RandomAccessFile raf new RandomAccessFile(filePath.toFile(), r); try { raf.seek(offset); UploadPartResponse resp s3Client.uploadPart(r - r .bucket(bucket) .key(key) .uploadId(uploadId) .partNumber(part) .contentLength(size) .requestBody(RequestBody.fromInputStream( () - new LimitedInputStream(raf, size), size))); return CompletedPart.builder() .partNumber(part) .eTag(resp.eTag()) .build(); } catch (IOException e) { throw new RuntimeException(e); } finally { try { raf.close(); } catch (IOException ignored) {} } }, executor); futures.add(future); }这里有个细节容易踩坑LimitedInputStream必须在RequestBody被消费时保持RandomAccessFile打开所以我把流的创建放在了RequestBody.fromInputStream(SupplierInputStream)的 supplier 里而不是提前创建。上传完成后SDK 会关闭这个流我再在 finally 里兜底关闭一次确保文件句柄不泄漏。4.3 第三步Complete 前必须做 ETag 校验和排序所有分片上传完毕后需要等待所有 Future 成功返回然后组装CompletedPart列表提交合并。有两个点必须注意第一CompleteMultipartUpload要求分片列表按partNumber升序排列。异步上传完成顺序不一定和分片编号一致所以提交前要排序。第二如果某个分片失败不要急着走 Complete 流程。先把异常记录下来针对失败的分片重试全部成功后再合并。ListCompletedPart completedParts new ArrayList(); for (CompletableFutureCompletedPart f : futures) { completedParts.add(f.get(5, TimeUnit.MINUTES)); } completedParts.sort(Comparator.comparingInt(CompletedPart::partNumber)); CompleteMultipartUploadResponse completeResp s3Client.completeMultipartUpload(r - r .bucket(bucket) .key(key) .uploadId(uploadId) .multipartUpload(c - c.parts(completedParts)));CompleteMultipartUpload返回后原始文件在 S3 上就是一个完整对象这次传输才真正结束。4.4 完整类设计建议生产环境里我不会把上面这些代码直接堆在一个方法里而是拆成几个职责清晰的类PartSizeCalculator根据文件大小计算分片大小和分片数量。MultipartInitializer负责创建上传任务并管理uploadId的持久化。PartUploader负责并发提交分片、收集结果、重试失败项。MultipartCompleter负责最后排序、合并和清理。如果还要做断点续传PartUploader的每个已完成分片记录partNumber ETag都需要写入本地文件或数据库后面恢复时才能直接跳过。这部分在第六章展开。5. 实测与参数调优2GB 文件在并发度 1/4/8/16/32 下的表现5.1 表中的数据是怎么回事在带宽约 80Mbps、RTT 约 40ms 的测试环境中我用 2GB 的随机数据文件做了一组对比测试分片大小固定为 8MB。结果如下上传方式并发分片数耗时相对单线程提升putObject 单请求1约 9 分 20 秒基线多线程分段4 线程4约 2 分 15 秒约 4.1 倍多线程分段8 线程8约 1 分 42 秒约 5.5 倍多线程分段16 线程16约 1 分 38 秒约 5.7 倍多线程分段32 线程32约 2 分 05 秒约 4.5 倍这组数据不是严格的基准测试但它很能说明问题从单线程到 8 线程性能提升非常明显从 8 线程到 16 线程收益已经很小到 32 线程不仅没有继续变快反而变慢了。变慢的原因主要有三个一是线程多了之后线程切换和内存分配开销上升二是 S3 对单账号的请求速率有限制超过一定阈值会返回 503 SlowDown触发重试反而拖慢速度三是分片读取时大量RandomAccessFile同时打开磁盘 IO 也可能成为瓶颈。5.2 从带宽和 RTT 倒推“最优并发”在真实项目中我给出一个可直接套用的估算方法。假设你的可用上行带宽是BByte/s单请求可以占用的有效带宽大约是B / 4到B / 2经验值那么建议并发数约等于4到8。如果用带宽和 RTT 算得更精确一点可以这样理解带宽 10MB/sRTT 50ms网络管道容量 10MB/s x 0.05s 512KB。一个 8MB 分片在网络上传输需要约 0.8 秒那么与 RTT 相比单个请求已经足够“粗”理论上 4 个并发就能维持管道处于接近满载的状态。所以 8 到 16 是一个很宽的安全区间。我不建议为了追求极限把并发调到 32 以上除非你的场景是跨大洲、高延迟、超大批量上传并且已经用压测验证过 S3 没有返回限流。分片大小的选择也要和并发数联动。分片小时单个分片传输时间短单位时间内需要开启更多请求才能打满带宽分片大时并发数可以适当减少。一般遵循“并发数 x 分片大小 带宽 x RTT x 2”即可。5.3 503 SlowDown 与重试策略并发一旦调高遇见SlowDownException或503的概率会明显增加这通常是请求速率超过账号或桶级别限制的信号。SDK 默认有重试机制但默认的重试次数和退避策略不一定适合所有场景。我建议显式配置重试策略RetryPolicy retryPolicy RetryPolicy.builder() .numRetries(5) .backoffStrategy(FixedDelayBackoffStrategy.create(Duration.ofMillis(500))) .build(); S3Client s3 S3Client.builder() .overrideConfiguration(b - b.retryPolicy(retryPolicy)) .build();如果出现了大量 503第一件事不是继续调大重试次数而是降低并发。重试只是兜底不是解决限流的正确手段。6. 中断恢复与过期任务清理不能只负责“传上去”6.1 持久化 uploadId 和已完成分片记录很多团队做完多线程分段上传就以为完事了其实最大的坑在后面任务中断后重新上传时uploadId已经丢了已传好的分片全部作废只能重新开始。正确的做法是在CreateMultipartUpload返回uploadId后立刻把它写入本地文件或数据库并记录对应的文件路径、目标 bucket、key、分片大小。每完成一个分片就把(partNumber, eTag)追加写入记录。这些记录是断点续传的全部家当。没有它们就算 S3 服务端还保留着已上传的分片你也无法找回并提交。6.2 ListParts 找回现场中断后的恢复逻辑很简单用旧的uploadId调用ListParts拿到服务端已经保存的分片列表。ListPartsRequest listRequest ListPartsRequest.builder() .bucket(bucket) .key(key) .uploadId(uploadId) .build(); ListPartsResponse response s3Client.listParts(listRequest); MapInteger, String existingParts response.parts().stream() .collect(Collectors.toMap(Part::partNumber, Part::eTag));然后在上传循环里跳过existingParts已包含的分片只上传缺失的。所有分片补齐后用合并后的新列表调用CompleteMultipartUpload。这里要注意如果已经存在的一个分片和你重新计算的 ETag 不一致说明文件可能被修改过不要盲目跳过需要人工确认或重新上传该分片。所以在恢复逻辑里我会同时记录文件的最后修改时间和大小上传前先验证一下原始文件没有变化。6.3 生命周期规则清理未完成分片未完成的 Multipart Upload 也会产生存储费用而且很多人根本意识不到。上传任务中断后已上传的分片会一直留在 S3 上如果没有清理策略费用会日积月累。S3 控制台里可以为桶配置生命周期规则专门清理过期的未完成分片。在 Lifecycle rule 里选择 “Abort incomplete multipart upload”设置天数比如 7 天这样任何 7 天内没有完成的分片上传任务都会被自动终止并删除。用 SDK 配置也很直接s3Client.putBucketLifecycleConfiguration(r - r .bucket(bucket) .lifecycleConfiguration(cfg - cfg.rules(rule - rule .id(abort-incomplete-upload) .status(ExpirationStatus.ENABLED) .abortIncompleteMultipartUpload(a - a .daysAfterInitiation(7)))));我的建议是上线前就配好这个规则不要在欠费之后才想起来。7. 大量小文件场景S3AsyncClient 限流并发的批处理方式7.1 小文件不要上 multipart分段上传虽然好但它并不适合小文件。一个 1MB 的小文件如果走 multipart要先发一次CreateMultipartUpload再发一次UploadPart最后发一次CompleteMultipartUpload光请求开销就比直接putObject大好几倍。我见过有人把多线程分段上传的代码套用到所有文件上结果小文件上传速度反而变慢。判断标准很简单文件小于 8MB 或 16MB 的直接用putObject并发上传大于阈值的才走分段上传。7.2 Semaphore 限流批量上传大量小文件并发的核心问题不是传输速度而是控制请求洪峰和内存占用。用S3AsyncClient加信号量可以很优雅地控制同时进行的请求数。S3AsyncClient asyncClient S3AsyncClient.builder() .region(Region.AP_SOUTHEAST_1) .build(); int maxConcurrent 32; Semaphore semaphore new Semaphore(maxConcurrent); for (Path path : fileList) { semaphore.acquire(); asyncClient.putObject(r - r .bucket(bucket) .key(path.getFileName().toString()), RequestBody.fromFile(path)) .whenComplete((resp, err) - { semaphore.release(); if (err ! null) { log.error(upload failed: {}, path, err); } }); }使用S3AsyncClient的意义在于同步客户端每个请求通常占用一个线程而异步客户端基于 Netty 事件循环可以在较少线程上处理大量并发请求线程开销更小。加上Semaphore限流内存和连接池都不会被冲爆。7.3 从网络侧还能再挤压的性能Transfer Acceleration / 就近终端节点代码层面优化的收益是有上限的如果跨地域上传网络路径本身就可能成为瓶颈。S3 的 Transfer Acceleration 利用边缘节点加速传输适合跨大洲、长距离场景。启用方式是在桶上开启该功能然后 SDK 里指定加速端点S3Client s3 S3Client.builder() .endpointOverride(URI.create(https://bucket.s3-accelerate.amazonaws.com)) .build();如果只是同一区域内网或者同 Region 内的 EC2 上传Transfer Acceleration 反而可能绕路变慢所以使用前一定要实测。另一个常见优化是把上传任务放到离 S3 Region 最近的机器上执行比如在同一个 VPC 内的实例上做中转比从办公室公网直传稳定得多。最后再说点实操感受整个调优过程做下来我个人最深的体会是多线程分段上传的核心不是“把线程数调到最大”而是理解分片、并发和网络三者之间的关系。8MB 分片加 8 到 16 并发在绝大多数带宽场景下已经能拿到很好的效果超过这个范围性能不升反降还会引入 503 和成本问题。另外上传模块的健壮性比峰值性能更重要。我强烈建议你在项目里至少做到三件事uploadId持久化、已完成分片记录可追溯、生命周期规则兜底清理。这些不会让你的上传变快多少但在真正出故障时能救你一条命。最后再分享一个小技巧上线前用真实的文件大小分布做一轮压测不要只测一个大文件。因为你可能发现80% 的上传量来自几 MB 的小文件这时候最该优化的反而不是 multipart而是小文件并发批处理。方向对了优化才有效。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价