Spring Boot 上传大文件卡死的IO瓶颈分析与分片上传实践

Spring Boot 大文件上传卡死:IO瓶颈分析与分片上传实践

大家好,今天我们来聊聊在使用Spring Boot进行大文件上传时,经常遇到的卡死问题,以及如何通过IO瓶颈分析和分片上传来解决这个问题。

问题重现:大文件上传的噩梦

在很多Web应用中,文件上传是一个常见的功能。通常情况下,上传小文件不会有什么问题。但当文件体积增大到几百MB,甚至几GB时,我们就会发现服务器CPU占用率飙升,响应时间变得异常漫长,甚至直接卡死。

@RestController
@RequestMapping("/upload")
public class UploadController {

    @PostMapping("/single")
    public String uploadSingleFile(@RequestParam("file") MultipartFile file) {
        try {
            // 直接读取文件内容到内存
            byte[] bytes = file.getBytes();
            Path path = Paths.get(file.getOriginalFilename());
            Files.write(path, bytes);
            return "File uploaded successfully: " + file.getOriginalFilename();
        } catch (IOException e) {
            e.printStackTrace();
            return "File upload failed: " + e.getMessage();
        }
    }
}

上面的代码是最简单的单文件上传示例。乍一看没什么问题,但问题就出在 file.getBytes() 这一行。它会将整个文件加载到内存中,如果文件过大,就会导致以下问题:

  • OOM (OutOfMemoryError): 内存溢出,导致应用崩溃。
  • 长时间GC (Garbage Collection): 大量对象创建和销毁,频繁触发GC,导致应用暂停。
  • 阻塞IO: 传统的IO操作是阻塞的,当读取大文件时,线程会一直等待,无法处理其他请求。

IO瓶颈分析:罪魁祸首是谁?

为了更清晰地了解问题所在,我们需要分析IO瓶颈。主要涉及以下几个方面:

  1. 网络带宽: 上传速度受到客户端到服务器的网络带宽限制。这个属于硬件层面的限制,在应用程序层面优化空间有限。
  2. 服务器带宽: 服务器的网络带宽也会影响上传速度。如果服务器带宽不足,即使客户端速度很快,也无法快速接收文件。
  3. 内存: 上面已经提到,一次性加载整个文件到内存会导致OOM和GC问题。
  4. 磁盘IO: 写入文件到磁盘的速度也会影响上传速度。如果磁盘IO性能较差,即使内存足够,也会出现卡顿。
  5. 线程模型: 如果使用传统的阻塞IO,单个请求会占用一个线程,大量并发请求会导致线程池耗尽。

可以使用一些工具来监控服务器资源使用情况,例如:

  • top / htop: 监控CPU、内存使用情况。
  • iostat: 监控磁盘IO情况。
  • netstat: 监控网络连接情况。
  • VisualVM / JConsole: JVM监控工具,可以查看内存使用、GC情况、线程状态等。

通过监控,我们可以找到瓶颈所在,并针对性地进行优化。 通常情况下,内存和磁盘IO是瓶颈的主要来源。

解决方案:分片上传的优势

为了解决大文件上传的问题,最有效的方案是分片上传。 分片上传将大文件分割成多个小块(chunk),然后逐个上传。 这样可以避免一次性加载整个文件到内存,降低服务器压力,提高上传速度。

分片上传的优势:

  • 减少内存占用: 每次只上传一个小块,避免OOM。
  • 提高上传速度: 可以并行上传多个分片,提高上传速度。
  • 断点续传: 如果上传过程中出现错误,可以只重新上传失败的分片,避免重新上传整个文件。
  • 降低服务器压力: 降低了单个请求的资源占用,提高了服务器的并发处理能力。

分片上传实践:代码示例

下面是一个使用Spring Boot实现分片上传的示例。

1. 前端实现 (JavaScript)

这里使用 JavaScript 实现分片上传,可以使用 XMLHttpRequestfetch API。 以下示例使用 fetch API。

async function uploadFile(file, chunkSize = 1024 * 1024) { // 1MB chunk size
    const totalChunks = Math.ceil(file.size / chunkSize);
    let currentChunk = 0;

    while (currentChunk < totalChunks) {
        const start = currentChunk * chunkSize;
        const end = Math.min(file.size, start + chunkSize);
        const chunk = file.slice(start, end);

        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('chunkNumber', currentChunk + 1);
        formData.append('totalChunks', totalChunks);
        formData.append('filename', file.name);
        formData.append('totalSize', file.size);

        try {
            const response = await fetch('/upload/chunk', {
                method: 'POST',
                body: formData,
            });

            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }

            const data = await response.json();
            console.log(`Chunk ${currentChunk + 1} uploaded:`, data);

        } catch (error) {
            console.error(`Error uploading chunk ${currentChunk + 1}:`, error);
            // Handle error, retry, etc.
            return; // Or throw the error to be handled further up
        }

        currentChunk++;
    }

    console.log('File uploaded successfully!');
}

