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
- Khi nào ai đó thật sự yêu cầu xây cái này?
- Số ước lượng nhanh nào định hình thiết kế?
- Kiến trúc trông thế nào?
- Triển khai .NET 10 đầu cuối ra sao?
- Pipeline analytics click trông thế nào?
- Đường scale-out thiết kế hỗ trợ?
- Failure mode nào cần monitor?
- Khi nào URL shortener tự xây là quá liều?
- Đ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:
- Tier web: stateless, thêm replica sau load balancer.
- Cache: scale cluster Redis; partition theo prefix code.
- DB: ở 1B URL, partition bảng theo năm tạo; read replica cho dashboard analytics.
- Queue: cluster RabbitMQ; hoặc chuyển Kafka nếu volume click vượt 1M/s.
- Analytics: ClickHouse shard theo ngày tự nhiên.
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?
- Cache stampede trên URL viral - một key nhận 100K rps; cache xử lý nổi nhưng DB sập khi miss. Phòng: single-flight lock khi miss; pre-populate key hot.
- Trùng code (nếu hash) - hiếm nhưng có. Phòng: retry với salt mới; counter-based tránh hoàn toàn.
- Backlog queue click - consumer analytics chậm, queue lớn. Phòng: alert độ sâu queue; drop metric observability.
- Lạm dụng open redirect - spammer rút gọn URL độc. Phòng: allowlist domain, scan malware lúc shorten.
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.