HiHuo
首页
博客
手册
工具
关于
首页
博客
手册
工具
关于
  • 系统设计实战

    • 系统设计面试教程
    • 系统设计方法论
    • 01-短链系统设计
    • 02 - 秒杀系统设计
    • 03 - IM 即时通讯系统设计
    • 04 - Feed 流系统设计
    • 05 - 分布式 ID 生成器设计
    • 06 - 限流系统设计
    • 第7章:搜索引擎设计
    • 08 - 推荐系统设计
    • 09 - 支付系统设计
    • 10 - 电商系统设计
    • 11 - 直播系统设计
    • 第12章:缓存系统设计
    • 第13章:消息队列设计
    • 第14章:分布式事务
    • 15 - 监控系统设计

第7章:搜索引擎设计

面试频率: D|/avgdl))

其中:

  • D: 文档
  • Q: 查询
  • qi: 查询中的第i个词
  • f(qi,D): qi在D中出现的频率
  • |D|: 文档D的长度
  • avgdl: 平均文档长度
  • k1, b: 调节参数(默认k1=1.2, b=0.75)

优点: 词频饱和(避免高频词主导) 文档长度归一化


### Go实现BM25

```go
package search

import (
	"math"
)

type BM25 struct {
	k1         float64  // 调节参数(默认1.2)
	b          float64  // 调节参数(默认0.75)
	avgDocLen  float64  // 平均文档长度
	totalDocs  int      // 文档总数
}

func NewBM25(avgDocLen float64, totalDocs int) *BM25 {
	return &BM25{
		k1:        1.2,
		b:         0.75,
		avgDocLen: avgDocLen,
		totalDocs: totalDocs,
	}
}

// CalculateIDF 计算IDF
func (bm *BM25) CalculateIDF(docsWithTerm int) float64 {
	return math.Log(float64(bm.totalDocs-docsWithTerm+0.5) / float64(docsWithTerm+0.5))
}

// CalculateScore 计算BM25分数
func (bm *BM25) CalculateScore(termFreq int, docLen int, docsWithTerm int) float64 {
	// IDF部分
	idf := bm.CalculateIDF(docsWithTerm)

	// 词频部分
	tf := float64(termFreq)
	
	// 文档长度归一化
	norm := 1 - bm.b + bm.b*(float64(docLen)/bm.avgDocLen)

	// BM25公式
	score := idf * (tf * (bm.k1 + 1)) / (tf + bm.k1*norm)

	return score
}

// CalculateQueryScore 计算查询得分
func (bm *BM25) CalculateQueryScore(query []string, doc *Document) float64 {
	score := 0.0

	for _, term := range query {
		termFreq := doc.GetTermFrequency(term)
		docsWithTerm := GetDocsWithTerm(term)
		
		score += bm.CalculateScore(termFreq, doc.Length, docsWithTerm)
	}

	return score
}

搜索建议

Trie树(前缀树)

type TrieNode struct {
	children map[rune]*TrieNode
	isEnd    bool
	freq     int  // 词频(用于排序)
}

type Trie struct {
	root *TrieNode
}

func NewTrie() *Trie {
	return &Trie{
		root: &TrieNode{
			children: make(map[rune]*TrieNode),
		},
	}
}

// Insert 插入词
func (t *Trie) Insert(word string, freq int) {
	node := t.root
	for _, ch := range word {
		if node.children[ch] == nil {
			node.children[ch] = &TrieNode{
				children: make(map[rune]*TrieNode),
			}
		}
		node = node.children[ch]
	}
	node.isEnd = true
	node.freq = freq
}

// Search 搜索建议(自动补全)
func (t *Trie) Search(prefix string, limit int) []string {
	node := t.root
	
	// 找到前缀节点
	for _, ch := range prefix {
		if node.children[ch] == nil {
			return []string{}
		}
		node = node.children[ch]
	}

	// DFS收集所有后缀
	results := make([]string, 0)
	t.dfs(node, prefix, &results, limit)

	return results
}

func (t *Trie) dfs(node *TrieNode, current string, results *[]string, limit int) {
	if len(*results) >= limit {
		return
	}

	if node.isEnd {
		*results = append(*results, current)
	}

	for ch, child := range node.children {
		t.dfs(child, current+string(ch), results, limit)
	}
}

Elasticsearch Completion Suggester

// Mapping定义
{
  "mappings": {
    "properties": {
      "suggest": {
        "type": "completion"
      }
    }
  }
}

// 索引文档
POST /products/_doc/1
{
  "title": "iPhone 15 Pro",
  "suggest": {
    "input": ["iPhone", "iPhone 15", "iPhone Pro"],
    "weight": 100
  }
}

// 搜索建议
POST /products/_search
{
  "suggest": {
    "my-suggest": {
      "prefix": "iphone",
      "completion": {
        "field": "suggest",
        "size": 10
      }
    }
  }
}

架构设计

V1:单机全文检索

┌─────────────┐
│ Application │
└──────┬──────┘
       │
       ↓
┌──────────────┐
│   Lucene     │ (倒排索引)
│   (单机)     │
└──────┬───────┘
       │
       ↓
┌──────────────┐
│  File System │
└──────────────┘

优点:
 简单

缺点:
✗ 容量受限
✗ 单点故障
✗ 性能瓶颈

V2:Elasticsearch集群

┌────────────────────────────────────────────┐
│              Load Balancer                 │
│              (Nginx/HAProxy)               │
└───────────────────┬────────────────────────┘
                    │
        ┌───────────┼───────────┐
        │           │           │
        ↓           ↓           ↓
┌─────────────┬─────────────┬─────────────┐
│ Coordinating│ Coordinating│ Coordinating│
│   Node 1    │   Node 2    │   Node 3    │
└──────┬──────┴──────┬───────┴──────┬──────┘
       │             │              │
   ┌───┴─────────────┴──────────────┴───┐
   │                                     │
   ↓                                     ↓
┌──────────────────────┐     ┌──────────────────────┐
│   Data Nodes (10)    │     │   Master Nodes (3)   │
│   - Shard 0 Primary  │     │   - Cluster State    │
│   - Shard 1 Replica  │     │   - Node Management  │
│   - ...              │     └──────────────────────┘
└──────────────────────┘

优点:
 水平扩展
 高可用
 自动分片

V3:多级架构(推荐)

┌─────────────┐
│   用户请求   │
└──────┬──────┘
       │
       ↓
┌──────────────┐
│   CDN缓存    │ (静态资源)
└──────┬───────┘
       │
       ↓
┌──────────────┐
│ Application  │
│   Gateway    │ (限流、鉴权)
└──────┬───────┘
       │
       ↓
┌──────────────┐
│  Redis缓存   │ (热点查询)
│   (30min)    │
└──────┬───────┘
       │ Miss
       ↓
┌──────────────┐
│ Elasticsearch│
│   Cluster    │
└──────┬───────┘
       │
       ↓
┌──────────────┐
│   MySQL      │ (商品基础数据)
└──────────────┘

查询流程:
1. 检查Redis缓存
2. 未命中 → ES查询
3. ES查询结果写入Redis
4. 异步更新ES索引(从MySQL)

核心实现

简化版搜索引擎

package search

import (
	"sort"
	"strings"
	"sync"
)

// SearchEngine 搜索引擎
type SearchEngine struct {
	index     map[string]*PostingList  // 倒排索引
	documents map[int]*Document        // 文档存储
	mu        sync.RWMutex
	tokenizer *ChineseTokenizer
	bm25      *BM25
}

// Document 文档
type Document struct {
	ID      int
	Title   string
	Content string
	Length  int
	Terms   map[string]int  // term → frequency
}

// PostingList 倒排列表
type PostingList struct {
	Term     string
	Postings []*Posting
}

// Posting 倒排项
type Posting struct {
	DocID     int
	Frequency int
	Positions []int
}

func NewSearchEngine() *SearchEngine {
	return &SearchEngine{
		index:     make(map[string]*PostingList),
		documents: make(map[int]*Document),
		tokenizer: NewChineseTokenizer(),
		bm25:      NewBM25(100, 0),
	}
}

// IndexDocument 索引文档
func (se *SearchEngine) IndexDocument(doc *Document) {
	se.mu.Lock()
	defer se.mu.Unlock()

	// 分词
	terms := se.tokenizer.Tokenize(doc.Title + " " + doc.Content)

	// 统计词频和位置
	doc.Terms = make(map[string]int)
	termPositions := make(map[string][]int)

	for pos, term := range terms {
		doc.Terms[term]++
		termPositions[term] = append(termPositions[term], pos)
	}

	doc.Length = len(terms)

	// 存储文档
	se.documents[doc.ID] = doc

	// 构建倒排索引
	for term, freq := range doc.Terms {
		if se.index[term] == nil {
			se.index[term] = &PostingList{
				Term:     term,
				Postings: make([]*Posting, 0),
			}
		}

		se.index[term].Postings = append(se.index[term].Postings, &Posting{
			DocID:     doc.ID,
			Frequency: freq,
			Positions: termPositions[term],
		})
	}

	// 更新BM25参数
	se.updateBM25Stats()
}

// Search 搜索
func (se *SearchEngine) Search(query string, limit int) []*SearchResult {
	se.mu.RLock()
	defer se.mu.RUnlock()

	// 分词
	terms := se.tokenizer.Tokenize(query)

	// 找到包含所有词的文档(AND查询)
	candidates := se.findCandidates(terms)

	// 计算BM25分数
	results := make([]*SearchResult, 0)
	for docID := range candidates {
		doc := se.documents[docID]
		score := se.calculateScore(terms, doc)

		results = append(results, &SearchResult{
			DocID: docID,
			Title: doc.Title,
			Score: score,
		})
	}

	// 按分数排序
	sort.Slice(results, func(i, j int) bool {
		return results[i].Score > results[j].Score
	})

	// 返回Top N
	if len(results) > limit {
		results = results[:limit]
	}

	return results
}

// findCandidates 找候选文档
func (se *SearchEngine) findCandidates(terms []string) map[int]bool {
	candidates := make(map[int]bool)

	for i, term := range terms {
		postingList := se.index[term]
		if postingList == nil {
			// 有词未找到,返回空
			return make(map[int]bool)
		}

		if i == 0 {
			// 第一个词,加入所有文档
			for _, posting := range postingList.Postings {
				candidates[posting.DocID] = true
			}
		} else {
			// 后续词,取交集
			newCandidates := make(map[int]bool)
			for _, posting := range postingList.Postings {
				if candidates[posting.DocID] {
					newCandidates[posting.DocID] = true
				}
			}
			candidates = newCandidates
		}
	}

	return candidates
}

// calculateScore 计算BM25分数
func (se *SearchEngine) calculateScore(terms []string, doc *Document) float64 {
	score := 0.0

	for _, term := range terms {
		termFreq := doc.Terms[term]
		docsWithTerm := len(se.index[term].Postings)

		score += se.bm25.CalculateScore(termFreq, doc.Length, docsWithTerm)
	}

	return score
}

// updateBM25Stats 更新BM25统计信息
func (se *SearchEngine) updateBM25Stats() {
	totalLen := 0
	for _, doc := range se.documents {
		totalLen += doc.Length
	}

	avgDocLen := float64(totalLen) / float64(len(se.documents))
	se.bm25 = NewBM25(avgDocLen, len(se.documents))
}

// SearchResult 搜索结果
type SearchResult struct {
	DocID int
	Title string
	Score float64
}

性能优化

1. 索引优化

{
  "settings": {
    // 写入优化
    "index.refresh_interval": "30s",  // 默认1s,增加可提升写入
    "index.number_of_replicas": 0,   // 写入时关闭副本

    // 查询优化
    "index.max_result_window": 10000,

    // 缓存
    "index.queries.cache.enabled": true
  }
}

2. 查询优化

// 使用Filter(可缓存)代替Query
GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "iPhone" } }
      ],
      "filter": [  // Filter会被缓存
        { "range": { "price": { "gte": 5000, "lte": 10000 } } },
        { "term": { "category": "Electronics" } }
      ]
    }
  }
}

// 分页优化:使用search_after代替from/size
GET /products/_search
{
  "size": 10,
  "query": { "match_all": {} },
  "search_after": [12345],  // 上一页最后一个文档的sort值
  "sort": [
    { "_id": "asc" }
  ]
}

3. 分片优化

分片数量选择:
- 单个分片大小:20-40GB
- 分片数 = 总数据量 / 单个分片大小

示例:
总数据:500GB
单分片:25GB
分片数:500 / 25 = 20个

副本数量:
- 生产环境:至少1个副本
- 高可用:2个副本

监控告警

Elasticsearch监控指标

# Prometheus Exporter
elasticsearch_exporter:
  metrics:
    # 集群健康
    - elasticsearch_cluster_health_status
    - elasticsearch_cluster_health_number_of_nodes
    - elasticsearch_cluster_health_active_shards

    # 索引性能
    - elasticsearch_indices_indexing_index_total
    - elasticsearch_indices_search_query_total
    - elasticsearch_indices_search_query_time_seconds

    # JVM内存
    - elasticsearch_jvm_memory_used_bytes
    - elasticsearch_jvm_memory_max_bytes
    - elasticsearch_jvm_gc_collection_seconds_sum

    # 节点性能
    - elasticsearch_process_cpu_percent
    - elasticsearch_fs_total_available_bytes

告警规则

groups:
  - name: elasticsearch_alerts
    rules:
      # 集群状态异常
      - alert: ClusterHealthRed
        expr: elasticsearch_cluster_health_status{color="red"} == 1
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "ES集群状态RED"

      # 查询延迟高
      - alert: HighQueryLatency
        expr: |
          rate(elasticsearch_indices_search_query_time_seconds[5m]) /
          rate(elasticsearch_indices_search_query_total[5m]) > 1
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "查询平均延迟超过1秒"

      # 磁盘使用率高
      - alert: HighDiskUsage
        expr: |
          (elasticsearch_fs_total_total_bytes - elasticsearch_fs_total_available_bytes) /
          elasticsearch_fs_total_total_bytes > 0.85
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "磁盘使用率超过85%"

面试问答

倒排索引和正排索引有什么区别?

正排索引:文档ID → 内容(适合根据ID查文档) 倒排索引:词 → 文档列表(适合全文检索)

正排索引:
Doc1 → "iPhone 15 Pro"
Doc2 → "Samsung Galaxy"

倒排索引:
"iPhone" → [Doc1]
"15" → [Doc1]
"Pro" → [Doc1]
"Samsung" → [Doc2]
"Galaxy" → [Doc2]

Elasticsearch如何实现分布式搜索?

分片 + 路由:

1. 写入:
   hash(document_id) % num_primary_shards → Shard ID
   写入Primary Shard,同步到Replica

2. 搜索:
   Coordinating Node收到请求
   → 广播到所有Shard(Primary或Replica)
   → 并行查询
   → 聚合结果,排序
   → 返回Top N

如何优化Elasticsearch查询性能?

答案:

  1. 使用Filter代替Query(Filter可缓存)
  2. 合理设置分片数(20-40GB/分片)
  3. 增加副本(提升读性能)
  4. 使用search_after代替from/size(深分页)
  5. Mapping优化(不需要分词的用keyword)
  6. 禁用_source(只返回需要的字段)
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "iPhone" } }
      ],
      "filter": [  // 优先使用filter
        { "range": { "price": { "gte": 5000 } } }
      ]
    }
  },
  "_source": ["title", "price"]  // 只返回需要的字段
}

什么是BM25算法?

BM25(Best Matching 25) 是改进的TF-IDF:

改进点:

  1. 词频饱和:避免高频词主导分数
  2. 文档长度归一化:短文档和长文档公平比较
BM25 = IDF × (TF × (k1+1)) / (TF + k1 × (1-b+b×docLen/avgDocLen))

k1: 调节TF饱和度(默认1.2)
b: 调节文档长度影响(默认0.75)

如何实现搜索建议(自动补全)?

方案1:Trie树(前缀树)

输入:"iph"
Trie查找:
  i → p → h → one (iPhone)
         → ad (iPad)

返回:["iPhone", "iPad"]

方案2:ES Completion Suggester

{
  "suggest": {
    "type": "completion",
    "input": ["iPhone", "iPhone 15"],
    "weight": 100
  }
}

如何处理海量数据的索引更新?

答案:

  1. 批量索引(Bulk API)
POST /_bulk
{ "index": { "_index": "products", "_id": "1" } }
{ "title": "iPhone 15" }
{ "index": { "_index": "products", "_id": "2" } }
{ "title": "iPad Pro" }
  1. 调整refresh_interval
{
  "settings": {
    "index.refresh_interval": "30s"  // 默认1s
  }
}
  1. 关闭副本(导入时)
{
  "settings": {
    "index.number_of_replicas": 0
  }
}
// 导入完成后再打开副本
  1. 增量更新(使用update API)

Elasticsearch的写入流程是什么?

1. 客户端发送写入请求
   ↓
2. Coordinating Node接收请求
   ↓
3. 路由到Primary Shard(hash(doc_id) % num_shards)
   ↓
4. Primary Shard写入:
   - 写入Memory Buffer
   - 写入Translog(持久化)
   ↓
5. 并行复制到Replica Shards
   ↓
6. 所有Replica确认后,返回成功
   ↓
7. Refresh(默认1秒):
   - Memory Buffer → Segment(可搜索)
   ↓
8. Flush(默认30分钟):
   - Segment → 磁盘
   - 清空Translog

如何实现拼写纠错?

方案1:编辑距离(Levenshtein Distance)

func LevenshteinDistance(s1, s2 string) int {
	// 计算两个字符串的最小编辑距离
	// "iphone" vs "iphoen" = 1
}

// 纠错:找编辑距离<=2的词

方案2:ES Suggester

POST /products/_search
{
  "suggest": {
    "my-suggest": {
      "text": "ipone",
      "term": {
        "field": "title",
        "suggest_mode": "popular",  // popular/missing/always
        "max_edits": 2
      }
    }
  }
}

// 返回:"iphone"

Elasticsearch和Solr有什么区别?

特性ElasticsearchSolr
架构原生分布式需要ZooKeeper
性能写入快,实时性好查询快(复杂查询)
易用性REST API,简单配置复杂
生态ELK Stack完善依赖Hadoop生态
社区活跃,发展快成熟,更新慢

选择建议:

  • 实时搜索、日志分析 → Elasticsearch
  • 传统搜索、复杂查询 → Solr

如何设计一个电商搜索系统?

答案:

架构:

用户 → CDN → API Gateway → Redis缓存 → Elasticsearch → MySQL

核心功能:

  1. 全文检索:商品标题、描述
  2. 过滤:价格区间、品牌、分类
  3. 排序:相关性、销量、价格
  4. 搜索建议:输入补全
  5. 拼写纠错:自动纠正错误

Mapping设计:

{
  "mappings": {
    "properties": {
      "title": { "type": "text", "analyzer": "ik_max_word" },
      "price": { "type": "double" },
      "sales": { "type": "long" },
      "category": { "type": "keyword" },
      "brand": { "type": "keyword" },
      "suggest": { "type": "completion" }
    }
  }
}

查询示例:

{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "iPhone" } }
      ],
      "filter": [
        { "range": { "price": { "gte": 5000, "lte": 10000 } } },
        { "term": { "brand": "Apple" } }
      ]
    }
  },
  "sort": [
    { "_score": "desc" },
    { "sales": "desc" }
  ]
}

Prev
06 - 限流系统设计
Next
08 - 推荐系统设计