Skip to content

设计一个搜索系统(全文搜索引擎)

提出问题

搜索是互联网产品的标配能力——用户搜商品、搜文章、搜订单、搜日志,背后都需要一个全文搜索引擎。面试官问"设计一个搜索系统",本质是在考察三个东西:你是否理解倒排索引这个核心数据结构;你是否了解 Elasticsearch 这类分布式搜索引擎的架构原理;以及你在生产上踩过哪些坑——分片设计、深度分页、集群脑裂、冷热分离,这些都是 P7/P8 干活时躲不开的问题。

搜索系统与普通数据库查询的差别在于:数据库的 LIKE '%keyword%' 不走索引,全表扫描,百万级数据就扛不住了;而搜索引擎通过事先建好的倒排索引,把"查文档"转换成"查词表",时间复杂度从 O(n) 降到 O(1),这是质的飞跃。

先从 Java 后端的视角看: 你用过 MySQL 的 LIKE '%xxx%',知道大表全表扫描有多痛。搜索系统本质上就是给"任意关键词查找"这件事建一个专用的索引结构,让查询不需要扫描全部数据。

分析问题

倒排索引的核心原理

倒排索引的本质是"词 → 文档列表"的映射关系。普通索引(正排)是"文档 → 词汇",倒排反过来。举个例子,有两篇文档:

  • Doc1: "Elasticsearch 是一个分布式搜索引擎"
  • Doc2: "搜索引擎的核心是倒排索引"

分词后建立倒排索引:

词项倒排列表
elasticsearch[Doc1]
分布式[Doc1]
搜索引擎[Doc1, Doc2]
核心[Doc2]
倒排索引[Doc2]

搜索"搜索引擎"时,查倒排索引得到 [Doc1, Doc2],按 BM25 相关性打分排序后返回。

对比数据库索引:MySQL B+ 树的索引是"值 → 行"的映射,适合精确匹配或范围查询。但"全文搜索"需要的是"词 → 包含该词的所有文档"的映射,B+ 树做不到——你不可能预知用户会搜哪个词,所以没法提前建好所有可能的 WHERE text LIKE '%word%' 索引。倒排索引解决了这个问题:建索引时就把文本拆成词,反过来建立"词→文档"的映射,查询时直接查词表即可。

Lucene 三层存储结构

倒排索引在 Lucene 中的存储结构是 Term Dictionary + Term Index + Postings List 三层:

Term Index (FST, 内存)
    ↓  通过 FST 找到词项在 Term Dictionary 中的偏移量
Term Dictionary (有序词项列表, 磁盘 .tim 文件)
    ↓  读 Term 元数据,拿到文档列表的文件指针
Postings List (文档ID列表 + 词频 + 位置信息, 磁盘 .doc/.pos 文件)

Term Index 用 FST(Finite State Transducer)保存在内存中。FST 比 HashMap 省内存:1 亿个词项约 200-300 MB,而 HashMap 存同样数量的字符串需要 5-10 GB。FST 的空间优势来自"前缀共享"——"elasticsearch"和"elastic"共享前缀,在 FST 中只存一次。

查询路径:搜索 "elasticsearch" → 内存 FST 定位到词项在 Term Dictionary 中的文件偏移 → 读磁盘 .tim 文件拿到 Term 元数据 → 读 .doc 文件拿到文档 ID 列表 → 读 .pos 文件拿到词在文档中的位置(用于短语查询)。

性能数据:一个 1000 万文档的索引,冷启动时 FST 加载到内存约 80ms,Term Dictionary 占用约 200MB 磁盘,Postings List 约 1.2GB。查询一次(不含网络开销)约 2-5ms。如果不用 FST,用 HashMap 做 Term Index,内存占用会飙到 1.5GB,而且 GC 压力巨大——FST 是纯结构体,不产生 GC 对象。

java
// 伪代码:搜索引擎的写入与查询流程
// 写入链路
class IndexWriter {
    void addDocument(Document doc) {
        List<String> tokens = analyzer.tokenize(doc.getText()); // 分词
        for (String token : tokens) {
            // 构建倒排索引:Term Dictionary + Postings List
            invertedIndex.add(token, doc.getId(), doc.getBoost());
        }
        // 写入内存缓冲区,refresh 后生成 Segment 文件
        memoryBuffer.add(doc);
    }
}

