Thực chiến Spring Boot: Giải pháp tải lên tệp lớn theo phân đoạn - Hoàn thiện cả frontend và backend

Phân tích chi tiết tải lên tệp lớn ở frontend - Triển khai giao diện backend Spring Boot

1. Phần giới thiệu

Bối cảnh kỹ thuật

Trong các ứng dụng internet hiện nay, chức năng tải lên tệp là một yêu cầu phổ biến. Với sự phổ biến của video độ phân giải cao, tệp thiết kế lớn và các tệp thể tích lớn khác, phương thức tải lên tệp đơn lẻ truyền thống phải đối mặt với nhiều thách thức: kết nối mạng không ổn định dẫn đến tải lên thất bại, áp lực bộ nhớ máy chủ cao, trải nghiệm người dùng kém và các vấn đề khác ngày càng trở nên nổi bật.

Định nghĩa vấn đề

Tải lên tệp lớn (thường là trên 100MB) phải đối mặt với các vấn đề cốt lõi bao gồm:

  1. Độ ổn định truyền tải mạng kém, dễ bị gián đoạn
  2. Tận dụng bộ nhớ máy chủ quá cao
  3. Khó khăn trong việc giám sát tiến độ tải lên
  4. Khó khăn trong việc thực hiện tải lên lại từ điểm dừng

Giá trị bài viết

Bài viết này sẽ chi tiết giới thiệu giải pháp kỹ thuật dựa trên tải lên theo phân đoạn, người đọc sẽ nhận được:

  • Giải pháp triển khai tải lên theo phân đoạn hoàn chỉnh cả frontend và backend
  • Phương pháp thực hiện tải lên lại từ điểm dừng và chức năng tải lên tức thì
  • Mẹo tối ưu hóa hiệu suất trong môi trường sản xuất
  • Mã nguồn triển khai giao diện backend Spring Boot hoàn chỉnh

Tóm tắt nội dung

  1. Nguyên lý tải lên theo phân đoạn và lựa chọn công nghệ
  2. Thiết kế giao diện backend Spring Boot
  3. Triển khai tải lên phân đoạn ở frontend
  4. Thực hiện tải lên lại từ điểm dừng và tải lên tức thì
  5. Tối ưu hóa hiệu suất và best practices

2. Phân tích kỹ thuật cốt lõi

3.1 Phân tích sâu nguyên lý kỹ thuật

Luồng xử lý cốt lõi tải lên phân đoạn
  1. Xử lý ở frontend:
  • Tính toán MD5 tệp (dấu hiệu duy nhất)
  • Cắt tệp thành các phân đoạn có kích thước cố định (ví dụ 5MB)
  • Tải lên phân đoạn song song hoặc tuần tự
  1. Xử lý ở backend:
  • Nhận và xác thực phân đoạn
  • Lưu trữ tạm thời phân đoạn
  • Hợp nhất phân đoạn thành tệp hoàn chỉnh
  • Dọn dẹp tệp tạm
Điểm kỹ thuật quan trọng
  • Chiến lược phân đoạn: kích thước cố định vs kích thước động
  • Kiểm soát đồng thời: giới hạn số lượng đồng thời của trình duyệt (thường là 6)
  • Tải lên lại từ điểm dừng: dựa trên bản ghi phân đoạn đã tải lên
  • Tải lên tức thì: dựa trên kiểm tra băm nội dung tệp

3.2 Thiết kế giải pháp triển khai

Thiết kế kiến trúc tổng thể
Thiết kế API
Điểm cuối Phương thức Mô tả
/api/upload/check GET Kiểm tra tệp đã tồn tại chưa
/api/upload/chunk POST Tải lên phân đoạn tệp
/api/upload/merge POST Hợp nhất tất cả phân đoạn

3.3 Triển khai mã nguồn quan trọng

Mã nguồn cốt lõi backend Spring Boot
@RestController
@RequestMapping("/api/upload")
public class FileUploadController {
    
    @Autowired
    private FileUploadService fileUploadService;
    
    /**
     * Kiểm tra tệp đã tồn tại (dùng cho tải lên tức thì)
     */
    @GetMapping("/check")
    public ResponseEntity<?> checkFile(
            @RequestParam String fileMd5,
            @RequestParam String fileName,
            @RequestParam Long fileSize) {
        
        UploadResult result = fileUploadService.checkFile(fileMd5, fileName, fileSize);
        return ResponseEntity.ok(result);
    }
    
