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

Thiết kế URL Shortener (TinyURL/Bitly) trong .NET

Thiết kế đầu cuối URL shortener: ước lượng capacity, encode base62, lưu Postgres + Redis, phân tích click, và code ASP.NET Core gắn lại với nhau.

Mục lục
  1. Khi nào ai đó thật sự yêu cầu xây cái này?
  2. Số ước lượng nhanh nào định hình thiết kế?
  3. Kiến trúc trông thế nào?
  4. Triển khai .NET 10 đầu cuối ra sao?
  5. Pipeline analytics click trông thế nào?
  6. Đường scale-out thiết kế hỗ trợ?
  7. Failure mode nào cần monitor?
  8. Khi nào URL shortener tự xây là quá liều?
  9. Đi tiếp đâu từ đây?

URL shortener là hệ đầu cuối đơn giản nhất bạn xây mà chạy qua mọi khối trong series: cache, database, queue, observability, rate limit. Chương này thiết kế một, rồi cài đặt nó trong ASP.NET Core, kèm các con số ước lượng nhanh biện minh cho mỗi lựa chọn.

Khi nào ai đó thật sự yêu cầu xây cái này?

Ba bối cảnh. Phỏng vấn, là câu khởi động. Công cụ nội bộ, nơi link Slack/email cần ngắn và theo dõi được. Tính năng sản phẩm, nơi bạn xây Bitly hay dịch vụ QR.

Ý tưởng kiến trúc chuyển sang mọi hệ "tra cứu key đơn": feature flag, geo-DNS, gán A/B test. URL shortener là bài luyện tập kinh điển vì làm mọi ràng buộc rõ ràng.

Số ước lượng nhanh nào định hình thiết kế?

Tái dùng tính từ chương 2:

DAU                 1M
Shorten / ngày      1M  (mỗi user active một cái)
Redirect / ngày     100M (tỉ lệ read:write 100:1)
Đỉnh redirect/s     100M / 100K * 5 = 5K req/s
Kích thước URL row  200 byte (short + long + meta)
Storage / năm       1M * 365 * 200 = 73 GB
Cache hit rate      90% (Zipf; 1% URL phục vụ 90% read)
Tải read DB         500 req/s sau cache

Các con số nói: một Postgres node, một Redis cache, hai replica ASP.NET Core, một queue analytics. Không shard, không NoSQL, không microservice.

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

flowchart LR
    Client[Browser] -->|GET /abc123| LB[Load Balancer]
    LB --> App[ASP.NET Core]
    App -->|GET cache| Redis[(Redis cache)]
    Redis -->|miss| App
    App -->|SELECT long_url| PG[(Postgres)]
    App -->|publish ClickEvent| Q[(Queue)]
    App -->|301 / 302 redirect| Client
    Q --> Analytics[Worker analytics]
    Analytics --> CH[(ClickHouse / DW)]

Hai đường. Hot path: redirect, cache hit, một call Redis, trả. Cold path: cache miss, SELECT Postgres, populate cache, trả. Analytics async qua queue - không bao giờ chặn redirect chờ đếm click.

Triển khai .NET 10 đầu cuối ra sao?

// Schema
public class ShortUrl
{
    public long Id { get; set; }
    public string Code { get; set; } = "";   // base62 của Id
    public string LongUrl { get; set; } = "";
    public DateTime CreatedAt { get; set; }
    public Guid OwnerId { get; set; }
}

// Sinh code
public static class Base62
{
    private const string Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    public static string Encode(long n)
    {
        if (n == 0) return "0";
        var sb = new StringBuilder();
        while (n > 0) { sb.Insert(0, Alphabet[(int)(n % 62)]); n /= 62; }
        return sb.ToString();
    }
}

// Endpoint shorten
app.MapPost("/shorten", async (ShortenDto dto, AppDbContext db, ClaimsPrincipal user) =>
{
    var entity = new ShortUrl { LongUrl = dto.Url, OwnerId = user.GetUserId(), CreatedAt = DateTime.UtcNow };
    db.ShortUrls.Add(entity);
    await db.SaveChangesAsync();
    entity.Code = Base62.Encode(entity.Id);   // Id gán bởi DB sau Save
    await db.SaveChangesAsync();
    return Results.Ok(new { code = entity.Code, url = $"https://anhtu.dev/{entity.Code}" });
})
.RequireAuthorization()
.RequireRateLimiting("per-user");

