电商项目仿京东商品搜索服务实战:基于Elasticsearch的高性能商品搜索实现
·
一、业务场景分析
在电商项目中,商品搜索功能是核心功能之一,直接影响用户体验和转化率。仿京东商品搜索服务需要支持以下业务场景:
- 根据关键字查询:用户输入关键词,搜索匹配的商品
- 根据品牌筛选:筛选特定品牌下的商品
- 根据商品类别筛选:按商品分类进行筛选
- 根据商品属性信息筛选:如颜色、尺寸、CPU等
- 根据价格区间筛选:设置价格范围
- 根据是否有库存筛选:只显示有库存的商品
- 根据销量、价格、上架时间等排序:提供多种排序方式
二、商品文档建模
2.1 数据结构设计
在电商系统中,商品与商品属性之间存在一对多的关系,需要合理设计数据模型。根据文档内容,商品文档包含以下关键字段:
{
"id": "26",
"name": "小米 11手机",
"keywords": "小米手机",
"subTitle": "AI智慧全面屏 6GB+64GB亮黑色全网通版移动联通电信4G手机双卡双待双卡双待",
"price": "3999",
"promotionPrice": "2999",
"originalPrice": "5999",
"pic": "http://macro-oss.oss-cn-shenzhen.aliyuncs.com/mall/images/20180615/xiaomi.jpg",
"sale": 999,
"hasStock": true,
"salecount": 999,
"putawayDate": "2021-04-01",
"brandId": 6,
"brandName": "小米",
"brandImg": "http://macro-oss.oss-cn-shenzhen.aliyuncs.com/mall/images/20190129/1e34aef2a409119018a4c6258e39ecfb_222_222.png",
"categoryId": 19,
"categoryName": "手机通讯",
"attrs": [
{
"attrId": 1,
"attrName": "cpu",
"attrValue": "2核"
},
{
"attrId": 2,
"attrName": "颜色",
"attrValue": "黑色"
}
]
}
2.2 关键字段说明
- name, keywords, subTitle:需要使用中文分词器(如ik_max_word)进行分词,便于全文搜索
- categoryName, brandName:类型为keyword,用于精确匹配和聚合
- attrs:商品属性数组,不同商品的属性不同,需要特殊处理
2.3 商品属性关联关系处理
思考:如何处理商品和商品属性之间的关联关系?
解决方案:定义mapping,创建索引时使用nested类型
"attrs": {
"type": "nested",
"properties": {
"attrId": {
"type": "long"
},
"attrName": {
"type": "keyword"
},
"attrValue": {
"type": "keyword"
}
}
}
使用nested类型可以确保每个属性都作为一个独立的文档存储,避免属性值之间的交叉影响。
三、Elasticsearch索引创建
3.1 创建索引的mapping定义
根据商品文档建模,创建索引的mapping如下:
PUT product_db
{
"mappings": {
"properties": {
"id": {
"type": "long"
},
"name": {
"type": "text",
"analyzer": "ik_max_word"
},
"keywords": {
"type": "text",
"analyzer": "ik_max_word"
},
"subTitle": {
"type": "text",
"analyzer": "ik_max_word"
},
"salecount": {
"type": "long"
},
"putawayDate": {
"type": "date"
},
"price": {
"type": "double"
},
"promotionPrice": {
"type": "keyword"
},
"originalPrice": {
"type": "keyword"
},
"pic": {
"type": "keyword"
},
"sale": {
"type": "long"
},
"hasStock": {
"type": "boolean"
},
"brandId": {
"type": "long"
},
"brandName": {
"type": "keyword"
},
"brandImg": {
"type": "keyword"
},
"categoryId": {
"type": "long"
},
"categoryName": {
"type": "keyword"
},
"attrs": {
"type": "nested",
"properties": {
"attrId": {
"type": "long"
},
"attrName": {
"type": "keyword"
},
"attrValue": {
"type": "keyword"
}
}
}
}
}
}
3.2 测试数据导入
文档中提供了16条测试数据,用于验证索引和搜索功能。例如:
PUT product_db/_doc/1
{
"id": "26",
"name": "小米 11手机",
"keywords": "小米手机",
"subTitle": "AI智慧全面屏 6GB+64GB亮黑色全网通版移动联通电信4G手机双卡双待双卡双待",
"price": "3999",
"promotionPrice": "2999",
"originalPrice": "5999",
"pic": "http://macro-oss.oss-cn-shenzhen.aliyuncs.com/mall/images/20180615/xiaomi.jpg",
"sale": 999,
"hasStock": true,
"salecount": 999,
"putawayDate": "2021-04-01",
"brandId": 6,
"brandName": "小米",
"brandImg": "http://macro-oss.oss-cn-shenzhen.aliyuncs.com/mall/images/20190129/1e34aef2a409119018a4c6258e39ecfb_222_222.png",
"categoryId": 19,
"categoryName": "手机通讯",
"attrs": [
{
"attrId": 1,
"attrName": "cpu",
"attrValue": "2核"
},
{
"attrId": 2,
"attrName": "颜色",
"attrValue": "黑色"
}
]
}
四、构建DSL语句实现商品搜索
4.1 基础搜索DSL
以下是一个完整的搜索DSL示例,包含关键字查询、过滤条件、排序、聚合和高亮:
POST product_db/_doc/_search
{
"from": 0,
"size": 8,
"query": {
"bool": {
"must": [
{
"match": {
"name": {
"query": "手机"
}
}
}
],
"filter": [
{
"term": {
"hasStock": {
"value": true
}
}
},
{
"range": {
"price": {
"from": "1",
"to": "5000"
}
}
}
]
}
},
"sort": [
{
"salecount": {
"order": "asc"
}
}
],
"aggregations": {
"brand_agg": {
"terms": {
"field": "brandId",
"size": 50
},
"aggregations": {
"brand_name_agg": {
"terms": {
"field": "brandName"
}
},
"brand_img_agg": {
"terms": {
"field": "brandImg"
}
}
}
},
"category_agg": {
"terms": {
"field": "categoryId",
"size": 50,
"min_doc_count": 1
},
"aggregations": {
"category_name_agg": {
"terms": {
"field": "categoryName"
}
}
}
},
"attr_agg": {
"nested": {
"path": "attrs"
},
"aggregations": {
"attr_id_agg": {
"terms": {
"field": "attrs.attrId"
},
"aggregations": {
"attr_name_agg": {
"terms": {
"field": "attrs.attrName"
}
},
"attr_value_agg": {
"terms": {
"field": "attrs.attrValue"
}
}
}
}
}
}
},
"highlight": {
"pre_tags": [
"<b style='color:red'>"
],
"post_tags": [
"</b>"
],
"fields": {
"name": {}
}
}
}
4.2 关键功能说明
- 关键字查询:使用
multi_match对name、keywords、subTitle字段进行多字段匹配 - 过滤条件:
term:精确匹配hasStock为true的商品range:价格区间过滤(1-5000元)
- 排序:按salecount升序排序
- 聚合分析:
- 品牌聚合:统计各品牌商品数量
- 分类聚合:统计各分类商品数量
- 属性聚合:统计各属性值的分布
- 高亮:对搜索结果中的关键字进行红色高亮显示
五、微服务实现商品搜索功能
5.1 依赖引入
在Spring Boot项目中引入Elasticsearch依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
5.2 核心代码实现
以下为商品搜索服务的核心实现代码:
@Service(value = "tulingMallSearchService")
public class TulingMallSearchServiceImpl implements TulingMallSearchService {
@Qualifier("elasticsearchClient")
@Autowired
private ElasticsearchClient client;
@Override
public ESResponseResult search(ESRequestParam param) {
try {
// 1. 构建检索对象-封装请求相关参数信息
SearchRequest searchRequest = startBuildRequestParam(param);
// 2. 进行检索操作
SearchResponse response = client.search(searchRequest, EsProduct.class);
// 3. 分析响应数据,封装成指定的格式
ESResponseResult responseResult = startBuildResponseResult(response, param);
return responseResult;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
private SearchRequest startBuildRequestParam(ESRequestParam param) {
// 构建搜索请求
SearchRequest.Builder searchRequestBuilder = new SearchRequest.Builder();
// 构建bool查询
BoolQuery.Builder boolQueryBuilder = new BoolQuery.Builder();
// 1. 查询关键字
if (!StringUtils.isEmpty(param.getKeyword())) {
boolQueryBuilder.must(m -> m.multiMatch(
q -> q.fields("name", "keywords", "subTitle").query(param.getKeyword())
));
}
// 2. 根据类目ID进行过滤
if (null != param.getCategoryId()) {
boolQueryBuilder.filter(QueryBuilders.term(t -> t.field("categoryId").value(param.getCategoryId())));
}
// 3. 根据品牌ID进行过滤
if (null != param.getBrandId() && param.getBrandId().size() > 0) {
List<FieldValue> brandIds = param.getBrandId().stream()
.map(b -> FieldValue.of(b))
.collect(Collectors.toList());
boolQueryBuilder.filter(QueryBuilders.terms(t -> t.field("brandId").terms(v -> v.value(brandIds))));
}
// 4. 根据属性进行相关过滤
if (param.getAttrs() != null && param.getAttrs().size() > 0) {
param.getAttrs().forEach(item -> {
// attrs=1_白色&2_4核
String[] s = item.split("_");
String attrId = s[0];
String[] attrValues = s[1].split(":");
BoolQuery.Builder boolQuery = QueryBuilders.bool();
boolQuery.filter(QueryBuilders.term(t -> t.field("attrs.attrId").value(attrId)));
List<FieldValue> attrValueList = Arrays.stream(attrValues)
.map(b -> FieldValue.of(b))
.collect(Collectors.toList());
boolQuery.filter(QueryBuilders.terms(t -> t.field("attrs.attrValue").terms(v -> v.value(attrValueList))));
NestedQuery.Builder nestedQueryBuilder = new NestedQuery.Builder();
// nested查询
nestedQueryBuilder.path("attrs").query(q -> q.bool(boolQuery.build())).scoreMode(ChildScoreMode.None);
boolQueryBuilder.filter(q -> q.nested(nestedQueryBuilder.build()));
});
}
// 5. 是否有库存
if (null != param.getHasStock()) {
boolQueryBuilder.filter(QueryBuilders.term(t -> t.field("hasStock").value(param.getHasStock() == 1)));
}
// 6. 根据价格过滤
if (!StringUtils.isEmpty(param.getPrice())) {
RangeQuery.Builder rangeQueryBuilder = QueryBuilders.range().field("price");
String[] price = param.getPrice().split("_");
if (price.length == 2) {
// price:1_5000 [1, 5000]
if (param.getPrice().startsWith("_")) {
rangeQueryBuilder.lte(JsonData.of(price[1]));
} else {
rangeQueryBuilder.gte(JsonData.of(price[0])).lte(JsonData.of(price[1]));
}
} else if (price.length == 1) {
// price:1_ [1]
if (param.getPrice().endsWith("_")) {
rangeQueryBuilder.gte(JsonData.of(price[0]));
}
}
boolQueryBuilder.filter(r -> r.range(rangeQueryBuilder.build()));
}
// 封装所有查询条件
searchRequestBuilder.query(q -> q.bool(boolQueryBuilder.build()));
// 实现排序、高亮、分页操作
// 排序
if (!StringUtils.isEmpty(param.getSort())) {
String sort = param.getSort();
String[] sortFileds = sort.split("_");
if (!StringUtils.isEmpty(sortFileds[0])) {
SortOrder sortOrder = "asc".equalsIgnoreCase(sortFileds[1]) ?
SortOrder.Asc : SortOrder.Desc;
// 排序
FieldSort fieldSort = SortOptionsBuilders.field()
.field(sortFileds[0])
.order(sortOrder)
.build();
searchRequestBuilder.sort(s -> s.field(fieldSort));
}
}
// 分页查询
searchRequestBuilder.from((param.getPageNum() - 1) * SearchConstant.PAGE_SIZE);
searchRequestBuilder.size(SearchConstant.PAGE_SIZE);
// 高亮显示
if (!StringUtils.isEmpty(param.getKeyword())) {
HighlightField highlightField = new HighlightField.Builder()
.preTags("<b style='color:red'>")
.postTags("</b>")
.build();
searchRequestBuilder.highlight(h -> h.fields("name", highlightField));
}
// 对品牌、分类信息、属性信息进行聚合分析
// 1. 按照品牌进行聚合
Aggregation brand_name_agg = AggregationBuilders.terms(t -> t.field("brandName").size(1));
Aggregation brand_img_agg = AggregationBuilders.terms(t -> t.field("brandImg").size(1));
Aggregation brand_agg = new Aggregation.Builder()
.terms(t -> t.field("brandId").size(50))
.aggregations("brand_name_agg", brand_name_agg)
.aggregations("brand_img_agg", brand_img_agg)
.build();
searchRequestBuilder.aggregations("brand_agg", brand_agg);
// 2. 按照分类信息进行聚合
Aggregation category_agg = new Aggregation.Builder()
.terms(t -> t.field("categoryId").size(50))
.aggregations("category_name_agg", AggregationBuilders.terms(t -> t.field("categoryName").size(1)))
.build();
searchRequestBuilder.aggregations("category_agg", category_agg);
// 3. 按照属性信息进行聚合
NestedAggregation attrs = new NestedAggregation.Builder().path("attrs").build();
Aggregation attr_id_agg = new Aggregation.Builder()
.terms(t -> t.field("attrs.attrId"))
.aggregations("attr_name_agg", AggregationBuilders.terms(t -> t.field("attrs.attrName").size(1)))
.aggregations("attr_value_agg", AggregationBuilders.terms(t -> t.field("attrs.attrValue").size(1)))
.build();
Aggregation attrs_agg = new Aggregation.Builder()
.nested(attrs)
.aggregations("attr_id_agg", attr_id_agg)
.build();
searchRequestBuilder.aggregations("attrs_agg", attrs_agg);
SearchRequest searchRequest = searchRequestBuilder.index(SearchConstant.INDEX_NAME).build();
return searchRequest;
}
private ESResponseResult startBuildResponseResult(SearchResponse response, ESRequestParam param) {
// 构建返回结果
ESResponseResult result = new ESResponseResult();
// 1. 获取查询到的商品信息
HitsMetadata<EsProduct> hitsMetadata = response.hits();
List<Hit<EsProduct>> hits = hitsMetadata.hits();
List<EsProduct> esProducts = new ArrayList<>();
// 2. 遍历所有商品信息
if (!hits.isEmpty()) {
for (Hit<EsProduct> hit : hits) {
EsProduct product = hit.source();
// 2.1 判断是否按关键字检索,若是就显示高亮,否则不显示
if (!StringUtils.isEmpty(param.getKeyword())) {
// 2.2 拿到高亮信息显示标题
List<String> name = hit.highlight().get("name");
// 2.3 判断 name 中是否含有查询的关键字(因为是多字段查询,因此可能不包含指定的关键字,假设不包含则显示原始 name 字段的信息)
String nameValue = name != null ? name.get(0) : product.getName();
product.setName(nameValue);
}
esProducts.add(product);
}
}
result.setProducts(esProducts);
// 3. 当前商品涉及到的所有品牌信息
List<ESResponseResult.BrandVo> brandVos = new ArrayList<>();
// 获取聚合结果
Map<String, Aggregate> aggs = response.aggregations();
// 获取到品牌的聚合
Aggregate brandAgg = aggs.get("brand_agg");
if (brandAgg != null) {
List<LongTermsBucket> brandIdBuckets = brandAgg.lterms().buckets().array();
for (LongTermsBucket brandIdBucket : brandIdBuckets) {
// 构建品牌信息
ESResponseResult.BrandVo brandVo = new ESResponseResult.BrandVo();
// 设置品牌ID
brandVo.setBrandId(brandIdBucket.key());
Aggregate brandImgAgg = brandIdBucket.aggregations().get("brand_img_agg");
Aggregate brandNameAgg = brandIdBucket.aggregations().get("brand_name_agg");
if (brandImgAgg != null && brandNameAgg != null) {
StringTermsBucket imgBucket = brandImgAgg.sterms().buckets().array().get(0);
StringTermsBucket nameBucket = brandNameAgg.sterms().buckets().array().get(0);
// 设置品牌的图片和名称
brandVo.setBrandImg(imgBucket.key().stringValue());
brandVo.setBrandName(nameBucket.key().stringValue());
}
brandVos.add(brandVo);
}
}
result.setBrands(brandVos);
// 4. 当前商品相关的所有类目信息
List<ESResponseResult.CategoryVo> categoryVos = new ArrayList<>();
Aggregate categoryAgg = aggs.get("category_agg");
if (categoryAgg != null) {
List<LongTermsBucket> categoryBuckets = categoryAgg.lterms().buckets().array();
for (LongTermsBucket categoryBucket : categoryBuckets) {
// 构建分类信息
ESResponseResult.CategoryVo categoryVo = new ESResponseResult.CategoryVo();
// 设置分类ID
categoryVo.setCategoryId(categoryBucket.key());
Aggregate categoryNameAgg = categoryBucket.aggregations().get("category_name_agg");
if (categoryNameAgg != null) {
StringTermsBucket nameBucket = categoryNameAgg.sterms().buckets().array().get(0);
// 设置分类名称
categoryVo.setCategoryName(nameBucket.key().stringValue());
}
categoryVos.add(categoryVo);
}
}
result.setCategories(categoryVos);
// 5. 获取商品相关的所有属性信息
List<ESResponseResult.AttrVo> attrVos = new ArrayList<>();
Aggregate attrsAgg = aggs.get("attrs_agg");
if (attrsAgg != null) {
// 获取属性id的集合
Aggregate attrIdAgg = attrsAgg.nested().aggregations().get("attr_id_agg");
List<LongTermsBucket> attrBuckets = attrIdAgg.lterms().buckets().array();
for (LongTermsBucket attrBucket : attrBuckets) {
// 构建属性信息
ESResponseResult.AttrVo attrVo = new ESResponseResult.AttrVo();
// 设置属性ID
attrVo.setAttrId(attrBucket.key());
Aggregate attrNameAgg = attrBucket.aggregations().get("attr_name_agg");
Aggregate attrValueAgg = attrBucket.aggregations().get("attr_value_agg");
if (attrNameAgg != null && attrValueAgg != null) {
StringTermsBucket attrNameBucket = attrNameAgg.sterms().buckets().array().get(0);
// 设置属性名称
attrVo.setAttrName(attrNameBucket.key().stringValue());
List<StringTermsBucket> attrValueBuckets = attrValueAgg.sterms().buckets().array();
List<String> attrValues = new ArrayList<>();
for (StringTermsBucket attrValueBucket : attrValueBuckets) {
attrValues.add(attrValueBucket.key().stringValue());
}
// 设置属性值
attrVo.setAttrValue(attrValues);
}
attrVos.add(attrVo);
}
}
result.setAttrs(attrVos);
// 6. 进行分页操作
result.setPageNum(param.getPageNum());
// 获取总记录数
long total = hitsMetadata.total().value();
result.setTotal(total);
// 计算总页码
int totalPages = (int) total % SearchConstant.PAGE_SIZE == 0 ?
(int) total / SearchConstant.PAGE_SIZE :
((int) total / SearchConstant.PAGE_SIZE + 1);
result.setTotalPages(totalPages);
List<Integer> pageNavs = new ArrayList<>();
for (int i = 1; i <= totalPages; i++) {
pageNavs.add(i);
}
result.setPageNavs(pageNavs);
return result;
}
}
5.3 测试接口
通过以下URL进行测试:
http://localhost:8054/searchList?price=1_5000&keyword=手机&sort=salecount_asc&hasStock=1&pageNum=1&pageSize=20&categoryId=19&attrs=2_蓝色&attrs=1_2核
参数说明:
- price=1_5000:价格区间1-5000元
- keyword=手机:搜索关键字
- sort=salecount_asc:按销量升序排序
- hasStock=1:只显示有库存的商品
- pageNum=1:第1页
- pageSize=20:每页20条
- categoryId=19:手机通讯类目
- attrs=2_蓝色:颜色为蓝色
- attrs=1_2核:CPU为2核
六、总结与最佳实践
6.1 电商商品搜索的关键技术点
- 中文分词:使用ik_max_word分词器对商品标题、关键词进行分词,提升搜索相关性
- nested类型:处理商品属性与商品的多对多关系
- 聚合分析:实现品牌、分类、属性等筛选条件的动态生成
- 高亮显示:提升搜索结果的用户体验
- 多条件组合查询:实现复杂的搜索条件组合
6.2 电商商品搜索的优化建议
-
索引优化:
- 为高频查询字段(如brandId, categoryId)设置keyword类型
- 为价格字段使用double类型,方便范围查询
- 为销量、库存等字段设置long类型,提升查询性能
-
查询优化:
- 对于高频查询,可以考虑使用Elasticsearch的缓存功能
- 对于复杂查询,可以考虑使用bool查询的filter上下文,避免影响相关性评分
- 对于大范围查询,考虑使用scroll API避免深度分页问题
-
性能优化:
- 合理设置分片数和副本数
- 定期进行索引优化(optimize)
- 使用合适的硬件配置
-
用户体验优化:
- 实现搜索建议(Search Suggest)
- 实现自动补全功能
- 提供更丰富的筛选条件(如价格区间、品牌、属性等)
6.3 电商商品搜索的架构图
架构说明:
- 前端:用户输入搜索关键词,选择筛选条件
- API网关:接收请求,进行权限校验、参数校验
- 搜索服务:处理搜索请求,与Elasticsearch交互
- Elasticsearch:存储和检索商品数据
- 数据源:商品数据库,用于数据同步
七、结语
本文详细介绍了电商项目中商品搜索服务的实现,从商品文档建模、Elasticsearch索引创建到搜索功能的实现,涵盖了电商搜索的核心技术点。通过使用Elasticsearch的nested类型处理商品属性,结合聚合分析实现筛选条件的动态生成,以及高亮显示提升用户体验,能够构建一个高性能、易用的商品搜索服务。
在实际项目中,可以根据业务需求进一步优化,如实现搜索建议、自动补全、热门搜索词统计等功能,进一步提升用户体验和转化率。
附录:常用Elasticsearch查询参数
| 参数 | 说明 | 示例 |
|---|---|---|
| keyword | 搜索关键字 | keyword=手机 |
| price | 价格区间 | price=1_5000 |
| hasStock | 是否有库存 | hasStock=1 |
| pageNum | 页码 | pageNum=1 |
| pageSize | 每页数量 | pageSize=20 |
| categoryId | 类目ID | categoryId=19 |
| brandId | 品牌ID | brandId=6 |
| attrs | 商品属性 | attrs=2_蓝色&attrs=1_2核 |
| sort | 排序 | sort=salecount_asc |
提示:在实际开发中,建议使用Swagger生成API文档,方便前端开发人员理解和使用搜索接口。
更多推荐



所有评论(0)