File lớn, S3, presigned URL, multipartLarge files, S3, presigned URLs, multipart
Client xin API một URL đã ký, rồi tải byte thẳng lên S3. S3 phát sự kiện ObjectCreated vào SQS; worker quét virus, tạo thumbnail và cập nhật bảng files. Người xem lấy file qua CloudFront với URL có chữ ký ngắn hạn. Bật “Video 2 GB qua multipart” hoặc “File nhiễm mã độc” để xem hai luồng còn lại.The client asks the API for a signed URL and sends the bytes straight to S3. S3 emits ObjectCreated into SQS; workers scan for malware, build thumbnails and update the files table. Viewers fetch files through CloudFront with a short-lived signed URL. Turn on “2 GB video via multipart” or “Infected file” to see the other two flows.
1Client khai báo tên, loại và kích thước file. API kiểm tra quyền và giới hạn trước khi ký.The client declares the file name, type and size. The API checks permissions and limits before signing anything.
Đẩy file qua API nghĩa là giữ thread và băng thông của server suốt thời gian tải, 2 GB trên mạng 5 MB/s là gần 7 phút. Presigned URL cho client nói chuyện thẳng với S3; API chỉ ký và ghi trạng thái.Streaming files through the API ties up a server thread and bandwidth for the whole upload; 2 GB over 5 MB/s is close to 7 minutes. A presigned URL lets the client talk to S3 directly while the API only signs and tracks status.
Kiểm tra ở API là chưa đủ vì byte không đi qua API. Presigned POST đặt điều kiện content-length-range và Content-Type trong policy; presigned PUT đưa Content-Length và Content-Type vào chữ ký. Sau đó HEAD object khi confirm và kiểm tra magic bytes khi quét, vì Content-Type do client tự khai.Checking at the API is not enough because the bytes skip it. A presigned POST puts content-length-range and Content-Type conditions in its policy; a presigned PUT signs Content-Length and Content-Type. Then HEAD the object on confirm and check magic bytes during the scan, because the client chooses Content-Type.
Multipart chia file thành part 5 MB tới 5 GB (tối đa 10.000 part). Mỗi part có URL ký riêng và thử lại độc lập; ListParts cho biết part nào đã có để tải tiếp sau khi mất mạng.Multipart splits the file into parts of 5 MB to 5 GB (up to 10,000 parts). Each part has its own signed URL and retries independently; ListParts shows which parts exist so the upload resumes after a disconnect.
S3 phát sự kiện ObjectCreated vào SQS; scan worker rồi processing worker xử lý theo từng bước. Message lỗi quá 5 lần vào DLQ. Worker phải idempotent vì SQS giao ít nhất một lần.S3 emits ObjectCreated into SQS; the scan worker and then the processing worker handle it stage by stage. A message that fails 5 times goes to the DLQ. Workers must be idempotent because SQS delivers at least once.
Part của multipart chưa complete không hiện trong danh sách object nhưng vẫn bị tính phí. Lifecycle rule AbortIncompleteMultipartUpload sau 1 ngày, rule xoá prefix tmp/, và job dọn dòng files UPLOADING quá 24 giờ.Parts of an unfinished multipart upload do not show up as objects but are still billed. Add a lifecycle rule AbortIncompleteMultipartUpload after 1 day, an expiry rule on tmp/, and a job that clears files rows stuck in UPLOADING for over 24 hours.
Bucket private, chỉ CloudFront đọc qua Origin Access Control. API ký URL CDN 5 phút sau khi kiểm tra quyền và status=READY. Adapter bọc S3, MinIO hoặc GCS sau cùng một interface lưu trữ.Keep the bucket private and let only CloudFront read it through Origin Access Control. The API signs a 5-minute CDN URL after checking access and status=READY. An adapter hides S3, MinIO or GCS behind one storage interface.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
files | id, owner_id, object_key, original_name, declared_type, declared_size, actual_size, etag, upload_id, status, reject_reason, created_at | UPLOADING → SCANNING → READY · REJECTED; index(status, created_at) cho job dọnUPLOADING → SCANNING → READY · REJECTED; index(status, created_at) for the cleanup job |
file_variant | file_id, kind, object_key, width, height, size | thumb_200, thumb_800, video_720p; unique(file_id, kind) để worker chạy lại không tạo trùngthumb_200, thumb_800, video_720p; unique(file_id, kind) so a re-run never duplicates |
upload_part | file_id, part_number, etag, size, uploaded_at | Tuỳ chọn: lưu ETag phía server để resume trên thiết bị khácOptional: keep ETags server-side to resume from another device |
S3 lifecycle (config) | AbortIncompleteMultipartUpload 1 day · tmp/ expire 1 day · variants → IA after 30 days | Dọn rác ở tầng lưu trữ, không cần cronCleanup at the storage layer, no cron needed |
Hai việc của Upload API: kiểm tra giới hạn rồi ký URL, và xác nhận upload bằng HEAD object. Không có byte nào đi qua đây.The Upload API does two things: check limits and sign a URL, then confirm the upload with a HEAD on the object. No bytes pass through it.
@Service
@RequiredArgsConstructor
public class UploadService {
private static final long MAX_BYTES = 25L * 1024 * 1024;
private static final Set<String> ALLOWED = Set.of("image/jpeg", "image/png", "application/pdf");
private final S3Presigner presigner;
private final S3Client s3;
private final FileRepository files;
@Transactional
public PresignResponse presign(UUID ownerId, PresignRequest req) {
if (req.size() > MAX_BYTES || !ALLOWED.contains(req.contentType()))
throw new InvalidUploadException("size or type not allowed");
FileRecord f = files.save(FileRecord.uploading(ownerId, req));
PutObjectRequest put = PutObjectRequest.builder()
.bucket("uploads").key(f.objectKey())
.contentType(req.contentType())
.contentLength(req.size())
.build();
PresignedPutObjectRequest url = presigner.presignPutObject(b -> b
.signatureDuration(Duration.ofMinutes(10))
.putObjectRequest(put));
return new PresignResponse(f.id(), url.url(), url.signedHeaders());
}
@Transactional
public void confirm(UUID ownerId, UUID fileId) {
FileRecord f = files.lockOwned(fileId, ownerId); // SELECT … FOR UPDATE
if (f.status() != FileStatus.UPLOADING) return; // second confirm: no-op
HeadObjectResponse head = s3.headObject(b -> b.bucket("uploads").key(f.objectKey()));
if (head.contentLength() != f.declaredSize()) {
f.reject("size mismatch");
return;
}
f.markScanning(head.eTag()); // workers set READY
}
}type UploadService struct {
presign *s3.PresignClient
s3 *s3.Client
files FileStore
}
const maxBytes = 25 << 20
var allowed = map[string]bool{"image/jpeg": true, "image/png": true, "application/pdf": true}
func (s *UploadService) Presign(ctx context.Context, owner uuid.UUID, req PresignRequest) (*PresignResponse, error) {
if req.Size > maxBytes || !allowed[req.ContentType] {
return nil, ErrInvalidUpload
}
f, err := s.files.CreateUploading(ctx, owner, req)
if err != nil {
return nil, err
}
post, err := s.presign.PresignPostObject(ctx, &s3.PutObjectInput{
Bucket: aws.String("uploads"), Key: aws.String(f.ObjectKey),
}, func(o *s3.PresignPostOptions) {
o.Expires = 10 * time.Minute
o.Conditions = []interface{}{
[]interface{}{"content-length-range", 1, maxBytes},
[]interface{}{"eq", "$Content-Type", req.ContentType},
}
})
if err != nil {
return nil, err
}
return &PresignResponse{FileID: f.ID, URL: post.URL, Fields: post.Values}, nil
}
func (s *UploadService) Confirm(ctx context.Context, owner, id uuid.UUID) error {
return s.files.WithLock(ctx, id, owner, func(f *File) error {
if f.Status != StatusUploading {
return nil // already confirmed
}
head, err := s.s3.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String("uploads"), Key: aws.String(f.ObjectKey),
})
if err != nil {
return err // object not in S3 yet
}
if aws.ToInt64(head.ContentLength) != f.DeclaredSize {
return f.Reject("size mismatch")
}
return f.MarkScanning(aws.ToString(head.ETag))
})
}| Presigned PUTPresigned PUT | Presigned POSTPresigned POST | |
|---|---|---|
| Giới hạn kích thướcSize limit | Ký đúng một Content-Length, phải khai trước chính xácSigns one exact Content-Length, declared up front | Policy content-length-range cho phép một khoảngA content-length-range policy allows a range |
| Phía clientClient side | Gửi body thô, dễ dùng với fetch và mobileRaw body, easy with fetch and mobile apps | Form multipart/form-data, hợp với form HTMLA multipart/form-data form, natural for HTML forms |
| File lớnLarge files | Dùng cho từng part của multipart uploadUsed for every part of a multipart upload | Một request, tối đa 5 GB, không tải tiếp đượcOne request, up to 5 GB, cannot resume |
| Chọn khiPick it when | App mobile, SPA, hoặc cần multipartMobile apps, SPAs, or when you need multipart | Form trình duyệt, cần khoảng kích thước linh hoạtBrowser forms that need a flexible size range |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Mỗi upload giữ một connection và thread trong suốt thời gian truyền, băng thông đi hai lần, và API phải scale theo dung lượng thay vì số request. Presigned URL chuyển phần nặng sang S3; API chỉ làm việc nhỏ và nhanh.Each upload holds a connection and a thread for the whole transfer, the bandwidth is paid twice, and the API has to scale with bytes instead of requests. A presigned URL hands the heavy part to S3 and leaves the API with small, fast work.
Đặt giới hạn ngay trong chữ ký: content-length-range với presigned POST, Content-Length ký sẵn với presigned PUT. S3 từ chối trước khi lưu. Sau đó vẫn HEAD object khi confirm để đối chiếu.Put the limit in the signature itself: content-length-range on a presigned POST, a signed Content-Length on a presigned PUT. S3 refuses before storing. Still HEAD the object on confirm to cross-check.
Các part đã tải vẫn nằm trong S3 và bị tính phí dù không thấy trong danh sách object. Cần lifecycle rule AbortIncompleteMultipartUpload và job đánh dấu dòng files UPLOADING quá hạn.The uploaded parts stay in S3 and are billed even though they are not listed as objects. You need an AbortIncompleteMultipartUpload lifecycle rule and a job that expires stale UPLOADING rows.
Bucket private, không có URL công khai. API chỉ ký URL đọc khi status=READY và người xin có quyền. File bị từ chối được chuyển sang bucket cách ly.The bucket is private with no public URLs. The API signs a read URL only when status=READY and the requester has access. Rejected files move to a quarantine bucket.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.