Upload và download file: MultipartFile, streaming, pre-signed URL
Tiếp đi: upload ảnh sản phẩm và tải PDF hóa đơn trong shop-app.
Được. File khác JSON ở một điểm lớn: nó có thể rất to. Mọi quyết định ở bài này xoay quanh câu hỏi "file có bị load hết vào RAM không?".
1. Bài toán
- Admin upload ảnh sản phẩm:
POST /api/products/{id}/images, tối đa 5MB, chỉ JPEG/PNG/WebP. - Khách tải hóa đơn:
GET /api/orders/{id}/invoice.pdf. - Kế toán tải file export đơn hàng tháng: CSV 500MB.
2. Vấn đề
// cách hay thấy, nhưng sai
@GetMapping("/export")
public byte[] export() {
return exportService.buildCsv(); // 500MB byte[] trong heap
}
- 10 người tải cùng lúc = 5GB heap →
OutOfMemoryError, cả shop-app chết. - Upload:
file.getBytes()cũng load cả file vào RAM. - Không giới hạn kích thước: ai đó upload 2GB làm đầy disk tạm.
- Tin
file.getContentType(): header do client gửi, đổi tênshell.phpthànhcat.jpglà qua. - Tên file từ client như
../../etc/passwd→ path traversal nếu ghép thẳng vào đường dẫn.
3. Giải pháp
- Giới hạn kích thước bằng config multipart (và ở gateway/nginx).
- Stream: đọc
file.getInputStream()/transferTo(), khônggetBytes(). - Download: trả
Resource(file có sẵn) hoặcStreamingResponseBody(sinh dần) +Content-Disposition. - Kiểm tra nội dung thật bằng magic bytes (Apache Tika), tự sinh tên file.
- File lớn / nhiều: không đi qua app, dùng pre-signed URL tới object storage (S3, GCS, MinIO). Chi tiết thiết kế ở System design: file upload.
4. Code từng bước
Bước 1: Dependency
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>3.0.0</version>
</dependency>
Bước 2: Config
spring:
servlet:
multipart:
max-file-size: 5MB # mỗi file
max-request-size: 20MB # cả request (nhiều file)
file-size-threshold: 256KB # nhỏ hơn: giữ RAM; lớn hơn: ghi file tạm
server:
tomcat:
max-swallow-size: 2MB # Tomcat không đọc hết body quá lớn bị từ chối
Vượt giới hạn → MaxUploadSizeExceededException, map sang 413 trong advice:
@ExceptionHandler(MaxUploadSizeExceededException.class)
ProblemDetail tooLarge() {
return ProblemDetail.forStatusAndDetail(HttpStatus.PAYLOAD_TOO_LARGE, "File tối đa 5MB");
}
Bước 3: Code Java
Upload ảnh:
@RestController
@RequestMapping("/api/products/{productId}/images")
@RequiredArgsConstructor
public class ProductImageController {
private static final Set<String> ALLOWED = Set.of("image/jpeg", "image/png", "image/webp");
private final Tika tika = new Tika();
private final ImageStorage storage;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ImageResponse> upload(@PathVariable Long productId,
@RequestParam("file") MultipartFile file) throws IOException {
if (file.isEmpty()) throw new InvalidFileException("File rỗng");
String realType;
try (InputStream in = file.getInputStream()) {
realType = tika.detect(in); // đọc magic bytes, không tin header
}
if (!ALLOWED.contains(realType)) throw new InvalidFileException("Chỉ nhận JPEG/PNG/WebP");
String key = "products/%d/%s.%s".formatted(productId, UUID.randomUUID(), ext(realType));
try (InputStream in = file.getInputStream()) {
storage.put(key, in, file.getSize(), realType); // stream thẳng lên storage
}
URI location = URI.create("/api/products/%d/images/%s".formatted(productId, key));
return ResponseEntity.created(location).body(new ImageResponse(key));
}
}
Download PDF có sẵn trên disk: trả Resource, Spring tự stream theo buffer.
@GetMapping("/api/orders/{id}/invoice.pdf")
public ResponseEntity<Resource> invoice(@PathVariable Long id) {
Path path = invoiceService.pathOf(id); // kiểm tra quyền + tồn tại bên trong
Resource pdf = new FileSystemResource(path);
ContentDisposition cd = ContentDisposition.attachment()
.filename("hoa-don-%d.pdf".formatted(id), StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, cd.toString())
.contentLength(pdf.contentLength())
.body(pdf);
}
Export CSV sinh dần: StreamingResponseBody ghi từng dòng, không dựng file trong RAM.
@GetMapping("/api/admin/orders/export.csv")
public ResponseEntity<StreamingResponseBody> export(@RequestParam YearMonth month) {
StreamingResponseBody body = out -> {
var writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
writer.write("id,customer,total\n");
exportService.forEachOrder(month, o -> { // đọc DB theo trang / Stream + fetchSize
writer.write("%d,%s,%s\n".formatted(o.id(), o.customer(), o.total()));
});
writer.flush();
};
return ResponseEntity.ok()
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename("orders-" + month + ".csv").build().toString())
.body(body);
}
StreamingResponseBody chạy trên thread của async executor (bài 4.2.12), nên cấu hình spring.mvc.async.request-timeout đủ dài và dùng executor riêng.
Bước 4: Test bằng curl
curl -i -F "file=@cat.jpg" localhost:8080/api/products/10/images
# 201, Location: /api/products/10/images/products/10/9b1e....jpg
curl -i -F "file=@shell.php;type=image/jpeg" localhost:8080/api/products/10/images
# 400 "Chỉ nhận JPEG/PNG/WebP" ← header nói jpeg, Tika nói không
curl -i -F "file=@big-10mb.jpg" localhost:8080/api/products/10/images
# 413
curl -OJ localhost:8080/api/orders/42/invoice.pdf # lưu thành hoa-don-42.pdf
curl -o orders.csv "localhost:8080/api/admin/orders/export.csv?month=2026-09"
5. Cách hoạt động
Upload multipart
Client ──body──► Tomcat ──► MultipartResolver (StandardServletMultipartResolver)
├─ part < threshold → RAM
└─ part ≥ threshold → file tạm (java.io.tmpdir)
──► MultipartFile (getInputStream() đọc từ đó)
──► app stream lên storage
──► request kết thúc → file tạm bị xóa
Download
Resource: ResourceHttpMessageConverter đọc từng buffer ──► socket
StreamingResponseBody: thread async gọi lambda, ghi vào OutputStream ──► socket
heap chỉ giữ buffer, không giữ cả file
Pre-signed URL (file lớn)
Client ──1. xin URL upload──► shop-app (kiểm tra quyền, sinh URL hết hạn 5 phút)
Client ──2. PUT file────────────────────────────────► S3 / MinIO
Client ──3. báo xong (key)──► shop-app (kiểm tra object, lưu DB)
6. So sánh
| Cách | RAM | Dùng khi |
|---|---|---|
byte[] / getBytes() | Cả file | File rất nhỏ (avatar vài KB) |
Resource | Buffer | File đã có (disk, classpath, storage stream) |
StreamingResponseBody | Buffer | Nội dung sinh dần (CSV export, zip) |
| Pre-signed URL | 0 (không qua app) | File lớn, nhiều, cần CDN |
7. Khi nào nên dùng / không nên dùng
- Ảnh vài MB, số lượng vừa: multipart qua app là đủ, đơn giản, dễ kiểm tra quyền.
- Video, file trăm MB, lượng upload lớn: pre-signed URL, app chỉ cấp quyền.
- Không lưu file vào disk của container (mất khi redeploy, không chia sẻ giữa các pod).
- Không dùng tên file client làm đường dẫn; luôn sinh key mới.
Mình đặt max-file-size 5MB rồi, sao vẫn cần kiểm tra ở nginx hay gateway nữa?
Bạn hiểu đúng là Spring đã chặn, chỉ cần thêm một điểm: Spring chặn khá muộn. Khi exception ném ra, Tomcat đã nhận một phần body, đã chiếm một thread và băng thông. Kẻ xấu gửi nhiều request 1GB vẫn làm app bận.
nginx: client_max_body_size 6m; → từ chối ngay ở biên, app không thấy request
gateway: giới hạn body + rate limit
Spring: max-file-size → lớp bảo vệ cuối + thông báo lỗi đẹp
Nhiều lớp, mỗi lớp rẻ hơn lớp sau.
Tên file tiếng Việt như "Hóa đơn tháng 9.pdf" tải về bị lỗi font tên, sửa sao?
Đúng rồi, header HTTP vốn chỉ an toàn với ASCII. Cần dạng RFC 6266 có filename*:
Content-Disposition: attachment; filename="=?UTF-8?Q?...?="; filename*=UTF-8''H%C3%B3a%20%C4%91%C6%A1n%20th%C3%A1ng%209.pdf
Không cần tự encode: ContentDisposition.attachment().filename(name, StandardCharsets.UTF_8) sinh đúng cả hai. Muốn mở trong tab thay vì tải về thì dùng inline() thay cho attachment().
Phỏng vấn hỏi về upload/download file thì trả lời sao?
Q1. Làm sao download file lớn mà không bị OutOfMemory?
🇻🇳 Tiếng Việt:
Không trả byte[]. Nếu file có sẵn em trả Resource, Spring đọc và ghi theo buffer. Nếu nội dung sinh dần như export CSV em dùng StreamingResponseBody, ghi từng dòng ra OutputStream và đọc DB theo trang. Với file rất lớn em cho client tải thẳng từ object storage bằng pre-signed URL.
🇬🇧 English:
I never return a byte array. For an existing file I return a Resource so Spring copies it in buffers. For generated content like a CSV export I use StreamingResponseBody, writing row by row to the output stream while reading the database in pages. For very large files the client downloads directly from object storage with a pre-signed URL.
Q2. Bảo mật khi nhận file upload cần chú ý gì?
🇻🇳 Tiếng Việt:
Giới hạn kích thước ở cả gateway và Spring. Kiểm tra loại file bằng magic bytes, không tin Content-Type hay đuôi file. Không dùng tên file của client làm đường dẫn để tránh path traversal, tự sinh key. Lưu ngoài web root hoặc trên object storage, và kiểm tra quyền trước khi cho tải.
🇬🇧 English:
Limit the size at both the gateway and Spring. Check the real file type with magic bytes instead of trusting the Content-Type header or the extension. Never use the client file name as a path, to avoid path traversal; generate my own key. Store files outside the web root or in object storage, and check authorization before serving them.
Q3. Khi nào dùng pre-signed URL?
🇻🇳 Tiếng Việt:
Khi file lớn hoặc lượng upload nhiều. App chỉ kiểm tra quyền và sinh URL có hạn, client upload thẳng lên storage nên app không tốn thread, RAM và băng thông. Sau đó client báo key cho app để lưu metadata.
🇬🇧 English:
When files are large or uploads are frequent. The app only checks permissions and issues a short-lived URL, and the client uploads straight to storage, so the app spends no threads, memory or bandwidth. Afterwards the client sends the object key back so the app can store the metadata.
Cách nhớ ngắn gọn
- Không
byte[]cho file lớn: stream vào, stream ra. - Resource = file có sẵn; StreamingResponseBody = sinh dần.
- Giới hạn size nhiều lớp; kiểm tra magic bytes; tự sinh tên.
- File to → pre-signed URL, app chỉ cấp quyền.
Tự kiểm tra
max-file-size và max-request-size khác nhau thế nào?
Một file so với tổng cả request multipart (nhiều file + field).
file-size-threshold dùng để làm gì?
Part nhỏ hơn ngưỡng giữ trong RAM, lớn hơn ghi ra file tạm.
Vì sao không tin file.getContentType()?
Do client gửi, giả được; phải đọc magic bytes.
Export CSV 500MB nên trả kiểu gì?
StreamingResponseBody, ghi dần và đọc DB theo trang.
Upload vượt giới hạn nên trả status nào?
413 Payload Too Large.