Level 2: thiết kế File / Image / Video Upload
Tiếp đi: Level 2, File/Image Upload.
Bài này đổi hẳn bài toán: thay vì vài KB JSON, user upload video 500MB. Câu hỏi trung tâm: có nên để 500MB đi xuyên qua Spring Boot không? Từ đó tự nhiên xuất hiện S3/Blob, pre-signed URL, multipart, queue, worker và CDN.
1. Đề bài và requirements
Design một hệ thống cho phép user upload ảnh/video, sau đó xem/download lại.
- Upload image/video.
- Download/xem file.
- Lưu metadata.
- User chỉ truy cập file mình được phép.
- Tạo thumbnail cho ảnh, transcode cho video.
2. Estimate
10M users
Upload: 1,000 files/s
View: 50,000 req/s
Size: 1 KB → vài GB
Giả sử trung bình 5 MB/file:
1,000 × 5 MB = 5 GB/s ghi vào storage
≈ 430 TB/ngày → không thể là local disk hay PostgreSQL
Observation: đây không còn chủ yếu là bài toán database. Ta đang lưu binary object rất lớn.
3. Thiết kế ngây thơ: upload qua Spring Boot
@PostMapping("/upload")
public void upload(MultipartFile file) {
s3.upload(file);
}
10,000 users × 500MB
↓
Load Balancer → Spring Boot (nhận 500MB, giữ connection hàng phút)
↓ forward 500MB
S3
- Backend thành đường ống trung chuyển bytes: tốn bandwidth, connection, buffer, thread.
- Nếu lưu local disk: upload vào A1, request xem vào A2 → "file đâu?". A1 chết là mất file. Đi ngược stateless.
- Business logic thật ra chỉ cần biết "user123 uploaded video456".
4. Data model: tách metadata và binary
CREATE TABLE files (
id UUID PRIMARY KEY,
user_id BIGINT NOT NULL,
object_key VARCHAR(512) NOT NULL UNIQUE, -- users/10/FILE-123/video.mp4
filename VARCHAR(255) NOT NULL,
content_type VARCHAR(100) NOT NULL,
size_bytes BIGINT NOT NULL,
status VARCHAR(20) NOT NULL, -- PENDING_UPLOAD, UPLOADED, PROCESSING, READY, FAILED
created_at TIMESTAMP NOT NULL
);
Backend ─┬── PostgreSQL : metadata (vài trăm byte/file)
└── S3 / Blob : binary (500MB)
Mỗi storage làm đúng thứ nó mạnh. Lưu binary trong relational DB làm được về kỹ thuật, nhưng với file/video lớn thì object storage hợp hơn nhiều.
5. Pre-signed URL: backend là control plane
Muốn file đi thẳng Client → S3. Nhưng không thể đưa AWS access key cho browser. Giải pháp: backend cấp quyền tạm thời, giới hạn phạm vi.
Client Backend S3
│ POST /files/upload-request │
├──────────────────────►│ │
│ authenticate, authorize │
│ check type, size, quota │
│ INSERT files(PENDING_UPLOAD) │
│ ký URL bằng credential của server │
│◄──── fileId + uploadUrl │
│ │
│ PUT 500MB (signed URL) │
├───────────────────────────────────────────────►│
│ S3 verify signature, hạn, │
│ method PUT, đúng object key│
Separate control plane from data plane: backend quyết định ai, được upload gì, vào đâu, bao lâu; bytes do hạ tầng lưu file xử lý.
@Service
@RequiredArgsConstructor
public class UploadService {
private static final long MAX_SIZE = 5L * 1024 * 1024 * 1024; // 5 GB
private static final Set<String> ALLOWED = Set.of("image/jpeg", "image/png", "video/mp4");
private final S3Presigner presigner; // AWS SDK v2, credential lấy từ IAM role
private final FileRepository files;
@Value("${app.upload.bucket}") private String bucket;
@Transactional
public UploadResponse requestUpload(long userId, UploadRequest req) {
if (!ALLOWED.contains(req.contentType()) || req.size() > MAX_SIZE) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File not allowed");
}
UUID fileId = UUID.randomUUID();
String key = "users/%d/%s/original".formatted(userId, fileId); // không dùng filename của client làm key
files.save(FileMeta.pending(fileId, userId, key, req));
PutObjectRequest put = PutObjectRequest.builder()
.bucket(bucket).key(key).contentType(req.contentType()).build();
PresignedPutObjectRequest signed = presigner.presignPutObject(b -> b
.signatureDuration(Duration.ofMinutes(15))
.putObjectRequest(put));
return new UploadResponse(fileId, signed.url().toString());
}
}
Backend chỉ xử lý request vài KB. Credential nằm ở IAM role của server, không bao giờ xuống client.
Download private file cũng vậy: GET /files/{id} → backend check quyền → trả pre-signed GET URL ngắn hạn → client tải thẳng từ storage.
6. Multipart upload: file 10GB đứt ở 9.8GB
10 GB → Part 1 ✅ Part 2 ✅ Part 3 ❌ Part 4 ✅ ...
retry Part 3 thôi, không upload lại từ 0
cuối cùng: CompleteMultipartUpload → storage ghép thành 1 object
Backend tạo upload ID và ký URL cho từng part. Các part upload song song được. Đặt lifecycle rule để dọn multipart dở dang sau vài ngày.
7. Client nói "upload xong rồi" có tin được không?
Không. Client không phải nguồn đáng tin để xác nhận trạng thái storage (có thể đóng browser, có thể gian lận). Để storage tự báo:
Client → S3
│ ObjectCreated event
▼
Queue (SQS)
▼
Upload-confirm worker:
- tìm file theo object_key
- HEAD object: kiểm tra size, content-type thật
- PENDING_UPLOAD → UPLOADED
- đẩy job xử lý (thumbnail/transcode)
State machine của file:
PENDING_UPLOAD → UPLOADED → PROCESSING → READY
└──────→ FAILED
8. Thumbnail, video: queue + worker
Resize/transcode mất từ vài giây đến vài phút. Không giữ HTTP request chờ. Client poll GET /files/{id} nhận {"status":"PROCESSING"}, sau đó READY.
10,000 video upload cùng lúc, chỉ có 100 worker
↓
████ Queue ████ (buffer: producer nhanh, consumer theo capacity)
↓
Worker 1..100 → transcode 1080p/720p/480p + thumbnail → S3 → READY
Worker chết giữa chừng → message được redeliver → có thể xử lý 2 lần. Worker phải idempotent:
@RabbitListener(queues = "media.process")
public void handle(ProcessJob job) {
// chỉ một worker "giành" được job: UPDATE có điều kiện trên status
int claimed = files.transition(job.fileId(), "UPLOADED", "PROCESSING");
if (claimed == 0) return; // đã có worker khác xử lý / đã xong → bỏ qua, ack
String thumbKey = "users/%d/%s/thumb_500".formatted(job.userId(), job.fileId());
imageService.resizeAndPut(job.objectKey(), thumbKey, 500); // key cố định → ghi lại cũng không sao
files.transition(job.fileId(), "PROCESSING", "READY");
}
// @Modifying @Query("UPDATE FileMeta f SET f.status = :to WHERE f.id = :id AND f.status = :from")
// int transition(UUID id, String from, String to);
Hai lớp: conditional update chặn xử lý trùng, output key cố định khiến ghi lại vô hại. Lỗi lặp lại nhiều lần → DLQ + alert (xem Bài 8.5). Nếu worker chết sau khi đã set PROCESSING, cần job quét file kẹt PROCESSING quá lâu để đưa lại.
9. Download nhiều: CDN
1M user cùng xem video 100MB, nếu đi qua backend là 100 TB phải stream. Đặt CDN trước object storage:
Object Storage (origin)
▲
CDN
/ | \
Edge VN Edge SG Edge US
│ │ │
Users Users Users
Request đầu: CDN MISS → origin → cache
Các request sau: CDN HIT, gần user, origin nhẹ
File private: dùng CDN signed URL/cookie (CloudFront signed URL) để edge vẫn kiểm tra quyền.
10. CDN khác Redis chỗ nào?
| Redis | CDN | |
|---|---|---|
| Nằm ở đâu | Trong backend, gần application | Edge, gần user |
| Cache gì | Application data: product:123 → JSON, session, counter | Content: ảnh, video, JS, CSS, file tải về |
| Ai đọc | Code backend | Browser/app gọi thẳng |
| Giảm | Tải DB, latency query | Latency mạng, tải origin, bandwidth |
Dễ nhớ: Redis = cache gần application, CDN = cache gần users.
11. Full architecture
Client
┌──────────┴───────────┐
API requests File bytes (signed URL)
│ │
API Gateway │
│ │
Backend (stateless) │
│ │ │
Metadata DB ký URL ▼
Object Storage
│ ObjectCreated
▼
Queue ──→ DLQ
/ \
Worker Worker (resize/transcode)
│
Object Storage
│
CDN → Users
12. Map sang AWS / Azure
| Concept | AWS | Azure |
|---|---|---|
| Object storage | S3 | Blob Storage |
| Temporary access | S3 Presigned URL | SAS (Shared Access Signature) |
| Storage event | S3 Event Notification | Event Grid |
| Queue | SQS | Service Bus Queue / Storage Queue |
| Worker | Lambda (ảnh) / ECS (video nặng) | Functions / Container Apps |
| Metadata | RDS / DynamoDB | Azure SQL / Cosmos DB |
| CDN | CloudFront | Front Door / Azure CDN |
Bản serverless: API Gateway + Lambda + DynamoDB + S3 + SQS + CloudFront. Nhưng hãy nhớ vì sao từng service tồn tại, đừng thuộc combo.
13. Failure và security
| Sự cố | Xử lý |
|---|---|
| Upload fail giữa chừng | Client retry part bị lỗi (multipart) |
| Backend chết khi đang upload | Không ảnh hưởng: backend không nằm trên data path |
| Worker chết | Queue redeliver, worker idempotent |
| Xử lý lỗi nhiều lần | Retry hết → DLQ → alert, file FAILED |
| Event bị mất | Job reconcile: quét PENDING_UPLOAD cũ, HEAD object để kiểm tra |
| DB chết | Metadata không truy cập được → cần HA/failover |
Security: auth + authz trước khi ký URL, hạn ngắn, giới hạn content-type và size (S3 POST policy có content-length-range), bucket private, object key do server sinh, quét malware trước khi READY. Không tin filename/content-type do client gửi.
Signed URL thì S3 phải support mới làm được nhỉ?
Đúng. Object storage phải hỗ trợ cơ chế signed URL thì mới thiết kế theo cách này được. Backend không tự phát minh URL rồi S3 tự hiểu. Backend dùng AWS SDK và credential của nó để ký URL theo thuật toán S3 verify được:
https://bucket.s3.amazonaws.com/users/10/FILE-123/original
?X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=...
&X-Amz-Expires=900
&X-Amz-Signature=ABCXYZ...
S3 nhận request rồi kiểm tra:
signature đúng? → hết hạn chưa? → đúng method (PUT/GET)? → đúng object key?
↓
ALLOW / DENY
URL chỉ cho phép đúng một thao tác trên đúng một object trong thời gian giới hạn, không phải quyền vào cả bucket. Việc ký diễn ra cục bộ ở backend, không cần gọi S3.
| Platform | Cơ chế |
|---|---|
| AWS S3 | Presigned URL |
| Azure Blob Storage | SAS |
| Google Cloud Storage | Signed URL |
Khi phỏng vấn: "signed URL" là solution concept, "S3 Presigned URL" là một implementation cụ thể.
Nếu client xin signed URL nhưng không bao giờ upload thì record PENDING_UPLOAD trong DB thế nào? Còn nếu event từ S3 bị gửi trùng, hai worker cùng xử lý một file thì sao?
Hai câu này interviewer rất hay hỏi tiếp sau thiết kế.
1. PENDING_UPLOAD mồ côi. Chạy job định kỳ:
@Scheduled(fixedDelay = 600_000)
public void cleanupAbandoned() {
Instant cutoff = Instant.now().minus(Duration.ofHours(24));
for (FileMeta f : files.findPendingOlderThan(cutoff)) {
if (storage.exists(f.objectKey())) {
files.transition(f.id(), "PENDING_UPLOAD", "UPLOADED"); // event bị mất → tự sửa
} else {
files.transition(f.id(), "PENDING_UPLOAD", "EXPIRED");
}
}
}
Job này vừa dọn rác vừa là lưới an toàn khi event bị mất (reconciliation). Bên storage thì lifecycle rule xóa multipart dở dang.
2. Event trùng. S3 event và SQS standard là at-least-once: trùng là chuyện bình thường, phải thiết kế cho nó. Như mục 8: UPDATE ... WHERE status = 'UPLOADED' chỉ cho một worker thắng, worker kia thấy 0 row thì ack và bỏ qua. Output key cố định nên lỡ xử lý 2 lần cũng chỉ ghi đè cùng một thumbnail. Đây là idempotency, xem thêm Bài 8.1.
Phỏng vấn hỏi "How would you design a large file upload system?" thì trả lời sao?
Q1. High-level design?
🇻🇳 Tiếng Việt:
Em tách metadata khỏi binary: metadata ở DB, file ở object storage như S3 hay Azure Blob. Thay vì cho file đi qua app server, backend xác thực, phân quyền rồi cấp signed URL ngắn hạn, client upload thẳng lên storage. File lớn dùng multipart để retry từng part. Resize, transcode xử lý bất đồng bộ bằng storage event, queue và worker. Nội dung xem nhiều đặt CDN phía trước.
🇬🇧 English:
I would store metadata separately from binary content: metadata in a database, files in object storage such as S3 or Azure Blob. Instead of routing uploads through the application servers, the backend authenticates and authorizes the user and issues a short-lived signed upload URL, so the client uploads directly to storage. Large files use multipart upload so failed parts can be retried independently. Resizing and transcoding run asynchronously through storage events, a queue and workers, and a CDN sits in front of popular content.
Q2. How do you know the upload really finished?
🇻🇳 Tiếng Việt:
Em không tin client. File có state machine PENDING_UPLOAD → UPLOADED → PROCESSING → READY/FAILED. Event ObjectCreated của storage đẩy vào queue, worker kiểm tra object thật rồi mới chuyển trạng thái. Thêm job reconcile cho trường hợp event bị mất hoặc client bỏ dở.
🇬🇧 English:
I don't trust the client. Each file has a state machine: PENDING_UPLOAD, UPLOADED, PROCESSING, then READY or FAILED. The storage ObjectCreated event goes to a queue, and a worker verifies the actual object before changing the state. A reconciliation job handles lost events and abandoned uploads.
Q3. What if a processing job runs twice?
🇻🇳 Tiếng Việt:
Event là at-least-once nên phải idempotent. Worker giành job bằng conditional update trên status, chỉ một worker thắng. Output ghi vào key cố định nên chạy lại cũng vô hại. Lỗi lặp lại thì vào DLQ và alert.
🇬🇧 English:
Events are at-least-once, so processing must be idempotent. A worker claims the job with a conditional update on the status, so only one wins. Outputs go to deterministic keys, so a rerun is harmless. Repeated failures go to a DLQ with an alert.
Cách nhớ ngắn gọn
- Backend = control plane, storage = data plane: bytes không đi qua Spring Boot.
- Signed URL = quyền tạm thời, một thao tác, một object (S3 presigned, Azure SAS).
- Không tin client: storage event + verify + state machine + reconcile.
- Việc lâu → queue + worker idempotent; xem nhiều → CDN.
Tự kiểm tra
Vì sao không cho file 500MB đi qua Spring Boot?
Backend thành ống trung chuyển bytes, tốn bandwidth, connection, buffer, trong khi nó chỉ cần authorize và lưu metadata. Local disk còn phá tính stateless.
Pre-signed URL giới hạn những gì?
Một method (PUT/GET), một object key, một thời hạn. Được ký bằng credential của backend, storage tự verify chữ ký.
Tại sao cần queue giữa storage event và worker?
Làm buffer: 10,000 file đến cùng lúc nhưng worker xử lý theo capacity; worker chết thì message được redeliver.
Làm sao tránh hai worker cùng transcode một file?
Conditional update WHERE status='UPLOADED' chỉ cho một worker giành job; output key cố định để chạy lại vô hại.
CDN khác Redis thế nào?
Redis cache application data gần backend; CDN cache content (ảnh, video, JS) ở edge gần user, giảm latency và tải origin.