跳到主要內容

系統設計

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

主題 · 案例:搜尋引擎

案例:搜尋引擎

離線的爬蟲和建索引管線,加上線上的查詢服務:兩邊的需求完全不同,一邊要大吞吐量,一邊要毫秒級的延遲。

流程

上面一列是離線管線:爬網頁、存原始檔、批次建索引,看的是吞吐量。第三列是線上查詢,看的是最慢的那一片要多久。兩邊只在索引段載入時交會。選一個流程一步步看;點方塊可以進到那個元件的主題。

亮起來的是這一步執行的程式碼
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。固定亂數種子,所以每個設定回答的是同一批查詢。

模型假設與範圍

  • 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
  • 離線建立索引與線上分片查詢分開;種子化延遲比較扇出。未包含網頁規模、完整排名、索引副本與真實網路。

什麼時候用

  • 離線和線上分開設計:爬蟲和建索引追求吞吐量、可以慢幾分鐘;查詢追求毫秒級延遲。兩邊只透過「載入索引段」交會,重建索引就不會拖慢查詢。
  • 原始網頁要留著:排序或解析規則改了,從物件儲存重建索引,比重新爬整個網路便宜幾個數量級。
  • 依文件切片,再處理長尾:負載平均、搬動少;每次查詢都得等最慢的一片,就用副本加對沖請求解決。

和其他主題的關係

時間與空間複雜度(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 On = 4n = 16n = 64n = 256成長倍數:實測(理論)
依文件切片O(n)41664256×64 (×64)
依詞切片O(1)1.81.92.02.0×1.1 (×1.0)

依文件切片時,分片越多、每次查詢要問的機器就越多;依詞切片則固定是一兩台。前者的長尾靠副本和對沖請求解決,後者的熱點很難解決。

和其他做法比

每次查詢問幾片p50p99最忙一片/平均每次查詢搬動
依文件切片64.0018.2 ms51.2 ms1.0×5.1 KB
依文件切片+對沖請求64.0014.1 ms24.0 ms1.0×5.1 KB
依詞切片1.9811.4 ms54.3 ms38.5×241.0 KB

同一批 2,000 個查詢、64 片。依文件切片的負載完全平均、搬動的資料很少,代價是每次都要等最慢的一片;對沖請求用多一點點請求量換掉大部分的長尾。依詞切片問的片數少,但熱門詞那一片會被打爆,而且每次都要搬一整串清單。實務上的大型搜尋引擎幾乎都依文件切片,再加上副本和對沖請求。

真實世界裡的它

  • Google 的經典論文描述了同樣的分工:爬蟲、儲存原始網頁、批次建倒排索引、依文件分片查詢。
  • Elasticsearch 和 OpenSearch 的索引就是依文件分片,每片有副本,查詢由協調節點分散再合併。
  • 「The Tail at Scale」一文提出的對沖請求,就是下方模擬裡的做法。

取捨與陷阱

  • 扇出越大、長尾越長:一台機器 1% 的機率變慢,問 100 台時大約 63% 的查詢都會碰上。平均延遲看不出來,要看 p99。
  • 爬蟲要有禮貌:同一個網站要限速、遵守 robots.txt,否則等於對別人發動阻斷服務攻擊。
  • 網址要先正規化:同一頁有很多種寫法(大小寫、結尾斜線、追蹤參數),不處理就會重複抓、重複收錄。

LeetCode 練習