Case study Trung bình 5 phút đọc

Thiết kế service upload file trong .NET (chunked, resumable)

Cách xây service upload file trong .NET: presigned URL, upload chunked resumable lên Azure Blob hay S3, pipeline scan virus, và metadata trong Postgres.

Mục lục
  1. Khi nào service upload file thành nghiêm túc?
  2. Số nào nên ngân sách?
  3. Kiến trúc trông thế nào?
  4. Cấu hình .NET 10 cho luồng upload?
  5. Đường scale-out hỗ trợ?
  6. Tạo failure mode nào?
  7. Khi nào service tuỳ là quá liều?
  8. Đi tiếp đâu từ đây?

Service upload file là một trong những bài trông đơn giản đến khi phải scale. Phiên bản đầu - endpoint multipart/form-data stream xuống đĩa local - sập ngay khi có nhiều replica, file lớn, hay virus cần scan. Chương này thiết kế hình production: presigned URL, upload chunked resumable, và pipeline scan bất đồng bộ.

Khi nào service upload file thành nghiêm túc?

Ba tín hiệu.

File lớn hơn 10 MB. Stream qua service dùng băng thông và thread bạn không xử lý nổi dưới tải.

Client mobile hoặc không tin cậy. Upload 50 MB qua Wi-Fi khách sạn sẽ fail; client phải resume thay vì restart.

File từ user không tin cậy. Bất cứ đâu user upload, scan virus là bắt buộc; download phải check status scan trước khi phục vụ.

Nếu không cái nào (avatar nhỏ từ employee đã xác thực), stream thẳng vào blob ổn.

Số nào nên ngân sách?

Upload / ngày              500K
Avg size file              5 MB
Storage / năm              5 MB * 500K * 365 = ~900 TB
Đỉnh upload / giây         500K / 100K * 5 = 25/giây
Egress CDN (download)      thường 5x băng thông upload
Băng thông vào (đỉnh)      25 * 5 MB = 125 MB/giây

Số 900 TB / năm cho biết object storage là câu trả lời thực dụng duy nhất; volume đó trong database là thảm hoạ. Đỉnh 25/giây nhỏ cho tier app - ký URL là việc nhẹ.

Kiến trúc trông thế nào?

flowchart LR
    Client --> App[ASP.NET Core API]
    App -->|1. POST /uploads<br/>trả presigned URL| Client
    Client -->|2. PUT chunk| Blob[(Azure Blob / S3)]
    Client -->|3. POST /uploads/{id}/complete| App
    App --> PG[(Postgres<br/>metadata)]
    App --> Q[(queue scan)]
    Q --> Scanner[Worker antivirus]
    Scanner --> Blob
    Scanner --> PG
    Reader[Client download] --> App
    App -->|signed CDN URL| CDN[(CDN)] --> Reader

Ba đường. Init: client xin URL upload từ API, nhận presigned blob URL kèm upload ID. Upload: client PUT chunk thẳng vào blob. Complete: client báo API xong; API enqueue scan. Download là URL signed CDN ngắn.

Cấu hình .NET 10 cho luồng upload?

public record InitUploadRequest(string FileName, string ContentType, long Size);

app.MapPost("/uploads", async (InitUploadRequest req, AppDbContext db,
                                 BlobContainerClient blobs, ClaimsPrincipal user) =>
{
    var upload = new UploadSession
    {
        Id = Guid.NewGuid(),
        UserId = user.GetUserId(),
        FileName = req.FileName,
        ContentType = req.ContentType,
        Size = req.Size,
        BlobName = $"{Guid.NewGuid()}/{req.FileName}",
        Status = "pending",
        CreatedAt = DateTimeOffset.UtcNow
    };
    db.Uploads.Add(upload);
    await db.SaveChangesAsync();

    var blobClient = blobs.GetBlobClient(upload.BlobName);
    var sas = blobClient.GenerateSasUri(
        BlobSasPermissions.Write | BlobSasPermissions.Create,
        DateTimeOffset.UtcNow.AddHours(1));

    return Results.Ok(new { uploadId = upload.Id, uploadUrl = sas.ToString() });
})
.RequireAuthorization()
.RequireRateLimiting("per-user");

