设计一个搜索系统(全文搜索引擎)
提出问题
搜索是互联网产品的标配能力——用户搜商品、搜文章、搜订单、搜日志,背后都需要一个全文搜索引擎。面试官问"设计一个搜索系统",本质是在考察三个东西:你是否理解倒排索引这个核心数据结构;你是否了解 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 对象。
// 伪代码:搜索引擎的写入与查询流程
// 写入链路
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-IDF | BM25 |
|---|---|---|
| 词频饱和 | 线性增长,词频 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,合并时删除被标记为删除的文档,释放磁盘空间。
# 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 小时。这就是为什么上线前要做数据量预估。
常见分片配置对比
| 场景 | 数据量 | 分片数 | 副本数 | 节点数 | 注意事项 |
|---|---|---|---|---|---|
| 小项目 | < 100GB | 3-5 | 1 | 3 | 够用,3 节点各放 1 主 + 若干副本 |
| 中型 | 1-5TB | 20-30 | 1 | 6-10 | 按 50GB/分片,分片数预留 2 倍余量 |
| 日志 | 10TB+ 天级 | 按天建索引,每天 10-20 分片 | 1 | 20+ | 配合 ILM 冷热分离,7 天前的自动降副本+迁移到 HDD |
| 搜索 | 100GB+ | 5-10 | 2 | 5-10 | 搜索场景副本多有益,利用副本分担查询负载 |
# 动态调整副本数(不影响服务)
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 条直接报错:
{
"error": {
"reason": "Result window is too large, from + size must be less than or equal to [10000]"
}
}不要调大这个值,调大只延缓崩溃,不解决根本问题。
正确的方案:
- search_after(推荐):记住最后一条的排序值,下一页从它后面开始取。比
from + size快 100 倍以上,因为每个分片只需返回size条,不需要丢弃前面的数据。
// 第一页
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"}]
}- Scroll(批处理场景):快照式查询,适合导出全量数据。但 scroll 有上下文过期时间(
scroll=1m),超过 1 分钟没取完就失效。
// 初始化 scroll 上下文
GET /my_index/_search?scroll=1m
{
"size": 1000
}
// 后续一直拿 scroll_id 翻页
GET /_search/scroll
{
"scroll": "1m",
"scroll_id": "DXF1ZXJ5QW5kRmV0Y2gB..."
}注意:scroll 必须在 delete 或 scroll 超时后释放,不清理的 scroll 上下文会一直占用内存。生产上遇到过 scroll 设置 5 分钟超时但忘了调用,高峰期 500 个 scroll 上下文把内存吃光的案例。
Java 客户端处理翻页:RestHighLevelClient 的 SearchSourceBuilder 默认 from=0, size=10。如果业务上需要翻页且页数可能超过 1000,不要用 from + size,直接上 searchAfterBuilder:
// 正确做法:用 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。
# 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)自动管理:
// 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 映射不兼容
@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 默认会同时建 title 和 title.keyword 两个字段。前者是分词后的,后者是原值。如果你用 @Field(type = FieldType.Text) 但不配 fielddata=true,做聚合(aggregation)时会报错:Fielddata is disabled on text fields。
解决方案:需要聚合的字段用 @Field(type = FieldType.Keyword),不要用 Text。或者显式声明 @MultiField:
@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 大小选择
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,取先到者。分批提交:
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: false或dynamic: "strict",只对明确字段建索引
坑 4:Java 客户端版本不匹配
- 现象:
NoSuchMethodError或ClassNotFoundException,启动就报错 - 原因: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 面试中,搜索系统的核心考点不是"倒排索引是什么",而是:
- 分片设计的 trade-off — 分片过少→单分片过大,查询慢;分片过多→集群管理开销大。经验公式:20-50GB/分片,按数据量反推分片数,宁少勿多。
- NRT 写入的实时性取舍 — 默认 1 秒 refresh 在写入场景下是瓶颈。调大
refresh_interval或改用async写入模式,写入吞吐可提升 5-10 倍,代价是搜索延迟变高。 - 深度分页别用
from + size— 超过 1 万条要用search_after或scroll,否则协调节点要从每个分片拉取全部结果再排序,内存和网络双重爆炸。 - 集群脑裂的预防 —
discovery.zen.minimum_master_nodes = (master-eligible-nodes / 2) + 1,一句话背下来,这是面试高频。 - 冷热数据分离 — 热数据(7 天)SSD + 高副本,冷数据(7 天以上)HDD + 低副本,ILM 自动迁移,存储成本降 70%。
- mapping 字段爆炸要防 — 日志场景
dynamic: false,不要等 memory 爆了再改。 - Java 客户端集成 — 版本必须匹配,
RestHighLevelClient在 ES 8.x 已废弃,升级时注意。批量写入控制每批 5000 条/5MB。
参考:Elasticsearch 官方文档(倒排索引原理)、Lucene 打分公式(BM25 算法)、ES 分片策略最佳实践、ILM 冷热分离方案、Spring Data Elasticsearch 官方文档