// 查询链路
class Searcher {
    List<SearchHit> search(String queryText) {
        List<String> queryTokens = analyzer.tokenize(queryText);
        // 从倒排索引中取每个词项的文档列表
        List<PostingsList> postings = queryTokens.stream()
            .map(token -> invertedIndex.get(token))
            .collect(toList());
        // 取交集/并集,计算 BM25 分数
        return merge(postings).stream()
            .sorted(byScore())
            .limit(10)
            .collect(toList());
    }
}

BM25 打分公式

BM25 是 Lucene 6+ 的默认相关性算法,替代了早期的 TF-IDF。核心公式:

Score(D, Q) = Σ (IDF * (TF * (k1 + 1)) / (TF + k1 * (1 - b + b * |D| / avgdl)))

参数含义:

  • k1:控制词频饱和速度,默认 1.2。值越大,词频越高对分数贡献越大
  • b:控制文档长度归一化,默认 0.75。值越大,长文档惩罚越重

真实调参案例:某电商搜索团队发现搜索"手机"时,4000 字的详情页排在 200 字标题页前面,用户不满意。将 b 从 0.75 调到 0.3 后,降权不那么狠,短标题文档的权重提升,点击率上涨 12%。

TF-IDF vs BM25 对比

维度TF-IDFBM25
词频饱和线性增长,词频 100 的分数是词频 1 的 100 倍对数饱和,词频到一定程度后不再显著增加分数
文档长度归一化无,长文档天然高分有,通过 b 参数控制
实际效果长文档霸榜,短文档很难排上来平衡长短文档,效果稳定
Lucene 版本5.x 及之前6.x 起为默认

写入流程:近实时(NRT)的代价

ES 的写入是 近实时(NRT) 而非实时。写入链路:

客户端 → 协调节点(Coordinating Node)
       → 路由到主分片所在节点(根据 _id 哈希)
       → 写入主分片的 Lucene 内存缓冲区 + Translog(防止宕机丢数据)
       → 同步到副本分片(副本数 ≥ 1)
       → 主分片和副本都成功后返回 ACK

时序图(文字描述)

时间线 →
Client   Coord.Node   Primary Shard   Replica Shard
  |           |              |              |
  |---POST---->|              |              |  1. 客户端发写入请求
  |           |---hash路由---->|              |  2. 协调节点计算 _id 的路由
  |           |              |              |
  |           |              |---同步请求---->|  3. 主分片同步到副本
  |           |              |<---ACK--------|  4. 副本写入完成
  |           |<---ACK-------|              |  5. 主分片返回 ACK
  |<---201----|              |              |  6. 协调节点返回客户端
  |           |              |              |
  |           |              |  refresh(1s) |  7. 默认每秒生成 Segment
  |           |              |  可搜索      |

为什么是近实时,不是实时?

写入到内存缓冲区后,默认每秒触发一次 refresh,将缓冲区中的数据生成一个不可变的 Segment 文件,写入后才可被搜索。这意味着刚写入的数据在 1 秒内是不可见的。

如果不 refresh 直接搜会怎样? 数据还在内存缓冲区,没落盘成 Segment,查询引擎根本看不到它。

Segment 合并:每秒生成一个 Segment,10 分钟就有 600 个 Segment。段太多 → 查询时要打开所有 Segment 的文件句柄 → 查询变慢。ES 后台线程会定期合并小 Segment 为大 Segment,合并时删除被标记为删除的文档,释放磁盘空间。

yaml
# elasticsearch.yml 配置示例
# 调整 refresh 间隔以优化写入吞吐
index.refresh_interval: 30s  # 写入密集型场景,牺牲搜索实时性提升写入速度

生产踩坑:某日志平台用默认 1s refresh,写入 100 MB/s 日志时 ES 集群 CPU 打满。把 refresh_interval 调到 30s 后,CPU 从 95% 降到 40%,写入吞吐提升 6 倍。代价是搜索延迟从 1s 变成 30s——对于日志搜索场景完全可接受。

Translog 的作用:如果写入后、refresh 前节点宕机,内存缓冲区里的数据会丢失。Translog 是操作日志,每次写入写 Translog,宕机后重放 Translog 恢复数据。index.translog.durability: request 每次请求都 fsync Translog(性能差但安全);index.translog.durability: async 异步 fsync(性能好,但可能丢几毫秒的数据)。

分片与副本策略

分片设计是 ES 最容易被低估的决策。分片数一旦创建就不可修改,选错了只能重建索引。

