設計短網址系統 — Base62 編碼、分散式 ID 生成與重導向架構 | 資料結構與演算法

2026/07/27
設計短網址系統 — Base62 編碼、分散式 ID 生成與重導向架構 | 資料結構與演算法

短網址系統(URL Shortener) 是系統設計面試中的經典入門題——將冗長的 URL 映射為短碼(如 https://short.ly/aB3kZ9),訪問時重導向至原始網址。本文將從需求分析出發,深入比較 雜湊截斷法自增 ID + Base62Snowflake 分散式 ID 三種方案,實作完整的 Base62 編解碼器Snowflake ID 生成器,探討 301 vs 302 重導向策略快取架構水平擴展設計,搭配 JavaScript/TypeScriptC++ 雙語言完整程式碼,帶你徹底掌握短網址系統的設計與實戰。

前言

在上一篇文章中,我們實作了

任務排程器(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 + Base62Snowflake + 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 Bshort_code + long_url + metadata
5 年總資料~575 GB18.25 億條
B-Tree 索引~60-170 GB資料的 10-30%
Redis 熱門快取~210 MB100 萬條 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)衝突
  • 保留字清單:維護一份保留字清單(apiadminhealthlogin 等),自訂短碼不得與保留字重複
  • 衝突檢查:寫入前查詢 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. 總結

在這篇文章中,我們從需求分析出發,完整設計並實作了一套短網址系統:

  1. Base62 編解碼:將十進位 ID 轉換為 62 進位字串,7 碼空間可表示 3.5 兆個唯一值,遠超 5 年需求。編碼過程本質上是進位轉換,解碼則是反向計算——簡單、高效、URL 安全。

  2. 三種 ID 生成方案:雜湊截斷法簡單但有碰撞風險;自增 ID 唯一但有中央瓶頸和可預測性問題;Snowflake ID 兼顧唯一性、分散式擴展與安全性,是生產環境的首選。

  3. 快取架構:Cache-Aside 策略搭配 Redis LRU 淘汰,熱門短碼的 95% 請求在快取層完成,單機即可支撐 9.5 萬 QPS 的重導向流量。

  4. 重導向策略:301 降低負載但犧牲分析能力;302 保留完整點擊追蹤,是商業短網址服務的標準選擇。

  5. 生產環境擴展:自訂短碼、過期清理、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 存在,就可以嘗試 aB3kZ8aB3kZa 等。Snowflake ID 解決了這兩個問題:每台機器有獨立的 machineId(10 位元,最多 1024 台),可以完全獨立產生 ID,不需要中央協調;41 位時間戳 + 12 位序列號的組合讓 ID 在時間上有序但不可預測,單機每毫秒可產生 4096 個 ID(每秒 409 萬),遠超短網址服務的需求。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →