Tuần 16: Design URL Shortener — Full System Design Interview Walkthrough
“URL Shortener nhìn đơn giản nhưng chạm tới mọi khía cạnh của system design: hashing, database, caching, scaling, analytics. Đó là lý do nó là câu hỏi phỏng vấn kinh điển.”
Tags: system-design case-study url-shortener alex-xu interview Student: Hieu Prerequisite: Tuan-01-Scale-From-Zero-To-Millions · Tuan-02-Back-of-the-envelope Liên quan: Tuan-06-Cache-Strategy · Tuan-07-Database-Sharding-Replication · Tuan-09-Rate-Limiter · Tuan-10-Consistent-Hashing
Overview — Bốn bước của Alex Xu
Alex Xu chia mọi System Design Interview thành 4 bước:
| Bước | Tên gọi | Thời gian (45 phút) |
|---|---|---|
| 1 | Understand the Problem & Establish Design Scope | ~5 phút |
| 2 | Propose High-Level Design | ~15 phút |
| 3 | Design Deep Dive | ~20 phút |
| 4 | Wrap Up | ~5 phút |
Aha Moment: Nhiều ứng viên nhảy thẳng vào code/diagram. Interviewer đánh giá cao người biết hỏi đúng câu hỏi trước khi thiết kế.
Step 1 — Understand the Problem & Establish Design Scope
1.1 Functional Requirements (Yêu cầu chức năng)
Hieu, trước khi thiết kế bất cứ thứ gì, em phải hỏi interviewer để làm rõ scope. Dưới đây là các câu hỏi và giả định:
| Câu hỏi | Trả lời / Giả định |
|---|---|
| URL shortening: nhận long URL → trả về short URL? | Có, đây là core feature |
| URL redirecting: nhận short URL → redirect về original? | Có |
| Custom alias: user có thể chọn short code riêng? | Có, optional |
| Expiration: short URL có hết hạn không? | Có, default 5 năm, user có thể tuỳ chỉnh |
| Analytics: đếm click, thống kê? | Có, basic analytics (click count, timestamp) |
| Xoá/update URL? | Không, keep simple |
Tóm tắt Functional Requirements:
- Shorten — Cho một long URL, tạo ra một short URL duy nhất
- Redirect — Khi user truy cập short URL, redirect về original URL
- Custom alias — User có thể chọn alias (vd:
sdi.vn/my-link) - Expiration — Short URL tự hết hạn sau thời gian quy định
- Analytics — Track số lần click, thời gian click
1.2 Non-Functional Requirements (Yêu cầu phi chức năng)
| Requirement | Target | Giải thích |
|---|---|---|
| High Availability (Tính sẵn sàng cao) | 99.99% uptime | Nếu short link chết → mọi link đã share đều chết |
| Low Latency (Độ trễ thấp) | Redirect < 50ms (P99) | User click link, phải redirect gần như tức thì |
| Scalability (Khả năng mở rộng) | 100M DAU | Quy mô Bitly/TinyURL |
| Durability (Bền vững) | Không mất URL mapping | Một khi tạo rồi, mapping phải tồn tại suốt TTL |
| Uniqueness (Tính duy nhất) | Không có 2 short code trùng nhau | Collision = thảm hoạ |
1.3 Capacity Estimation (Ước lượng dung lượng)
Tham chiếu chi tiết: Tuan-02-Back-of-the-envelope — Section 4
Assumptions (Giả thiết)
| Thông số | Giá trị | Giải thích |
|---|---|---|
| DAU | 100M | Hệ thống quy mô lớn (Bitly-like) |
| URL shortens/user/day | 0.1 | Không phải ai cũng tạo link mỗi ngày |
| URL reads (redirects)/user/day | 1 | Mỗi người click trung bình 1 short link/ngày |
| Read:Write ratio | 10:1 | Read-heavy system (đọc nhiều hơn ghi) |
| Avg URL record size | 500 bytes | Original URL + short code + metadata |
| Retention (thời gian lưu) | 5 năm | Default expiration |
| Short code length | 7 ký tự | Base62 encoding |
Tính QPS (Queries Per Second)
Nhận xét: 3,500 read QPS peak — một server trung bình với Redis đã handle được. Chưa cần phức tạp hoá ban đầu, nhưng cần thiết kế để scale horizontal khi cần.
Tính Storage
Nhận xét quan trọng: 18.25 tỷ URL trong 5 năm. Short code 7 ký tự Base62 = combinations → dư sức chứa. Nhưng 10TB data → cần xem xét partition strategy.
Tính Bandwidth
Nhận xét: Bandwidth rất nhỏ — không phải bottleneck.
Tính Cache Memory
Theo Pareto principle (Nguyên tắc 80/20): 20% URL tạo ra 80% traffic.
Redis cluster 3 nodes x 16GB = 48GB → đủ chứa hot data.
Tóm tắt Estimation
| Metric | Value |
|---|---|
| Write QPS (avg / peak) | ~116/s / ~350/s |
| Read QPS (avg / peak) | ~1,157/s / ~3,500/s |
| New URLs/day | 10M |
| Total URLs (5 years) | ~18.25B |
| Storage (5 years) | ~10 TB |
| Bandwidth out (peak) | ~14 Mbps |
| Cache memory | ~30 GB |
Step 2 — Propose High-Level Design
2.1 API Design (Thiết kế API)
API 1: Shorten URL (Rút gọn URL)
POST /api/v1/shorten
Request Body:
{
"long_url": "https://example.com/very/long/path?param=value",
"custom_alias": "my-link", // optional (tuỳ chọn)
"expiration_days": 365 // optional, default 1825 (5 năm)
}Response (201 Created):
{
"short_url": "https://sdi.vn/aB3x7Kp",
"short_code": "aB3x7Kp",
"long_url": "https://example.com/very/long/path?param=value",
"created_at": "2026-03-18T10:00:00Z",
"expires_at": "2027-03-18T10:00:00Z"
}Error Cases:
| Status Code | Khi nào | Ý nghĩa |
|---|---|---|
| 400 Bad Request | URL không hợp lệ | Invalid URL format |
| 409 Conflict | Custom alias đã tồn tại | Alias trùng |
| 429 Too Many Requests | Vượt rate limit | Chống spam/abuse |
API 2: Redirect (Chuyển hướng)
GET /:shortCode
Response: 301 Moved Permanently hoặc 302 Found (xem trade-off bên dưới)
Headers:
Location: https://example.com/very/long/path?param=value
API 3: Analytics (Thống kê — optional)
GET /api/v1/stats/:shortCode
Response:
{
"short_code": "aB3x7Kp",
"total_clicks": 15420,
"created_at": "2026-03-18T10:00:00Z",
"recent_clicks": [
{ "timestamp": "2026-03-18T14:30:00Z", "country": "VN", "referer": "facebook.com" }
]
}2.2 Redirect: 301 vs 302 — Trade-off quan trọng
| Aspect | 301 Moved Permanently | 302 Found (Temporary) |
|---|---|---|
| Browser caching | Browser cache redirect, lần sau không gọi server | Browser luôn gọi server mỗi lần |
| Server load | Thấp hơn (browser tự redirect) | Cao hơn (mỗi click đều qua server) |
| Analytics accuracy | Kém (nhiều click không qua server) | Tốt (mọi click đều được track) |
| SEO | Pass link juice vĩnh viễn | Không pass link juice |
| Khi nào dùng | Khi không cần analytics, muốn giảm load | Khi cần track analytics chính xác |
Aha Moment #1: Nếu analytics là requirement (và thường là vậy), dùng 302. Bitly dùng 301 kèm JavaScript tracking. TinyURL dùng 302. Trong interview, nên mention cả hai và giải thích trade-off.
2.3 URL Shortening Flow (Luồng rút gọn URL)
sequenceDiagram participant C as Client participant LB as Load Balancer participant API as API Server participant Cache as Redis Cache participant DB as PostgreSQL Note over C,DB: === URL Shortening Flow === C->>LB: POST /api/v1/shorten {long_url} LB->>API: Forward request API->>API: Validate URL format API->>API: Generate short code (Base62) API->>DB: INSERT (short_code, long_url, created_at, expires_at) DB-->>API: Success API->>Cache: SET short_code → long_url (TTL) Cache-->>API: OK API-->>C: 201 {short_url, short_code} Note over C,DB: === URL Redirect Flow === C->>LB: GET /aB3x7Kp LB->>API: Forward request API->>Cache: GET aB3x7Kp alt Cache HIT Cache-->>API: long_url else Cache MISS API->>DB: SELECT long_url WHERE short_code = 'aB3x7Kp' DB-->>API: long_url API->>Cache: SET aB3x7Kp → long_url end API-->>C: 302 Redirect → long_url API--)DB: ASYNC: INSERT click event (analytics)
2.4 Database Schema (Lược đồ cơ sở dữ liệu)
URLs Table (Bảng chính)
CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(16) NOT NULL UNIQUE,
long_url TEXT NOT NULL,
custom_alias VARCHAR(64),
user_id BIGINT, -- nullable, anonymous users allowed
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
-- Index cho redirect lookup (query chính, cần cực nhanh)
CREATE INDEX idx_urls_short_code ON urls (short_code) WHERE is_active = TRUE;
-- Index cho expiration cleanup job
CREATE INDEX idx_urls_expires_at ON urls (expires_at) WHERE is_active = TRUE;
-- Index cho user's URLs listing
CREATE INDEX idx_urls_user_id ON urls (user_id) WHERE user_id IS NOT NULL;Analytics Table (Bảng thống kê click)
CREATE TABLE click_events (
id BIGSERIAL,
short_code VARCHAR(16) NOT NULL,
clicked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip_address INET,
user_agent TEXT,
referer TEXT,
country_code CHAR(2),
-- Partition by time cho performance
PRIMARY KEY (id, clicked_at)
) PARTITION BY RANGE (clicked_at);
-- Tạo partition theo tháng
CREATE TABLE click_events_2026_03 PARTITION OF click_events
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE TABLE click_events_2026_04 PARTITION OF click_events
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
-- Index cho analytics query
CREATE INDEX idx_clicks_short_code ON click_events (short_code, clicked_at);Tại sao partition by time? Click events là append-only, write-heavy data. Partition theo tháng giúp: (1) query analytics theo thời gian nhanh hơn, (2) drop old partitions dễ dàng, (3) vacuum/maintenance hiệu quả hơn.
2.5 High-Level Architecture Diagram
flowchart TB subgraph "Client Layer" Browser["Browser / Mobile App"] end subgraph "Edge Layer" CDN["CDN<br/>(CloudFlare)"] DNS["DNS"] end subgraph "Application Layer" LB["Load Balancer<br/>(Nginx / ALB)"] API1["API Server 1<br/>(Node.js)"] API2["API Server 2<br/>(Node.js)"] API3["API Server N<br/>(Node.js)"] end subgraph "Cache Layer" Redis1["Redis Primary"] Redis2["Redis Replica"] end subgraph "Data Layer" PG_Primary["PostgreSQL Primary<br/>(Write)"] PG_Replica1["PostgreSQL Replica 1<br/>(Read)"] PG_Replica2["PostgreSQL Replica 2<br/>(Read)"] end subgraph "Analytics Pipeline" Kafka["Kafka<br/>(Click Events)"] Consumer["Analytics Consumer"] ClickHouse["ClickHouse<br/>(Analytics Store)"] end subgraph "Background Jobs" Cron["Cron / Scheduler"] end Browser --> DNS --> CDN --> LB LB --> API1 & API2 & API3 API1 & API2 & API3 --> Redis1 Redis1 --> Redis2 API1 & API2 & API3 -->|Write| PG_Primary API1 & API2 & API3 -->|Read| PG_Replica1 & PG_Replica2 PG_Primary --> PG_Replica1 & PG_Replica2 API1 & API2 & API3 -->|Click Event| Kafka Kafka --> Consumer --> ClickHouse Cron -->|Expire old URLs| PG_Primary style Redis1 fill:#dc3545,stroke:#333,color:#fff style Kafka fill:#f9a825,stroke:#333 style PG_Primary fill:#0d6efd,stroke:#333,color:#fff
Step 3 — Design Deep Dive
3.1 Hash Function Selection (Lựa chọn hàm băm)
Đây là trái tim của URL shortener. Từ long URL, làm sao tạo ra short code 7 ký tự duy nhất?
Approach 1: Hash + Truncate (Băm + Cắt ngắn)
| Hash Function | Output Length | Collision Resistance | Tốc độ |
|---|---|---|---|
| CRC32 | 32 bits (8 hex chars) | Thấp | Rất nhanh |
| MD5 | 128 bits (32 hex chars) | Trung bình | Nhanh |
| SHA-256 | 256 bits (64 hex chars) | Cao | Trung bình |
| MurmurHash | 32/128 bits | Trung bình | Rất nhanh |
Quy trình:
- Hash long URL:
MD5("https://example.com/...") → "5d41402abc4b2a76b9719d911017c592" - Lấy 7 ký tự đầu:
"5d41402" - Convert sang Base62:
"aB3x7Kp"
Vấn đề: Truncate → collision (trùng). Hai URL khác nhau có thể ra cùng 7 ký tự.
Giải pháp collision:
Bước 1: hash(long_url) → short_code
Bước 2: Check DB: short_code đã tồn tại?
- Nếu chưa → Lưu
- Nếu rồi → hash(long_url + "1") → thử lại (tối đa N lần)
Pitfall #1: Collision handling với retry tạo race condition trong concurrent system. Cần dùng DB unique constraint + retry với backoff.
Approach 2: Base62 Conversion từ Unique ID (Chuyển đổi Base62 từ ID duy nhất)
Ý tưởng: Thay vì hash URL, tạo một unique numeric ID rồi convert sang Base62.
Ví dụ: ID = 2009215674938 → Base62 → "aB3x7Kp"
2009215674938 ÷ 62 = 32406704434 dư 30 → 'U'
32406704434 ÷ 62 = 522688781 dư 12 → 'c'
522688781 ÷ 62 = 8430464 dư 53 → 'R'
8430464 ÷ 62 = 135975 dư 14 → 'e'
135975 ÷ 62 = 2193 dư 9 → '9'
2193 ÷ 62 = 35 dư 23 → 'n'
35 ÷ 62 = 0 dư 35 → 'z'
→ Kết quả: "zn9eRcU"
Ưu điểm: Không bao giờ collision (mỗi ID là duy nhất). Nhược điểm: Cần một Unique ID Generator đáng tin cậy.
So sánh hai approaches
| Aspect | Hash + Truncate | Base62 from Unique ID |
|---|---|---|
| Collision | Có thể xảy ra, cần handling | Không bao giờ |
| Cùng URL → cùng short code? | Có (deterministic) | Không (mỗi lần tạo ID mới) |
| Dependency | Chỉ cần hash function | Cần ID generator |
| Đoán được URL tiếp theo? | Không | Có thể (nếu dùng auto-increment) |
| Performance | Nhanh (compute only) | Phụ thuộc ID generator |
| Phổ biến trong thực tế | Ít | Nhiều (Bitly, TinyURL dùng cách này) |
Aha Moment #2: Trong interview, nên trình bày cả hai rồi chọn Base62 from Unique ID vì không có collision. Interviewer đánh giá cao khả năng phân tích trade-off.
3.2 Unique ID Generator (Bộ tạo ID duy nhất)
Nếu chọn Base62 from Unique ID, câu hỏi tiếp theo: lấy unique ID từ đâu?
| Approach | Ưu điểm | Nhược điểm | QPS |
|---|---|---|---|
| Auto-increment (DB) | Đơn giản, đảm bảo unique | Single point of failure, bottleneck khi scale | ~5K/s |
| UUID v4 | Distributed, không cần coordination | 128 bits quá dài (22 chars Base62), không sequential | Unlimited |
| Snowflake ID (Twitter) | 64-bit, distributed, sequential, chứa timestamp | Cần setup worker ID, clock sync | ~4M/s/node |
| Pre-generated ID ranges | Rất nhanh (lấy từ memory), không bottleneck | Phức tạp hơn, cần range allocation service | Unlimited |
Snowflake ID — Recommended
┌─────────────┬──────────┬───────────┬──────────────┐
│ 1 bit │ 41 bits │ 10 bits │ 12 bits │
│ (unused) │ timestamp│ machine ID│ sequence │
└─────────────┴──────────┴───────────┴──────────────┘
- 41 bits timestamp: ~69 năm (đủ cho hầu hết hệ thống)
- 10 bits machine ID: 1,024 machines
- 12 bits sequence: 4,096 IDs/ms/machine
Nhận xét: 4 triệu IDs/ms — thừa sức cho 350 write QPS peak của URL shortener.
Pre-generated ID Ranges — Alternative
flowchart LR subgraph "ID Range Service" RangeDB["Range DB<br/>next_range: 18,000,001"] end subgraph "API Server 1" Pool1["Local Pool<br/>[15,000,001 - 16,000,000]<br/>current: 15,000,042"] end subgraph "API Server 2" Pool2["Local Pool<br/>[16,000,001 - 17,000,000]<br/>current: 16,000,789"] end subgraph "API Server 3" Pool3["Local Pool<br/>[17,000,001 - 18,000,000]<br/>current: 17,000,123"] end RangeDB -->|"Allocate 1M range"| Pool1 RangeDB -->|"Allocate 1M range"| Pool2 RangeDB -->|"Allocate 1M range"| Pool3
Cách hoạt động:
- Mỗi API server xin một range (vd: 1 triệu IDs) từ Range Service
- Server dùng ID từ local pool (không cần gọi DB mỗi lần)
- Khi pool gần hết → xin range mới
Ưu điểm: Cực kỳ nhanh (lấy ID từ memory), không bottleneck. Nhược điểm: Nếu server crash → mất IDs chưa dùng trong range (acceptable loss).
3.3 Database Choice (Lựa chọn cơ sở dữ liệu)
SQL vs NoSQL cho URL Shortener
| Tiêu chí | SQL (PostgreSQL) | NoSQL (DynamoDB / Cassandra) |
|---|---|---|
| Data model | Relational, structured | Key-Value, flexible |
| Read pattern | Point query by short_code | Point query by partition key |
| Write pattern | Sequential, indexed | Distributed, auto-sharded |
| Consistency | Strong consistency | Eventual consistency (tunable) |
| Scaling | Vertical + Read replicas + Partitioning | Horizontal, built-in |
| Query flexibility | JOIN, complex queries, analytics | Limited query patterns |
| 10TB data | Cần partitioning, manageable | Native distribution |
Recommendation: Cả hai đều phù hợp. Trong interview, chọn dựa trên team expertise. Nếu cần analytics phức tạp → PostgreSQL. Nếu cần scale vô hạn với simple key-value lookup → DynamoDB.
Partition Strategy (Chiến lược phân vùng)
Option A — Range-based partitioning (theo short_code):
-- Partition theo first character of short_code
CREATE TABLE urls_a PARTITION OF urls FOR VALUES FROM ('a') TO ('b');
CREATE TABLE urls_b PARTITION OF urls FOR VALUES FROM ('b') TO ('c');
-- ... 62 partitions (0-9, a-z, A-Z)Option B — Hash-based partitioning (theo hash):
-- PostgreSQL hash partitioning
CREATE TABLE urls (
id BIGSERIAL,
short_code VARCHAR(16) NOT NULL,
long_url TEXT NOT NULL,
...
) PARTITION BY HASH (short_code);
CREATE TABLE urls_p0 PARTITION OF urls FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE urls_p1 PARTITION OF urls FOR VALUES WITH (MODULUS 8, REMAINDER 1);
-- ... 8 partitionsRecommendation: Hash-based partition vì phân bố data đều hơn. Range-based có risk hot partition (vd: partition ‘a’ có nhiều data hơn partition ‘Z’).
3.4 Cache Layer (Tầng cache)
Cache-Aside Pattern (Lazy Loading)
flowchart TD A["GET /aB3x7Kp"] --> B{"Redis: GET aB3x7Kp"} B -->|"HIT ✓"| C["Return long_url<br/>Latency: ~1ms"] B -->|"MISS ✗"| D["PostgreSQL: SELECT<br/>WHERE short_code = 'aB3x7Kp'"] D --> E{"Found?"} E -->|Yes| F["Redis: SET aB3x7Kp<br/>TTL = URL's expiration"] F --> G["Return long_url<br/>Latency: ~5ms"] E -->|No| H["Return 404<br/>NOT FOUND"] style C fill:#28a745,stroke:#333,color:#fff style G fill:#ffc107,stroke:#333 style H fill:#dc3545,stroke:#333,color:#fff
Tại sao Cache-Aside mà không phải Write-Through?
| Pattern | Khi nào dùng | Lý do |
|---|---|---|
| Cache-Aside (Lazy Loading) | Read-heavy, data ít thay đổi | URL mapping gần như immutable. Chỉ cache khi cần → tiết kiệm memory |
| Write-Through | Data thay đổi thường xuyên, cần consistency | Overkill cho URL shortener |
Cache eviction strategy: LRU (Least Recently Used) — URL nào lâu không ai click → bị evict trước.
Cache key design:
Key: url:{short_code}
Value: {long_url}
TTL: min(URL's expires_at - now(), 24 hours)
Aha Moment #3: Set cache TTL = thời gian URL hết hạn (hoặc max 24h, tuỳ cái nào ngắn hơn). Nếu set TTL quá dài → URL đã expire nhưng cache vẫn redirect. Nếu quá ngắn → tăng cache miss.
Cache Performance Impact
| Scenario | Latency | QPS capacity |
|---|---|---|
| Không cache | ~5ms (DB lookup) | ~5,000 QPS (DB limit) |
| Có Redis cache (80% hit rate) | ~1.8ms avg | ~50,000+ QPS |
| Có Redis cache (95% hit rate) | ~1.2ms avg | ~80,000+ QPS |
3.5 Rate Limiting (Giới hạn tốc độ)
Chi tiết: Tuan-09-Rate-Limiter
Tại sao cần rate limit cho URL shortener?
- Chống spam: Bot tạo hàng triệu short URLs cho phishing
- Chống abuse: Dùng service như open redirect proxy
- Bảo vệ resource: Ngăn một user chiếm hết write capacity
| Endpoint | Rate Limit | Strategy |
|---|---|---|
POST /api/v1/shorten | 10 req/min/IP (anonymous), 100 req/min/user (authenticated) | Token Bucket |
GET /:shortCode | 1000 req/min/IP | Sliding Window |
GET /api/v1/stats/:shortCode | 30 req/min/user | Fixed Window |
Rate limit implementation dùng Redis:
Key: rate_limit:{ip}:{endpoint}:{window}
Value: counter
TTL: window duration
Ví dụ: rate_limit:192.168.1.1:shorten:2026-03-18T14:30 → 7
3.6 Analytics Pipeline (Hệ thống phân tích)
Analytics cho URL shortener cần xử lý write-heavy (mỗi click = 1 event) mà không ảnh hưởng redirect latency.
flowchart LR subgraph "Hot Path (Synchronous)" A["GET /aB3x7Kp"] --> B["API Server"] B --> C["Redis Lookup"] C --> D["302 Redirect"] end subgraph "Warm Path (Asynchronous)" B --> E["Kafka Producer<br/>(fire & forget)"] E --> F["Kafka Topic:<br/>click-events"] F --> G["Analytics Consumer<br/>(batch processing)"] end subgraph "Cold Path (Batch)" G --> H["ClickHouse<br/>(OLAP Database)"] H --> I["Analytics Dashboard<br/>(Grafana)"] end style D fill:#28a745,stroke:#333,color:#fff style F fill:#f9a825,stroke:#333 style H fill:#0d6efd,stroke:#333,color:#fff
Tại sao tách analytics ra async?
- Redirect phải < 50ms. Nếu sync write analytics → thêm 5-10ms DB write → tăng latency 20%.
- Kafka producer fire-and-forget chỉ thêm ~0.5ms.
- Nếu analytics pipeline chết → redirect vẫn hoạt động bình thường.
Click event schema (Kafka message):
{
"short_code": "aB3x7Kp",
"timestamp": "2026-03-18T14:30:00.123Z",
"ip": "203.113.x.x",
"user_agent": "Mozilla/5.0...",
"referer": "https://facebook.com/post/123",
"country": "VN"
}Step 4 — Wrap Up
4.1 Scaling Discussion (Thảo luận mở rộng)
Horizontal Scaling
| Component | Scaling Strategy |
|---|---|
| API Servers | Stateless → thêm instances sau Load Balancer |
| PostgreSQL | Read replicas cho read, partitioning cho data size |
| Redis | Redis Cluster (sharding by key hash) |
| Kafka | Thêm partitions + consumers |
| ClickHouse | Sharding by short_code hash |
Multi-region Deployment
Nếu cần serve global users:
flowchart TB subgraph "Region: US-East" LB_US["Load Balancer"] API_US["API Servers"] Redis_US["Redis"] DB_US["PostgreSQL Primary"] end subgraph "Region: Asia-Pacific (Vietnam)" LB_AP["Load Balancer"] API_AP["API Servers"] Redis_AP["Redis"] DB_AP["PostgreSQL Replica"] end subgraph "Global" GDNS["GeoDNS<br/>(Route53)"] end GDNS -->|"User ở VN"| LB_AP GDNS -->|"User ở US"| LB_US DB_US -->|"Cross-region<br/>Replication"| DB_AP style GDNS fill:#f9a825,stroke:#333 style DB_US fill:#0d6efd,stroke:#333,color:#fff
Trade-off multi-region:
- Write chỉ đi vào Primary (US-East) → write latency cao cho user VN
- Read từ local replica → read latency thấp
- Replication lag: 50-200ms → URL vừa tạo ở VN có thể chưa redirect được ở US trong vài trăm ms
4.2 Security (Bảo mật)
Malicious URL Detection (Phát hiện URL độc hại)
| Threat | Giải pháp | Chi tiết |
|---|---|---|
| Phishing URLs | URL scanning service | Google Safe Browsing API, VirusTotal API |
| Malware distribution | Real-time + periodic scan | Scan khi tạo + rescan weekly |
| Open redirect abuse | Restrict target domains | Blacklist known malicious domains |
| Spam/SEO abuse | Rate limiting + CAPTCHA | Chống bot tạo hàng loạt |
| Enumeration attack | Non-sequential short codes | Snowflake ID → Base62 (không đoán được URL tiếp theo) |
OWASP Top 10 Considerations
| OWASP Risk | Áp dụng cho URL Shortener | Giải pháp |
|---|---|---|
| A01 Broken Access Control | User A xoá/edit URL của User B | Authorization check, ownership validation |
| A03 Injection | SQL injection qua long_url parameter | Parameterized queries, input validation |
| A05 Security Misconfiguration | API endpoint không cần auth bị expose | Chỉ expose GET /:shortCode công khai |
| A07 XSS | Long URL chứa JavaScript | Encode output, Content-Security-Policy header |
| A09 Security Logging | Không log suspicious activities | Log failed rate limits, malicious URL attempts |
Input Validation Checklist
1. long_url:
- Phải bắt đầu bằng http:// hoặc https://
- Max length: 2,048 characters
- Không chứa localhost, 127.0.0.1, private IP ranges
- URL encode properly
2. custom_alias:
- Chỉ cho phép [a-zA-Z0-9-_]
- Min: 4 chars, Max: 64 chars
- Không trùng reserved words (api, admin, health, metrics)
3. expiration_days:
- Min: 1, Max: 1825 (5 năm)
- Integer only
4.3 DevOps — Deployment, Monitoring, Alerting
Deployment Strategy
# docker-compose.yml (Development)
version: '3.8'
services:
api:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://user:pass@postgres:5432/urlshortener
- REDIS_URL=redis://redis:6379
- KAFKA_BROKERS=kafka:9092
depends_on:
- postgres
- redis
- kafka
deploy:
replicas: 2
postgres:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: urlshortener
redis:
image: redis:7-alpine
command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru
kafka:
image: confluentinc/cp-kafka:7.5.0
environment:
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"Monitoring & Alerting
# prometheus-alerts.yml
groups:
- name: url_shortener_alerts
rules:
# Redirect latency quá cao
- alert: RedirectLatencyHigh
expr: histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{path="/:shortCode"}[5m])) > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "P99 redirect latency > 50ms ({{ $value }}s)"
# Cache hit rate giảm
- alert: CacheHitRateLow
expr: >
redis_keyspace_hits_total /
(redis_keyspace_hits_total + redis_keyspace_misses_total) < 0.75
for: 10m
labels:
severity: warning
annotations:
summary: "Cache hit rate dropped below 75%"
# Write QPS vượt ngưỡng
- alert: WriteQPSHigh
expr: rate(http_requests_total{method="POST", path="/api/v1/shorten"}[5m]) > 280
for: 5m
labels:
severity: warning
annotations:
summary: "Write QPS approaching peak capacity ({{ $value }}/s vs 350/s peak)"
# Error rate tăng
- alert: HighErrorRate
expr: >
rate(http_requests_total{status=~"5.."}[5m]) /
rate(http_requests_total[5m]) > 0.01
for: 5m
labels:
severity: critical
annotations:
summary: "Error rate > 1% ({{ $value | humanizePercentage }})"
# DB connection pool cạn
- alert: DBConnectionPoolExhausted
expr: pg_stat_activity_count / pg_settings_max_connections > 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "DB connection pool > 80% capacity"Grafana Dashboard Panels
| Panel | PromQL | Threshold |
|---|---|---|
| Redirect QPS | rate(http_requests_total{path="/:shortCode"}[1m]) | Warning: 2,800; Critical: 3,325 |
| Redirect P99 Latency | histogram_quantile(0.99, rate(http_request_duration_seconds_bucket{path="/:shortCode"}[5m])) | < 50ms |
| URL Creation Rate | rate(http_requests_total{method="POST",path="/api/v1/shorten"}[1m]) | Warning: 280/s |
| Cache Hit Rate | redis_keyspace_hits / (hits + misses) | > 80% |
| DB Disk Usage | pg_database_size_bytes{datname="urlshortener"} | Predict 90 days |
| Kafka Consumer Lag | kafka_consumergroup_lag | < 10,000 messages |
| Active Short URLs | pg_stat_user_tables_n_live_tup{relname="urls"} | Informational |
Health Check Endpoint
GET /health
Response 200:
{
"status": "healthy",
"version": "1.2.0",
"uptime": "72h30m",
"checks": {
"database": "ok",
"redis": "ok",
"kafka": "ok"
}
}
Response 503 (nếu bất kỳ dependency nào chết):
{
"status": "unhealthy",
"checks": {
"database": "ok",
"redis": "timeout after 2s",
"kafka": "ok"
}
}
5. Code Example — Node.js URL Shortener (Redis + PostgreSQL)
Project Structure
url-shortener/
├── src/
│ ├── server.js # Entry point
│ ├── routes/
│ │ ├── shorten.js # POST /api/v1/shorten
│ │ └── redirect.js # GET /:shortCode
│ ├── services/
│ │ ├── urlService.js # Business logic
│ │ └── idGenerator.js # Base62 + Snowflake
│ ├── middleware/
│ │ ├── rateLimiter.js # Rate limiting
│ │ └── validator.js # Input validation
│ ├── db/
│ │ ├── postgres.js # PostgreSQL connection pool
│ │ └── redis.js # Redis client
│ └── config.js # Environment config
├── docker-compose.yml
├── package.json
└── .env
Full Implementation
// ============================================================
// src/config.js — Configuration
// ============================================================
const config = {
port: process.env.PORT || 3000,
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
// PostgreSQL
db: {
connectionString: process.env.DATABASE_URL || 'postgresql://localhost:5432/urlshortener',
max: 20, // Connection pool size
idleTimeoutMillis: 30000, // Close idle connections after 30s
},
// Redis
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
defaultTTL: 86400, // 24 hours default cache TTL
},
// URL Shortener
shortCode: {
length: 7,
maxCustomAliasLength: 64,
defaultExpirationDays: 1825, // 5 năm
maxUrlLength: 2048,
},
// Rate Limiting
rateLimit: {
shortenAnonymous: { max: 10, windowMs: 60 * 1000 }, // 10 req/min
shortenAuth: { max: 100, windowMs: 60 * 1000 }, // 100 req/min
redirect: { max: 1000, windowMs: 60 * 1000 }, // 1000 req/min
},
};
module.exports = config;
// ============================================================
// src/db/postgres.js — PostgreSQL Connection Pool
// ============================================================
const { Pool } = require('pg');
const config = require('../config');
const pool = new Pool(config.db);
pool.on('error', (err) => {
console.error('Unexpected PostgreSQL error:', err);
process.exit(1);
});
module.exports = pool;
// ============================================================
// src/db/redis.js — Redis Client
// ============================================================
const { createClient } = require('redis');
const config = require('../config');
const redis = createClient({ url: config.redis.url });
redis.on('error', (err) => console.error('Redis error:', err));
redis.on('connect', () => console.log('Redis connected'));
// Connect when imported
redis.connect();
module.exports = redis;
// ============================================================
// src/services/idGenerator.js — Base62 Encoding + ID Generation
// ============================================================
const BASE62_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
/**
* Convert a numeric ID to Base62 string
* Chuyển đổi số → chuỗi Base62
*
* @param {bigint|number} num - The numeric ID
* @param {number} minLength - Minimum output length (padded with '0')
* @returns {string} Base62 encoded string
*/
function toBase62(num, minLength = 7) {
if (num === 0n || num === 0) return '0'.repeat(minLength);
let n = BigInt(num);
let result = '';
while (n > 0n) {
result = BASE62_CHARS[Number(n % 62n)] + result;
n = n / 62n;
}
// Pad to minimum length (đệm đến độ dài tối thiểu)
while (result.length < minLength) {
result = '0' + result;
}
return result;
}
/**
* Simple Snowflake-like ID generator
* Bộ tạo ID kiểu Snowflake đơn giản
*
* Format: timestamp(41 bits) + machineId(10 bits) + sequence(12 bits)
*/
class SnowflakeGenerator {
constructor(machineId = 0) {
this.machineId = BigInt(machineId & 0x3FF); // 10 bits max
this.sequence = 0n;
this.lastTimestamp = 0n;
// Custom epoch: 2024-01-01T00:00:00Z
this.epoch = 1704067200000n;
}
nextId() {
let timestamp = BigInt(Date.now()) - this.epoch;
if (timestamp === this.lastTimestamp) {
this.sequence = (this.sequence + 1n) & 0xFFFn; // 12 bits
if (this.sequence === 0n) {
// Wait for next millisecond (chờ mili giây tiếp theo)
while (timestamp <= this.lastTimestamp) {
timestamp = BigInt(Date.now()) - this.epoch;
}
}
} else {
this.sequence = 0n;
}
this.lastTimestamp = timestamp;
return (timestamp << 22n) | (this.machineId << 12n) | this.sequence;
}
}
const generator = new SnowflakeGenerator(
parseInt(process.env.MACHINE_ID || '0', 10)
);
/**
* Generate a unique short code
* Tạo mã ngắn duy nhất
*/
function generateShortCode() {
const id = generator.nextId();
return toBase62(id);
}
module.exports = { toBase62, generateShortCode, SnowflakeGenerator };
// ============================================================
// src/middleware/validator.js — Input Validation
// ============================================================
const config = require('../config');
// Danh sách reserved words không cho dùng làm custom alias
const RESERVED_WORDS = new Set([
'api', 'admin', 'health', 'metrics', 'stats',
'login', 'signup', 'dashboard', 'static', 'assets',
]);
/**
* Validate URL format (Kiểm tra định dạng URL)
*/
function isValidUrl(str) {
try {
const url = new URL(str);
// Chỉ cho phép http và https
if (!['http:', 'https:'].includes(url.protocol)) return false;
// Chặn private/internal URLs
const hostname = url.hostname.toLowerCase();
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname.startsWith('10.') ||
hostname.startsWith('192.168.') ||
hostname.startsWith('172.') ||
hostname.endsWith('.internal')
) {
return false;
}
return true;
} catch {
return false;
}
}
/**
* Validate custom alias (Kiểm tra alias tuỳ chỉnh)
*/
function isValidAlias(alias) {
if (!alias) return true; // optional field
if (alias.length < 4 || alias.length > config.shortCode.maxCustomAliasLength) return false;
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) return false;
if (RESERVED_WORDS.has(alias.toLowerCase())) return false;
return true;
}
/**
* Express middleware: validate shorten request
*/
function validateShortenRequest(req, res, next) {
const { long_url, custom_alias, expiration_days } = req.body;
// Validate long_url
if (!long_url || typeof long_url !== 'string') {
return res.status(400).json({ error: 'long_url is required' });
}
if (long_url.length > config.shortCode.maxUrlLength) {
return res.status(400).json({ error: `URL exceeds max length of ${config.shortCode.maxUrlLength}` });
}
if (!isValidUrl(long_url)) {
return res.status(400).json({ error: 'Invalid URL format or blocked domain' });
}
// Validate custom_alias
if (!isValidAlias(custom_alias)) {
return res.status(400).json({
error: 'Invalid alias. Must be 4-64 chars, alphanumeric with - and _ only, not a reserved word.',
});
}
// Validate expiration_days
if (expiration_days !== undefined) {
const days = parseInt(expiration_days, 10);
if (isNaN(days) || days < 1 || days > config.shortCode.defaultExpirationDays) {
return res.status(400).json({
error: `expiration_days must be between 1 and ${config.shortCode.defaultExpirationDays}`,
});
}
req.body.expiration_days = days;
}
next();
}
module.exports = { validateShortenRequest, isValidUrl, isValidAlias };
// ============================================================
// src/middleware/rateLimiter.js — Redis-based Rate Limiter
// ============================================================
const redis = require('../db/redis');
/**
* Token Bucket rate limiter using Redis
* Giới hạn tốc độ dùng Token Bucket với Redis
*
* @param {object} options - { max, windowMs }
*/
function createRateLimiter({ max, windowMs }) {
return async (req, res, next) => {
const key = `rate_limit:${req.ip}:${req.path}`;
try {
const current = await redis.incr(key);
if (current === 1) {
// Lần đầu trong window → set TTL
await redis.pExpire(key, windowMs);
}
// Set rate limit headers (thông báo cho client)
res.set('X-RateLimit-Limit', String(max));
res.set('X-RateLimit-Remaining', String(Math.max(0, max - current)));
if (current > max) {
const ttl = await redis.pTTL(key);
res.set('Retry-After', String(Math.ceil(ttl / 1000)));
return res.status(429).json({
error: 'Too many requests. Please try again later.',
retry_after_seconds: Math.ceil(ttl / 1000),
});
}
next();
} catch (err) {
// Nếu Redis lỗi → cho request qua (fail-open)
// Trong production, cân nhắc fail-closed cho security-critical endpoints
console.error('Rate limiter error:', err);
next();
}
};
}
module.exports = { createRateLimiter };
// ============================================================
// src/services/urlService.js — Core Business Logic
// ============================================================
const pool = require('../db/postgres');
const redis = require('../db/redis');
const { generateShortCode } = require('./idGenerator');
const config = require('../config');
/**
* Tạo short URL mới
* Create a new short URL
*/
async function createShortUrl({ longUrl, customAlias, expirationDays }) {
const shortCode = customAlias || generateShortCode();
const expDays = expirationDays || config.shortCode.defaultExpirationDays;
const query = `
INSERT INTO urls (short_code, long_url, custom_alias, expires_at)
VALUES ($1, $2, $3, NOW() + INTERVAL '1 day' * $4)
RETURNING short_code, long_url, created_at, expires_at
`;
try {
const result = await pool.query(query, [
shortCode,
longUrl,
customAlias || null,
expDays,
]);
const row = result.rows[0];
// Cache ngay sau khi tạo (write-around → cache-aside hybrid)
const ttlSeconds = Math.floor(
(new Date(row.expires_at).getTime() - Date.now()) / 1000
);
await redis.set(`url:${shortCode}`, longUrl, { EX: Math.min(ttlSeconds, config.redis.defaultTTL) });
return {
shortUrl: `${config.baseUrl}/${row.short_code}`,
shortCode: row.short_code,
longUrl: row.long_url,
createdAt: row.created_at,
expiresAt: row.expires_at,
};
} catch (err) {
// Unique constraint violation → alias đã tồn tại
if (err.code === '23505') {
const error = new Error('Short code or alias already exists');
error.statusCode = 409;
throw error;
}
throw err;
}
}
/**
* Tìm long URL từ short code (dùng cho redirect)
* Resolve short code to long URL (for redirect)
*/
async function resolveShortCode(shortCode) {
// Bước 1: Check cache trước (Cache-Aside pattern)
const cached = await redis.get(`url:${shortCode}`);
if (cached) {
return { longUrl: cached, source: 'cache' };
}
// Bước 2: Cache miss → query DB
const query = `
SELECT long_url, expires_at
FROM urls
WHERE short_code = $1
AND is_active = TRUE
AND expires_at > NOW()
`;
const result = await pool.query(query, [shortCode]);
if (result.rows.length === 0) {
return null; // Not found or expired
}
const { long_url: longUrl, expires_at: expiresAt } = result.rows[0];
// Bước 3: Cache lại cho lần sau
const ttlSeconds = Math.floor(
(new Date(expiresAt).getTime() - Date.now()) / 1000
);
await redis.set(`url:${shortCode}`, longUrl, {
EX: Math.min(ttlSeconds, config.redis.defaultTTL),
});
return { longUrl, source: 'database' };
}
/**
* Ghi nhận click event (async, không block redirect)
* Record click event asynchronously
*/
async function recordClick(shortCode, req) {
// Fire-and-forget: không await, không block redirect
const query = `
INSERT INTO click_events (short_code, ip_address, user_agent, referer)
VALUES ($1, $2, $3, $4)
`;
pool.query(query, [
shortCode,
req.ip,
req.get('User-Agent') || null,
req.get('Referer') || null,
]).catch((err) => {
// Log nhưng không crash (analytics loss is acceptable)
console.error('Failed to record click:', err.message);
});
}
/**
* Lấy analytics cho một short code
* Get analytics for a short code
*/
async function getStats(shortCode) {
const urlQuery = `
SELECT short_code, long_url, created_at, expires_at
FROM urls
WHERE short_code = $1 AND is_active = TRUE
`;
const clickCountQuery = `
SELECT COUNT(*) as total_clicks
FROM click_events
WHERE short_code = $1
`;
const recentClicksQuery = `
SELECT clicked_at, country_code, referer
FROM click_events
WHERE short_code = $1
ORDER BY clicked_at DESC
LIMIT 10
`;
const [urlResult, countResult, recentResult] = await Promise.all([
pool.query(urlQuery, [shortCode]),
pool.query(clickCountQuery, [shortCode]),
pool.query(recentClicksQuery, [shortCode]),
]);
if (urlResult.rows.length === 0) return null;
return {
...urlResult.rows[0],
totalClicks: parseInt(countResult.rows[0].total_clicks, 10),
recentClicks: recentResult.rows,
};
}
module.exports = { createShortUrl, resolveShortCode, recordClick, getStats };
// ============================================================
// src/routes/shorten.js — POST /api/v1/shorten
// ============================================================
const express = require('express');
const router = express.Router();
const { createShortUrl } = require('../services/urlService');
const { validateShortenRequest } = require('../middleware/validator');
const { createRateLimiter } = require('../middleware/rateLimiter');
const config = require('../config');
// Rate limit: 10 req/min cho anonymous
const shortenLimiter = createRateLimiter(config.rateLimit.shortenAnonymous);
router.post(
'/api/v1/shorten',
shortenLimiter,
validateShortenRequest,
async (req, res) => {
try {
const { long_url, custom_alias, expiration_days } = req.body;
const result = await createShortUrl({
longUrl: long_url,
customAlias: custom_alias,
expirationDays: expiration_days,
});
res.status(201).json(result);
} catch (err) {
if (err.statusCode === 409) {
return res.status(409).json({ error: err.message });
}
console.error('Shorten error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
module.exports = router;
// ============================================================
// src/routes/redirect.js — GET /:shortCode
// ============================================================
const express = require('express');
const router = express.Router();
const { resolveShortCode, recordClick } = require('../services/urlService');
router.get('/:shortCode', async (req, res) => {
try {
const { shortCode } = req.params;
// Validate short code format (chỉ cho phép Base62 chars)
if (!/^[a-zA-Z0-9_-]{4,16}$/.test(shortCode)) {
return res.status(400).json({ error: 'Invalid short code format' });
}
const result = await resolveShortCode(shortCode);
if (!result) {
return res.status(404).json({ error: 'Short URL not found or expired' });
}
// Ghi nhận click async (fire-and-forget)
recordClick(shortCode, req);
// 302 Found — để track analytics chính xác
res.redirect(302, result.longUrl);
} catch (err) {
console.error('Redirect error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;
// ============================================================
// src/server.js — Express Application Entry Point
// ============================================================
const express = require('express');
const helmet = require('helmet');
const config = require('./config');
const pool = require('./db/postgres');
const redis = require('./db/redis');
const app = express();
// Security middleware
app.use(helmet());
app.use(express.json({ limit: '10kb' })); // Giới hạn payload size
// Trust proxy (nếu sau Load Balancer)
app.set('trust proxy', 1);
// Health check — cho Load Balancer / Kubernetes
app.get('/health', async (req, res) => {
const checks = {};
try {
await pool.query('SELECT 1');
checks.database = 'ok';
} catch {
checks.database = 'error';
}
try {
await redis.ping();
checks.redis = 'ok';
} catch {
checks.redis = 'error';
}
const allHealthy = Object.values(checks).every((v) => v === 'ok');
res.status(allHealthy ? 200 : 503).json({
status: allHealthy ? 'healthy' : 'unhealthy',
checks,
uptime: process.uptime(),
});
});
// Routes
app.use(require('./routes/shorten'));
app.use(require('./routes/redirect')); // Phải đặt sau /api routes
// Graceful shutdown (tắt server an toàn)
process.on('SIGTERM', async () => {
console.log('SIGTERM received. Shutting down gracefully...');
await pool.end();
await redis.quit();
process.exit(0);
});
app.listen(config.port, () => {
console.log(`URL Shortener running on port ${config.port}`);
console.log(`Base URL: ${config.baseUrl}`);
});6. Detailed System Architecture Diagram
flowchart TB subgraph "Clients" Web["Web Browser"] Mobile["Mobile App"] APIClient["API Client<br/>(curl, SDK)"] end subgraph "Edge" WAF["WAF<br/>(Web Application Firewall)<br/>Chặn malicious requests"] CDN["CDN / GeoDNS<br/>(CloudFlare)<br/>Route to nearest PoP"] end subgraph "Load Balancing" LB["Load Balancer<br/>(Nginx / ALB)<br/>Round-Robin + Health Check"] end subgraph "Application Servers (Stateless)" direction LR API1["API Server 1"] API2["API Server 2"] APIN["API Server N"] end subgraph "Cache Layer" direction LR RedisCluster["Redis Cluster<br/>3 shards x 2 replicas<br/>LRU eviction<br/>~30GB hot URLs"] end subgraph "Database Layer" direction LR PGPrimary["PostgreSQL Primary<br/>(Write Master)<br/>Hash Partitioned<br/>urls + click_events"] PGReplica1["PG Read Replica 1"] PGReplica2["PG Read Replica 2"] end subgraph "Async Processing" Kafka["Kafka<br/>Topic: click-events<br/>Partitions: 8"] AnalyticsWorker["Analytics Worker<br/>(Consumer Group)"] ClickHouse["ClickHouse<br/>OLAP Analytics Store"] end subgraph "Monitoring" Prometheus["Prometheus"] Grafana["Grafana Dashboard"] AlertManager["AlertManager<br/>→ Slack / PagerDuty"] end subgraph "Security" SafeBrowsing["Google Safe<br/>Browsing API"] RateLimitStore["Rate Limit<br/>(Redis counters)"] end subgraph "Background" ExpirationJob["Expiration Cleanup<br/>Cron: hourly"] URLScanner["URL Safety Scanner<br/>Cron: daily"] end Web & Mobile & APIClient --> WAF --> CDN --> LB LB --> API1 & API2 & APIN API1 & API2 & APIN --> RedisCluster API1 & API2 & APIN -->|Write| PGPrimary API1 & API2 & APIN -->|Read| PGReplica1 & PGReplica2 PGPrimary --> PGReplica1 & PGReplica2 API1 & API2 & APIN -->|Async Click| Kafka Kafka --> AnalyticsWorker --> ClickHouse API1 & API2 & APIN --> RateLimitStore API1 & API2 & APIN -.->|Validate URL| SafeBrowsing ExpirationJob --> PGPrimary URLScanner --> PGPrimary URLScanner -.-> SafeBrowsing API1 & API2 & APIN --> Prometheus Prometheus --> Grafana Prometheus --> AlertManager style RedisCluster fill:#dc3545,stroke:#333,color:#fff style PGPrimary fill:#0d6efd,stroke:#333,color:#fff style Kafka fill:#f9a825,stroke:#333 style WAF fill:#6f42c1,stroke:#333,color:#fff style ClickHouse fill:#28a745,stroke:#333,color:#fff
7. Aha Moments — Đúc kết
#1 — 301 vs 302 là câu hỏi trap: Interviewer muốn nghe trade-off analysis, không phải câu trả lời đúng/sai. 302 cho analytics, 301 cho performance. Mention cả hai.
#2 — Base62 from Unique ID > Hash + Truncate: Hash có collision, Base62 from unique ID thì không. Nhưng hãy trình bày cả hai approaches rồi chọn — đó mới là System Design thinking.
#3 — Cache TTL phải gắn với URL expiration: Nếu URL hết hạn mà cache chưa hết → user vẫn redirect được đến trang đã “xoá”. Đây là data consistency issue thường bị bỏ qua.
#4 — Analytics phải async: Redirect là hot path, phải < 50ms. Bất kỳ synchronous operation nào thêm vào hot path đều là risk. Kafka fire-and-forget chỉ thêm ~0.5ms.
#5 — 10TB trong 5 năm nghe nhiều nhưng manageable: PostgreSQL với partitioning handle được. Không cần nhảy sang NoSQL ngay. Start simple, add complexity when needed → Tuan-01-Scale-From-Zero-To-Millions.
#6 — Short code length 7 = = 3.5 trillion: Đủ cho 18.25B URLs trong 5 năm với biên độ an toàn cực lớn. Đừng over-engineer chiều dài short code.
8. Common Pitfalls — Sai lầm thường gặp
Pitfall 1: Không xử lý collision cho Hash approach
Sai: “Dùng MD5 hash, lấy 7 chars đầu, xong.” Đúng: MD5 truncated tới 7 chars có collision probability cao. Cần retry mechanism với unique constraint trong DB. Hoặc tốt hơn: dùng Unique ID + Base62.
Pitfall 2: Dùng 301 redirect rồi hỏi sao analytics không chính xác
Sai: “Dùng 301 cho nhanh, rồi đếm click ở server.” Đúng: 301 = browser cache → lần click thứ 2 trở đi không qua server → không đếm được. Dùng 302 nếu cần analytics.
Pitfall 3: Synchronous analytics trong redirect path
Sai:
await db.insertClick(...)rồi mới redirect. Đúng: Fire-and-forget hoặc push vào message queue. Analytics loss vài click acceptable, nhưng tăng redirect latency 10ms thì không.
Pitfall 4: Quên input validation cho long_url
Sai: Accept mọi string làm long_url. Đúng: Validate URL format, chặn
javascript:,data:, private IPs (SSRF prevention), và scan malicious URLs.
Pitfall 5: Cache không có TTL hoặc TTL quá dài
Sai: Cache URL mapping vĩnh viễn. Đúng: TTL = min(URL expiration, 24h). Nếu không có TTL → expired URLs vẫn redirect, cache chiếm memory mãi.
Pitfall 6: Không nghĩ tới thundering herd khi cache miss
Sai: Một hot URL expire khỏi cache → 1000 concurrent requests đồng thời query DB. Đúng: Dùng cache stampede protection (singleflight pattern / lock-based cache refresh) → xem Tuan-06-Cache-Strategy.
9. Internal Links — Liên kết nội bộ
| Topic | Link | Liên quan |
|---|---|---|
| Capacity Estimation | Tuan-02-Back-of-the-envelope | Công thức QPS, Storage, Bandwidth |
| Networking & CDN | Tuan-03-Networking-DNS-CDN | DNS resolution, CDN cho edge caching |
| API Design | Tuan-04-API-Design-REST-gRPC | RESTful API conventions, status codes |
| Load Balancer | Tuan-05-Load-Balancer | Distribute traffic across API servers |
| Cache Strategy | Tuan-06-Cache-Strategy | Cache-aside, LRU, thundering herd |
| Database Sharding | Tuan-07-Database-Sharding-Replication | Partition strategy, read replicas |
| Message Queue | Tuan-08-Message-Queue | Kafka cho async analytics pipeline |
| Rate Limiter | Tuan-09-Rate-Limiter | Token bucket, sliding window |
| Consistent Hashing | Tuan-10-Consistent-Hashing | Cache node distribution |
| Monitoring | Tuan-13-Monitoring-Observability | Prometheus, Grafana, alerting |
| Security | Tuan-14-AuthN-AuthZ-Security · Tuan-15-Data-Security-Encryption | Input validation, OWASP, encryption |
Tham khảo
- Alex Xu, System Design Interview — Chapter 8: Design a URL Shortener
- sdi.anhvy.dev — Vietnamese System Design Reference
- Bitly Engineering Blog — Real-world URL shortener architecture
- Base62 Encoding — Wikipedia
- Twitter Snowflake ID — Distributed ID generation
- Google Safe Browsing API — Malicious URL detection
- OWASP Top 10 — Web security risks
Tuần tới: Tuan-17-Design-Chat-System — Real-time messaging, WebSocket, presence system