最近在开发一个基于Spring Boot的文件上传服务时,遇到了一个棘手的内存溢出问题。这个服务的主要功能是允许用户通过多线程方式上传PDF和图片文件到S3存储服务器,同时将文件元数据保存到数据库。在测试过程中,当多个用户同时上传较大文件(如100MB以上的PDF)时,系统频繁出现OutOfMemoryError错误,导致服务崩溃。
通过分析堆内存转储文件,发现内存主要被byte数组占用,而这些byte数组正是文件内容被完整加载到内存的结果。进一步检查代码,发现问题出在文件上传的处理方式上——当前实现是将整个文件内容一次性读取到内存中,然后再进行上传操作。
这是导致内存溢出的最主要原因。在当前的实现中,readBytesFromInputStream方法将整个文件内容读取到一个byte数组中:
java复制public byte[] readBytesFromInputStream(InputStream inputStream) throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int bytesRead;
try {
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
outputStream.flush();
return outputStream.toByteArray();
} finally {
outputStream.close();
}
}
这种方法对于小文件可能没有问题,但当处理大文件时,会导致:
当前实现中有两个几乎相同的方法uploadPDFFromCompressedFileToS3AndSaveAppendix和uploadImageFromCompressedFileToS3AndSaveAppendix,它们的主要区别仅在于文件类型和MIME类型的设置。这种重复导致:
每次上传文件时都会创建一个新的DynamicS3Util实例:
java复制DynamicS3Util s3 = new DynamicS3Util(s3Endpoint, s3AccessKey, s3SecretKey);
这不仅增加了内存开销,还可能导致底层网络连接资源的浪费,因为每次创建新实例都可能建立新的网络连接。
代码中创建File对象仅用于获取文件名:
java复制File file = new File(fileName);
String name = file.getName();
这完全没有必要,因为可以通过字符串操作直接获取文件名,创建File对象只会增加不必要的内存开销。
最关键的优化是改变文件上传方式,从"先读内存再上传"改为"流式上传":
readBytesFromInputStream方法优化后的上传逻辑大致如下:
java复制@Async("taskExecutor")
@Transactional
public CompletableFuture<Void> uploadFileToS3(InputStream fileInputStream,
String fileName, int patentId, int appendixType) throws Exception {
// 参数校验和准备
String fileExtension = getFileExtension(fileName);
String contentType = getContentType(appendixType, fileExtension);
String objectName = generateObjectName(fileExtension);
// 使用try-with-resources确保资源释放
try (InputStream inputStream = fileInputStream) {
s3Client.upload(s3Bucket, objectName, inputStream, contentType);
// 保存元数据到数据库
saveAppendixMetadata(fileName, patentId, appendixType, fileExtension);
}
return CompletableFuture.completedFuture(null);
}
这种方式的优势在于:
将两个上传方法合并为一个通用方法,通过参数控制差异部分:
java复制private String getContentType(int appendixType, String fileExtension) {
return appendixType == 1 ? "image/" + fileExtension : "application/pdf";
}
这样可以:
将S3客户端改为单例模式:
java复制@Service
public class Task {
private final DynamicS3Util s3Client;
public Task(@Value("${aws.s3.endpoint}") String endpoint,
@Value("${aws.s3.access-key}") String accessKey,
@Value("${aws.s3.secret-key}") String secretKey) {
this.s3Client = new DynamicS3Util(endpoint, accessKey, secretKey);
}
// ...
}
这样可以:
移除不必要的File对象创建,改用字符串操作:
java复制private String getFileName(String filePath) {
int lastSeparator = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
return lastSeparator >= 0 ? filePath.substring(lastSeparator + 1) : filePath;
}
private String getFileExtension(String fileName) {
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex <= 0) {
throw new IllegalArgumentException("文件缺少扩展名: " + fileName);
}
return fileName.substring(dotIndex + 1).toLowerCase();
}
原代码中同时使用@Async和@Transactional注解可能导致事务不生效。解决方案:
以下是优化后的完整代码实现:
java复制@Service
public class FileUploadService {
private final DynamicS3Util s3Client;
private final TPatentAppendixDao appendixDao;
@Value("${aws.s3.bucket-name}")
private String bucketName;
@Value("${s3.imageDirectory}")
private String imageDirectory;
public FileUploadService(@Value("${aws.s3.endpoint}") String endpoint,
@Value("${aws.s3.access-key}") String accessKey,
@Value("${aws.s3.secret-key}") String secretKey,
TPatentAppendixDao appendixDao) {
this.s3Client = new DynamicS3Util(endpoint, accessKey, secretKey);
this.appendixDao = appendixDao;
}
@Async("taskExecutor")
public CompletableFuture<Void> uploadFile(InputStream fileInputStream,
String fileName, int patentId, int appendixType) {
try {
String fileExtension = getFileExtension(fileName);
String contentType = getContentType(appendixType, fileExtension);
String objectName = generateObjectName(fileExtension);
uploadToS3(fileInputStream, objectName, contentType);
saveAppendix(fileName, patentId, appendixType, fileExtension);
return CompletableFuture.completedFuture(null);
} catch (Exception e) {
return CompletableFuture.failedFuture(e);
}
}
private void uploadToS3(InputStream inputStream, String objectName, String contentType)
throws IOException {
try (InputStream stream = inputStream) {
s3Client.upload(bucketName, objectName, stream, -1, contentType, null);
}
}
private void saveAppendix(String fileName, int patentId,
int appendixType, String fileExtension) {
TPatentAppendix appendix = TPatentAppendix.FACTORY.create();
appendix.setAppendixType(appendixType);
appendix.setPatentId(patentId);
appendix.setFileId(generateFileId());
appendix.setFileType(fileExtension);
appendix.setCreateUser(ContextHolder.getValue());
appendix.setCreateTime(new DateTime());
appendix.setFileName(getFileName(fileName));
appendixDao.getPrimaryKey(appendix);
appendixDao.insertEntity(appendix, false);
}
// 其他工具方法...
}
优化前后进行了对比测试:
| 测试场景 | 优化前内存占用 | 优化后内存占用 | 上传时间 |
|---|---|---|---|
| 单个10MB文件 | ~20MB | ~2MB | 基本不变 |
| 单个100MB文件 | ~200MB | ~2MB | 基本不变 |
| 并发5个50MB文件 | OOM错误 | ~10MB | 略有提升 |
| 并发10个20MB文件 | OOM错误 | ~10MB | 略有提升 |
从测试结果可以看出:
S3上传通常需要提供文件大小,但使用流式上传时可能不知道确切大小。解决方案:
使用try-with-resources语句确保InputStream正确关闭:
java复制try (InputStream stream = inputStream) {
// 上传操作
}
实现重试机制和断点续传:
可以实现进度监听接口:
java复制s3Client.upload(bucket, key, inputStream, listener);
监听器可以定期报告上传进度,用于显示进度条或记录日志。
对于超大文件(如超过1GB),可以考虑实现分块上传:
防止单个上传占用过多带宽:
java复制InputStream throttledStream = new ThrottledInputStream(rawStream, 1024 * 1024); // 限制1MB/s
增加详细的错误日志和上传统计,便于问题排查和性能分析。
对于确实需要内存操作的场景,可以考虑:
经过这些优化后,我们的文件上传服务不仅解决了内存溢出问题,还提高了整体的稳定性和性能。在实际生产环境中,这种流式处理方式对于资源敏感的应用尤为重要。