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ướcTên gọiThời gian (45 phút)
1Understand the Problem & Establish Design Scope~5 phút
2Propose High-Level Design~15 phút
3Design Deep Dive~20 phút
4Wrap 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ỏiTrả 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?
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:

  1. Shorten — Cho một long URL, tạo ra một short URL duy nhất
  2. Redirect — Khi user truy cập short URL, redirect về original URL
  3. Custom alias — User có thể chọn alias (vd: sdi.vn/my-link)
  4. Expiration — Short URL tự hết hạn sau thời gian quy định
  5. Analytics — Track số lần click, thời gian click

1.2 Non-Functional Requirements (Yêu cầu phi chức năng)

RequirementTargetGiải thích
High Availability (Tính sẵn sàng cao)99.99% uptimeNế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 DAUQuy mô Bitly/TinyURL
Durability (Bền vững)Không mất URL mappingMộ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 nhauCollision = 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
DAU100MHệ thống quy mô lớn (Bitly-like)
URL shortens/user/day0.1Không phải ai cũng tạo link mỗi ngày
URL reads (redirects)/user/day1Mỗi người click trung bình 1 short link/ngày
Read:Write ratio10:1Read-heavy system (đọc nhiều hơn ghi)
Avg URL record size500 bytesOriginal URL + short code + metadata
Retention (thời gian lưu)5 nămDefault expiration
Short code length7 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

MetricValue
Write QPS (avg / peak)~116/s / ~350/s
Read QPS (avg / peak)~1,157/s / ~3,500/s
New URLs/day10M
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 CodeKhi nàoÝ nghĩa
400 Bad RequestURL không hợp lệInvalid URL format
409 ConflictCustom alias đã tồn tạiAlias trùng
429 Too Many RequestsVượt rate limitChố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

Aspect301 Moved Permanently302 Found (Temporary)
Browser cachingBrowser cache redirect, lần sau không gọi serverBrowser luôn gọi server mỗi lần
Server loadThấp hơn (browser tự redirect)Cao hơn (mỗi click đều qua server)
Analytics accuracyKém (nhiều click không qua server)Tốt (mọi click đều được track)
SEOPass link juice vĩnh viễnKhông pass link juice
Khi nào dùngKhi không cần analytics, muốn giảm loadKhi 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 FunctionOutput LengthCollision ResistanceTốc độ
CRC3232 bits (8 hex chars)ThấpRất nhanh
MD5128 bits (32 hex chars)Trung bìnhNhanh
SHA-256256 bits (64 hex chars)CaoTrung bình
MurmurHash32/128 bitsTrung bìnhRất nhanh

Quy trình:

  1. Hash long URL: MD5("https://example.com/...") → "5d41402abc4b2a76b9719d911017c592"
  2. Lấy 7 ký tự đầu: "5d41402"
  3. 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

AspectHash + TruncateBase62 from Unique ID
CollisionCó thể xảy ra, cần handlingKhông bao giờ
Cùng URL → cùng short code?Có (deterministic)Không (mỗi lần tạo ID mới)
DependencyChỉ cần hash functionCần ID generator
Đoán được URL tiếp theo?KhôngCó thể (nếu dùng auto-increment)
PerformanceNhanh (compute only)Phụ thuộc ID generator
Phổ biến trong thực tếÍtNhiề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ểmNhược điểmQPS
Auto-increment (DB)Đơn giản, đảm bảo uniqueSingle point of failure, bottleneck khi scale~5K/s
UUID v4Distributed, không cần coordination128 bits quá dài (22 chars Base62), không sequentialUnlimited
Snowflake ID (Twitter)64-bit, distributed, sequential, chứa timestampCần setup worker ID, clock sync~4M/s/node
Pre-generated ID rangesRất nhanh (lấy từ memory), không bottleneckPhức tạp hơn, cần range allocation serviceUnlimited
┌─────────────┬──────────┬───────────┬──────────────┐
│  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:

  1. Mỗi API server xin một range (vd: 1 triệu IDs) từ Range Service
  2. Server dùng ID từ local pool (không cần gọi DB mỗi lần)
  3. 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 modelRelational, structuredKey-Value, flexible
