案例:短網址服務
把長網址換成短代碼:怎麼產生不重複的代碼、讀遠多於寫時怎麼擋住流量、資料多到要分片時怎麼切。
流程
讀遠多於寫,所以整個架構是為讀取路徑設計的:快取擋在依代碼分片的資料庫前面。選一個流程,一步步看請求怎麼走;點方塊可以進到那個元件的主題。
亮起來的是這一步執行的程式碼
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 O | n = 4,000 | n = 8,000 | n = 16,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 雜湊截斷 | O(n²) | 37 | 142 | 553 | ×15 (×16) |
| 隨機代碼 | O(n²) | 28 | 132 | 552 | ×20 (×16) |
網址數變成 4 倍,碰撞變成約 16 倍:生日問題。計數器不管 n 多大都是 0,所以沒有列進來。
和其他做法比
| 碰撞(重試) | 單一網址最多試幾次 | 代碼可以被猜到 | 需要共用的計數器 | 同一個網址得到同一個代碼 | |
|---|---|---|---|---|---|
| 雜湊截斷 | 553 | 4 | 否 | 否 | 是 |
| 全域計數器 | 0 | 1 | 是 | 是 | 否 |
| 隨機代碼 | 552 | 4 | 否 | 否 | 否 |
16,000 個不同的網址,代碼長 3 個字元(238,328 種),三種做法各跑一次實際數出來。生日問題估計 537 次碰撞。雜湊截斷的好處是同一個網址再縮一次會得到同一個代碼,可以省去重複的資料。
真實世界裡的它
- bit.ly、TinyURL、t.co(Twitter 對所有連結都會先換成自己的短網址)。
- Twitter 的 Snowflake 是分散式計數器的常見做法:時間戳+機器編號+序號,不需要每次都問同一台。
- YouTube 影片 ID 是 11 個字元的 base64 變形,空間大到隨機產生幾乎不會撞。
取捨與陷阱
- 連號代碼會被一個一個爬完:私人分享連結不能用計數器直接產生,至少要打亂或加密。
- 截斷雜湊或隨機代碼一定要檢查是否已經存在;生日問題讓碰撞比直覺早出現很多。
- 轉址用 301(永久)瀏覽器會快取,之後的點擊就統計不到;需要點擊數就用 302。