SpringBoot + Elasticsearch 电商产品搜索功能设计与实现
本文将详细介绍如何使用SpringBoot和Elasticsearch构建高性能的电商产品搜索系统,涵盖索引设计、核心功能实现、性能优化等关键方面。
一、系统架构设计
1.1 整体架构
电商搜索系统的典型架构如下:
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ MySQL数据库 │───>│ 同步服务 │───>│ Elasticsearch │
└─────────────┘ └──────────────┘ └─────────────────┘
│
┌─────────────┐ ┌──────────────┐ ┌───┴─────────────┐
│ 前端页面 │<──>│ SpringBoot API│<──>│ 搜索服务层 │
└─────────────┘ └──────────────┘ └─────────────────┘
1.2 技术栈选择
- Spring Boot: 2.7.x 或 3.x
- Elasticsearch: 7.17.x 或 8.x
- Spring Data Elasticsearch: 用于简化ES操作
- MySQL: 存储原始商品数据
- 消息队列: RabbitMQ/Kafka (用于数据同步)
- 分词器: IK Analyzer (中文分词)
二、索引设计
2.1 索引命名与分片策略
// 创建索引的代码示例
CreateIndexRequest request = new CreateIndexRequest("products");
request.settings(Settings.builder()
.put("index.number_of_shards", 5) // 主分片数
.put("index.number_of_replicas", 2) // 副本数
.put("index.refresh_interval", "30s") // 刷新间隔
.put("analysis.analyzer.default.type", "ik_max_word") // 默认分词器
);
分片策略建议:
- 单个分片大小控制在20GB-50GB之间
- 分片数量计算公式:
总分片数 = 数据总量(GB) / 单个分片推荐大小 - 搜索密集型场景:副本数 > 主分片数,提高查询性能
2.2 商品索引映射设计
@Document(indexName = "products")
@Setting(settingPath = "es-settings.json")
public class Product {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String name; // 商品名称,支持全文搜索
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String description; // 商品描述
@Field(type = FieldType.Keyword) // 不分词,用于精确匹配和聚合
private String brandName;
@Field(type = FieldType.Keyword)
private String categoryName;
@Field(type = FieldType.Double)
private Double price; // 价格,用于范围查询和排序
@Field(type = FieldType.Integer)
private Integer stock; // 库存
@Field(type = FieldType.Integer)
private Integer sales; // 销量
@Field(type = FieldType.Object) // 嵌套对象,存储规格信息
private Map<String, String> specMap;
@Field(type = FieldType.Boolean)
private Boolean isOnSale; // 是否上架
@Field(type = FieldType.Date, format = DateFormat.date_time)
private Date createTime; // 创建时间
// getter和setter方法
}
三、SpringBoot与Elasticsearch集成
3.1 添加依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-elasticsearch</artifactId>
</dependency>
<!-- IK分词器依赖 -->
<dependency>
<groupId>com.github.magese</groupId>
<artifactId>ik-analyzer</artifactId>
<version>8.7.0</version>
</dependency>
</dependencies>
3.2 配置Elasticsearch
spring:
elasticsearch:
rest:
uris: http://localhost:9200
# 如需认证
# username: elastic
# password: changeme
3.3 数据访问层
public interface ProductRepository extends ElasticsearchRepository<Product, String> {
// 基于名称的模糊查询
List<Product> findByNameContaining(String name);
// 基于品牌的精确查询
List<Product> findByBrandName(String brandName);
// 基于价格范围的查询
List<Product> findByPriceBetween(Double minPrice, Double maxPrice);
// 分页查询
Page<Product> findByNameContaining(String name, Pageable pageable);
}
四、核心搜索功能实现
4.1 搜索服务实现
@Service
public class ProductSearchServiceImpl implements ProductSearchService {
@Autowired
private ElasticsearchRestTemplate elasticsearchRestTemplate;
@Override
public SearchResult search(SearchRequest request) {
// 构建查询条件
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 关键词搜索
if (StringUtils.hasText(request.getKeywords())) {
boolQuery.must(QueryBuilders.matchQuery("name", request.getKeywords())
.operator(Operator.AND));
}
// 品牌过滤
if (StringUtils.hasText(request.getBrand())) {
boolQuery.filter(QueryBuilders.termQuery("brandName", request.getBrand()));
}
// 分类过滤
if (StringUtils.hasText(request.getCategory())) {
boolQuery.filter(QueryBuilders.termQuery("categoryName", request.getCategory()));
}
// 价格范围过滤
if (request.getMinPrice() != null && request.getMaxPrice() != null) {
boolQuery.filter(QueryBuilders.rangeQuery("price")
.gte(request.getMinPrice())
.lte(request.getMaxPrice()));
}
// 规格过滤
if (request.getSpecs() != null && !request.getSpecs().isEmpty()) {
for (Map.Entry<String, String> spec : request.getSpecs().entrySet()) {
boolQuery.filter(QueryBuilders.termQuery(
"specMap." + spec.getKey() + ".keyword", spec.getValue()));
}
}
// 上架状态过滤
boolQuery.filter(QueryBuilders.termQuery("isOnSale", true));
// 构建查询
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder()
.withQuery(boolQuery)
.withHighlightFields(
new HighlightBuilder.Field("name")
.preTags("<em>")
.postTags("</em>")
);
// 排序处理
if (StringUtils.hasText(request.getSortBy())) {
SortOrder order = request.isAscending() ? SortOrder.ASC : SortOrder.DESC;
queryBuilder.withSort(SortBuilders.fieldSort(request.getSortBy()).order(order));
} else {
// 默认按相关度排序
queryBuilder.withSort(SortBuilders.scoreSort());
}
// 分页
Pageable pageable = PageRequest.of(request.getPage() - 1, request.getSize());
queryBuilder.withPageable(pageable);
// 品牌聚合
String brandAggName = "brandAgg";
queryBuilder.addAggregation(AggregationBuilders.terms(brandAggName)
.field("brandName").size(100));
// 分类聚合
String categoryAggName = "categoryAgg";
queryBuilder.addAggregation(AggregationBuilders.terms(categoryAggName)
.field("categoryName").size(100));
// 规格聚合 (嵌套聚合)
String specAggName = "specAgg";
queryBuilder.addAggregation(AggregationBuilders.nested(specAggName, "specMap")
.subAggregation(AggregationBuilders.terms("specKeys")
.field("specMap.key")
.subAggregation(AggregationBuilders.terms("specValues")
.field("specMap.value").size(50))));
// 价格区间聚合
String priceAggName = "priceAgg";
queryBuilder.addAggregation(AggregationBuilders.range(priceAggName)
.field("price")
.addRange("0-100", 0, 100)
.addRange("100-500", 100, 500)
.addRange("500-1000", 500, 1000)
.addRange("1000+", 1000, null));
// 执行查询
NativeSearchQuery searchQuery = queryBuilder.build();
SearchHits<Product> searchHits = elasticsearchRestTemplate.search(searchQuery, Product.class);
// 处理结果
SearchResult result = new SearchResult();
// 处理商品列表和高亮
List<ProductDTO> products = new ArrayList<>();
for (SearchHit<Product> hit : searchHits) {
Product product = hit.getContent();
ProductDTO dto = new ProductDTO();
BeanUtils.copyProperties(product, dto);
// 设置高亮
Map<String, List<String>> highlightFields = hit.getHighlightFields();
if (highlightFields.containsKey("name")) {
dto.setHighlightName(highlightFields.get("name").get(0));
}
products.add(dto);
}
result.setProducts(products);
// 处理分页信息
result.setTotal(searchHits.getTotalHits());
result.setPage(request.getPage());
result.setSize(request.getSize());
// 处理聚合结果
Aggregations aggregations = searchHits.getAggregations();
// 处理品牌聚合
Terms brandTerms = aggregations.get(brandAggName);
List<String> brands = brandTerms.getBuckets().stream()
.map(Bucket::getKeyAsString)
.collect(Collectors.toList());
result.setBrands(brands);
// 处理分类聚合
Terms categoryTerms = aggregations.get(categoryAggName);
List<String> categories = categoryTerms.getBuckets().stream()
.map(Bucket::getKeyAsString)
.collect(Collectors.toList());
result.setCategories(categories);
// 处理价格区间聚合
Range priceRange = aggregations.get(priceAggName);
List<Range.Bucket> priceBuckets = priceRange.getBuckets();
// 构建价格区间结果...
// 处理规格聚合
// 解析嵌套聚合获取规格键值对...
return result;
}
}
4.2 控制器实现
@RestController
@RequestMapping("/api/search")
public class SearchController {
@Autowired
private ProductSearchService productSearchService;
@GetMapping
public ResponseEntity<SearchResult> search(
@RequestParam(required = false) String keywords,
@RequestParam(required = false) String brand,
@RequestParam(required = false) String category,
@RequestParam(required = false) Double minPrice,
@RequestParam(required = false) Double maxPrice,
@RequestParam(required = false) String sortBy,
@RequestParam(defaultValue = "false") Boolean ascending,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size,
@RequestParam(required = false) MultiValueMap<String, String> specs) {
// 构建搜索请求
SearchRequest request = new SearchRequest();
request.setKeywords(keywords);
request.setBrand(brand);
request.setCategory(category);
request.setMinPrice(minPrice);
request.setMaxPrice(maxPrice);
request.setSortBy(sortBy);
request.setAscending(ascending);
request.setPage(page);
request.setSize(size);
// 处理规格参数 (以spec_开头的参数)
if (specs != null) {
Map<String, String> specMap = new HashMap<>();
for (Map.Entry<String, List<String>> entry : specs.entrySet()) {
String key = entry.getKey();
if (key.startsWith("spec_")) {
String specName = key.substring(5); // 去掉spec_前缀
specMap.put(specName, entry.getValue().get(0));
}
}
request.setSpecs(specMap);
}
// 执行搜索
SearchResult result = productSearchService.search(request);
return ResponseEntity.ok(result);
}
}
五、数据同步策略
5.1 基于Canal的数据同步
使用Canal监听MySQL Binlog,实时同步数据到Elasticsearch:
@Component
public class ProductSyncHandler {
@Autowired
private ElasticsearchRestTemplate elasticsearchRestTemplate;
public void handleInsert(Product product) {
elasticsearchRestTemplate.save(product);
}
public void handleUpdate(Product product) {
elasticsearchRestTemplate.save(product);
}
public void handleDelete(String productId) {
elasticsearchRestTemplate.delete(productId, Product.class);
}
}
5.2 基于消息队列的数据同步
@Component
public class ProductMessageListener {
@Autowired
private ElasticsearchRestTemplate elasticsearchRestTemplate;
@RabbitListener(queues = "product-index-queue")
public void handleProductMessage(ProductMessage message) {
switch (message.getAction()) {
case INSERT:
elasticsearchRestTemplate.save(message.getProduct());
break;
case UPDATE:
elasticsearchRestTemplate.save(message.getProduct());
break;
case DELETE:
elasticsearchRestTemplate.delete(message.getProductId(), Product.class);
break;
}
}
}
六、高级功能实现
6.1 相关性优化
// 使用function_score提升搜索结果相关性
FunctionScoreQueryBuilder functionScoreQuery = QueryBuilders.functionScoreQuery(
boolQuery,
new FunctionScoreQueryBuilder.FilterFunctionBuilder[] {
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
QueryBuilders.termQuery("isHot", true),
ScoreFunctionBuilders.weightFactorFunction(1.5f)),
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
QueryBuilders.rangeQuery("sales").gt(1000),
ScoreFunctionBuilders.weightFactorFunction(1.2f))
}
);
queryBuilder.withQuery(functionScoreQuery);
6.2 同义词支持
在elasticsearch.yml中配置同义词文件:
index:
analysis:
filter:
synonym_filter:
type: synonym
synonyms_path: config/synonyms.txt
analyzer:
ik_syno:
tokenizer: ik_max_word
filter:
- synonym_filter
synonyms.txt 文件内容示例:
手机,智能手机,移动端
电脑,笔记本电脑,台式机
6.3 拼音搜索
// 添加拼音分词器配置
@Setting(settingPath = "es-settings.json")
public class Product {
// 使用多字段实现拼音搜索
@MultiField(mainField = @Field(type = FieldType.Text, analyzer = "ik_max_word"),
otherFields = { @InnerField(suffix = "pinyin", type = FieldType.Text, analyzer = "pinyin_analyzer") })
private String name;
// ...
}
七、性能优化策略
7.1 索引层面优化
- 合理设置分片和副本:根据数据量和查询压力调整
- 禁用不必要的字段索引:对不需要搜索的字段设置
index: false - 使用适当的字段类型:keyword vs text,数值类型等
- 使用延迟加载:非必要字段使用
enabled: false - 索引生命周期管理:定期清理历史数据
7.2 查询层面优化
- 避免使用wildcard查询:特别是前缀通配符
- 使用filter代替query:filter结果可以缓存,提高性能
- 分页深度限制:避免使用from/size进行深度分页,使用scroll或search_after
- 字段选择性加载:使用_source过滤只返回需要的字段
- 批量操作:使用bulk API进行批量写入和查询
// 优化示例:使用source filtering只返回必要字段
queryBuilder.withSourceFilter(new FetchSourceFilter(
new String[] {"id", "name", "price", "brandName"}, null
));
7.3 缓存优化
- Redis缓存热门搜索结果
- 使用Elasticsearch的filter缓存
- 实现搜索结果缓存
@Service
public class CachedSearchService {
@Autowired
private RedisTemplate<String, SearchResult> redisTemplate;
@Autowired
private ProductSearchService productSearchService;
public SearchResult search(SearchRequest request) {
// 生成缓存key
String cacheKey = generateCacheKey(request);
// 尝试从缓存获取
SearchResult cachedResult = redisTemplate.opsForValue().get(cacheKey);
if (cachedResult != null) {
return cachedResult;
}
// 执行搜索
SearchResult result = productSearchService.search(request);
// 存入缓存
redisTemplate.opsForValue().set(cacheKey, result, 5, TimeUnit.MINUTES);
return result;
}
private String generateCacheKey(SearchRequest request) {
// 生成唯一缓存key
return "search:" + DigestUtils.md5Hex(JSON.toJSONString(request));
}
}
八、部署与监控
8.1 Elasticsearch集群部署
推荐至少3节点集群,配置分离的主节点、数据节点和协调节点:
# elasticsearch.yml 配置示例
node.name: node-1
cluster.name: es-product-cluster
path.data: /data/es/data
path.logs: /data/es/logs
bootstrap.memory_lock: true
network.host: 0.0.0.0
discovery.seed_hosts: ["node-1", "node-2", "node-3"]
cluster.initial_master_nodes: ["node-1"]
8.2 监控与告警
- 使用Kibana监控ES集群状态
- 配置JVM监控
- 设置索引健康检查告警
- 监控查询性能
九、总结
通过合理的索引设计、优化的查询策略和可靠的数据同步机制,可以构建高性能、可扩展的电商产品搜索系统。本文介绍的方案涵盖了从基础架构到高级功能的各个方面,适用于构建生产级别的电商搜索服务。
在实际应用中,还需要根据业务场景和数据规模进行进一步的调优和优化,不断提升用户搜索体验。