分片数公式shard_count = 总数据量 / 单分片容量上限。单分片推荐 20-50GB,超过 50GB 后查询性能下降明显。10TB 数据,按 50GB/分片计算,需要 200 个分片。但分片不是越多越好,每个分片有独立的元数据、线程池、文件句柄,200 个分片意味着 ES 集群需要管理 200 个 Lucene 实例,集群层面的开销不可忽视。

分片数的坑:某电商公司初始分了 3 个分片,数据增长到 1TB 后单分片 330GB,查询慢到 10 秒以上。但分片数不能改,只能新建索引(reindex)迁移,迁移过程需要停机 2 小时。这就是为什么上线前要做数据量预估。

常见分片配置对比

场景数据量分片数副本数节点数注意事项
小项目< 100GB3-513够用,3 节点各放 1 主 + 若干副本
中型1-5TB20-3016-10按 50GB/分片,分片数预留 2 倍余量
日志10TB+ 天级按天建索引,每天 10-20 分片120+配合 ILM 冷热分离,7 天前的自动降副本+迁移到 HDD
搜索100GB+5-1025-10搜索场景副本多有益,利用副本分担查询负载
bash
# 动态调整副本数(不影响服务)
PUT /my_index/_settings
{
  "index": {
    "number_of_replicas": 2
  }
}

副本和查询性能的关系:副本越多,查询吞吐越高。每增加一个副本,查询容量翻倍。但副本也意味着写入要同步到更多节点,写入延迟会上升。如果写入 QPS 不高(< 1000/s),3 副本完全没问题;如果写入 QPS 超过 5000/s,副本数建议控制在 1-2 个。

深度分页:from + size 死亡陷阱

问题GET /my_index/_search?from=10000&size=10 为什么会 OOM?

原因:协调节点收到查询后,向每个分片发同样的请求,每个分片返回 from + size = 10010 条结果,协调节点汇总后排序,取前 10 条。如果有 20 个分片,协调节点要处理 20 × 10010 ≈ 200,200 条结果。当 from 很大时(比如 100 万),每个分片返回 100 万条,协调节点内存直接爆炸。

ES 默认限制index.max_result_window = 10000,超过 10000 条直接报错:

json
{
  "error": {
    "reason": "Result window is too large, from + size must be less than or equal to [10000]"
  }
}

不要调大这个值,调大只延缓崩溃,不解决根本问题。

正确的方案

  1. search_after(推荐):记住最后一条的排序值,下一页从它后面开始取。比 from + size 快 100 倍以上,因为每个分片只需返回 size 条,不需要丢弃前面的数据。
java
// 第一页
GET /my_index/_search
{
  "size": 10,
  "sort": [{"timestamp": "desc"}, {"_id": "asc"}]
}

// 第二页,把上一页最后一条的 sort 值传过来
GET /my_index/_search
{
  "size": 10,
  "search_after": [1628000000000, "abc123"],
  "sort": [{"timestamp": "desc"}, {"_id": "asc"}]
}
  1. Scroll(批处理场景):快照式查询,适合导出全量数据。但 scroll 有上下文过期时间(scroll=1m),超过 1 分钟没取完就失效。
java
// 初始化 scroll 上下文
GET /my_index/_search?scroll=1m
{
  "size": 1000
}

// 后续一直拿 scroll_id 翻页
GET /_search/scroll
{
  "scroll": "1m",
  "scroll_id": "DXF1ZXJ5QW5kRmV0Y2gB..."
}

注意:scroll 必须在 deletescroll 超时后释放,不清理的 scroll 上下文会一直占用内存。生产上遇到过 scroll 设置 5 分钟超时但忘了调用,高峰期 500 个 scroll 上下文把内存吃光的案例。

Java 客户端处理翻页RestHighLevelClientSearchSourceBuilder 默认 from=0, size=10。如果业务上需要翻页且页数可能超过 1000,不要用 from + size,直接上 searchAfterBuilder

java
// 正确做法:用 searchAfter
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
    .size(20)
    .sort("timestamp", SortOrder.DESC)
    .sort("_id", SortOrder.ASC);

// 把上一页最后一条的 sort 值传进来
Object[] sortValues = lastHit.getSortValues();
sourceBuilder.searchAfter(sortValues);

SearchRequest request = new SearchRequest("my_index").source(sourceBuilder);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);