    /**
     * Tải lên phân đoạn tệp
     */
    @PostMapping("/chunk")
    public ResponseEntity<?> uploadChunk(
            @RequestParam String fileMd5,
            @RequestParam Integer chunkNumber,
            @RequestParam Integer totalChunks,
            @RequestParam Long chunkSize,
            @RequestParam MultipartFile file) {
        
        fileUploadService.saveChunk(fileMd5, chunkNumber, file);
        return ResponseEntity.ok().build();
    }
    
    /**
     * Hợp nhất tất cả phân đoạn
     */
    @PostMapping("/merge")
    public ResponseEntity<?> mergeChunks(
            @RequestParam String fileMd5,
            @RequestParam String fileName,
            @RequestParam Integer totalChunks) {
        
        String fileUrl = fileUploadService.mergeChunks(fileMd5, fileName, totalChunks);
        return ResponseEntity.ok(fileUrl);
    }
}
Triển khai dịch vụ tệp
@Service
public class FileUploadServiceImpl implements FileUploadService {
    
    @Value("${file.upload.temp-dir}")
    private String tempDir;
    
    @Value("${file.upload.final-dir}")
    private String finalDir;
    
    @Override
    public UploadResult checkFile(String fileMd5, String fileName, Long fileSize) {
        // 1. Kiểm tra xem đã tải lên hoàn tất chưa (tải lên tức thì)
        File finalFile = new File(finalDir, fileMd5 + "_" + fileName);
        if (finalFile.exists()) {
            return UploadResult.exist(finalFile.getAbsolutePath());
        }
        
        // 2. Kiểm tra các phân đoạn đã tải lên (tải lên lại từ điểm dừng)
        File tempDir = new File(this.tempDir, fileMd5);
        if (tempDir.exists()) {
            File[] chunks = tempDir.listFiles();
            if (chunks != null) {
                List<Integer> uploaded = Arrays.stream(chunks)
                    .map(f -> Integer.parseInt(f.getName()))
                    .collect(Collectors.toList());
                return UploadResult.continueUpload(uploaded);
            }
        }
        
        return UploadResult.newUpload();
    }
    
    @Override
    public void saveChunk(String fileMd5, Integer chunkNumber, MultipartFile chunk) {
        try {
            // Tạo thư mục tạm theo tên MD5
            File tempDir = new File(this.tempDir, fileMd5);
            if (!tempDir.exists()) {
                tempDir.mkdirs();
            }
            
            // Lưu phân đoạn vào thư mục tạm, đặt tên theo số thứ tự phân đoạn
            File chunkFile = new File(tempDir, chunkNumber.toString());
            chunk.transferTo(chunkFile);
        } catch (IOException e) {
            throw new RuntimeException("Lưu phân đoạn thất bại", e);
        }
    }
    
    @Override
    public String mergeChunks(String fileMd5, String fileName, Integer totalChunks) {
        // Xác thực xem tất cả phân đoạn đã được tải lên hoàn tất chưa
        File tempDir = new File(this.tempDir, fileMd5);
        File[] chunks = tempDir.listFiles();
        if (chunks == null || chunks.length != totalChunks) {
            throw new RuntimeException("Số lượng phân đoạn không đầy đủ");
        }
        
        // Tạo tệp cuối cùng
        File finalFile = new File(finalDir, fileMd5 + "_" + fileName);
        try (RandomAccessFile raf = new RandomAccessFile(finalFile, "rw")) {
            byte[] buffer = new byte[1024];
            // Sắp xếp các tệp phân đoạn theo thứ tự
            List<File> sortedChunks = Arrays.stream(chunks)
                .sorted(Comparator.comparingInt(f -> Integer.parseInt(f.getName())))
                .collect(Collectors.toList());
            
            // Hợp nhất tất cả phân đoạn
            for (File chunk : sortedChunks) {
                try (FileInputStream fis = new FileInputStream(chunk)) {
                    int len;
                    while ((len = fis.read(buffer)) != -1) {
                        raf.write(buffer, 0, len);
                    }
                }
            }
            
            // Dọn dẹp tệp tạm
            deleteTempFiles(tempDir);
            
            return finalFile.getAbsolutePath();
        } catch (IOException e) {
            throw new RuntimeException("Hợp nhất tệp thất bại", e);
        }
    }
    