// Example usage:
const fileInput = document.getElementById('fileInput');
fileInput.addEventListener('change', (event) => {
    const file = event.target.files[0];
    uploadFile(file);
});

解释:

  • chunkSize: 定义每个分片的大小,默认为 1MB。 可以根据实际情况调整。
  • totalChunks: 计算总共需要分割成多少个分片。
  • 循环上传每个分片,构造FormData,包含分片数据、分片编号、总分片数、文件名和文件大小。
  • 发送 POST 请求到 /upload/chunk 接口。
  • 处理上传结果,如果出错,可以进行重试等操作。
  • totalSize:将文件的总大小传给后端。

2. 后端实现 (Spring Boot)

@RestController
@RequestMapping("/upload")
public class UploadController {

    private final String UPLOAD_DIR = "uploads"; // 存储分片的目录

    @PostMapping("/chunk")
    public ResponseEntity<String> uploadChunk(
            @RequestParam("file") MultipartFile file,
            @RequestParam("chunkNumber") int chunkNumber,
            @RequestParam("totalChunks") int totalChunks,
            @RequestParam("filename") String filename,
            @RequestParam("totalSize") long totalSize) {
        try {
            // 1. 创建分片存储目录
            Path uploadPath = Paths.get(UPLOAD_DIR);
            if (!Files.exists(uploadPath)) {
                Files.createDirectories(uploadPath);
            }

            // 2. 构建分片文件路径
            Path chunkPath = uploadPath.resolve(filename + ".part" + chunkNumber);

            // 3. 保存分片文件
            Files.copy(file.getInputStream(), chunkPath, StandardCopyOption.REPLACE_EXISTING);

            // 4. 检查是否所有分片都已上传完成
            if (chunkNumber == totalChunks) {
                // 5. 合并分片文件
                Path mergedFilePath = uploadPath.resolve(filename);
                try (OutputStream outputStream = new FileOutputStream(mergedFilePath.toFile())) {
                    for (int i = 1; i <= totalChunks; i++) {
                        Path partPath = uploadPath.resolve(filename + ".part" + i);
                        Files.copy(partPath, outputStream);
                        Files.delete(partPath); // 删除已合并的分片
                    }
                }

                System.out.println("File uploaded successfully: " + filename);

                // 6. 校验文件大小
                long mergedFileSize = Files.size(mergedFilePath);
                if (mergedFileSize != totalSize) {
                    Files.delete(mergedFilePath); // 删除合并后的文件
                    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                            .body("File size verification failed. Merged file size: " + mergedFileSize +
                                    ", Expected file size: " + totalSize);
                }

                return ResponseEntity.ok("File uploaded successfully: " + filename);
            } else {
                return ResponseEntity.ok("Chunk " + chunkNumber + " uploaded.");
            }
        } catch (IOException e) {
            e.printStackTrace();
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body("File upload failed: " + e.getMessage());
        }
    }

}

解释:

  • UPLOAD_DIR: 定义分片存储的目录。
  • uploadChunk 方法接收前端传递的分片数据、分片编号、总分片数、文件名和文件大小。
  • 步骤1: 创建分片存储目录,如果目录不存在,则创建。
  • 步骤2: 构建分片文件路径,每个分片的文件名格式为 filename.part[chunkNumber]
  • 步骤3: 保存分片文件到指定路径。
  • 步骤4: 检查是否所有分片都已上传完成。
  • 步骤5: 如果所有分片都已上传完成,则合并分片文件。 循环读取每个分片,并将内容写入到合并后的文件中。 合并完成后,删除分片文件。
  • 步骤6: 校验文件大小,确保合并后的文件大小与原始文件大小一致。 如果不一致,则删除合并后的文件,并返回错误信息。

3. 配置

application.propertiesapplication.yml 中,可以配置上传文件大小限制:

spring.servlet.multipart.max-file-size=100MB
spring.servlet.multipart.max-request-size=100MB

这里将最大文件大小和最大请求大小都设置为 100MB。 可以根据实际需求调整。

4. 完整流程

  1. 前端将大文件分割成多个小分片。
  2. 前端逐个上传分片到后端 /upload/chunk 接口。
  3. 后端接收分片数据,保存到指定目录。
  4. 后端检查是否所有分片都已上传完成。
  5. 如果所有分片都已上传完成,则合并分片文件。
  6. 后端校验文件大小,确保合并后的文件与原始文件一致。
  7. 后端返回上传结果。

优化技巧:提升上传效率

