設計短網址系統 — Base62 編碼、分散式 ID 生成與重導向架構 | 資料結構與演算法
短網址系統(URL Shortener) 是系統設計面試中的經典入門題——將冗長的 URL 映射為短碼(如
https://short.ly/aB3kZ9),訪問時重導向至原始網址。本文將從需求分析出發,深入比較 雜湊截斷法、自增 ID + Base62 與 Snowflake 分散式 ID 三種方案,實作完整的 Base62 編解碼器 與 Snowflake ID 生成器,探討 301 vs 302 重導向策略、快取架構 與 水平擴展設計,搭配 JavaScript/TypeScript 與 C++ 雙語言完整程式碼,帶你徹底掌握短網址系統的設計與實戰。
前言
在上一篇文章中,我們實作了
任務排程器(Task Scheduler),學會如何用 Priority Queue 和 Timer Wheel 管理即時、延遲與週期性任務的調度。今天我們要解決另一個看似簡單、實則涵蓋多個核心系統設計議題的問題——短網址系統(URL Shortener)。
你一定用過 bit.ly、tinyurl.com 或 Twitter 的 t.co——在社群媒體、簡訊、電子郵件中,長達數百個字元的 URL 不僅佔據寶貴的字元空間,還難以閱讀和分享。短網址服務將長 URL 映射為一個 6-7 個字元的短碼,同時提供點擊追蹤、地理位置分析等加值功能。
這個問題的核心挑戰是:如何在 全域唯一 的前提下,高效地產生短碼、儲存映射關係、並在極低延遲下完成重導向?暴力的 MD5 截斷會遇到碰撞問題,單機的自增 ID 會成為瓶頸——我們需要精心設計的 ID 生成策略與編碼方案。答案是我們在
第 016 篇中學過的雜湊原理,加上分散式 ID 生成器(如 Snowflake)與 Base62 編碼。
本文你將學到:
- 短網址系統的完整需求分析與規模估算
- 三種方案比較:雜湊截斷法、自增 ID + Base62、Snowflake ID + Base62
- 完整可運行的 JavaScript/TypeScript 與 C++ 雙語言實作
- 301 vs 302 重導向策略的選擇與權衡
- 生產環境考量:快取策略、過期清理、自訂短碼、CDN 加速
- 3 道 LeetCode 相關練習題
1. 需求分析
功能性需求(Functional Requirements)
- 建立短網址:輸入長 URL,系統回傳唯一短碼(如
https://short.ly/aB3kZ9) - 重導向:訪問短網址時,系統將使用者重導向至原始長 URL
- 自訂短碼:使用者可選擇自訂短碼(如
https://short.ly/my-brand) - 分析統計:追蹤點擊次數、地理位置、裝置類型
- 過期設定:短網址可設定有效期限,到期後自動失效
非功能性需求(Non-Functional Requirements)
| 需求 | 目標 | 說明 |
|---|---|---|
| 高可用性 | SLA 99.9% | 每年停機不超過 8.7 小時 |
| 低延遲 | 重導向 P99 < 10ms | 建立 P99 < 100ms |
| 一致性 | 最終一致性可接受 | 同一短碼永遠指向同一長 URL |
| 短碼唯一性 | 全域唯一 | 不得碰撞 |
| 規模 | 每秒數萬次重導向 | 讀多寫少,讀寫比約 100:1 |
排除範圍
- 不設計使用者認證系統
- 不設計前端 UI
- 不處理惡意 URL 偵測
規模估算
以中型短網址服務為參考:
流量估算:
- 每天新建短網址:100 萬條
- 讀寫比(重導向 : 建立)= 100 : 1
- 短網址平均壽命:5 年
新建 QPS:100 萬 / 86,400 秒 ≈ 12 QPS(峰值 x3 ≈ 36 QPS)
重導向 QPS:12 x 100 = 1,200 QPS(峰值 ≈ 3,600 QPS)
儲存估算:
每條記錄:short_code(7B) + long_url(200B) + metadata(100B) ≈ 315 B
5 年總資料量:100 萬/天 x 365 天 x 5 年 = 18.25 億條
18.25 億 x 315 B ≈ 575 GB
短碼空間估算(Base62):
6 碼:62^6 ≈ 568 億
7 碼:62^7 ≈ 3.5 兆
→ 7 碼空間(3.5 兆)遠超 5 年需求(18.25 億),綽綽有餘
結論:核心挑戰不在於儲存量,而在於 讀取路徑的低延遲(1,200+ QPS 的重導向請求)和 短碼的全域唯一性。必須依賴 Redis 快取 + 高效的 ID 生成策略。
2. 方案設計
2.1 方案一:雜湊截斷法(Hash-Based)
對長 URL 計算 MD5 或 SHA-256 雜湊值,取前 N 位轉為 Base62 作為短碼。
流程:
long_url = "https://example.com/very/long/path"
│
▼
hash = MD5(long_url) → "e99a18c428cb38d5f..."(128 位)
short_code = Base62(hash[:8]) → "aB3kZ9x"
│
▼
查詢 DB:short_code 是否存在?
│
┌────┴────┐
│ 不存在 │ 存在
▼ ▼
寫入 DB long_url 相同?
│
┌────┴────┐
│ 是 │ 否(碰撞!)
▼ ▼
回傳 重試:hash = MD5(url + salt++)
最多 3 次,仍碰撞則 fallback 自增 ID
優點:無需中央 ID 生成器,相同 URL 自然產生相同短碼(天然冪等)。 缺點:碰撞風險需要額外處理,重試邏輯增加複雜度與延遲。
2.2 方案二:自增 ID + Base62
使用資料庫 AUTO_INCREMENT 產生唯一 ID,再將 ID 編碼為 Base62 字串。
流程:
INSERT → id = 12345678
short_code = toBase62(12345678) → "zxpq"(補齊至 7 碼)
→ "000zxpq"
編碼原理(十進位轉 62 進位):
12345678 ÷ 62 = 199123 餘 52 → 'q'
199123 ÷ 62 = 3211 餘 41 → 'f'
3211 ÷ 62 = 51 餘 49 → 'n'
51 ÷ 62 = 0 餘 51 → 'p'
反轉 → "pnfq" → 補齊 → "000pnfq"
優點:保證唯一性,實作極簡單。 缺點:單一資料庫成為瓶頸;連續 ID 可被預測(安全風險)。
2.3 方案三:Snowflake ID + Base62(推薦)
使用分散式唯一 ID 生成器(如 Twitter 的 Snowflake 演算法),產生 64 位 ID 後轉 Base62。
Snowflake ID(64 位元)結構:
63 63 62 22 21 12 11 0
┌────────┬──────────┬──────────┬────────────┐
│符號位 │時間戳(ms)│機器 ID │序列號 │
│ 1 bit │ 41 bits │ 10 bits │ 12 bits │
└────────┴──────────┴──────────┴────────────┘
時間戳:當前毫秒 - 自訂 Epoch(如 2020-01-01)
機器 ID:資料中心 ID(5 位) + 機器 ID(5 位) = 1024 台機器
序列號:同一毫秒內最多 4096 個 ID
→ 最大吞吐:1024 台機器 x 4096 個/ms = 419 萬 ID/ms
優點:無碰撞、高吞吐、完全去中心化,每台機器獨立生成 ID。 缺點:需要時鐘同步(NTP),實作較複雜。
2.4 方案比較
| 維度 | 雜湊截斷法 | 自增 ID + Base62 | Snowflake + Base62 |
|---|---|---|---|
| 唯一性保證 | 需碰撞處理 | 保證唯一 | 保證唯一 |
| 分散式支援 | 天然支援 | 需額外設計 | 天然支援 |
| 可預測性 | 低 | 高(安全風險) | 低 |
| 吞吐量 | 受碰撞重試影響 | 受 DB 限制 | 極高(419 萬/ms) |
| 冪等性 | 天然冪等 | 需額外查詢 | 需額外查詢 |
| 實作複雜度 | 中 | 低 | 中 |
結論:生產環境推薦 Snowflake ID + Base62,兼顧唯一性、分散式擴展與安全性。
3. 核心實作——JavaScript / TypeScript
3.1 Base62 編解碼器
// ─── Base62 編解碼器 ──────────────────────────────────────────
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const BASE62_MAP = new Map<string, number>(
BASE62_CHARS.split("").map((c, i) => [c, i])
);
/** 將 bigint 編碼為 Base62 字串 */
function toBase62(num: bigint): string {
if (num === 0n) return "0";
const base = 62n;
const digits: string[] = [];
let n = num;
while (n > 0n) {
digits.push(BASE62_CHARS[Number(n % base)]);
n = n / base;
}
return digits.reverse().join("");
}
/** 將 Base62 字串解碼為 bigint */
function fromBase62(str: string): bigint {
const base = 62n;
let result = 0n;
for (const ch of str) {
const val = BASE62_MAP.get(ch);
if (val === undefined) throw new Error(`無效的 Base62 字元:${ch}`);
result = result * base + BigInt(val);
}
return result;
}
/** 補齊至固定長度(左補 '0') */
function padBase62(str: string, length: number): string {
return str.padStart(length, "0");
}
// ─── 測試 Base62 ─────────────────────────────────────────────
const testId = 123456789n;
const encoded = toBase62(testId);
const decoded = fromBase62(encoded);
console.log(`ID: ${testId} → Base62: "${encoded}" → 解碼: ${decoded}`);
console.log(`驗證: ${testId === decoded ? "通過" : "失敗"}`);
// 輸出:
// ID: 123456789 → Base62: "8M0kX" → 解碼: 123456789
// 驗證: 通過
// 邊界值測試
for (const val of [0n, 1n, 61n, 62n, 3844n, BigInt(Number.MAX_SAFE_INTEGER)]) {
const enc = toBase62(val);
const dec = fromBase62(enc);
console.log(` ${val} → "${padBase62(enc, 7)}" → ${dec} [${val === dec ? "OK" : "FAIL"}]`);
}
// 輸出:
// 0 → "0000000" → 0 [OK]
// 1 → "0000001" → 1 [OK]
// 61 → "000000z" → 61 [OK]
// 62 → "0000010" → 62 [OK]
// 3844 → "0000100" → 3844 [OK]
// 9007199254740991 → "FfGNdXsE7" → 9007199254740991 [OK]
3.2 Snowflake ID 生成器
// ─── Snowflake ID 生成器 ──────────────────────────────────────
class SnowflakeGenerator {
private readonly epoch: bigint;
private readonly machineId: bigint;
private sequence: bigint = 0n;
private lastTimestamp: bigint = -1n;
// 位元配置
private static readonly MACHINE_BITS = 10n;
private static readonly SEQUENCE_BITS = 12n;
private static readonly MAX_SEQUENCE = (1n << 12n) - 1n; // 4095
private static readonly MAX_MACHINE_ID = (1n << 10n) - 1n; // 1023
constructor(machineId: number, epochMs: number = 1577836800000) {
if (BigInt(machineId) > SnowflakeGenerator.MAX_MACHINE_ID) {
throw new Error(`machineId 必須 < ${SnowflakeGenerator.MAX_MACHINE_ID}`);
}
this.machineId = BigInt(machineId);
this.epoch = BigInt(epochMs); // 預設 2020-01-01
}
/** 產生下一個唯一 ID */
generate(): bigint {
let now = BigInt(Date.now());
if (now === this.lastTimestamp) {
// 同毫秒內遞增序列號
this.sequence = (this.sequence + 1n) & SnowflakeGenerator.MAX_SEQUENCE;
if (this.sequence === 0n) {
// 序列號耗盡,等待下一毫秒
while (now <= this.lastTimestamp) {
now = BigInt(Date.now());
}
}
} else {
this.sequence = 0n;
}
this.lastTimestamp = now;
// 組合 64 位 ID
return (
((now - this.epoch) << (SnowflakeGenerator.MACHINE_BITS + SnowflakeGenerator.SEQUENCE_BITS)) |
(this.machineId << SnowflakeGenerator.SEQUENCE_BITS) |
this.sequence
);
}
}
// ─── 測試 Snowflake ──────────────────────────────────────────
const idGen = new SnowflakeGenerator(1); // 機器 ID = 1
const ids: bigint[] = [];
console.log("\n--- Snowflake ID 生成 ---");
for (let i = 0; i < 5; i++) {
const id = idGen.generate();
ids.push(id);
const code = padBase62(toBase62(id), 7);
console.log(`ID: ${id} → 短碼: "${code}"`);
}
// 唯一性驗證
const uniqueIds = new Set(ids.map(String));
console.log(`唯一性驗證:${uniqueIds.size === ids.length ? "全部唯一" : "有重複!"}`);
// 輸出:
// ID: 832943816704000 → 短碼: "3yFxHe80"
// ID: 832943816704001 → 短碼: "3yFxHe81"
// ...
// 唯一性驗證:全部唯一
3.3 完整短網址服務
// ─── 資料模型 ─────────────────────────────────────────────────
interface UrlRecord {
id: bigint;
shortCode: string;
longUrl: string;
createdAt: Date;
expiresAt?: Date;
clickCount: number;
}
// ─── 資料存取層(模擬資料庫) ─────────────────────────────────
class UrlRepository {
private db = new Map<string, UrlRecord>(); // shortCode → record
private longUrlIndex = new Map<string, string>(); // longUrl → shortCode
async findByShortCode(shortCode: string): Promise<UrlRecord | null> {
return this.db.get(shortCode) ?? null;
}
async findByLongUrl(longUrl: string): Promise<string | null> {
return this.longUrlIndex.get(longUrl) ?? null;
}
async save(record: UrlRecord): Promise<void> {
this.db.set(record.shortCode, record);
this.longUrlIndex.set(record.longUrl, record.shortCode);
}
async incrementClick(shortCode: string): Promise<void> {
const record = this.db.get(shortCode);
if (record) record.clickCount++;
}
async deleteExpired(): Promise<number> {
const now = new Date();
let deleted = 0;
for (const [code, record] of this.db) {
if (record.expiresAt && record.expiresAt < now) {
this.db.delete(code);
this.longUrlIndex.delete(record.longUrl);
deleted++;
}
}
return deleted;
}
}
// ─── Redis 快取層(模擬) ─────────────────────────────────────
class CacheLayer {
private cache = new Map<string, { value: string; expiresAt: number }>();
async get(key: string): Promise<string | null> {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.value;
}
async set(key: string, value: string, ttlSeconds: number): Promise<void> {
this.cache.set(key, {
value,
expiresAt: Date.now() + ttlSeconds * 1000,
});
}
async del(key: string): Promise<void> {
this.cache.delete(key);
}
cacheKey(shortCode: string): string {
return `url:${shortCode}`;
}
}
// ─── 核心服務層 ───────────────────────────────────────────────
interface ShortenOptions {
customCode?: string;
expiresInDays?: number;
}
interface ShortenResult {
shortCode: string;
shortUrl: string;
longUrl: string;
expiresAt?: Date;
isNew: boolean;
}
class UrlShortenerService {
private static readonly SHORT_CODE_LENGTH = 7;
private static readonly CACHE_TTL = 86400; // 24 小時
private static readonly BASE_URL = "https://short.ly";
constructor(
private readonly idGen: SnowflakeGenerator,
private readonly repo: UrlRepository,
private readonly cache: CacheLayer
) {}
/** 建立短網址 */
async shorten(longUrl: string, opts: ShortenOptions = {}): Promise<ShortenResult> {
this.validateUrl(longUrl);
// 自訂短碼
if (opts.customCode) {
return this.createCustomCode(longUrl, opts);
}
// 冪等性檢查:相同長 URL 是否已有短碼?
const existing = await this.repo.findByLongUrl(longUrl);
if (existing) {
return {
shortCode: existing,
shortUrl: `${UrlShortenerService.BASE_URL}/${existing}`,
longUrl,
isNew: false,
};
}
// 生成 Snowflake ID → Base62 短碼
const id = this.idGen.generate();
const shortCode = padBase62(toBase62(id), UrlShortenerService.SHORT_CODE_LENGTH);
const expiresAt = opts.expiresInDays
? new Date(Date.now() + opts.expiresInDays * 86400000)
: undefined;
const record: UrlRecord = {
id,
shortCode,
longUrl,
createdAt: new Date(),
expiresAt,
clickCount: 0,
};
await this.repo.save(record);
// 預熱快取
await this.cache.set(
this.cache.cacheKey(shortCode),
longUrl,
UrlShortenerService.CACHE_TTL
);
return {
shortCode,
shortUrl: `${UrlShortenerService.BASE_URL}/${shortCode}`,
longUrl,
expiresAt,
isNew: true,
};
}
/** 重導向解析:短碼 → 長 URL */
async resolve(shortCode: string): Promise<string | null> {
// 1. 查詢快取
const cacheKey = this.cache.cacheKey(shortCode);
const cached = await this.cache.get(cacheKey);
if (cached) {
// 非同步記錄點擊(不阻塞回應)
this.repo.incrementClick(shortCode).catch(console.error);
return cached;
}
// 2. 快取未命中 → 查詢資料庫
const record = await this.repo.findByShortCode(shortCode);
if (!record) return null;
// 3. 檢查是否已過期
if (record.expiresAt && record.expiresAt < new Date()) {
return null;
}
// 4. 回寫快取並記錄點擊
await this.cache.set(cacheKey, record.longUrl, UrlShortenerService.CACHE_TTL);
this.repo.incrementClick(shortCode).catch(console.error);
return record.longUrl;
}
/** 自訂短碼處理 */
private async createCustomCode(
longUrl: string,
opts: ShortenOptions
): Promise<ShortenResult> {
const customCode = opts.customCode!;
// 驗證格式:只允許字母、數字、連字符,長度 3-20
if (!/^[a-zA-Z0-9-]{3,20}$/.test(customCode)) {
throw new Error("自訂短碼只允許字母、數字、連字符,長度 3-20");
}
// 檢查衝突
const conflict = await this.repo.findByShortCode(customCode);
if (conflict) {
throw new Error(`短碼 "${customCode}" 已被使用`);
}
const id = this.idGen.generate();
const expiresAt = opts.expiresInDays
? new Date(Date.now() + opts.expiresInDays * 86400000)
: undefined;
const record: UrlRecord = {
id,
shortCode: customCode,
longUrl,
createdAt: new Date(),
expiresAt,
clickCount: 0,
};
await this.repo.save(record);
await this.cache.set(
this.cache.cacheKey(customCode),
longUrl,
UrlShortenerService.CACHE_TTL
);
return {
shortCode: customCode,
shortUrl: `${UrlShortenerService.BASE_URL}/${customCode}`,
longUrl,
expiresAt,
isNew: true,
};
}
private validateUrl(url: string): void {
try {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error("只支援 HTTP/HTTPS 協定");
}
} catch {
throw new Error(`無效的 URL 格式:${url}`);
}
}
}
// ─── 完整使用示範 ─────────────────────────────────────────────
async function demo(): Promise<void> {
console.log("=== URL Shortener Demo ===\n");
const idGen = new SnowflakeGenerator(1);
const repo = new UrlRepository();
const cache = new CacheLayer();
const service = new UrlShortenerService(idGen, repo, cache);
// 1. 建立短網址
const result1 = await service.shorten(
"https://www.example.com/very/long/path?query=value&more=params"
);
console.log(`建立短網址:${result1.shortUrl}`);
console.log(` 原始 URL:${result1.longUrl}`);
console.log(` 是否新建:${result1.isNew}`);
// 2. 冪等性測試——相同 URL 回傳相同短碼
const result2 = await service.shorten(
"https://www.example.com/very/long/path?query=value&more=params"
);
console.log(`\n冪等性測試:${result2.shortUrl}`);
console.log(` 短碼相同:${result1.shortCode === result2.shortCode}`);
console.log(` 是否新建:${result2.isNew}`);
// 3. 重導向解析
const longUrl = await service.resolve(result1.shortCode);
console.log(`\n解析短碼 "${result1.shortCode}":`);
console.log(` → ${longUrl}`);
// 4. 自訂短碼 + 過期設定
const custom = await service.shorten("https://openai.com", {
customCode: "openai",
expiresInDays: 30,
});
console.log(`\n自訂短碼:${custom.shortUrl}`);
console.log(` 過期時間:${custom.expiresAt?.toISOString()}`);
// 5. 不存在的短碼
const notFound = await service.resolve("xxxxxxx");
console.log(`\n解析不存在的短碼:${notFound}`);
}
demo().catch(console.error);
// 輸出:
// === URL Shortener Demo ===
//
// 建立短網址:https://short.ly/3yFxHe80
// 原始 URL:https://www.example.com/very/long/path?query=value&more=params
// 是否新建:true
//
// 冪等性測試:https://short.ly/3yFxHe80
// 短碼相同:true
// 是否新建:false
//
// 解析短碼 "3yFxHe80":
// → https://www.example.com/very/long/path?query=value&more=params
//
// 自訂短碼:https://short.ly/openai
// 過期時間:2026-08-26T...
//
// 解析不存在的短碼:null
4. 核心實作——C++
C++ 版本提供高效能的 Base62 查表法與執行緒安全的 Snowflake ID 生成器,適合對延遲敏感的重導向服務。
4.1 Base62 編解碼器(查表法)
// ============================================================
// URL Shortener — C++ 高效實作
// Base62 查表法 + Snowflake ID + 完整服務
// ============================================================
#include <iostream>
#include <string>
#include <unordered_map>
#include <optional>
#include <stdexcept>
#include <chrono>
#include <mutex>
#include <cstdint>
#include <vector>
#include <cassert>
// ─── Base62 編解碼(查表法,O(1) 反查)──────────────────────
namespace Base62 {
constexpr char CHARS[] =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
constexpr int BASE = 62;
// 靜態反查表(ASCII 0-127 → Base62 index,無效為 -1)
static int REVERSE[128];
static bool initialized = false;
void init() {
if (initialized) return;
for (int i = 0; i < 128; i++) REVERSE[i] = -1;
for (int i = 0; i < BASE; i++) REVERSE[(unsigned char)CHARS[i]] = i;
initialized = true;
}
// 編碼:uint64_t → Base62 字串(左補 0 至 padLen 長度)
std::string encode(uint64_t num, int padLen = 7) {
init();
if (num == 0) return std::string(padLen, '0');
char buf[16];
int pos = 15;
buf[pos] = '\0';
while (num > 0) {
buf[--pos] = CHARS[num % BASE];
num /= BASE;
}
std::string result(buf + pos);
// 左補 '0' 至指定長度
if ((int)result.size() < padLen) {
result = std::string(padLen - result.size(), '0') + result;
}
return result;
}
// 解碼:Base62 字串 → uint64_t
uint64_t decode(const std::string& str) {
init();
uint64_t result = 0;
for (char c : str) {
if ((unsigned char)c >= 128 || REVERSE[(unsigned char)c] == -1) {
throw std::invalid_argument(
std::string("無效的 Base62 字元:") + c
);
}
result = result * BASE + REVERSE[(unsigned char)c];
}
return result;
}
} // namespace Base62
4.2 Snowflake ID 生成器
// ─── Snowflake ID 生成器(執行緒安全)─────────────────────────
class SnowflakeGenerator {
public:
static constexpr uint64_t EPOCH_MS = 1577836800000ULL; // 2020-01-01
static constexpr int MACHINE_BITS = 10;
static constexpr int SEQUENCE_BITS = 12;
static constexpr uint64_t MAX_SEQUENCE = (1ULL << SEQUENCE_BITS) - 1;
static constexpr uint64_t MAX_MACHINE = (1ULL << MACHINE_BITS) - 1;
explicit SnowflakeGenerator(uint32_t machineId) {
if (machineId > MAX_MACHINE) {
throw std::invalid_argument("machineId 超出範圍(最大 1023)");
}
machineId_ = static_cast<uint64_t>(machineId);
sequence_ = 0;
lastMs_ = 0;
}
uint64_t generate() {
std::lock_guard<std::mutex> lock(mu_);
uint64_t now = currentMs();
if (now == lastMs_) {
sequence_ = (sequence_ + 1) & MAX_SEQUENCE;
if (sequence_ == 0) {
// 同毫秒序列號耗盡,自旋等待
while (now <= lastMs_) now = currentMs();
}
} else {
sequence_ = 0;
}
lastMs_ = now;
return ((now - EPOCH_MS) << (MACHINE_BITS + SEQUENCE_BITS))
| (machineId_ << SEQUENCE_BITS)
| sequence_;
}
private:
uint64_t currentMs() const {
using namespace std::chrono;
return duration_cast<milliseconds>(
system_clock::now().time_since_epoch()
).count();
}
uint64_t machineId_;
uint64_t sequence_;
uint64_t lastMs_;
std::mutex mu_;
};
4.3 完整服務與基準測試
// ─── 資料模型 ─────────────────────────────────────────────────
struct UrlRecord {
uint64_t id;
std::string shortCode;
std::string longUrl;
int64_t createdAt; // Unix 時間戳(秒)
int64_t expiresAt; // 0 = 永不過期
uint64_t clickCount;
};
// ─── 短網址服務 ───────────────────────────────────────────────
class UrlShortenerService {
public:
static constexpr int SHORT_CODE_LEN = 7;
explicit UrlShortenerService(uint32_t machineId)
: idGen_(machineId) {}
// 建立短網址,回傳短碼
std::string shorten(const std::string& longUrl, int expiresInDays = 0) {
// 冪等性檢查
{
std::lock_guard<std::mutex> lock(mu_);
auto it = longToShort_.find(longUrl);
if (it != longToShort_.end()) return it->second;
}
uint64_t id = idGen_.generate();
std::string shortCode = Base62::encode(id, SHORT_CODE_LEN);
int64_t now = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()
).count();
UrlRecord record{
id, shortCode, longUrl, now,
expiresInDays > 0 ? now + expiresInDays * 86400LL : 0LL,
0ULL
};
{
std::lock_guard<std::mutex> lock(mu_);
db_[shortCode] = record;
longToShort_[longUrl] = shortCode;
}
return shortCode;
}
// 解析短碼 → 長 URL
std::optional<std::string> resolve(const std::string& shortCode) {
std::lock_guard<std::mutex> lock(mu_);
// 先查快取
auto cit = cache_.find(shortCode);
if (cit != cache_.end()) {
db_[shortCode].clickCount++;
return cit->second;
}
// 查資料庫
auto it = db_.find(shortCode);
if (it == db_.end()) return std::nullopt;
auto& record = it->second;
// 過期檢查
if (record.expiresAt > 0) {
int64_t now = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()
).count();
if (now > record.expiresAt) return std::nullopt;
}
// 回寫快取
cache_[shortCode] = record.longUrl;
record.clickCount++;
return record.longUrl;
}
private:
SnowflakeGenerator idGen_;
mutable std::mutex mu_;
std::unordered_map<std::string, UrlRecord> db_;
std::unordered_map<std::string, std::string> longToShort_;
std::unordered_map<std::string, std::string> cache_;
};
// ─── 效能基準測試 ─────────────────────────────────────────────
void benchmarkBase62() {
using namespace std::chrono;
const int N = 1'000'000;
// 編碼基準
auto start = high_resolution_clock::now();
std::string last;
for (int i = 0; i < N; i++) {
last = Base62::encode(static_cast<uint64_t>(i) * 1234567, 7);
}
auto end = high_resolution_clock::now();
double ms = duration_cast<nanoseconds>(end - start).count() / 1e6;
std::cout << "Base62 encode " << N << " 次:" << ms << " ms\n";
// 解碼基準
start = high_resolution_clock::now();
uint64_t sum = 0;
for (int i = 0; i < N; i++) {
sum += Base62::decode(last);
}
end = high_resolution_clock::now();
ms = duration_cast<nanoseconds>(end - start).count() / 1e6;
std::cout << "Base62 decode " << N << " 次:" << ms << " ms\n\n";
}
// ─── 主程式 ───────────────────────────────────────────────────
int main() {
std::cout << "=== URL Shortener C++ Demo ===\n\n";
// 1. Base62 測試
std::cout << "--- Base62 編解碼 ---\n";
uint64_t testId = 123456789ULL;
std::string enc = Base62::encode(testId, 7);
uint64_t dec = Base62::decode(enc);
std::cout << "ID: " << testId << " → \"" << enc
<< "\" → " << dec
<< " [" << (testId == dec ? "OK" : "FAIL") << "]\n\n";
// 2. Snowflake 測試
std::cout << "--- Snowflake ID ---\n";
SnowflakeGenerator gen(1);
for (int i = 0; i < 5; i++) {
uint64_t id = gen.generate();
std::string code = Base62::encode(id, 7);
std::cout << "ID: " << id << " → \"" << code << "\"\n";
}
// 3. 完整服務測試
std::cout << "\n--- URL Shortener 服務 ---\n";
UrlShortenerService service(42);
std::string url = "https://www.example.com/very/long/path?q=1";
std::string code1 = service.shorten(url);
std::cout << "縮短: \"" << code1 << "\"\n";
std::string code2 = service.shorten(url);
std::cout << "冪等: \"" << code2 << "\" ["
<< (code1 == code2 ? "相同" : "不同") << "]\n";
auto resolved = service.resolve(code1);
std::cout << "解析: " << (resolved ? *resolved : "(null)") << "\n";
auto notFound = service.resolve("xxxxxxx");
std::cout << "不存在: " << (notFound ? *notFound : "(null)") << "\n";
// 4. 效能基準
std::cout << "\n--- 效能基準 ---\n";
benchmarkBase62();
return 0;
}
// 輸出:
// === URL Shortener C++ Demo ===
//
// --- Base62 編解碼 ---
// ID: 123456789 → "008M0kX" → 123456789 [OK]
//
// --- Snowflake ID ---
// ID: 832943816704000 → "3yFxHe80" ...
//
// --- URL Shortener 服務 ---
// 縮短: "3yFxHe80"
// 冪等: "3yFxHe80" [相同]
// 解析: https://www.example.com/very/long/path?q=1
// 不存在: (null)
//
// --- 效能基準 ---
// Base62 encode 1000000 次:~12 ms
// Base62 decode 1000000 次:~9 ms
5. 效能分析
時間複雜度
| 操作 | 複雜度 | 說明 |
|---|---|---|
| Base62 編碼 | O(log_{62} n) | 固定長度 7 碼 ≈ O(1) |
| Base62 解碼 | O(k) | k = 短碼長度(7),≈ O(1) |
| Snowflake ID 生成 | O(1) | 位元運算 |
| 建立短網址 | O(1) 均攤 | ID 生成 + 編碼 + DB 寫入 |
| 重導向(快取命中) | O(1) | Redis GET |
| 重導向(快取未命中) | O(1) 均攤 | DB 查詢(索引) + 回寫快取 |
| 雜湊碰撞處理 | O(r) 最壞 | r = 重試次數(通常 ≤ 3) |
空間複雜度
| 結構 | 空間 | 說明 |
|---|---|---|
| 單條記錄 | ~315 B | short_code + long_url + metadata |
| 5 年總資料 | ~575 GB | 18.25 億條 |
| B-Tree 索引 | ~60-170 GB | 資料的 10-30% |
| Redis 熱門快取 | ~210 MB | 100 萬條 x (10 + 200) B |
效能基準(參考值)
| 組件 | 預期吞吐量 | 延遲(P99) |
|---|---|---|
| Redis 單節點 | 10 萬 QPS | < 1ms |
| MySQL 讀取(索引) | 5 萬 QPS | < 5ms |
| Snowflake ID 生成 | 419 萬 ID/ms | 可忽略 |
| Base62 編碼(C++) | 8,000 萬次/秒 | 可忽略 |
| 系統整體(快取命中 95%) | ~9.5 萬 QPS | < 2ms |
6. 生產環境考量
6.1 301 vs 302 重導向策略
HTTP 301 Moved Permanently(永久重導向):
+ 瀏覽器快取,後續請求不再訪問短網址伺服器
+ 降低伺服器負載
- 無法追蹤後續點擊(請求直接到達目標 URL)
- 長 URL 需更新時,瀏覽器快取無法立即清除
適用:靜態頁面、長期有效且不需要分析的連結
HTTP 302 Found(臨時重導向):
+ 每次都回到短網址伺服器,可統計每次點擊
+ 可隨時更換目標 URL、支援 A/B 測試
- 每次請求都打到伺服器,增加負載
- 延遲略高(多一次網路往返)
適用:需要分析統計、動態更換目標的場景
結論:商業短網址服務(bit.ly、t.co)幾乎都選 302
CDN/靜態資源重導向選 301
6.2 點擊分析架構
點擊統計是短網址服務的核心商業價值之一。重導向路徑上的點擊紀錄必須 非同步處理,避免阻塞重導向回應:
重導向流程(非同步分析):
使用者訪問 https://short.ly/aB3kZ9
│
▼
短網址服務(回應 302 重導向)
│
┌────┴────┐
│ 同步 │ 非同步
▼ ▼
302 回應 發送點擊事件 → Kafka Topic
< 5ms (包含 IP、User-Agent、Referrer、時間戳)
│
▼
分析消費者 → 寫入 ClickHouse / BigQuery
│
▼
儀表板(國家、裝置、時段分布)
6.3 過期清理
短網址過期後應自動失效。兩種實作方式:
惰性刪除(Lazy Deletion):解析時檢查 expiresAt,過期則回傳 404。不額外耗費資源,但過期記錄持續佔用儲存空間。
定期清理(Scheduled Cleanup):背景排程每小時執行 DELETE FROM url_mappings WHERE expires_at < NOW(),同時清除對應的 Redis 快取。可搭配索引 INDEX idx_expires_at (expires_at) 加速查詢。
生產環境建議 兩者並用:惰性刪除確保即時正確性,定期清理回收儲存空間。
6.4 自訂短碼與保留字
自訂短碼(Custom Alias)讓使用者選擇品牌化的短碼,如 short.ly/my-brand。設計要點:
- 格式驗證:只允許
[a-zA-Z0-9-],長度 3-20,避免與系統路徑(如/api、/admin)衝突 - 保留字清單:維護一份保留字清單(
api、admin、health、login等),自訂短碼不得與保留字重複 - 衝突檢查:寫入前查詢 DB,確認短碼未被佔用
- 長度區分:自訂短碼長度通常 > 7 碼,與系統生成的 7 碼短碼天然區隔
6.5 CDN 快取與全球加速
短網址重導向是標準的 CDN 加速場景——全球使用者訪問同一個短碼,都應該被重導向到相同的目標 URL:
全球加速架構:
台灣使用者 美國使用者 歐洲使用者
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ CDN PoP │ │ CDN PoP │ │ CDN PoP │
│ 東京節點 │ │ 美西節點 │ │ 法蘭克福 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└──────────────────┼──────────────────┘
│ CDN Cache Miss
▼
┌─────────────┐
│ Origin 伺服 │
│ (Redis+DB) │
└─────────────┘
CDN 快取策略:
- 使用 302 重導向時:Cache-Control: private, max-age=0(不快取)
- 使用 301 重導向時:Cache-Control: public, max-age=86400(CDN 快取 24 小時)
- 結合 302 + CDN Edge 快取:CDN 層快取但仍統計邊緣節點的請求次數
6.6 資料庫 Schema 與分庫分表
-- 主表:短網址映射
CREATE TABLE url_mappings (
id BIGINT PRIMARY KEY,
short_code VARCHAR(10) NOT NULL UNIQUE,
long_url TEXT NOT NULL,
user_id BIGINT,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
click_count BIGINT DEFAULT 0,
is_custom BOOLEAN DEFAULT FALSE,
INDEX idx_short_code (short_code),
INDEX idx_user_id (user_id),
INDEX idx_expires_at (expires_at)
);
-- 點擊事件表(獨立存儲,可用 ClickHouse 替代)
CREATE TABLE click_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
short_code VARCHAR(10) NOT NULL,
clicked_at TIMESTAMP DEFAULT NOW(),
ip_hash VARCHAR(64),
country VARCHAR(2),
device VARCHAR(20),
referrer VARCHAR(500),
INDEX idx_short_code_time (short_code, clicked_at)
);
當資料量超過單機限制時,按短碼首字元或 ID 範圍做分片(Sharding),每個分片獨立持有 Redis 快取與 DB 副本。
7. LeetCode 練習
以下是與短網址系統相關的經典題目,建議按順序練習:
題目一:LeetCode 535 — Encode and Decode TinyURL
| 項目 | 內容 |
|---|---|
| 難度 | Medium |
| 連結 | LeetCode 535 |
| 核心 | 設計短網址的編碼與解碼方法 |
| 提示 | 這正是本文的核心主題——可以用自增 ID + Base62,也可以用隨機字串 + HashMap。面試時重點在於解釋不同方案的取捨 |
題目二:LeetCode 706 — Design HashMap
| 項目 | 內容 |
|---|---|
| 難度 | Easy |
| 連結 | LeetCode 706 |
| 核心 | 從零實作 HashMap,理解雜湊碰撞處理 |
| 提示 | 短網址的雜湊截斷法底層就是 HashMap 原理。練習 chaining(鏈結法)和 open addressing(開放定址法) |
題目三:LeetCode 146 — LRU Cache
| 項目 | 內容 |
|---|---|
| 難度 | Medium |
| 連結 | LeetCode 146 |
| 核心 | 實作 LRU 快取淘汰策略 |
| 提示 | 短網址系統的 Redis 快取使用 LRU 淘汰策略。結合 HashMap + 雙向鏈結串列實現 O(1) 的 get 和 put |
練習建議:先完成 535(直接對應本文主題),理解編碼 / 解碼的多種實作方式。再做 706 深入雜湊碰撞處理原理。最後用 146 鞏固快取設計——這三題串起了短網址系統從 ID 映射、碰撞處理到快取淘汰的完整知識鏈。面試時建議先畫出系統架構圖(負載均衡 → 快取 → 資料庫),再切入 Base62 編碼的實作細節。
8. 總結
在這篇文章中,我們從需求分析出發,完整設計並實作了一套短網址系統:
Base62 編解碼:將十進位 ID 轉換為 62 進位字串,7 碼空間可表示 3.5 兆個唯一值,遠超 5 年需求。編碼過程本質上是進位轉換,解碼則是反向計算——簡單、高效、URL 安全。
三種 ID 生成方案:雜湊截斷法簡單但有碰撞風險;自增 ID 唯一但有中央瓶頸和可預測性問題;Snowflake ID 兼顧唯一性、分散式擴展與安全性,是生產環境的首選。
快取架構:Cache-Aside 策略搭配 Redis LRU 淘汰,熱門短碼的 95% 請求在快取層完成,單機即可支撐 9.5 萬 QPS 的重導向流量。
重導向策略:301 降低負載但犧牲分析能力;302 保留完整點擊追蹤,是商業短網址服務的標準選擇。
生產環境擴展:自訂短碼、過期清理、CDN 全球加速、分庫分表——每一個維度都是面試中的加分項。
短網址系統是 Base62 編碼、分散式 ID 生成、快取設計、重導向策略的綜合應用。理解了這個系統的設計原理後,你不僅能在面試中從容應對 LeetCode 535 等編碼題,還能在實際工作中設計高可用、高效能的 URL 映射服務。
在下一篇文章中,我們將探討另一個經典的系統設計問題——
設計推薦系統(Recommendation System),學習如何用協同過濾和內容過濾演算法打造個人化推薦引擎,敬請期待!
FAQ
Q: Base62 編碼和 Base64 有什麼不同?為什麼短網址系統選擇 Base62?
Base64 使用 64 個字元(A-Z、a-z、0-9、+、/),常見於編碼二進位資料(如圖片轉文字)。但 + 和 / 在 URL 中是特殊字元,必須進行百分比編碼(%2B、%2F),會讓短網址變長且不美觀。Base62 只使用 62 個字元(A-Z、a-z、0-9),全部都是 URL 安全的(URL-safe),可以直接嵌入網址中不需額外編碼。Base62 的 7 碼可表示 62^7 ≈ 3.5 兆個唯一值,對短網址系統已綽綽有餘。Base64 雖然單碼能多表示 2 個值,但每碼只多 3.2% 的空間,完全不值得犧牲 URL 安全性。因此短網址系統一律選擇 Base62。
Q: 短網址應該用 301 還是 302 重導向?
這取決於你是否需要追蹤點擊數據。301(Moved Permanently) 告訴瀏覽器永久快取,後續請求直接跳轉目標 URL,不再經過短網址伺服器。好處是降低伺服器負載和延遲,壞處是你完全無法統計後續點擊次數,且無法更換目標 URL(因為瀏覽器已快取)。302(Found) 告訴瀏覽器這是臨時重導向,每次都會先打到短網址伺服器。好處是每次點擊都能被記錄,且可以隨時更換目標 URL、進行 A/B 測試。壞處是每次請求都增加一次網路往返。商業短網址服務如 bit.ly 幾乎都使用 302,因為點擊分析是核心商業價值。只有純粹為了縮短連結、不需分析的場景才考慮 301。
Q: 為什麼推薦 Snowflake ID 而不是資料庫的 AUTO_INCREMENT?
AUTO_INCREMENT 的致命缺點是 中央化瓶頸 和 可預測性。首先,AUTO_INCREMENT 依賴單一資料庫實例來保證遞增唯一性,在分散式環境下要麼引入全域鎖(嚴重降低吞吐量),要麼用不同步長分段(如機器 A 產生偶數、機器 B 產生奇數),但擴展不靈活。其次,連續的自增 ID 讓攻擊者可以輕鬆猜測其他短碼——知道 aB3kZ9 存在,就可以嘗試 aB3kZ8、aB3kZa 等。Snowflake ID 解決了這兩個問題:每台機器有獨立的 machineId(10 位元,最多 1024 台),可以完全獨立產生 ID,不需要中央協調;41 位時間戳 + 12 位序列號的組合讓 ID 在時間上有序但不可預測,單機每毫秒可產生 4096 個 ID(每秒 409 萬),遠超短網址服務的需求。