    private void deleteTempFiles(File tempDir) {
        if (tempDir.exists()) {
            try {
                FileUtils.deleteDirectory(tempDir);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

3.4 Vấn đề kỹ thuật và giải pháp

Vấn đề 1: Hiệu suất tính toán MD5 tệp lớn

Vấn đề: Tính toán MD5 tệp lớn sẽ chặn luồng chính và tốn nhiều thời gian Giải pháp:

  1. Tính toán đa luồng bằng Web Worker
  2. Tính toán mẫu (lấy dữ liệu từ đầu, giữa, cuối tệp để tính)
Vấn đề 2: Hợp nhất phân đoạn gây tràn bộ nhớ

Vấn đề: Đọc tất cả phân đoạn hợp nhất cùng lúc sẽ gây tràn bộ nhớ Giải pháp:

  1. Sử dụng RandomAccessFile để truy cập ngẫu nhiên
  2. Hợp nhất theo luồng, chỉ đọc một phân đoạn mỗi lần
Vấn đề 3: Đảm bảo tính nhất quán khi tải lên lại từ điểm dừng

Vấn đề: Mạng bị gián đoạn gây ra tình trạng không nhất quán Giải pháp:

  1. Sử dụng Redis để ghi lại thông tin phân đoạn đã tải lên
  2. Dọn dẹp các tác vụ tải lên hết hạn định kỳ

4. Ví dụ thực chiến

Ví dụ triển khai frontend (Vue.js)

<template>
  <div>
    <input type="file" @change="handleFileChange" />
    <button @click="startUpload">Bắt đầu tải lên</button>
    <div>Tiến độ: {{ progress }}%</div>
  </div>
</template>

<script>
import axios from 'axios';
import SparkMD5 from 'spark-md5';

export default {
  data() {
    return {
      file: null,
      fileMd5: '',
      chunkSize: 5 * 1024 * 1024, // 5MB
      progress: 0,
      uploadedChunks: []
    };
  },
  
  methods: {
    async handleFileChange(e) {
      this.file = e.target.files[0];
      this.fileMd5 = await this.calculateMd5();
      
      // Kiểm tra trạng thái tệp (tải lên tức thì/tải lên lại từ điểm dừng)
      const res = await axios.get('/api/upload/check', {
        params: {
          fileMd5: this.fileMd5,
          fileName: this.file.name,
          fileSize: this.file.size
        }
      });
      
      if (res.data.exist) {
        alert('Tệp đã tồn tại, tải lên tức thì thành công!');
        return;
      }
      
      this.uploadedChunks = res.data.uploaded || [];
    },
    
    calculateMd5() {
      return new Promise((resolve) => {
        const spark = new SparkMD5.ArrayBuffer();
        const fileReader = new FileReader();
        const chunkSize = this.chunkSize;
        const chunks = Math.ceil(this.file.size / chunkSize);
        
        let currentChunk = 0;
        
        fileReader.onload = (e) => {
          spark.append(e.target.result);
          currentChunk++;
          
          if (currentChunk < chunks) {
            loadNext();
          } else {
            resolve(spark.end());
          }
        };
        
        const loadNext = () => {
          const start = currentChunk * chunkSize;
          const end = Math.min(start + chunkSize, this.file.size);
          fileReader.readAsArrayBuffer(this.file.slice(start, end));
        };
        
        loadNext();
      });
    },
    
    async startUpload() {
      const chunks = Math.ceil(this.file.size / this.chunkSize);
      
      for (let i = 0; i < chunks; i++) {
        // Bỏ qua các phân đoạn đã tải lên (tải lên lại từ điểm dừng)
        if (this.uploadedChunks.includes(i)) continue;
        
        const start = i * this.chunkSize;
        const end = Math.min(start + this.chunkSize, this.file.size);
        const chunkBlob = this.file.slice(start, end);
        
        const formData = new FormData();
        formData.append('file', chunkBlob);
        formData.append('fileMd5', this.fileMd5);
        formData.append('chunkNumber', i);
        formData.append('totalChunks', chunks);
        
        try {
          await axios.post('/api/upload/chunk', formData, {
            onUploadProgress: (progressEvent) => {
              const percent = Math.round(
                ((i * this.chunkSize + progressEvent.loaded) / 
                 this.file.size * 100)
              );
              this.progress = Math.min(percent, (i + 1) / chunks * 100);
            }
          });
          
          this.progress = ((i + 1) / chunks * 100).toFixed(2);
          
          if (i === chunks - 1) {
            // Tải lên phân đoạn cuối cùng, kích hoạt hợp nhất
            await axios.post('/api/upload/merge', null, { 
              params: { 
                fileMd5: this.fileMd5,
                fileName: this.file.name,
                totalChunks: chunks 
              } 
            });
            
            alert('Tải lên hoàn tất!');
          }
        } catch (error) {
          console.error('Tải lên thất bại:', error);
          break;
        }
      }
    }
  }
};
</script>

5. Tối ưu hóa hiệu suất và best practices

Dữ liệu kiểm tra hiệu suất so sánh

Giải pháp Tệp 100MB Tệp 1GB Tận dụng CPU Tận dụng MEM
Tải lên truyền thống 45s(30% thất bại) Quá thời gian Cao Cao
Tải lên phân đoạn (tuần tự) 60s(100%) 8 phút(100%) Trung bình Trung bình
Tải lên phân đoạn (đồng thời) 25s(100%) 3 phút(100%) Trung bình cao Trung bình

Chiến lược tối ưu hóa

  1. Điều chỉnh kích thước phân đoạn động
// Điều chỉnh kích thước phân đoạn động theo tình trạng mạng
function getDynamicChunkSize() {
  const connectionSpeed = navigator.connection?.downlink; // MB/s
  if (!connectionSpeed) return DEFAULT_CHUNK_SIZE;
  
  // Mạng WiFi sử dụng phân đoạn lớn hơn (10MB)
  if (connectionSpeed > 5) return DEFAULT_CHUNK_SIZE * 2;
  
  // Mạng di động sử dụng phân đoạn nhỏ hơn (2MB)
  if (connectionSpeed < 1) return DEFAULT_CHUNK_SIZE / 2;
  
  return DEFAULT_CHUNK_SIZE;
}

  1. Tính MD5 bằng Web Worker
// worker.js
self.onmessage = function(e) {
  const spark = new SparkMD5.ArrayBuffer();
  const fileReader = new FileReaderSync();
  
  for (let i = e.data.start; i < e.data.end; i++) {
    const chunkBlob = e.data.file.slice(
      i * e.data.chunkSize,
      Math.min((i +1)*e.data.chunkSize, e.data.file.size)
    );
    spark.append(fileReader.readAsArrayBuffer(chunkBlob));
  }
  
  self.postMessage(spark.end());
};

  1. Tối ưu hóa cache Redis
@Service
public class UploadCacheService {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final String UPLOAD_PREFIX = "upload:";
    
    public void addUploadedChunk(String fileMd5, Integer chunkNumber) {
        redisTemplate.opsForSet().add(UPLOAD_PREFIX + fileMd5, chunkNumber);
    }
    
    public Set<Integer> getUploadedChunks(String fileMd5) {
        Set<Object> chunks = redisTemplate.opsForSet().members(UPLOAD_PREFIX + fileMd5);
        return chunks != null ? 
            chunks.stream().map(c -> (Integer)c).collect(Collectors.toSet()) : 
            Collections.emptySet();
    }
    
    public void clearUploadRecord(String fileMd5) {
        redisTemplate.delete(UPLOAD_PREFIX + fileMd5);
    }
}

Best practices

  1. Đề xuất cấu hình môi trường sản xuất
# Ví dụ cấu hình application.yml
spring:
  servlet:
    multipart:
      max-file-size: 10GB # Cần điều chỉnh từ 1MB mặc định của Spring Boot
      max-request-size: 10GB
     
file:
  upload:
    temp-dir: /data/temp_uploads # Lưu trữ SSD để cải thiện hiệu suất IO
    final-dir: /data/final_uploads # Lưu trữ HDD là đủ
    cleanup-cron: "0 */6 * * *" # Dọn dẹp tệp tạm hết hạn mỗi 6 giờ
     
redis:
  upload-expire: 86400 # Giữ bản ghi tải lên trong 24 giờ (giây)

  1. Biện pháp bảo mật
@Aspect
@Component
public class UploadSecurityAspect {

@Pointcut("execution(* com.example.controller.FileUploadController.*(..))")
public void uploadPointcut() {}

@Around("uploadPointcut()")
public Object checkUploadSecurity(ProceedingJoinPoint joinPoint) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();

// Kiểm tra hạn chế IP (ngăn chặn tải lên ác ý)
String ip = request.getRemoteAddr();
if (!RateLimiter.check(ip)) { // Triển khai hạn chế tùy chỉnh...
throw new RuntimeException("Thao tác quá thường xuyên");
}

// Kiểm tra loại MIME (ngăn chặn tệp ác ý)
if (joinPoint.getArgs()[0] instanceof MultipartFile) {
MultipartFile file = (MultipartFile) joinPoint.getArgs()[0];
String[] allowedTypes = {"image/*", "video/*", "application/pdf"};
if (!isAllowedType(file.getContentType(), allowedTypes)) {
throw new RuntimeException("Loại tệp không được hỗ trợ");
}
}

return joinPoint.proceed();
}

private boolean isAllowedType(String contentType, String[] allowedTypes) { /*...*/ }
}

Thẻ: Spring Boot Vue.js Web Worker Redis MultipartFile

Đăng vào ngày 27 tháng 7 lúc 23:04