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:
- Độ ổn định truyền tải mạng kém, dễ bị gián đoạn
- Tận dụng bộ nhớ máy chủ quá cao
- Khó khăn trong việc giám sát tiến độ tải lên
- 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
- Nguyên lý tải lên theo phân đoạn và lựa chọn công nghệ
- Thiết kế giao diện backend Spring Boot
- Triển khai tải lên phân đoạn ở frontend
- Thực hiện tải lên lại từ điểm dừng và tải lên tức thì
- 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
- 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ự
- 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:
- Tính toán đa luồng bằng Web Worker
- 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:
- Sử dụng RandomAccessFile để truy cập ngẫu nhiên
- 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:
- Sử dụng Redis để ghi lại thông tin phân đoạn đã tải lên
- 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
- Đ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;
}
- 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());
};
- 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
- Đề 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)
- 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) { /*...*/ }
}