集群脑裂的预防

脑裂是指集群中多个节点同时认为自己是 master,导致数据写入不一致。

触发条件:网络分区导致 master 节点被多数节点隔离,被隔离的节点触发选举,选出新的 master,原来的 master 还在"我是 master"的状态,两个 master 同时接受写入。

预防:设置 discovery.zen.minimum_master_nodes = (master-eligible-nodes / 2) + 1。3 个 master 节点 → 值为 2,5 个 -> 值为 3。这样即使网络分区,少数派那边凑不够法定票数,不会选出新 master。

yaml
# ES 7.x 配置
discovery.seed_hosts: ["node1:9300", "node2:9300", "node3:9300"]
cluster.initial_master_nodes: ["node1", "node2", "node3"]

ES 7.x 的改进:ES 7.x 引入了 cluster.initial_master_nodes 替代了 minimum_master_nodes 的部分功能,但 minimum_master_nodes 在 7.x 仍然有效。ES 8.x 开始使用 cluster.initial_master_nodes 配合 discovery.seed_providers,不再需要手动设置 minimum_master_nodes

冷热数据分离

架构:将集群节点分为热节点(hot)和冷节点(warm/cold)。热节点 SSD + 高副本,冷节点 HDD + 低副本。

ILM(Index Lifecycle Management)自动管理

json
// ILM 策略:7 天后自动迁移到冷节点,30 天后删除
PUT _ilm/policy/log_rollover
{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": {
          "rollover": {"max_size": "50GB", "max_age": "1d"},
          "set_priority": {"priority": 100}
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "allocate": {"require": {"box_type": "warm"}},
          "forcemerge": {"max_num_segments": 1},
          "shrink": {"number_of_shards": 1},
          "set_priority": {"priority": 50}
        }
      },
      "delete": {
        "min_age": "30d",
        "actions": {"delete": {}}
      }
    }
  }
}

成本对比:1TB 日志数据,全部 SSD 约 8000 元/月;SSD 存 7 天热数据(约 230GB)+ HDD 存 23 天冷数据(约 770GB),约 4000 元/月,成本降 50%,且热数据查询性能不受影响。

Java 集成实战:Spring Data Elasticsearch 的坑

如果你在 Spring Boot 里集成 ES,大概率会用 Spring Data Elasticsearch。以下是生产上踩过的三个坑:

坑 1:@Field 注解的 type 映射不兼容

java
@Document(indexName = "product")
public class Product {
    @Id
    private String id;
    
    @Field(type = FieldType.Text, analyzer = "ik_max_word")
    private String title;
    
    @Field(type = FieldType.Keyword)  // 注意:Keyword 不分词,用于精确匹配
    private String category;
    
    @Field(type = FieldType.Long)
    private Long price;
}

问题FieldType.Text 默认会同时建 titletitle.keyword 两个字段。前者是分词后的,后者是原值。如果你用 @Field(type = FieldType.Text) 但不配 fielddata=true,做聚合(aggregation)时会报错:Fielddata is disabled on text fields

解决方案:需要聚合的字段用 @Field(type = FieldType.Keyword),不要用 Text。或者显式声明 @MultiField

java
@MultiField(
    mainField = @Field(type = FieldType.Text, analyzer = "ik_max_word"),
    otherFields = {
        @InnerField(suffix = "keyword", type = FieldType.Keyword)
    }
)
private String title;

坑 2:N+1 查询问题

Spring Data Elasticsearch@Field(type = FieldType.Nested)@Field(type = FieldType.Object) 表现不同:

  • Object:内部对象会被扁平化,[{ "a": 1, "b": 2 }, { "a": 3, "b": 4 }] 查询 a=1 AND b=4 会误匹配
  • Nested:保持对象独立性,但查询语法更复杂,写入性能差(内部维护了独立的 Lucene 块)

生产建议:如果对象数组不需要独立查询(比如只是展示),用 Object。如果需要跨字段匹配(比如"搜到张三,他参加了2024年培训"),用 Nested。

坑 3:批量写入的 bulk 大小选择

java
BulkRequest bulkRequest = new BulkRequest();
for (Product product : productList) {
    bulkRequest.add(new IndexRequest("product").id(product.getId())
        .source(JSON.toJSONString(product), XContentType.JSON));
}
// 问题:一次性塞太多,内存溢出
BulkResponse response = client.bulk(bulkRequest, RequestOptions.DEFAULT);