// Endpoint redirect
app.MapGet("/{code}", async (string code, IDistributedCache cache, AppDbContext db,
                              IPublishEndpoint bus, HttpContext ctx) =>
{
    var cacheKey = $"u:{code}";
    var cached = await cache.GetStringAsync(cacheKey);
    if (cached is not null)
    {
        await bus.Publish(new ClickEvent(code, DateTimeOffset.UtcNow, ctx.Connection.RemoteIpAddress?.ToString()));
        return Results.Redirect(cached, permanent: false);  // 302
    }

    var url = await db.ShortUrls.AsNoTracking().Where(u => u.Code == code).Select(u => u.LongUrl).FirstOrDefaultAsync();
    if (url is null) return Results.NotFound();

    await cache.SetStringAsync(cacheKey, url,
        new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24) });
    await bus.Publish(new ClickEvent(code, DateTimeOffset.UtcNow, ctx.Connection.RemoteIpAddress?.ToString()));
    return Results.Redirect(url, permanent: false);
});

Ba chi tiết. Đôi SaveChangesAsync là pattern đơn giản nhất để dùng ID auto-gen cho code; hệ production batch bằng pre-allocate sequence. Results.Redirect(..., permanent: false) trả 302 và cho phép đếm click. Click event vào queue (chương 6) - không vào database đồng bộ.

Pipeline analytics click trông thế nào?

flowchart LR
    App[Handler redirect] --> Q[(RabbitMQ)]
    Q --> W1[Worker đếm click]
    Q --> W2[Worker enrich geo]
    W1 --> CH[(ClickHouse)]
    W2 --> CH
    CH --> Dash[Dashboard]

Queue fan out ra nhiều consumer. Counter aggregate theo phút và ghi ClickHouse (hoặc Postgres cho traffic thấp). Geo enricher giải IP sang quốc gia và ghi event đã enrich. Dashboard đọc aggregate từ ClickHouse. Case study analytics lo tầng này chi tiết.

Đường scale-out thiết kế hỗ trợ?

Mỗi thành phần scale độc lập:

Lựa chọn thiết kế duy nhất đau ở scale là ID auto-increment - sequence phân tán cần service riêng (ID generator kiểu Snowflake) trên ~10K shorten/giây.

Failure mode nào cần monitor?

Khi nào URL shortener tự xây là quá liều?

Hai trường hợp.

Một: volume thấp. Nếu rút gọn 100 URL/ngày cho công cụ nội bộ, tài khoản Bitly rẻ hơn. Chi phí xây schema + analytics + xử abuse không đáng tái tạo cho quy mô nhỏ.

Hai: nhu cầu tracking phong phú. UTM parameter, theo dõi conversion, A/B test link - đây là 80% thứ sản phẩm chuyên biệt (Bitly, Rebrandly) tính tiền. Xây từ đầu nghĩa là xây lại bộ tính năng của họ, không chỉ redirect.

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

Case study kế tiếp: thiết kế rate limiter - middleware rate-limit ở chương 14 là phía consumer; case study cho thấy cách xây limiter phân tán từ đầu. Sau đó news feed và chat case study phức tạp hơn.

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

Hash hay counter cho short key?
Counter encode base62 thắng cho read (tuần tự, thân thiện cache) và lưu trữ (không retry trùng). Hash (MD5/SHA cắt) thắng khi key phải khó đoán - link công khai user không nên enum được. Phần lớn hệ production là counter-based với salt che thứ tự; xử lý trùng không cần.
301 hay 302 cho redirect?
302 (tạm) cho analytics, 301 (vĩnh viễn) cho hiệu năng. 301 cho browser cache redirect mãi - click sau bypass service, tốt cho tải nhưng tệ cho đếm click. Phần lớn shortener dùng 302 vì tracking là sản phẩm. Cache-Control: private, max-age=86400 trên 302 giữ click trong ngày ở browser cache.
Sao không hash URL dài luôn?
Hai vấn đề: (1) trùng hash có thật ngay cả 8 ký tự và thêm vòng retry khi write; (2) cùng URL dài hash ra cùng short key, nên hai user nhận cùng key - thường không mong vì mỗi user muốn link riêng với analytics riêng. Counter-based cho một short mỗi yêu cầu rút gọn và tránh cả hai vấn đề.
Xử 'URL viral' ra sao?
Một short URL viral sinh hàng triệu redirect/phút. Cache hấp thụ gần hết - URL hot, Redis trả <1 ms. Click event vào queue và aggregate downstream; không bao giờ write counter mỗi click. Case study news feed lo cùng pattern hot-key sâu hơn.