设计一个搜索系统:倒排索引、相关性排序、搜索建议的技术选型
设计一个搜索系统:倒排索引、相关性排序、搜索建议的技术选型
适读人群:Java中高级工程师、需要做站内搜索的技术负责人 | 阅读时长:约20分钟 | 难度:★★★★☆
开篇故事
两年前我们平台要做商品搜索,数据量3000万SKU,日搜索量500万次。我在技术评审会上提议用Elasticsearch,结果运维同学当场就皱眉头:"ES那玩意儿内存吃得太多了,我们集群资源本来就紧,能不能用MySQL的FULLTEXT?"
我当时没有立刻说"不行",而是做了一个对比测试:FULLTEXT在3000万条数据、100个并发下,平均响应时间4.2秒。Elasticsearch同样条件下,平均50毫秒。四个数量级的差距摆在那里,运维再也没意见了。
搜索是个说起来简单、做起来复杂的系统。这篇文章从零讲清楚搜索系统的核心原理和工程实现,从倒排索引到搜索建议,把我们生产上的架构经验全部输出。
一、需求分析与规模估算
功能需求
- 全文检索: 支持中文分词,多字段联合检索(商品名、品牌、描述)
- 过滤筛选: 价格区间、品类、品牌、标签的组合过滤
- 相关性排序: 文本相关性 + 销量/评分等业务权重的综合排序
- 搜索建议(Suggest): 输入时实时联想词,前缀匹配 + 热词推荐
- 搜索纠错: 输入"iphoneee"能理解为"iphone"并返回结果
- 高亮显示: 搜索关键词在结果中高亮
规模估算
数据规模:
- 商品总量:3000万SKU
- 每个商品文档约2KB(标题、描述、属性等)
- 索引总大小:3000万 × 2KB = 60GB(ES索引通常是原始数据的2-3倍,约120-180GB)
查询规模:
- 每天搜索量:500万次
- 平均QPS:500万 / 86400 ≈ 58 QPS
- 峰值QPS:约580 QPS(促销期间10倍)
- P99延迟要求:< 200ms
索引更新规模:
- 商品新增/修改:每天5万次变更
- 写入QPS:约0.6 QPS(极低,异步更新即可)
- 但促销期间价格批量变更:可能1小时内更新100万条,写入QPS约277
带宽估算:
- 每次搜索返回20条结果,每条约1KB
- 峰值带宽:580 QPS × 20KB = 11.6 MB/s(可接受)
二、系统架构设计
架构分层说明:
数据写入走异步链路:商品变更 → binlog → Kafka → IndexWorker → ES。这样不阻塞业务写入,也允许IndexWorker做复杂的数据转换(比如把商品属性的键值对展开成ES的扁平化字段)。
查询链路走Redis缓存:热门搜索词的结果缓存5分钟,命中率大约在40%。ES承受的真实QPS只有约350。
建议词用Redis的ZSet存储(按搜索频次排序),前缀匹配用ES的completion Suggester,两者结合。
三、核心组件详解
3.1 倒排索引原理与ES映射设计
倒排索引是搜索的核心数据结构。普通索引是"文档→词"的映射,倒排索引是"词→文档"的映射。
比如有三个商品:
- 商品1:"iPhone 15 Pro手机"
- 商品2:"华为 Mate60 Pro手机"
- 商品3:"iPhone 手机壳"
中文分词后的倒排索引大致如下:
| 词 | 包含该词的文档 |
|---|---|
| iphone | [1, 3] |
| 手机 | [1, 2] |
| pro | [1, 2] |
| 华为 | [2] |
用户搜索"iphone手机",分词得到[iphone, 手机],取交集 = [1],商品1命中。
ES Mapping设计(商品索引):
{
"mappings": {
"properties": {
"itemId": { "type": "keyword" },
"title": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"fields": {
"keyword": { "type": "keyword" }
}
},
"brand": {
"type": "keyword",
"copy_to": "all_text"
},
"categoryId": { "type": "keyword" },
"price": { "type": "double" },
"salesCount": { "type": "integer" },
"score": { "type": "float" },
"tags": { "type": "keyword" },
"titleSuggest": {
"type": "completion",
"analyzer": "ik_smart"
},
"status": { "type": "keyword" },
"updateTime": { "type": "date" }
}
}
}注意title字段的双分词器设计:ik_max_word用于建索引(最细粒度分词,提高召回率),ik_smart用于搜索(智能分词,提高精度)。
3.2 相关性评分
ES默认用BM25算法计算文本相关性。但纯文本相关性不够,还需要结合业务指标(销量、评分、是否促销)做综合排序。
ES的function_score查询可以把文本相关性得分和自定义的业务得分结合:
{
"query": {
"function_score": {
"query": { "match": { "title": "iphone" } },
"functions": [
{
"field_value_factor": {
"field": "salesCount",
"factor": 0.001,
"modifier": "log1p"
}
},
{
"field_value_factor": {
"field": "score",
"factor": 0.2,
"modifier": "none"
}
}
],
"score_mode": "sum",
"boost_mode": "multiply"
}
}
}最终得分 = 文本相关性得分 × (log(1+销量) × 0.001 + 评分 × 0.2)
3.3 搜索建议的三种实现
- 前缀匹配(Completion Suggester): 用户输入"iPh",返回"iPhone 15"等。速度极快(<10ms),基于FST数据结构。
- 热词推荐: 基于历史搜索频次,从Redis ZSet里取TopN热词。
- 拼写纠错(Term Suggester): 输入"iphone手肌",纠错为"iphone手机"。基于编辑距离算法。
四、关键代码实现
4.1 商品搜索服务
@Service
@Slf4j
public class ItemSearchService {
@Autowired
private RestHighLevelClient esClient;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String INDEX_NAME = "items";
private static final long CACHE_TTL_SECONDS = 300; // 结果缓存5分钟
/**
* 搜索商品
*/
public SearchResult<ItemDoc> search(SearchRequest request) {
// 1. 尝试从缓存中读取
String cacheKey = buildCacheKey(request);
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JsonUtils.fromJson(cached, new TypeReference<SearchResult<ItemDoc>>(){});
}
// 2. 构建ES查询
org.elasticsearch.action.search.SearchRequest esRequest =
buildEsQuery(request);
// 3. 执行查询
SearchResult<ItemDoc> result;
try {
org.elasticsearch.action.search.SearchResponse response =
esClient.search(esRequest, RequestOptions.DEFAULT);
result = parseResponse(response);
} catch (IOException e) {
log.error("ES搜索异常, query={}", request.getKeyword(), e);
throw new SearchException("搜索服务暂时不可用");
}
// 4. 结果写缓存
redisTemplate.opsForValue().set(
cacheKey, JsonUtils.toJson(result), CACHE_TTL_SECONDS, TimeUnit.SECONDS
);
return result;
}
private org.elasticsearch.action.search.SearchRequest buildEsQuery(SearchRequest req) {
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
// 构建Query
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 全文搜索条件(必须匹配)
if (StringUtils.hasText(req.getKeyword())) {
MultiMatchQueryBuilder multiMatch = QueryBuilders.multiMatchQuery(req.getKeyword())
.field("title", 3.0f) // 标题权重最高
.field("brand", 2.0f) // 品牌次之
.field("tags", 1.5f)
.type(MultiMatchQueryBuilder.Type.BEST_FIELDS)
.operator(Operator.AND);
boolQuery.must(multiMatch);
}
// 过滤条件(不参与评分)
if (req.getCategoryId() != null) {
boolQuery.filter(QueryBuilders.termQuery("categoryId", req.getCategoryId()));
}
if (req.getBrand() != null) {
boolQuery.filter(QueryBuilders.termQuery("brand", req.getBrand()));
}
if (req.getMinPrice() != null || req.getMaxPrice() != null) {
RangeQueryBuilder priceRange = QueryBuilders.rangeQuery("price");
if (req.getMinPrice() != null) priceRange.gte(req.getMinPrice());
if (req.getMaxPrice() != null) priceRange.lte(req.getMaxPrice());
boolQuery.filter(priceRange);
}
// 只查上架商品
boolQuery.filter(QueryBuilders.termQuery("status", "ACTIVE"));
// 综合评分:文本相关性 × 业务指标
FunctionScoreQueryBuilder functionScore = QueryBuilders.functionScoreQuery(
boolQuery,
new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
ScoreFunctionBuilders.fieldValueFactorFunction("salesCount")
.factor(0.001f)
.modifier(FieldValueFactorFunction.Modifier.LOG1P)
),
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
ScoreFunctionBuilders.fieldValueFactorFunction("score")
.factor(0.2f)
)
}
).scoreMode(FunctionScoreQuery.ScoreMode.SUM)
.boostMode(CombineFunction.MULTIPLY);
sourceBuilder.query(functionScore);
// 分页
sourceBuilder.from(req.getPage() * req.getSize());
sourceBuilder.size(req.getSize());
// 高亮
HighlightBuilder highlightBuilder = new HighlightBuilder()
.field(new HighlightBuilder.Field("title")
.preTags("<em>").postTags("</em>"))
.requireFieldMatch(false);
sourceBuilder.highlighter(highlightBuilder);
// 聚合:用于左侧筛选栏(品牌列表、价格区间)
sourceBuilder.aggregation(
AggregationBuilders.terms("brands").field("brand").size(20)
);
sourceBuilder.aggregation(
AggregationBuilders.range("price_ranges").field("price")
.addRange(0, 100).addRange(100, 500)
.addRange(500, 1000).addUnboundedFrom(1000)
);
org.elasticsearch.action.search.SearchRequest esRequest =
new org.elasticsearch.action.search.SearchRequest(INDEX_NAME);
esRequest.source(sourceBuilder);
return esRequest;
}
private SearchResult<ItemDoc> parseResponse(
org.elasticsearch.action.search.SearchResponse response) {
long total = response.getHits().getTotalHits().value;
List<ItemDoc> items = Arrays.stream(response.getHits().getHits())
.map(hit -> {
ItemDoc doc = JsonUtils.fromJson(hit.getSourceAsString(), ItemDoc.class);
// 处理高亮
if (hit.getHighlightFields().containsKey("title")) {
String highlightedTitle = hit.getHighlightFields()
.get("title").getFragments()[0].toString();
doc.setHighlightTitle(highlightedTitle);
}
return doc;
})
.collect(Collectors.toList());
// 解析聚合结果
Terms brandAgg = response.getAggregations().get("brands");
List<AggBucket> brandBuckets = brandAgg.getBuckets().stream()
.map(b -> new AggBucket(b.getKeyAsString(), b.getDocCount()))
.collect(Collectors.toList());
return SearchResult.<ItemDoc>builder()
.total(total)
.items(items)
.brandBuckets(brandBuckets)
.build();
}
private String buildCacheKey(SearchRequest req) {
return "search:" + DigestUtils.md5DigestAsHex(JsonUtils.toJson(req).getBytes());
}
}4.2 搜索建议服务
@Service
public class SuggestService {
@Autowired
private RestHighLevelClient esClient;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String HOT_SEARCH_KEY = "hotsearch:words";
private static final String INDEX_NAME = "items";
/**
* 获取搜索建议
* 优先返回热词,再返回前缀补全结果
*/
public List<String> suggest(String prefix, int limit) {
List<String> suggestions = new ArrayList<>();
// 1. 从热词库匹配
List<String> hotMatches = matchHotWords(prefix, 5);
suggestions.addAll(hotMatches);
if (suggestions.size() >= limit) {
return suggestions.subList(0, limit);
}
// 2. ES Completion Suggester补充
List<String> completionSuggests = completionSuggest(prefix, limit - suggestions.size());
// 去重合并
completionSuggests.stream()
.filter(s -> !suggestions.contains(s))
.forEach(suggestions::add);
return suggestions.subList(0, Math.min(suggestions.size(), limit));
}
/**
* 从Redis ZSet中匹配前缀热词
* 热词按搜索频次降序排列
*/
private List<String> matchHotWords(String prefix, int limit) {
// Redis ZSet按分数倒序取TopN热词,再过滤前缀
// 更高效的方式是用Redis的lexicographic range(ZRANGEBYLEX)
// 但需要所有词的score相同,适合字典序检索
Set<String> topWords = redisTemplate.opsForZSet()
.reverseRange(HOT_SEARCH_KEY, 0, 100); // 取Top100热词
if (topWords == null) return Collections.emptyList();
return topWords.stream()
.filter(word -> word.startsWith(prefix))
.limit(limit)
.collect(Collectors.toList());
}
/**
* ES Completion Suggester:基于FST的前缀补全
*/
private List<String> completionSuggest(String prefix, int limit) {
SuggestBuilder suggestBuilder = new SuggestBuilder()
.addSuggestion("title_suggest",
SuggestBuilders.completionSuggestion("titleSuggest")
.prefix(prefix)
.size(limit)
);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
.suggest(suggestBuilder)
.size(0); // 不需要文档结果
org.elasticsearch.action.search.SearchRequest request =
new org.elasticsearch.action.search.SearchRequest(INDEX_NAME)
.source(sourceBuilder);
try {
org.elasticsearch.action.search.SearchResponse response =
esClient.search(request, RequestOptions.DEFAULT);
CompletionSuggestion suggestion = response.getSuggest()
.getSuggestion("title_suggest");
return suggestion.getEntries().stream()
.flatMap(entry -> entry.getOptions().stream())
.map(option -> option.getText().toString())
.distinct()
.collect(Collectors.toList());
} catch (IOException e) {
log.warn("Completion suggest异常, prefix={}", prefix, e);
return Collections.emptyList();
}
}
/**
* 记录搜索词,更新热词库
* 每次搜索后异步调用
*/
@Async
public void recordSearchWord(String keyword) {
if (StringUtils.hasText(keyword)) {
redisTemplate.opsForZSet().incrementScore(
HOT_SEARCH_KEY, keyword.trim(), 1
);
}
}
}4.3 索引同步Worker(Canal监听商品变更)
@Component
@Slf4j
public class ItemIndexSyncWorker {
@Autowired
private RestHighLevelClient esClient;
private static final String INDEX_NAME = "items";
/**
* 消费Kafka中的商品变更消息,同步到ES
*/
@KafkaListener(topics = "item-change", groupId = "es-sync-group", concurrency = "3")
public void onItemChange(ItemChangeMessage message) {
switch (message.getChangeType()) {
case CREATE:
case UPDATE:
upsertIndex(message.getItemId(), message.getItem());
break;
case DELETE:
deleteIndex(message.getItemId());
break;
default:
log.warn("未知变更类型: {}", message.getChangeType());
}
}
/**
* 新增/更新文档
*/
private void upsertIndex(String itemId, Item item) {
// 将商品数据转换为ES文档
Map<String, Object> doc = buildDocument(item);
UpdateRequest request = new UpdateRequest(INDEX_NAME, itemId)
.doc(doc)
.docAsUpsert(true); // 不存在则新增,存在则更新
try {
esClient.update(request, RequestOptions.DEFAULT);
log.debug("ES索引更新成功, itemId={}", itemId);
} catch (IOException e) {
log.error("ES索引更新失败, itemId={}", itemId, e);
// 实际中需要发告警、记录失败日志、支持人工重试
throw new RuntimeException("ES更新失败", e);
}
}
private Map<String, Object> buildDocument(Item item) {
Map<String, Object> doc = new HashMap<>();
doc.put("itemId", item.getId());
doc.put("title", item.getTitle());
doc.put("brand", item.getBrand());
doc.put("categoryId", item.getCategoryId());
doc.put("price", item.getPrice());
doc.put("salesCount", item.getSalesCount());
doc.put("score", item.getScore());
doc.put("status", item.getStatus().name());
doc.put("tags", item.getTags());
doc.put("updateTime", item.getUpdateTime());
// 构建Completion Suggest字段
Map<String, Object> titleSuggest = new HashMap<>();
titleSuggest.put("input", buildSuggestInputs(item.getTitle()));
titleSuggest.put("weight", item.getSalesCount()); // 销量高的排前面
doc.put("titleSuggest", titleSuggest);
return doc;
}
private List<String> buildSuggestInputs(String title) {
// 为标题生成多个搜索建议词:完整标题 + 前缀组合
List<String> inputs = new ArrayList<>();
inputs.add(title);
// 也可以加入品牌词、分词后的词组等
return inputs;
}
private void deleteIndex(String itemId) {
DeleteRequest request = new DeleteRequest(INDEX_NAME, itemId);
try {
esClient.delete(request, RequestOptions.DEFAULT);
} catch (IOException e) {
log.error("ES索引删除失败, itemId={}", itemId, e);
}
}
}五、扩展性设计
从500 QPS扩展到5000 QPS
第一阶段(500 QPS):3节点ES + Redis缓存
基础配置:3个ES数据节点,每节点32GB内存,商品索引1个主分片+1个副本。Redis缓存热门搜索,命中率约40%,ES实际承受约300 QPS,完全够用。
第二阶段(2000 QPS):扩容ES + 提升缓存命中率
将ES数据节点扩展到6个,主分片增加到3个,并行度提升。同时优化缓存策略:对相同关键词的前5页结果都缓存(大多数用户只看前几页),缓存命中率提升到70%,ES实际承受约600 QPS。
第三阶段(5000 QPS):读写分离 + 预热
引入专门的搜索代理层,对热门词(Top 1000搜索词)做结果预热,在低峰期提前计算并缓存,高峰期直接走缓存。Top 1000词覆盖约60%的搜索量,真正打到ES的请求降至2000 QPS,加上ES扩容到10节点,轻松应对。
大索引的分片策略
// 按品类分索引,不同品类数据量差异大
// 3C电子独立索引,服装独立索引,食品独立索引
// 跨品类搜索时走ES的Multi-Index查询
List<String> searchIndices = determineIndices(request.getCategoryId());
// 如果是全局搜索,搜索所有索引
if (searchIndices.isEmpty()) {
searchIndices = ALL_CATEGORY_INDICES;
}六、踩坑实录
坑1:中文分词导致搜索"iPhone15"搜不出"iPhone 15"
IK分词器把"iPhone 15"分成["iPhone", "15"],但用户输入"iPhone15"(无空格),IK分成["iPhone15"](一个词),两者词表不交叉,搜不到结果。
解决方案:在ES Mapping里,title字段建立edge_ngram子字段,把词语进行前缀扩展索引,同时搜索时用OR逻辑让分词和原词都能命中。另外也可以在同义词表里加上"iPhone15 => iPhone 15"的映射。
坑2:删除后的商品还出现在搜索结果里
ES的删除操作是"标记删除",并不立即从磁盘删除,而是在Segment合并时才真正删除。在此之前,被删除的文档会占用内存且影响评分。更糟糕的是,我们的Canal同步偶尔会丢消息(Kafka消费者重启时),导致ES里有"僵尸"商品。
解决方案:定期做全量校验,每天凌晨对比ES和MySQL的数据,把多余的文档删掉;同时在过滤条件里强制加上status=ACTIVE,确保已下架商品不出现。
坑3:ES堆内存溢出,搜索服务宕机
某次促销活动,搜索量突增10倍,ES的聚合查询(计算各品牌商品数量)需要把大量数据加载到内存,直接把ES的堆内存撑爆了。
原因:聚合查询用了text类型的字段做Terms聚合,ES为此创建了Field Data,把整个字段数据加载到JVM堆。3000万条数据,每个品牌名平均10字节,Field Data就是300MB。多个并发的聚合查询同时加载,OOM。
解决方案:凡是需要聚合的字段,一律使用keyword类型(不支持分词但能聚合),禁止在text类型字段上做聚合。同时设置Circuit Breaker,限制单次查询占用的内存上限。
坑4:搜索建议接口被爬虫恶意调用
搜索建议接口要求每次用户输入都触发,单个用户的请求量就很大(每秒可能10次以上)。被爬虫发现后,以每秒10万次的速度猛打,直接把建议服务打挂了。
解决方案:对建议接口加严格的IP限流(每秒最多50次),对重复前缀的建议请求做客户端去抖(300ms内同一前缀只发一次请求),同时在建议接口前加CDN缓存(相同前缀的建议词缓存10分钟)。
七、总结
搜索系统的设计重点可以归纳为三个层次:
底层:数据索引设计
- Mapping设计:text分词索引 vs keyword精确索引,双字段策略处理全文检索+聚合的需求
- 分词器选择:ik_max_word建索引,ik_smart搜索,召回精度双保障
- 同步策略:binlog → Kafka → Worker,异步增量同步为主,定期全量校验为辅
中层:查询策略设计
- function_score把文本相关性和业务权重融合
- Filter vs Query的区别:Filter不参与评分且可缓存,性能更好
- 高亮、聚合的使用场景
上层:系统稳定性设计
- 多级缓存(本地缓存 + Redis)保护ES
- 热词预热减少冷启动问题
- 限流保护搜索集群
