案例:搜尋引擎
離線的爬蟲和建索引管線,加上線上的查詢服務:兩邊的需求完全不同,一邊要大吞吐量,一邊要毫秒級的延遲。
流程
上面一列是離線管線:爬網頁、存原始檔、批次建索引,看的是吞吐量。第三列是線上查詢,看的是最慢的那一片要多久。兩邊只在索引段載入時交會。選一個流程一步步看;點方塊可以進到那個元件的主題。
亮起來的是這一步執行的程式碼
function tokenize(text: string): string[] { return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];} function crawl(seeds: string[], links: Record<string, string[]>, limit: number): string[] { const frontier = [...seeds]; const seen = new Set(seeds); const fetched: string[] = []; while (frontier.length > 0 && fetched.length < limit) { const url = frontier.shift()!; const outLinks = links[url] ?? []; fetched.push(url); for (const next of outLinks) { if (!seen.has(next)) { seen.add(next); frontier.push(next); } } } return fetched;} type Hit = [doc: number, score: number];const byScore = (a: Hit, b: Hit) => b[1] - a[1] || a[0] - b[0]; class Shard { private postings = new Map<string, Map<number, number>>(); // term -> doc -> count add(term: string, doc: number): void { const docs = this.postings.get(term) ?? new Map<number, number>(); docs.set(doc, (docs.get(doc) ?? 0) + 1); this.postings.set(term, docs); } topK(terms: string[], k: number): Hit[] { const lists = terms.map((t) => this.postings.get(t) ?? new Map<number, number>()); const hits: Hit[] = []; for (const [doc] of lists[0] ?? []) { if (lists.every((list) => list.has(doc))) { hits.push([doc, lists.reduce((sum, list) => sum + list.get(doc)!, 0)]); } } return hits.sort(byScore).slice(0, k); }} class SearchService { private cache = new Map<string, Hit[]>(); constructor(private shards: Shard[]) {} index(doc: number, text: string): void { const shard = this.shards[doc % this.shards.length]; for (const term of tokenize(text)) shard.add(term, doc); } search(query: string, k: number): Hit[] { const cached = this.cache.get(query); if (cached) return cached; const terms = tokenize(query); const partial = this.shards.map((s) => s.topK(terms, k)); const merged = partial.flat().sort(byScore).slice(0, k); this.cache.set(query, merged); return merged; }}索引怎麼切
64
對沖請求
每次查詢問幾片
64
延遲 p50
18.2 ms
延遲 p99
51.2 ms
碰到卡住的查詢
48%
最忙一片/平均
1.0×
每次查詢搬動
5.1 KB
每次查詢都要問全部 64 片,並等最慢的那一片。每 100 次分片請求就有一次卡住 40 毫秒,所以 47% 的查詢會碰到至少一次(實測 48%),p99(第 99 百分位)是 51.2 ms。打開對沖請求,看多一份副本能換到什麼。
假設:10,000,000 篇文件;2,000 個雙詞查詢,從 5,000 個詞依 Zipf 熱度抽出;排名第 r 的詞出現在 2,000,000 ÷ r 篇文件。一片的回應時間是 3 毫秒加上平均 2 毫秒的指數分布、再加上以每秒五千萬筆掃描清單的時間,另有 1% 的請求會卡住 40 毫秒。搬資料每筆 4 bytes、頻寬 1 GB/s。固定亂數種子,所以每個設定回答的是同一批查詢。
模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- 離線建立索引與線上分片查詢分開;種子化延遲比較扇出。未包含網頁規模、完整排名、索引副本與真實網路。
什麼時候用
- 離線和線上分開設計:爬蟲和建索引追求吞吐量、可以慢幾分鐘;查詢追求毫秒級延遲。兩邊只透過「載入索引段」交會,重建索引就不會拖慢查詢。
- 原始網頁要留著:排序或解析規則改了,從物件儲存重建索引,比重新爬整個網路便宜幾個數量級。
- 依文件切片,再處理長尾:負載平均、搬動少;每次查詢都得等最慢的一片,就用副本加對沖請求解決。
和其他主題的關係
- 延伸閱讀
- 演算法 · BFS 與 DFS
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 爬一個網頁(不含下載) L 是網頁裡的連結數;去重靠雜湊集合,每個連結 O(1) | O(L) | O(L) |
| 建索引 T 是所有網頁的總詞數;可以完全平行 | O(T) | O(T) |
| 查詢:依文件切片 問 S 片,每片掃 P/S 筆清單;延遲取決於最慢的那片 | O(S + P/S) | O(S + P/S) |
| 查詢:依詞切片 最多問兩片,但整串清單 P 由一台掃、還要搬過網路 | O(P) | O(P) |
| 結果快取命中 | O(1) | O(1) |
空間:O(P × R),P 是所有清單的總筆數,R 是副本數;原始網頁另外存在物件儲存
Big O 實測:n 變大時步數怎麼長
數的是:每次查詢要問的分片數(n 是分片數)
| Big O | n = 4 | n = 16 | n = 64 | n = 256 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|---|
| 依文件切片 | O(n) | 4 | 16 | 64 | 256 | ×64 (×64) |
| 依詞切片 | O(1) | 1.8 | 1.9 | 2.0 | 2.0 | ×1.1 (×1.0) |
依文件切片時,分片越多、每次查詢要問的機器就越多;依詞切片則固定是一兩台。前者的長尾靠副本和對沖請求解決,後者的熱點很難解決。
和其他做法比
| 每次查詢問幾片 | p50 | p99 | 最忙一片/平均 | 每次查詢搬動 | |
|---|---|---|---|---|---|
| 依文件切片 | 64.00 | 18.2 ms | 51.2 ms | 1.0× | 5.1 KB |
| 依文件切片+對沖請求 | 64.00 | 14.1 ms | 24.0 ms | 1.0× | 5.1 KB |
| 依詞切片 | 1.98 | 11.4 ms | 54.3 ms | 38.5× | 241.0 KB |
同一批 2,000 個查詢、64 片。依文件切片的負載完全平均、搬動的資料很少,代價是每次都要等最慢的一片;對沖請求用多一點點請求量換掉大部分的長尾。依詞切片問的片數少,但熱門詞那一片會被打爆,而且每次都要搬一整串清單。實務上的大型搜尋引擎幾乎都依文件切片,再加上副本和對沖請求。
真實世界裡的它
- Google 的經典論文描述了同樣的分工:爬蟲、儲存原始網頁、批次建倒排索引、依文件分片查詢。
- Elasticsearch 和 OpenSearch 的索引就是依文件分片,每片有副本,查詢由協調節點分散再合併。
- 「The Tail at Scale」一文提出的對沖請求,就是下方模擬裡的做法。
取捨與陷阱
- 扇出越大、長尾越長:一台機器 1% 的機率變慢,問 100 台時大約 63% 的查詢都會碰上。平均延遲看不出來,要看 p99。
- 爬蟲要有禮貌:同一個網站要限速、遵守 robots.txt,否則等於對別人發動阻斷服務攻擊。
- 網址要先正規化:同一頁有很多種寫法(大小寫、結尾斜線、追蹤參數),不處理就會重複抓、重複收錄。