Level 2: thiết kế File / Image / Video Upload

4 câu hỏi~30 phútTừ khóa: object storage, pre-signed URL, SAS, control plane vs data plane, multipart upload, S3 event, queue + worker, thumbnail, transcode, CDN

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.

  1. Upload image/video.
  2. Download/xem file.
  3. Lưu metadata.
  4. User chỉ truy cập file mình được phép.
  5. 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

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?

RedisCDN
Nằm ở đâuTrong backend, gần applicationEdge, gần user
Cache gìApplication data: product:123 → JSON, session, counterContent: ảnh, video, JS, CSS, file tải về
Ai đọcCode backendBrowser/app gọi thẳng
GiảmTải DB, latency queryLatency 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

ConceptAWSAzure
Object storageS3Blob Storage
Temporary accessS3 Presigned URLSAS (Shared Access Signature)
Storage eventS3 Event NotificationEvent Grid
QueueSQSService Bus Queue / Storage Queue
WorkerLambda (ảnh) / ECS (video nặng)Functions / Container Apps
MetadataRDS / DynamoDBAzure SQL / Cosmos DB
CDNCloudFrontFront 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ừngClient retry part bị lỗi (multipart)
Backend chết khi đang uploadKhông ảnh hưởng: backend không nằm trên data path
Worker chếtQueue redeliver, worker idempotent
Xử lý lỗi nhiều lầnRetry hết → DLQ → alert, file FAILED
Event bị mấtJob reconcile: quét PENDING_UPLOAD cũ, HEAD object để kiểm tra
DB chếtMetadata 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.

PlatformCơ chế
AWS S3Presigned URL
Azure Blob StorageSAS
Google Cloud StorageSigned 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


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.