設計搜尋自動補全(Search Autocomplete)

問題定義

ℹ️核心挑戰

設計一個搜尋自動補全系統,在用戶輸入的每一次按鍵後,200 毫秒內回傳 Top-K 最相關的搜尋建議。系統需處理每秒數萬次查詢,且建議內容隨搜尋趨勢即時更新。

需求分析

Functional Requirements:

Non-functional Requirements:

注意事項

⚠️實戰陷阱

前端 Debounce 是第一道防線。 如果每次按鍵都發 API 請求,打「JavaScript」8 個字母就是 8 次請求。用 Debounce(等用戶停止打字 100-200ms 再發請求)可以減少 50-80% 的請求量。另外,瀏覽器快取前綴結果也很重要——「jav」的結果可以複用給「java」的前端過濾。

設計流程

1

收集搜尋資料

Data Collection Service 記錄每次搜尋查詢和頻率

2

聚合頻率統計

Analytics 定期(或即時)計算每個查詢詞的熱門程度

3

建構 Trie

Trie Builder 將頻率資料建構成前綴樹,每個節點附帶 Top-K 結果

4

分散式部署

將 Trie 分片部署到多個 Trie Service 節點

5

用戶輸入查詢

前端 Debounce 後發送前綴給 API Gateway

6

查詢 Trie Service

API Gateway 路由到對應的 Trie 分片,查詢 Top-K 結果

7

多層快取回傳

結果經 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]
            |
           (*)
csharp
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 + 快取

typescript
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)

python
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

架構圖

Client (Browser)
Debounce 後查詢
CDN Cache
Cache Miss
API Gateway
查 Redis
Trie Service (Read)
Server Cache (Redis)
Cache Miss
Data Collection
聚合頻率
Analytics (Spark)
觸發重建
Trie Builder
新 Trie 快照
Trie Storage (S3)

上圖清楚呈現了 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 在實務中也被用來做自動補全(用 completion suggester)

為什麼不直接用 Elasticsearch? Elasticsearch 的 Completion Suggester 底層也是 FST(Finite State Transducer,Trie 的壓縮變體),但多了序列化和網路開銷。純記憶體 Trie 的 P99 延遲可達 1-5ms,Elasticsearch 通常在 10-50ms。對於 Google 等級的查詢量,這個差距很重要。

面試常見問題與參考答案

Q1:如何處理多語系(中文、日文)的自動補全?

A1:中日文沒有天然的字母前綴,需要不同策略:

  1. 拼音轉換:將中文查詢同時索引其拼音形式。例如「北京」同時索引為 "beijing""北京",用戶輸入 "bei" 也能命中
  2. Character-level Trie:直接用字元(Unicode)作為 Trie 節點。中文「北京天氣」建立 北 → 京 → 天 → 氣 的路徑
  3. 混合策略:支援拼音、注音、和直接輸入中文字。實務上 Google 搜尋同時支援這三種輸入方式

Q2:趨勢搜尋(Trending Topic)如何快速出現在建議中?

A2:離線 Trie 重建通常每 15-30 分鐘一次,無法滿足分鐘級別的即時性需求。解決方案:

  1. 在 Trie Service 前加一層 Trending Cache(Redis Sorted Set)
  2. Data Collection Service 即時統計最近 10 分鐘的高頻查詢
  3. 查詢時先查 Trending Cache,將結果與 Trie 結果合併
  4. 判斷標準:最近 10 分鐘內查詢次數超過平常同時段 5 倍的查詢詞,視為 Trending

Q3:如何防止惡意或不當內容出現在搜尋建議中?

A3:三道防線:

  1. 建構時過濾:Trie Builder 維護一份黑名單(敏感詞、色情關鍵字、仇恨言論),建構 Trie 時直接排除
  2. 即時下架:提供管理後台 API,能在 1 分鐘內將特定查詢從 Trending Cache 和 Trie 中移除
  3. 人工審核:新出現的高頻查詢在進入 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 存熱門,防抖減請求,快取壓延遲,離線建新樹」

你可能也想看

設計網路爬蟲設計影音串流服務

按 ← → 鍵切換課程