跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 案例:短網址服務

案例:短網址服務

把長網址換成短代碼:怎麼產生不重複的代碼、讀遠多於寫時怎麼擋住流量、資料多到要分片時怎麼切。

流程

讀遠多於寫,所以整個架構是為讀取路徑設計的:快取擋在依代碼分片的資料庫前面。選一個流程,一步步看請求怎麼走;點方塊可以進到那個元件的主題。

亮起來的是這一步執行的程式碼
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
function encode(n: number, length: number): string {
let code = "";
for (let i = 0; i < length; i++) {
code = ALPHABET[n % 62] + code;
n = Math.floor(n / 62);
}
return code;
}
function fnv1a(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
return h >>> 0;
}
class Shortener {
private db = new Map<string, string>(); // code -> long URL
private cache = new Map<string, string>(); // oldest use first
private counter = 0;
constructor(
private strategy: "hash" | "counter" | "random",
private length: number,
private nextRandom: () => number = () => Math.floor(Math.random() * 2 ** 32),
private cacheSize = 100,
) {}
shorten(url: string): string {
const space = 62 ** this.length;
for (let attempt = 0; ; attempt++) {
let n: number;
if (this.strategy === "counter") n = this.counter++;
else if (this.strategy === "hash") {
n = fnv1a(attempt === 0 ? url : url + "#" + attempt);
} else n = this.nextRandom();
const code = encode(n % space, this.length);
if (!this.db.has(code)) {
this.db.set(code, url);
return code;
}
}
}
resolve(code: string): string | undefined {
const hit = this.cache.get(code);
if (hit !== undefined) {
this.cache.delete(code);
this.cache.set(code, hit);
return hit;
}
const url = this.db.get(code);
if (url === undefined) return undefined;
this.cache.set(code, url);
if (this.cache.size > this.cacheSize) {
this.cache.delete(this.cache.keys().next().value!);
}
return url;
}
}
產生代碼
3
10,000
程式碼路徑

① 產生代碼

可用代碼數
238,328
實測碰撞(重試)
216
生日問題估計
209.8
單一網址最多試幾次
3

前幾個代碼:TcV, zkA, uLZ, 7zc, 0Fj, olS

把 10,000 個網址放進 238,328 種 3 字元的代碼:有 216 次撞到已經用掉的代碼、必須重試。生日問題的估計 n(n−1)/2 ÷ 代碼總數 預測是 209.8 次。碰撞次數跟著網址數量的平方長,所以實際的服務都用 7 個字元以上。

② 整個服務要多大

1,000,000
100×
10%
5 年累積的連結
1,825,000,000
需要的代碼長度
7
儲存空間
913 GB
資料庫分片
1

假設:每個連結 500 bytes;最忙的那小時是平均的 3 倍;讀取的熱度分布和「快取」主題一樣集中(s = 1),命中率直接用那邊的模擬算;一片資料庫每秒能服務 5,000 次讀取;代碼長度預留 100 倍空間,讓隨機代碼很少撞到。點方塊可以看那個元件自己的主題。

亮起來的是這一步執行的程式碼
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
function encode(n: number, length: number): string {
let code = "";
for (let i = 0; i < length; i++) {
code = ALPHABET[n % 62] + code;
n = Math.floor(n / 62);
}
return code;
}
function fnv1a(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
return h >>> 0;
}
class Shortener {
private db = new Map<string, string>(); // code -> long URL
private cache = new Map<string, string>(); // oldest use first
private counter = 0;
constructor(
private strategy: "hash" | "counter" | "random",
private length: number,
private nextRandom: () => number = () => Math.floor(Math.random() * 2 ** 32),
private cacheSize = 100,
) {}
shorten(url: string): string {
const space = 62 ** this.length;
for (let attempt = 0; ; attempt++) {
let n: number;
if (this.strategy === "counter") n = this.counter++;
else if (this.strategy === "hash") {
n = fnv1a(attempt === 0 ? url : url + "#" + attempt);
} else n = this.nextRandom();
const code = encode(n % space, this.length);
if (!this.db.has(code)) {
this.db.set(code, url);
return code;
}
}
}
resolve(code: string): string | undefined {
const hit = this.cache.get(code);
if (hit !== undefined) {
this.cache.delete(code);
this.cache.set(code, hit);
return hit;
}
const url = this.db.get(code);
if (url === undefined) return undefined;
this.cache.set(code, url);
if (this.cache.size > this.cacheSize) {
this.cache.delete(this.cache.keys().next().value!);
}
return url;
}
}

模型假設與範圍

  • 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
  • 以固定字母表生成短碼並計算容量、碰撞與快取成本;沒有公開服務、濫用偵測或 DNS/TLS 延遲。

什麼時候用

  • 這是系統設計面試最常見的暖身題:它小到講得完,卻同時碰到代碼產生、讀多寫少、快取、分片四件事。
  • 任何「把一個長的東西換成短 ID」的需求都一樣:邀請碼、分享連結、訂單編號。
  • 讀取遠多於寫入(這裡每個連結被讀上百次),所以設計的重點在讀取路徑:快取擋住熱門連結,資料庫只處理剩下的。

和其他主題的關係

延伸閱讀
負載平衡

時間與空間複雜度(Big O)

操作平均最差
縮短一個網址
平均重試次數只取決於代碼空間用掉多少比例;空間快滿時才會一直撞
O(1)O(n)
讀取(快取命中)O(1)O(1)
讀取(查資料庫索引)
B-tree 索引;用雜湊索引則是 O(1)
O(log n)O(log n)
base62 編碼
代碼長度隨連結數的對數成長
O(log n)O(log n)

空間:O(n),每個連結約 500 bytes

Big O 實測:n 變大時步數怎麼長

數的是:碰撞次數(代碼長 3 個字元,n 是網址數)

Big On = 4,000n = 8,000n = 16,000成長倍數:實測(理論)
雜湊截斷O(n²)37142553×15 (×16)
隨機代碼O(n²)28132552×20 (×16)

網址數變成 4 倍,碰撞變成約 16 倍:生日問題。計數器不管 n 多大都是 0,所以沒有列進來。

和其他做法比

碰撞(重試)單一網址最多試幾次代碼可以被猜到需要共用的計數器同一個網址得到同一個代碼
雜湊截斷5534否否是
全域計數器01是是否
隨機代碼5524否否否

16,000 個不同的網址,代碼長 3 個字元(238,328 種),三種做法各跑一次實際數出來。生日問題估計 537 次碰撞。雜湊截斷的好處是同一個網址再縮一次會得到同一個代碼,可以省去重複的資料。

真實世界裡的它

  • bit.ly、TinyURL、t.co(Twitter 對所有連結都會先換成自己的短網址)。
  • Twitter 的 Snowflake 是分散式計數器的常見做法:時間戳+機器編號+序號,不需要每次都問同一台。
  • YouTube 影片 ID 是 11 個字元的 base64 變形,空間大到隨機產生幾乎不會撞。

取捨與陷阱

  • 連號代碼會被一個一個爬完:私人分享連結不能用計數器直接產生,至少要打亂或加密。
  • 截斷雜湊或隨機代碼一定要檢查是否已經存在;生日問題讓碰撞比直覺早出現很多。
  • 轉址用 301(永久)瀏覽器會快取,之後的點擊就統計不到;需要點擊數就用 302。

LeetCode 練習