設計搜尋自動補全(Search Autocomplete)
問題定義
ℹ️核心挑戰
設計一個搜尋自動補全系統,在用戶輸入的每一次按鍵後,200 毫秒內回傳 Top-K 最相關的搜尋建議。系統需處理每秒數萬次查詢,且建議內容隨搜尋趨勢即時更新。
需求分析
Functional Requirements:
- 用戶輸入前綴,回傳 Top-K(通常 5-10 個)最熱門的搜尋建議
- 建議根據搜尋頻率排名
- 支援即時更新(trending topics 能快速出現)
- 支援多語系搜尋
Non-functional Requirements:
- 超低延遲:P99 < 200ms(用戶感知即時)
- 高可用:搜尋建議掛掉不影響主搜尋功能
- 可擴展:支撐每秒 10 萬次以上的查詢
- 資料新鮮度:熱門搜尋在分鐘級別內出現在建議中
注意事項
⚠️實戰陷阱
前端 Debounce 是第一道防線。 如果每次按鍵都發 API 請求,打「JavaScript」8 個字母就是 8 次請求。用 Debounce(等用戶停止打字 100-200ms 再發請求)可以減少 50-80% 的請求量。另外,瀏覽器快取前綴結果也很重要——「jav」的結果可以複用給「java」的前端過濾。
設計流程
收集搜尋資料
Data Collection Service 記錄每次搜尋查詢和頻率
聚合頻率統計
Analytics 定期(或即時)計算每個查詢詞的熱門程度
建構 Trie
Trie Builder 將頻率資料建構成前綴樹,每個節點附帶 Top-K 結果
分散式部署
將 Trie 分片部署到多個 Trie Service 節點
用戶輸入查詢
前端 Debounce 後發送前綴給 API Gateway
查詢 Trie Service
API Gateway 路由到對應的 Trie 分片,查詢 Top-K 結果
多層快取回傳
結果經 Browser Cache → CDN → Server Cache 多層快取加速
架構設計
Trie 資料結構(含 Top-K)
Trie(前綴樹)的核心概念:Trie 是一棵多叉樹,每個節點代表一個字元,從根節點到任意節點的路徑組成一個前綴。與 Hash Table 不同,Trie 天然支援前綴搜尋——輸入「app」就能找到「apple」、「application」、「appetizer」等所有以「app」開頭的字串。
Trie 操作的圖解說明:
假設插入三個查詢:"cat"(freq:100)、"car"(freq:80)、"card"(freq:60)
root
|
[c] ← TopK: ["cat":100, "car":80, "card":60]
|
[a] ← TopK: ["cat":100, "car":80, "card":60]
/ \
[t] [r] ← TopK: ["car":80, "card":60]
| |
(*) [d] ← TopK: ["card":60]
|
(*)
- 每個節點儲存
Children(子節點 Map)和TopK(此前綴下最熱門的 K 個結果) (*)標記IsEndOfWord = true,表示這是一個完整的查詢詞- 查詢
"ca":從 root → c → a,直接回傳該節點的 TopK =["cat", "car", "card"],時間複雜度 O(L),L 是前綴長度 - 為什麼要預存 TopK? 如果不預存,查詢
"ca"時需要遍歷a節點下的所有子孫節點來找最熱門的結果。當子孫節點有百萬個時,遍歷時間可能超過 1 秒,完全無法達到 200ms 的延遲要求
public class TrieNode
{
public Dictionary<char, TrieNode> Children { get; } = new();
public bool IsEndOfWord { get; set; }
// 預先計算此前綴下的 Top-K 結果,避免查詢時遍歷
public List<(string Query, long Frequency)> TopK { get; set; } = new();
}
public class AutocompleteTrie
{
private readonly TrieNode _root = new();
private const int K = 10;
public void Insert(string query, long frequency)
{
var node = _root;
foreach (var ch in query)
{
if (!node.Children.ContainsKey(ch))
node.Children[ch] = new TrieNode();
node = node.Children[ch];
// 更新每個前綴節點的 Top-K
UpdateTopK(node, query, frequency);
}
node.IsEndOfWord = true;
}
public List<string> Search(string prefix)
{
var node = _root;
foreach (var ch in prefix)
{
if (!node.Children.ContainsKey(ch))
return new List<string>();
node = node.Children[ch];
}
return node.TopK.Select(x => x.Query).ToList();
}
private void UpdateTopK(TrieNode node, string query, long freq)
{
var existing = node.TopK.FindIndex(x => x.Query == query);
if (existing >= 0) node.TopK.RemoveAt(existing);
node.TopK.Add((query, freq));
node.TopK = node.TopK.OrderByDescending(x => x.Frequency)
.Take(K).ToList();
}
}前端 Debounce + 快取
class AutocompleteClient {
private cache = new Map<string, string[]>();
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
onInput(prefix: string, callback: (results: string[]) => void): void {
// 先檢查本地快取
if (this.cache.has(prefix)) {
callback(this.cache.get(prefix)!);
return;
}
// Debounce:等用戶停止打字 150ms 再發請求
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(async () => {
const results = await fetch(`/api/autocomplete?q=${prefix}`)
.then((res) => res.json());
// 快取結果
this.cache.set(prefix, results);
callback(results);
}, 150);
}
}資料收集與 Trie 更新(Write Path)
from collections import Counter
from datetime import datetime, timedelta
class DataCollectionService:
"""收集搜尋查詢,定期聚合後觸發 Trie 重建"""
def __init__(self):
self.buffer: list[str] = []
self.frequency_store: Counter = Counter()
def record_query(self, query: str) -> None:
"""每次搜尋時記錄(寫入 Kafka/Log)"""
self.buffer.append(query.lower().strip())
def aggregate(self) -> dict[str, int]:
"""每 N 分鐘聚合一次頻率"""
self.frequency_store.update(self.buffer)
self.buffer.clear()
# 套用時間衰減:7 天前的搜尋權重降低
# 實務上用 Exponential Decay 或 Sliding Window
return dict(self.frequency_store.most_common(100_000))
def trigger_trie_rebuild(self, freq_data: dict[str, int]) -> None:
"""將聚合結果送給 Trie Builder 重建"""
# 離線建構新 Trie → 原子替換線上 Trie
pass架構圖
上圖清楚呈現了 Read Path 和 Write Path 的分離。Read Path(上半部):用戶輸入經 Debounce 後,依序查詢 CDN Cache → Redis Cache → Trie Service,任一層命中即回傳,確保 P99 延遲低於 200ms。Write Path(下半部):搜尋記錄經 Data Collection 收集,由 Analytics(如 Apache Spark)聚合頻率,Trie Builder 離線建構新 Trie 快照存入 S3,Trie Service 定期載入新快照,實現原子替換。
延伸討論
💡進階優化
Trie 分片策略。 當資料量大到一台機器放不下整棵 Trie 時,可以按首字母分片(a-f 到 Shard 1、g-m 到 Shard 2 等)。API Gateway 根據查詢前綴路由到對應分片。
分片的具體考量:
- 按首字母分片的問題:字母分佈不均(英文中
s開頭的查詢比z多 10 倍以上),導致 Hot Shard 問題 - 更好的做法:按 Hash 分片(前綴的前兩個字元做 Hash),確保負載均勻分佈到各節點
- 分片數量選擇:如果 Trie 總大小 50 GB,每台機器記憶體 16 GB,至少需要 4 個分片。實務上取 2-3 倍冗餘,部署 8-12 個分片
Trie 替代方案比較:
- Trie:前綴搜尋 O(L) 最優,但記憶體佔用大(每個節點需要儲存 Children Map)
- Sorted Array + Binary Search:記憶體更省,但前綴搜尋需要 O(log N) 的二分查找
- Inverted Index:更適合全文搜尋而非前綴搜尋,但 Elasticsearch 在實務中也被用來做自動補全(用
completionsuggester)
為什麼不直接用 Elasticsearch? Elasticsearch 的 Completion Suggester 底層也是 FST(Finite State Transducer,Trie 的壓縮變體),但多了序列化和網路開銷。純記憶體 Trie 的 P99 延遲可達 1-5ms,Elasticsearch 通常在 10-50ms。對於 Google 等級的查詢量,這個差距很重要。
面試常見問題與參考答案
Q1:如何處理多語系(中文、日文)的自動補全?
A1:中日文沒有天然的字母前綴,需要不同策略:
- 拼音轉換:將中文查詢同時索引其拼音形式。例如「北京」同時索引為
"beijing"和"北京",用戶輸入"bei"也能命中 - Character-level Trie:直接用字元(Unicode)作為 Trie 節點。中文「北京天氣」建立
北 → 京 → 天 → 氣的路徑 - 混合策略:支援拼音、注音、和直接輸入中文字。實務上 Google 搜尋同時支援這三種輸入方式
Q2:趨勢搜尋(Trending Topic)如何快速出現在建議中?
A2:離線 Trie 重建通常每 15-30 分鐘一次,無法滿足分鐘級別的即時性需求。解決方案:
- 在 Trie Service 前加一層 Trending Cache(Redis Sorted Set)
- Data Collection Service 即時統計最近 10 分鐘的高頻查詢
- 查詢時先查 Trending Cache,將結果與 Trie 結果合併
- 判斷標準:最近 10 分鐘內查詢次數超過平常同時段 5 倍的查詢詞,視為 Trending
Q3:如何防止惡意或不當內容出現在搜尋建議中?
A3:三道防線:
- 建構時過濾:Trie Builder 維護一份黑名單(敏感詞、色情關鍵字、仇恨言論),建構 Trie 時直接排除
- 即時下架:提供管理後台 API,能在 1 分鐘內將特定查詢從 Trending Cache 和 Trie 中移除
- 人工審核:新出現的高頻查詢在進入 Trending Cache 前,先經過自動分類器(ML 模型)判斷是否安全,不確定的送人工審核
理解測驗
🤔 為什麼 Trie 的每個節點都預先儲存 Top-K 結果?
🤔 前端 Debounce 的主要目的是什麼?
🤔 Trie 的離線重建(而非線上即時更新)有什麼好處?
重點整理
| 概念 | 說明 |
|------|------|
| Trie(前綴樹) | 多叉樹結構,每個節點代表一個字元,查詢複雜度 O(L),L 為前綴長度 |
| Top-K 預計算 | 每個節點預存該前綴下最熱門的 K 個結果,避免查詢時遍歷百萬子孫節點 |
| Debounce | 前端等用戶停止打字 150ms 後才發請求,減少 50-80% 的 API 請求量 |
| 多層快取 | Browser Cache(本地)→ CDN(邊緣)→ Redis(伺服器)→ Trie(記憶體),命中率逐層提升 |
| Read/Write 分離 | Read Path 延遲 P99 低於 200ms;Write Path 每 15-30 分鐘離線重建 Trie 並原子替換 |
| Trie 分片 | 按前綴 Hash 分片到多台機器,避免按首字母分片的 Hot Shard 問題 |
| 時間衰減 | score = frequency * e^(-lambda * days),7 天以上的搜尋權重指數下降 |
| Trending Cache | Redis Sorted Set 即時統計高頻查詢,10 分鐘內查詢量超過常態 5 倍即為 Trending |
💡一句話記住
搜尋自動補全 = Trie 預存 Top-K + Debounce 減請求 + 四層快取壓延遲 + 離線重建不影響讀取。
記憶口訣:「Trie 存熱門,防抖減請求,快取壓延遲,離線建新樹」。