一、业务场景分析

在电商项目中,商品搜索功能是核心功能之一,直接影响用户体验和转化率。仿京东商品搜索服务需要支持以下业务场景:

  1. 根据关键字查询:用户输入关键词,搜索匹配的商品
  2. 根据品牌筛选:筛选特定品牌下的商品
  3. 根据商品类别筛选:按商品分类进行筛选
  4. 根据商品属性信息筛选:如颜色、尺寸、CPU等
  5. 根据价格区间筛选:设置价格范围
  6. 根据是否有库存筛选:只显示有库存的商品
  7. 根据销量、价格、上架时间等排序:提供多种排序方式

二、商品文档建模

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 关键功能说明

  1. 关键字查询:使用multi_match对name、keywords、subTitle字段进行多字段匹配
  2. 过滤条件
    • term:精确匹配hasStock为true的商品
    • range:价格区间过滤(1-5000元)
  3. 排序:按salecount升序排序
  4. 聚合分析
    • 品牌聚合:统计各品牌商品数量
    • 分类聚合:统计各分类商品数量
    • 属性聚合:统计各属性值的分布
  5. 高亮:对搜索结果中的关键字进行红色高亮显示

五、微服务实现商品搜索功能

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 电商商品搜索的关键技术点

  1. 中文分词:使用ik_max_word分词器对商品标题、关键词进行分词,提升搜索相关性
  2. nested类型:处理商品属性与商品的多对多关系
  3. 聚合分析:实现品牌、分类、属性等筛选条件的动态生成
  4. 高亮显示:提升搜索结果的用户体验
  5. 多条件组合查询:实现复杂的搜索条件组合

6.2 电商商品搜索的优化建议

  1. 索引优化

    • 为高频查询字段(如brandId, categoryId)设置keyword类型
    • 为价格字段使用double类型,方便范围查询
    • 为销量、库存等字段设置long类型,提升查询性能
  2. 查询优化

    • 对于高频查询,可以考虑使用Elasticsearch的缓存功能
    • 对于复杂查询,可以考虑使用bool查询的filter上下文,避免影响相关性评分
    • 对于大范围查询,考虑使用scroll API避免深度分页问题
  3. 性能优化

    • 合理设置分片数和副本数
    • 定期进行索引优化(optimize)
    • 使用合适的硬件配置
  4. 用户体验优化

    • 实现搜索建议(Search Suggest)
    • 实现自动补全功能
    • 提供更丰富的筛选条件(如价格区间、品牌、属性等)

6.3 电商商品搜索的架构图

架构说明

  1. 前端:用户输入搜索关键词,选择筛选条件
  2. API网关:接收请求,进行权限校验、参数校验
  3. 搜索服务:处理搜索请求,与Elasticsearch交互
  4. Elasticsearch:存储和检索商品数据
  5. 数据源:商品数据库,用于数据同步

七、结语

本文详细介绍了电商项目中商品搜索服务的实现,从商品文档建模、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文档,方便前端开发人员理解和使用搜索接口。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