正确做法:控制每批大小,5000 条或 5MB,取先到者。分批提交:

java
int batchSize = 5000;
List<List<Product>> batches = Lists.partition(productList, batchSize);
for (List<Product> batch : batches) {
    BulkRequest request = new BulkRequest();
    for (Product p : batch) {
        request.add(new IndexRequest("product").id(p.getId())
            .source(JSON.toJSONString(p), XContentType.JSON));
    }
    BulkResponse response = client.bulk(request, RequestOptions.DEFAULT);
    if (response.hasFailures()) {
        log.error("Bulk failed: {}", response.buildFailureMessage());
        // 重试或记录失败 ID
    }
}

生产踩坑汇总

坑 1:分片数过多导致集群不稳定

  • 现象:集群频繁出现 CircuitBreakingException
  • 原因:某团队 5 节点集群建了 500 个分片,每个分片 2GB。每个分片 1 个线程池,500 个分片争抢 20 个线程,大量请求排队超时
  • 修复:重建索引,合并到 50 个分片,每分片 20GB,稳定运行

坑 2:不停机 reindex 翻车

  • 现象:reindex 进行中,旧索引被删除,新索引还没准备好,业务查询直接 404
  • 原因:reindex 完成后应该用 alias 切换,而不是删旧索引
  • 正确做法:应用层只通过 alias 访问索引,reindex 完成后 POST /_aliases 原子切换

坑 3:mapping 字段爆炸

  • 现象:写入速度越来越慢,磁盘用满,集群 yellow
  • 原因:日志字段动态映射,每天产生 2000+ 个新字段,每个字段都建索引,mapping 膨胀到 100MB+
  • 修复:dynamic: falsedynamic: "strict",只对明确字段建索引

坑 4:Java 客户端版本不匹配

  • 现象:NoSuchMethodErrorClassNotFoundException,启动就报错
  • 原因:ES 服务端版本 7.10,pom 里引了 elasticsearch-rest-high-level-client:7.17,大版本号一样但小版本不一致,某些 API 签名变了
  • 对应关系:ES 服务端 7.x → 客户端 7.x 任意小版本,但 7.10 和 7.17 之间某些 API 有差异。解决方案:保证客户端版本 ≤ 服务端版本,且大版本一致。ES 8.x 移除了 RestHighLevelClient,改用 ElasticsearchClient,注意不要混用

坑 5:分词器导致内存 OOM

  • 现象:写入 QPS 不高,但 CPU 居高不下,Young GC 频繁
  • 原因:ik 分词器自定义词典 10MB,每次分词都加载词典到内存,伴随大量 char[] 对象创建
  • 修复:自定义词典控制在 1MB 以内,开启 ik 的 use_smart 模式(细粒度切分转粗粒度),减少分词语法产生的对象数量

总结

P7/P8 面试中,搜索系统的核心考点不是"倒排索引是什么",而是:

  1. 分片设计的 trade-off — 分片过少→单分片过大,查询慢;分片过多→集群管理开销大。经验公式:20-50GB/分片,按数据量反推分片数,宁少勿多。
  2. NRT 写入的实时性取舍 — 默认 1 秒 refresh 在写入场景下是瓶颈。调大 refresh_interval 或改用 async 写入模式,写入吞吐可提升 5-10 倍,代价是搜索延迟变高。
  3. 深度分页别用 from + size — 超过 1 万条要用 search_afterscroll,否则协调节点要从每个分片拉取全部结果再排序,内存和网络双重爆炸。
  4. 集群脑裂的预防discovery.zen.minimum_master_nodes = (master-eligible-nodes / 2) + 1,一句话背下来,这是面试高频。
  5. 冷热数据分离 — 热数据(7 天)SSD + 高副本,冷数据(7 天以上)HDD + 低副本,ILM 自动迁移,存储成本降 70%。
  6. mapping 字段爆炸要防 — 日志场景 dynamic: false,不要等 memory 爆了再改。
  7. Java 客户端集成 — 版本必须匹配,RestHighLevelClient 在 ES 8.x 已废弃,升级时注意。批量写入控制每批 5000 条/5MB。

参考:Elasticsearch 官方文档(倒排索引原理)、Lucene 打分公式(BM25 算法)、ES 分片策略最佳实践、ILM 冷热分离方案、Spring Data Elasticsearch 官方文档

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。