app.MapPost("/uploads/{id:guid}/complete", async (Guid id, AppDbContext db,
                                                    IPublishEndpoint bus) =>
{
    var upload = await db.Uploads.FindAsync(id);
    if (upload is null) return Results.NotFound();
    upload.Status = "uploaded";
    upload.UploadedAt = DateTimeOffset.UtcNow;
    await db.SaveChangesAsync();

    await bus.Publish(new ScanRequested(upload.Id, upload.BlobName));
    return Results.Ok();
});

// Worker scan
public class ScanConsumer(BlobContainerClient blobs, IAntiVirus av, AppDbContext db)
    : IConsumer<ScanRequested>
{
    public async Task Consume(ConsumeContext<ScanRequested> ctx)
    {
        await using var stream = await blobs.GetBlobClient(ctx.Message.BlobName).OpenReadAsync();
        var verdict = await av.ScanAsync(stream, ctx.CancellationToken);
        var upload = await db.Uploads.FindAsync(ctx.Message.UploadId);
        upload!.Status = verdict.IsClean ? "clean" : "quarantined";
        upload.ScanResult = verdict.Detail;
        await db.SaveChangesAsync();
    }
}

// Endpoint download
app.MapGet("/files/{id:guid}", async (Guid id, AppDbContext db, BlobContainerClient blobs) =>
{
    var upload = await db.Uploads.FindAsync(id);
    if (upload is null || upload.Status != "clean") return Results.NotFound();
    var sas = blobs.GetBlobClient(upload.BlobName).GenerateSasUri(
        BlobSasPermissions.Read, DateTimeOffset.UtcNow.AddMinutes(15));
    return Results.Redirect(sas.ToString());  // 302 to CDN
});

Ba chi tiết. Presigned URL có expiry chặt (1 giờ upload, 15 phút download) nên URL bị rò chết nhanh. Cổng status scan ngăn phục vụ file nhiễm. Endpoint download redirect thay vì stream - CDN phục vụ byte.

Đường scale-out hỗ trợ?

Cổ chai băng thông biến mất với thiết kế này - mọi byte đi giữa client và blob storage / CDN, không bao giờ qua service bạn.

Tạo failure mode nào?

Khi nào service tuỳ là quá liều?

Cho avatar nhỏ và file đính kèm trong SaaS, dịch vụ host như Uploadcare, Filestack, hay Cloudinary xử lý cả luồng kèm scan và CDN. Họ rẻ hơn xây, vận hành, và bảo mật pipeline riêng. Xây tuỳ khi volume, tuân thủ, hay độ sâu tích hợp biện minh được.

Đi tiếp đâu từ đây?

Case study kế tiếp: typeahead autocomplete - case study nặng cấu trúc dữ liệu, nơi sorted set Redis và trie có giá trị.

Câu hỏi thường gặp

Sao presigned URL thay vì stream qua service?
Hai lý do: băng thông và CPU. Stream file 1 GB qua ASP.NET Core dùng 1 GB băng thông server và một thread suốt. Presigned URL cho client upload thẳng lên S3/Azure Blob - service tốn một mili giây ký URL và không bao giờ thấy byte. Pattern không thể thương lượng trên ~10 MB file.
Upload chunked resumable chạy ra sao?
Client chia file thành chunk (5-10 MB), upload song song, và track chunk thành công. Khi reconnect, query server chunk đã xong và resume chỉ cái thiếu. Azure Blob 'Append Blob' và S3 'Multipart Upload' đều hỗ trợ sẵn; service chỉ track session upload.
Scan virus đặt ở đâu?
Async qua queue. Upload xong, service lưu metadata status=pending, event vào queue scan, worker kéo file từ blob, chạy ClamAV hay Defender, update metadata thành status=clean hay quarantined. Endpoint download từ chối phục vụ file ngoài state clean.
Phục vụ download qua service hay CDN?
CDN với signed URL - cùng pattern upload. CDN cache ở edge, service ký URL ngắn hạn (15 phút). Endpoint download redirect 302 sang URL CDN. Pattern URL shortener redirect-and-track áp dụng ở đây cho analytics download.