除了分片上传,还可以使用以下技巧来提升上传效率:

  1. 使用非阻塞IO (NIO): NIO 可以使用更少的线程来处理更多的并发请求,提高服务器的吞吐量。 Spring WebFlux 是一个基于 Reactor 的非阻塞响应式框架,可以用来实现高性能的Web应用。
  2. 使用异步处理: 可以使用 @Async 注解将文件上传任务提交到线程池中异步执行,避免阻塞主线程。
  3. 使用CDN (Content Delivery Network): 将上传文件存储到CDN,可以提高下载速度,减轻服务器压力。
  4. 优化磁盘IO: 使用SSD (Solid State Drive) 硬盘,可以提高磁盘IO性能。
  5. 启用Gzip压缩: 对上传的数据进行压缩,可以减少网络传输量,提高上传速度。
  6. 使用数据库事务: 如果需要将上传的文件信息保存到数据库,可以使用事务来保证数据一致性。

关于断点续传的实现

断点续传是分片上传的一个重要特性。 实现断点续传需要记录已经上传的分片信息。 常用的方法有以下几种:

  1. 数据库存储: 创建一个表来存储已上传的分片信息,包括文件名、分片编号、分片大小、上传状态等。
  2. Redis 缓存: 使用Redis缓存来存储已上传的分片信息。 Redis具有高性能和高可用性的特点,适合存储临时数据。
  3. 文件系统存储: 创建一个文件来存储已上传的分片信息。 例如,可以使用 JSON 格式来存储分片信息。

在上传分片之前,先检查该分片是否已经上传过。 如果已经上传过,则跳过该分片,直接上传下一个分片。 如果上传过程中出现错误,可以根据已上传的分片信息,重新上传失败的分片。

示例(基于Redis):

@RestController
@RequestMapping("/upload")
public class UploadController {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private final String UPLOAD_DIR = "uploads";

    @PostMapping("/chunk")
    public ResponseEntity<String> uploadChunk(
            @RequestParam("file") MultipartFile file,
            @RequestParam("chunkNumber") int chunkNumber,
            @RequestParam("totalChunks") int totalChunks,
            @RequestParam("filename") String filename,
            @RequestParam("totalSize") long totalSize) {

        String chunkKey = "upload:" + filename + ":chunk:" + chunkNumber;

        if (Boolean.TRUE.equals(redisTemplate.hasKey(chunkKey))) {
            return ResponseEntity.ok("Chunk " + chunkNumber + " already uploaded, skipping.");
        }

        try {
            // ... (创建目录,构建路径,保存文件) ...

            Path uploadPath = Paths.get(UPLOAD_DIR);
            if (!Files.exists(uploadPath)) {
                Files.createDirectories(uploadPath);
            }
            Path chunkPath = uploadPath.resolve(filename + ".part" + chunkNumber);
            Files.copy(file.getInputStream(), chunkPath, StandardCopyOption.REPLACE_EXISTING);

            // Set the key in Redis to mark the chunk as uploaded
            redisTemplate.opsForValue().set(chunkKey, "uploaded");

            if (chunkNumber == totalChunks) {
                // ... (合并分片,校验文件大小) ...
                Path mergedFilePath = uploadPath.resolve(filename);

                try (OutputStream outputStream = new FileOutputStream(mergedFilePath.toFile())) {
                    for (int i = 1; i <= totalChunks; i++) {
                        Path partPath = uploadPath.resolve(filename + ".part" + i);
                        if (!Files.exists(partPath)) {
                            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Missing chunk " + i);
                        }
                        Files.copy(partPath, outputStream);
                        Files.delete(partPath); // 删除已合并的分片
                    }
                }

                long mergedFileSize = Files.size(mergedFilePath);
                if (mergedFileSize != totalSize) {
                    Files.delete(mergedFilePath);
                    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                            .body("File size verification failed. Merged file size: " + mergedFileSize +
                                    ", Expected file size: " + totalSize);
                }

                 // Clean up Redis keys after successful upload
                for (int i = 1; i <= totalChunks; i++) {
                    redisTemplate.delete("upload:" + filename + ":chunk:" + i);
                }

                return ResponseEntity.ok("File uploaded successfully: " + filename);
            } else {
                return ResponseEntity.ok("Chunk " + chunkNumber + " uploaded.");
            }
        } catch (IOException e) {
            e.printStackTrace();
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body("File upload failed: " + e.getMessage());
        }
    }
}

前端对应的修改:

前端需要保存已经成功上传的分片,并在重新上传时跳过这些分片。

注意事项:

  • 选择合适的存储方案,根据实际需求选择数据库、Redis或文件系统。
  • 设置合理的过期时间,避免缓存过期导致断点续传失败。

总结:分片上传,优化IO瓶颈,解决大文件上传难题

通过分片上传,我们可以有效地解决大文件上传带来的IO瓶颈问题。结合非阻塞IO、异步处理等优化技巧,可以进一步提升上传效率。 同时断点续传功能的加入,可以极大的提升用户体验。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注