Async Upload File

November 6, 2026

Asynchronous File Upload (Upload file bất đồng bộ) nhằm kiểm tra hiểu biết của bạn về:

  • Cách tránh thread starvation (cạn kiệt luồng) trên Servlet Container (như Tomcat) khi xử lý các file lớn hoặc lượng request lớn.
  • Khả năng thiết kế kiến trúc xử lý background (Background processing) và event-driven.
    Có 3 mô hình từ đơn giản đến kiến trúc phân tán thực tế

I. Phân loại các cách tiếp cận (Các giải pháp chính)

Giải pháp 1: Dùng Spring @Async kết hợp CompletableFuture (Phù hợp ứng dụng Monolithic hoặc app đơn lẻ)

  • Controller nhận MultipartFile từ client.
  • Controller gọi một Service method được đánh dấu @Async (chạy trên một TaskExecutor riêng, ví dụ ThreadPoolTaskExecutor).
  • Controller trả về ngay HTTP 202 (Accepted) hoặc một Task ID cho client, thay vì bắt client chờ file upload/xử lý xong.

Code minh hoạ

@RestController
@RequestMapping("/api/files")
public class FileController {

    @Autowired
    private FileUploadService fileUploadService;

    @PostMapping("/async")
    public ResponseEntity<String> uploadAsync(@RequestParam("file") MultipartFile file) {
        // Quan trọng: Phải đọc InputStream hoặc chuyển thành byte[] ngay tại Controller 
        // trước khi Thread chính kết thúc và Spring xóa file tạm (Temp file).
        try {
            byte[] fileBytes = file.getBytes();
            String originalFilename = file.getOriginalFilename();
            
            fileUploadService.processFileAsync(fileBytes, originalFilename);
            
            return ResponseEntity.accepted().body("File upload is being processed in the background.");
        } catch (IOException e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to read file");
        }
    }
}

file.getBytes() sẽ load toàn bộ nội dung file vào Heap Memory sẽ rủi ro OutOfMemory(OOM) với file lớn. Thay vì lưu vào RAM, bạn nên chuyển file sang một thư mục lưu trữ tạm thời trên ổ cứng ngay tại Controller, sau đó truyền đường dẫn (Path) hoặc File sang Service xử lý ngầm.

@PostMapping("/async")
public ResponseEntity<String> uploadAsync(@RequestParam("file") MultipartFile file) {
    try {
        // 1. Tạo file tạm an toàn trên ổ cứng
        String originalFilename = file.getOriginalFilename();
        Path tempFile = Files.createTempFile("upload-", "-" + originalFilename);
        
        // 2. Chuyển dữ liệu MultipartFile sang file tạm
        file.transferTo(tempFile.toFile());
        
        // 3. Truyền đường dẫn file tạm cho Service xử lý bất đồng bộ
        fileUploadService.processFileAsync(tempFile, originalFilename);
        
        return ResponseEntity.accepted().body("File upload is being processed in the background.");
    } catch (IOException e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to process file");
    }
}

Và ở Service, bạn đọc file từ Path bằng các stream tuần tự (giúp tiết kiệm RAM tối đa) và nhớ xóa file tạm sau khi xử lý xong (dù thành công hay thất bại):

@Service
public class FileUploadService {

    @Async("taskExecutor")
    public CompletableFuture<Void> processFileAsync(Path filePath, String filename) {
        try {
            // Đọc file bằng InputStream stream-based để không tốn RAM
            try (InputStream inputStream = Files.newInputStream(filePath)) {
                // Thực hiện upload lên S3, parse dữ liệu, v.v.
            }
        } catch (Exception e) {
            // Log lỗi
        } finally {
            // Luôn nhớ xóa file tạm sau khi hoàn tất
            try {
                Files.deleteIfExists(filePath);
            } catch (IOException ignored) {}
        }
        return CompletableFuture.completedFuture(null);
    }
}

Giải pháp 2: Kiến trúc Hàng đợi sự kiện (Message Queue - RabbitMQ / Kafka) (Mô hình Microservices chuẩn)

  • API Gateway/Service nhận request upload.
  • Lưu tạm file vào Object Storage (AWS S3, MinIO) hoặc thư mục chung.
  • Bắn một message (chứa metadata và file path trên S3) vào RabbitMQ hoặc Kafka.
  • Trả ngay phản hồi thành công/đang xử lý về cho Client.
  • Một Worker Service khác lắng nghe queue, lấy message và tiến hành xử lý ngầm (parse Excel, resize ảnh, v.v.).

Giải pháp 3: Direct-to-Cloud Upload (Presigned URL) - Giải pháp tối ưu nhất cho Production

Lý do đề xuất: Nếu để Client upload trực tiếp qua Spring Boot server, server của bạn sẽ gánh nặng băng thông (Network I/O) và dễ nghẽn RAM/Disk.

  • Client gọi API đến Spring Boot xin một Presigned URL (ví dụ từ AWS S3 hoặc MinIO).
  • Spring Boot tạo URL có thời hạn và trả về cho Client.
  • Client upload file trực tiếp lên S3 thông qua Presigned URL đó (Bỏ qua hoàn toàn Application Server).
  • Sau khi upload xong, Client gửi một request thông báo cho Spring Boot để Server bắn event xử lý tiếp.

II. Lưu Ý

1. Vấn đề vòng đời file tạm (Multipart Temp File Cleanup):

  • Vấn đề: Mặc định Spring Boot lưu file upload tạm vào thư mục java.io.tmpdir. Khi request HTTP kết thúc, Spring sẽ xóa ngay file tạm đó. Nếu thread @Async chạy sau khi request kết thúc mà cố đọc file từ đường dẫn tạm --> sẽ dính lỗi FileNotFoundException.
  • Cách giải quyết: Đọc file.getInputStream() hoặc file.getBytes() và chuyển thành mảng byte[] hoặc đẩy vào InputStream ngay. trong thread của Controller trước khi gọi hàm async.

2. Cấu hình Thread Pool (TaskExecutor):

Không dùng mặc định. SimpleAsyncTaskExecutor (vì nó tạo thread mới cho mỗi task, gây sập server nếu lượng request lớn). Phải cấu hình. ThreadPoolTaskExecutor với số lượng corePoolSize, maxPoolSize và queueCapacity rõ ràng.