搜索系统设计
2026/1/15大约 3 分钟
搜索系统设计
场景一:商品搜索
需求:实现商品搜索,支持关键词、分类、价格筛选、排序。
ES 索引设计
{
"mappings": {
"properties": {
"id": { "type": "long" },
"title": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
},
"category": { "type": "keyword" },
"brand": { "type": "keyword" },
"price": { "type": "double" },
"sales": { "type": "integer" },
"rating": { "type": "float" },
"createTime": { "type": "date" },
"tags": { "type": "keyword" }
}
}
}搜索实现
public SearchResult search(SearchRequest request) {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 关键词搜索
if (StringUtils.hasText(request.getKeyword())) {
boolQuery.must(QueryBuilders.multiMatchQuery(request.getKeyword())
.field("title", 2.0f)
.field("brand")
.field("tags"));
}
// 分类筛选
if (request.getCategory() != null) {
boolQuery.filter(QueryBuilders.termQuery("category", request.getCategory()));
}
// 价格范围
if (request.getMinPrice() != null || request.getMaxPrice() != null) {
RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("price");
if (request.getMinPrice() != null) rangeQuery.gte(request.getMinPrice());
if (request.getMaxPrice() != null) rangeQuery.lte(request.getMaxPrice());
boolQuery.filter(rangeQuery);
}
// 构建搜索
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
.query(boolQuery)
.from((request.getPage() - 1) * request.getSize())
.size(request.getSize());
// 排序
switch (request.getSort()) {
case "price_asc":
sourceBuilder.sort("price", SortOrder.ASC);
break;
case "price_desc":
sourceBuilder.sort("price", SortOrder.DESC);
break;
case "sales":
sourceBuilder.sort("sales", SortOrder.DESC);
break;
default:
sourceBuilder.sort("_score", SortOrder.DESC);
}
// 高亮
sourceBuilder.highlighter(new HighlightBuilder()
.field("title")
.preTags("<em>")
.postTags("</em>"));
// 聚合(用于筛选项)
sourceBuilder.aggregation(AggregationBuilders.terms("brands").field("brand"));
sourceBuilder.aggregation(AggregationBuilders.terms("categories").field("category"));
return execute(sourceBuilder);
}数据同步
// 方案1:双写
@Transactional
public void saveProduct(Product product) {
productMapper.insert(product);
esClient.index(product); // 同步写 ES
}
// 方案2:监听 Binlog
@Canal(destination = "product")
public void onProductChange(CanalEntry entry) {
if (entry.getEventType() == EventType.INSERT || entry.getEventType() == EventType.UPDATE) {
Product product = parseProduct(entry);
esClient.index(product);
} else if (entry.getEventType() == EventType.DELETE) {
esClient.delete(entry.getId());
}
}
// 方案3:MQ 异步
public void saveProduct(Product product) {
productMapper.insert(product);
mqTemplate.send("product-sync", product);
}
@RocketMQMessageListener(topic = "product-sync")
public void syncToEs(Product product) {
esClient.index(product);
}场景二:搜索建议
// Completion Suggester
public List<String> suggest(String prefix) {
SuggestBuilder suggestBuilder = new SuggestBuilder()
.addSuggestion("product-suggest",
SuggestBuilders.completionSuggestion("suggest")
.prefix(prefix)
.size(10));
SearchResponse response = client.search(new SearchRequest("products")
.source(new SearchSourceBuilder().suggest(suggestBuilder)));
return response.getSuggest()
.getSuggestion("product-suggest")
.getEntries().get(0)
.getOptions().stream()
.map(option -> option.getText().string())
.collect(Collectors.toList());
}场景三:热搜榜
// 记录搜索词
public void recordSearch(String keyword) {
// 今日热搜
String todayKey = "hot:search:" + LocalDate.now();
redisTemplate.opsForZSet().incrementScore(todayKey, keyword, 1);
redisTemplate.expire(todayKey, 2, TimeUnit.DAYS);
}
// 获取热搜榜
public List<HotWord> getHotSearch(int topN) {
String todayKey = "hot:search:" + LocalDate.now();
Set<ZSetOperations.TypedTuple<String>> tuples =
redisTemplate.opsForZSet().reverseRangeWithScores(todayKey, 0, topN - 1);
return tuples.stream()
.map(t -> new HotWord(t.getValue(), t.getScore().intValue()))
.collect(Collectors.toList());
}场景四:推荐系统
基于用户行为
// 记录用户行为
public void recordBehavior(Long userId, Long productId, String action) {
// 浏览历史
if ("view".equals(action)) {
redisTemplate.opsForZSet().add("user:view:" + userId, productId, System.currentTimeMillis());
redisTemplate.opsForZSet().removeRange("user:view:" + userId, 0, -101); // 保留100条
}
// 购买记录
if ("buy".equals(action)) {
redisTemplate.opsForSet().add("user:buy:" + userId, productId);
}
}
// 基于浏览历史推荐
public List<Product> recommendByHistory(Long userId) {
// 获取用户浏览的商品
Set<Long> viewedIds = redisTemplate.opsForZSet()
.reverseRange("user:view:" + userId, 0, 9);
// 获取这些商品的分类
List<Product> viewed = productMapper.selectByIds(viewedIds);
Set<String> categories = viewed.stream()
.map(Product::getCategory)
.collect(Collectors.toSet());
// 推荐同分类的热门商品
return productMapper.selectHotByCategories(categories, 10);
}协同过滤
// 基于物品的协同过滤
// 用户购买了 A,推荐"购买 A 的人也买了"的商品
public List<Product> recommendByCF(Long userId) {
// 获取用户购买的商品
Set<Long> boughtIds = redisTemplate.opsForSet().members("user:buy:" + userId);
Map<Long, Double> scores = new HashMap<>();
for (Long productId : boughtIds) {
// 获取购买该商品的其他用户
Set<Long> otherUsers = redisTemplate.opsForSet().members("product:buyers:" + productId);
for (Long otherUserId : otherUsers) {
if (!otherUserId.equals(userId)) {
// 获取其他用户购买的商品
Set<Long> otherBought = redisTemplate.opsForSet().members("user:buy:" + otherUserId);
for (Long otherId : otherBought) {
if (!boughtIds.contains(otherId)) {
scores.merge(otherId, 1.0, Double::sum);
}
}
}
}
}
// 按分数排序返回 Top N
return scores.entrySet().stream()
.sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
.limit(10)
.map(e -> productMapper.selectById(e.getKey()))
.collect(Collectors.toList());
}场景五:搜索优化
同义词
{
"settings": {
"analysis": {
"filter": {
"synonym_filter": {
"type": "synonym",
"synonyms": [
"手机,手机,移动电话",
"电脑,计算机,PC"
]
}
},
"analyzer": {
"synonym_analyzer": {
"tokenizer": "ik_smart",
"filter": ["synonym_filter"]
}
}
}
}
}拼音搜索
{
"settings": {
"analysis": {
"analyzer": {
"pinyin_analyzer": {
"tokenizer": "ik_smart",
"filter": ["pinyin_filter"]
}
},
"filter": {
"pinyin_filter": {
"type": "pinyin",
"keep_first_letter": true,
"keep_full_pinyin": true
}
}
}
}
}纠错
// 使用 ES 的 fuzzy 查询
QueryBuilders.matchQuery("title", keyword).fuzziness(Fuzziness.AUTO);
// 或使用 phrase_suggest
SuggestBuilders.phraseSuggestion("title")
.text(keyword)
.maxErrors(2);