Read patternPoint query by short_codePoint query by partition key
Write patternSequential, indexedDistributed, auto-sharded
ConsistencyStrong consistencyEventual consistency (tunable)
ScalingVertical + Read replicas + PartitioningHorizontal, built-in
Query flexibilityJOIN, complex queries, analyticsLimited query patterns
10TB dataCần partitioning, manageableNative 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 partitions

Recommendation: 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?

PatternKhi nào dùngLý do
Cache-Aside (Lazy Loading)Read-heavy, data ít thay đổiURL mapping gần như immutable. Chỉ cache khi cần → tiết kiệm memory
Write-ThroughData thay đổi thường xuyên, cần consistencyOverkill 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

ScenarioLatencyQPS 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?

  1. Chống spam: Bot tạo hàng triệu short URLs cho phishing
  2. Chống abuse: Dùng service như open redirect proxy
  3. Bảo vệ resource: Ngăn một user chiếm hết write capacity
EndpointRate LimitStrategy
POST /api/v1/shorten10 req/min/IP (anonymous), 100 req/min/user (authenticated)Token Bucket
GET /:shortCode1000 req/min/IPSliding Window
GET /api/v1/stats/:shortCode30 req/min/userFixed 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

ComponentScaling Strategy
API ServersStateless → thêm instances sau Load Balancer
PostgreSQLRead replicas cho read, partitioning cho data size
RedisRedis Cluster (sharding by key hash)
KafkaThêm partitions + consumers
ClickHouseSharding 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)

ThreatGiải phápChi tiết
Phishing URLsURL scanning serviceGoogle Safe Browsing API, VirusTotal API
Malware distributionReal-time + periodic scanScan khi tạo + rescan weekly
Open redirect abuseRestrict target domainsBlacklist known malicious domains
Spam/SEO abuseRate limiting + CAPTCHAChống bot tạo hàng loạt
Enumeration attackNon-sequential short codesSnowflake ID → Base62 (không đoán được URL tiếp theo)

OWASP Top 10 Considerations

OWASP RiskÁp dụng cho URL ShortenerGiải pháp
A01 Broken Access ControlUser A xoá/edit URL của User BAuthorization check, ownership validation
A03 InjectionSQL injection qua long_url parameterParameterized queries, input validation
A05 Security MisconfigurationAPI endpoint không cần auth bị exposeChỉ expose GET /:shortCode công khai
A07 XSSLong URL chứa JavaScriptEncode output, Content-Security-Policy header
A09 Security LoggingKhông log suspicious activitiesLog 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

PanelPromQLThreshold
Redirect QPSrate(http_requests_total{path="/:shortCode"}[1m])Warning: 2,800; Critical: 3,325
Redirect P99 Latencyhistogram_quantile(0.99, rate(http_request_duration_seconds_bucket{path="/:shortCode"}[5m]))< 50ms
URL Creation Raterate(http_requests_total{method="POST",path="/api/v1/shorten"}[1m])Warning: 280/s
Cache Hit Rateredis_keyspace_hits / (hits + misses)> 80%
DB Disk Usagepg_database_size_bytes{datname="urlshortener"}Predict 90 days
Kafka Consumer Lagkafka_consumergroup_lag< 10,000 messages
Active Short URLspg_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 neededTuan-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.


TopicLinkLiên quan
Capacity EstimationTuan-02-Back-of-the-envelopeCông thức QPS, Storage, Bandwidth
Networking & CDNTuan-03-Networking-DNS-CDNDNS resolution, CDN cho edge caching
API DesignTuan-04-API-Design-REST-gRPCRESTful API conventions, status codes
Load BalancerTuan-05-Load-BalancerDistribute traffic across API servers
Cache StrategyTuan-06-Cache-StrategyCache-aside, LRU, thundering herd
Database ShardingTuan-07-Database-Sharding-ReplicationPartition strategy, read replicas
Message QueueTuan-08-Message-QueueKafka cho async analytics pipeline
Rate LimiterTuan-09-Rate-LimiterToken bucket, sliding window
Consistent HashingTuan-10-Consistent-HashingCache node distribution
MonitoringTuan-13-Monitoring-ObservabilityPrometheus, Grafana, alerting
SecurityTuan-14-AuthN-AuthZ-Security · Tuan-15-Data-Security-EncryptionInput validation, OWASP, encryption

Tham khảo


Tuần tới: Tuan-17-Design-Chat-System — Real-time messaging, WebSocket, presence system