一、前提
ES作为全文检索引擎
- 承担项目中的所有全文检索功能。
比如京东商城手机检索功能:按照名称或者不同规格属性进行检索 - 承担日志的全文检索功能,ELK。
E:ElasticSearch,存储、分析
L:LogStash,收集日志,并存储在ES中
K:Kibana,可视化界面
二、商品上架
上架的商品才可以在网站展示。
上架的商品需要可以被检索。
2.1. 商品Mapping
分析:商品上架在 es 中是存 sku 还是 spu?
- 检索的时候输入名字,是需要按照 sku 的 title 进行全文检索的
- 检索使用商品规格,规格是 spu 的公共属性,每个 spu 是一样的
- 按照分类 id 进去的都是直接列出 spu 的,还可以切换。
- 我们如果将 sku 的全量信息保存到 es 中(包括 spu 属性)就太多量字段了。
- 我们如果将 spu 以及他包含的 sku 信息保存到 es 中,也可以方便检索。但是 sku 属于spu 的级联对象,在 es 中需要 nested 模型,这种性能差点。
- 但是存储与检索我们必须性能折中。
- 如果我们分拆存储,spu 和 attr 一个索引,sku 单独一个索引可能涉及的问题。
检索商品的名字,如“手机”,对应的 spu 有很多,我们要分析出这些 spu 的所有关联属性, 再做一次查询,就必须将所有 spu_id 都发出去。假设有 1 万个数据,数据传输一次就
10000*4=4MB;并发情况下假设 1000 检索请求,那就是 4GB 的数据,,传输阻塞时间会很长,业务更加无法继续。
所以,我们如下设计,这样才是文档区别于关系型数据库的地方,宽表设计,不能去考虑数据库范式。
PUT product
——product 的 mapping
PUT product
{
"mappings":{
"properties":{
"skuId":{
"type":"long"
},
"spuId":{
"type":"keyword"
},
"skuTitle":{
"type":"text",
"analyzer":"ik_smart"
},
"skuPrice":{
"type":"keyword"
},
"skuImg":{
"type":"keyword",
"index":false,
"doc_values":false
},
"saleCount":{
"type":"long"
},
"hasStock":{
"type":"boolean"
},
"hotScore":{
"type":"long"
},
"brandId":{
"type":"long"
},
"catalogId":{
"type":"long"
},
"brandName":{
"type":"keyword",
"index":false,
"doc_values":false
},
"brandImg":{
"type":"keyword",
"index":false,
"doc_values":false
},
"catalogName":{
"type":"keyword",
"index":false,
"doc_values":false
},
"attrs":{
"type":"nested",
"properties":{
"attrId":{
"type":"long"
},
"attrName":{
"type":"keyword",
"index":false,
"doc_values":false
},
"attrValue":{
"type":"keyword"
}
}
}
}
}
}
index:
默认 true,如果为 false,表示该字段不会被索引,但是检索结果里面有,但字段本身不能当做检索条件。
doc_values:
默认 true,设置为 false,表示不可以做排序、聚合以及脚本操作,这样更节省磁盘空间。
还可以通过设定 doc_values 为 true,index 为 false 来让字段不能被搜索但可以用于排序、聚合以及脚本操作。
2.2. 上架细节
上架是将后台的商品放在 es 中可以提供检索和查询功能
- hasStock:代表是否有库存。默认上架的商品都有库存。如果库存无货的时候才需要更新一下 es
- 库存补上以后,也需要重新更新一下 es
- hotScore 是热度值,我们只模拟使用点击率更新热度。点击率增加到一定程度才更新热度值。
- 下架就是从 es 中移除检索项,以及修改 mysql 状态
商品上架步骤:
- 先在 es 中按照之前的 mapping 信息,建立 product 索引。
- 点击上架,查询出所有 sku 的信息,保存到 es 中
- es 保存成功返回,更新数据库的上架状态信息。
2.3. 数据一致性
- 商品无库存的时候需要更新 es 的库存信息
- 商品有库存也要更新 es 的信息
三、商品检索
3.1. 检索业务分析
商品检索的三个入口:
- 选择分类进入商品检索
- 输入检索关键字展示检索页
- 选择筛选条件进入
删选条件&排序条件
- 全文检索:skuTitle
- 排序:saleCount、hotScore、skuPrice
- 过滤:hasStock、skuPrice 区间、brandId、catalogId、attrs
- 聚合:attrs
完整的 url 参数
keyword=小米&sort=saleCount_desc/asc&hasStock=0/1&skuPrice=400_1900&brandId=1
&catalogId=1&attrs=1_3G:4G:5G&attrs=2_骁龙 845&attrs=4_高清屏
3.2. 检索语句构建
3.2.1. 请求参数模型
@Data
public class SearchParam {
private String keyword;//页面传递过来的全文匹配关键字 v
private Long catalog3Id;//三级分类id v
/**
* sort=saleCount_asc/desc
* sort=skuPrice_asc/desc
* sort=hotScore_asc/desc
*/
private String sort;//排序条件 v
/**
* 过滤条件
* hasStock(是否有货)、skuPrice 区间、brandId、catalog3Id、attrs
* hasStock=0/1
* skuPrice=1_500/_500/500_
* brandId=1
* attrs=2_5 存:6 寸
*/
private Integer hasStock;//是否只显示有货 v 0(无库存)1(有库存)
private String skuPrice;//价格区间查询 v
private List<Long> brandId;//按照品牌进行查询,可以多选 v
private List<String> attrs;//按照属性进行筛选 v
private Integer pageNum = 1;//页码
private String _queryString;//原生的所有查询条件
}
3.2.2. 构建参数
/**
* 准备检索请求
* #模糊匹配,过滤(按照属性,分类,品牌,价格区间,库存),排序,分页,高亮,聚合分析
*
* @return
*/
private SearchRequest buildSearchRequrest(SearchParam param) {
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();//构建DSL语句的
/**
* 查询:过滤(按照属性,分类,品牌,价格区间,库存)
*/
//1、构建bool - query
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
//1.1、must-模糊匹配,
if (!StringUtils.isEmpty(param.getKeyword())) {
boolQuery.must(QueryBuilders.matchQuery("skuTitle", param.getKeyword()));
}
//1.2、bool - filter - 按照三级分类id查询
if (param.getCatalog3Id() != null) {
boolQuery.filter(QueryBuilders.termQuery("catalogId", param.getCatalog3Id()));
}
//1.2、bool - filter - 按照品牌id查询
if (param.getBrandId() != null && param.getBrandId().size() > 0) {
boolQuery.filter(QueryBuilders.termsQuery("brandId", param.getBrandId()));
}
//1.2、bool - filter - 按照所有指定的属性进行查询
if (param.getAttrs() != null && param.getAttrs().size() > 0) {
for (String attrStr : param.getAttrs()) {
//attrs=1_5寸:8寸&attrs=2_16G:8G
BoolQueryBuilder nestedboolQuery = QueryBuilders.boolQuery();
//attr = 1_5寸:8寸
String[] s = attrStr.split("_");
String attrId = s[0]; //检索的属性id
String[] attrValues = s[1].split(":"); //这个属性的检索用的值
nestedboolQuery.must(QueryBuilders.termQuery("attrs.attrId", attrId));
nestedboolQuery.must(QueryBuilders.termsQuery("attrs.attrValue", attrValues));
//每一个必须都得生成一个nested查询
NestedQueryBuilder nestedQuery = QueryBuilders.nestedQuery("attrs", nestedboolQuery, ScoreMode.None);
boolQuery.filter(nestedQuery);
}
}
//1.2、bool - filter - 按照库存是否有进行查询
if(param.getHasStock() != null){
boolQuery.filter(QueryBuilders.termQuery("hasStock", param.getHasStock() == 1));
}
//1.2、bool - filter - 按照价格区间
if (!StringUtils.isEmpty(param.getSkuPrice())) {
//1_500/_500/500_
/**
* "range": {
* "skuPrice": {
* "gte": 0,
* "lte": 6000
* }
* }
*/
RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("skuPrice");
String[] s = param.getSkuPrice().split("_");
if (s.length == 2) {
//区间
rangeQuery.gte(s[0]).lte(s[1]);
} else if (s.length == 1) {
if (param.getSkuPrice().startsWith("_")) {
rangeQuery.lte(s[0]);
}
if (param.getSkuPrice().endsWith("_")) {
rangeQuery.gte(s[0]);
}
}
boolQuery.filter(rangeQuery);
}
//把以前的所有条件都拿来进行封装
sourceBuilder.query(boolQuery);
/**
* 排序,分页,高亮,
*/
//2.1、排序
if (!StringUtils.isEmpty(param.getSort())) {
String sort = param.getSort();
//sort=hotScore_asc/desc
String[] s = sort.split("_");
SortOrder order = s[1].equalsIgnoreCase("asc") ? SortOrder.ASC : SortOrder.DESC;
sourceBuilder.sort(s[0], order);
}
//2.2、分页 pageSize:5
// pageNum:1 from:0 size:5 [0,1,2,3,4]
// pageNum:2 from:5 size:5
//from = (pageNum-1)*size
sourceBuilder.from((param.getPageNum() - 1) * EsConstant.PRODUCT_PAGESIZE);
sourceBuilder.size(EsConstant.PRODUCT_PAGESIZE);
//2.3、高亮
if (!StringUtils.isEmpty(param.getKeyword())) {
HighlightBuilder builder = new HighlightBuilder();
builder.field("skuTitle");
builder.preTags("<b style='color:red'>");
builder.postTags("</b>");
sourceBuilder.highlighter(builder);
}
/**
* 聚合分析
*/
//1、品牌聚合
TermsAggregationBuilder brand_agg = AggregationBuilders.terms("brand_agg");
brand_agg.field("brandId").size(50);
//品牌聚合的子聚合
brand_agg.subAggregation(AggregationBuilders.terms("brand_name_agg").field("brandName").size(1));
brand_agg.subAggregation(AggregationBuilders.terms("brand_img_agg").field("brandImg").size(1));
//TODO 1、聚合brand
sourceBuilder.aggregation(brand_agg);
//2、分类聚合 catalog_agg
TermsAggregationBuilder catalog_agg = AggregationBuilders.terms("catalog_agg").field("catalogId").size(20);
catalog_agg.subAggregation(AggregationBuilders.terms("catalog_name_agg").field("catalogName").size(1));
//TODO 2、聚合catalog
sourceBuilder.aggregation(catalog_agg);
//3、属性聚合 attr_agg
NestedAggregationBuilder attr_agg = AggregationBuilders.nested("attr_agg", "attrs");
//聚合出当前所有的attrId
TermsAggregationBuilder attr_id_agg = AggregationBuilders.terms("attr_id_agg").field("attrs.attrId");
//聚合分析出当前attr_id对应的名字
attr_id_agg.subAggregation(AggregationBuilders.terms("attr_name_agg").field("attrs.attrName").size(1));
//聚合分析出当前attr_id对应的所有可能的属性值attrValue
attr_id_agg.subAggregation(AggregationBuilders.terms("attr_value_agg").field("attrs.attrValue").size(50));
attr_agg.subAggregation(attr_id_agg);
//TODO 3、聚合attr
sourceBuilder.aggregation(attr_agg);
String s = sourceBuilder.toString();
System.out.println("构建的DSL" + s);
SearchRequest searchRequest = new SearchRequest(new String[]{EsConstant.PRODUCT_INDEX}, sourceBuilder);
return searchRequest;
}
3.3. 结果提取封装
3.3.1. 响应数据模型
package com.atguigu.gulimall.search.vo;
import com.atguigu.common.to.es.SkuEsModel;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class SearchResult {
//查询到的所有商品信息
private List<SkuEsModel> products;
/**
* 以下是分页信息
*/
private Integer pageNum;//当前页码
private Long total;//总记录数
private Integer totalPages;//总页码
private List<Integer> pageNavs;
private List<BrandVo> brands;//当前查询到的结果,所有涉及到的品牌
private List<CatalogVo> catalogs;//当前查询到的结果,所有涉及到的所有分类
private List<AttrVo> attrs;//当前查询到的结果,所有涉及到的所有属性
//==========以上是返回给页面的所有信息============
//面包屑导航数据
private List<NavVo> navs = new ArrayList<>();
private List<Long> attrIds = new ArrayList<>();
@Data
public static class NavVo{
private String navName;
private String navValue;
private String link;
}
@Data
public static class BrandVo{
private Long brandId;
private String brandName;
private String brandImg;
}
@Data
public static class CatalogVo{
private Long catalogId;
private String catalogName;
}
@Data
public static class AttrVo{
private Long attrId;
private String attrName;
private List<String> attrValue;
}
}
3.3.2. 响应结果封装
/**
* 构建结果数据
*
* @param response
*
* @return
*/
private SearchResult buildSearchResult(SearchResponse response, SearchParam param) {
SearchResult result = new SearchResult();
//1、返回的所有查询到的商品
SearchHits hits = response.getHits();
List<SkuEsModel> esModels = new ArrayList<>();
if (hits.getHits() != null && hits.getHits().length > 0) {
for (SearchHit hit : hits.getHits()) {
String sourceAsString = hit.getSourceAsString();
SkuEsModel esModel = JSON.parseObject(sourceAsString, SkuEsModel.class);
if(!StringUtils.isEmpty(param.getKeyword())){
HighlightField skuTitle = hit.getHighlightFields().get("skuTitle");
String string = skuTitle.getFragments()[0].string();
esModel.setSkuTitle(string);
}
esModels.add(esModel);
}
;
}
result.setProducts(esModels);
// //2、当前所有商品涉及到的所有属性信息
List<SearchResult.AttrVo> attrVos = new ArrayList<>();
ParsedNested attr_agg = response.getAggregations().get("attr_agg");
ParsedLongTerms attr_id_agg = attr_agg.getAggregations().get("attr_id_agg");
for (Terms.Bucket bucket : attr_id_agg.getBuckets()) {
SearchResult.AttrVo attrVo = new SearchResult.AttrVo();
//1、得到属性的id
long attrId = bucket.getKeyAsNumber().longValue();
//2、得到属性的名字
String attrName = ((ParsedStringTerms) bucket.getAggregations().get("attr_name_agg")).getBuckets().get(0).getKeyAsString();
//3、得到属性的所有值
List<String> attrValues = ((ParsedStringTerms) bucket.getAggregations().get("attr_value_agg")).getBuckets().stream().map(item -> {
String keyAsString = ((Terms.Bucket) item).getKeyAsString();
return keyAsString;
}).collect(Collectors.toList());
attrVo.setAttrId(attrId);
attrVo.setAttrName(attrName);
attrVo.setAttrValue(attrValues);
attrVos.add(attrVo);
}
result.setAttrs(attrVos);
// //3、当前所有商品涉及到的所有品牌信息
List<SearchResult.BrandVo> brandVos = new ArrayList<>();
ParsedLongTerms brand_agg = response.getAggregations().get("brand_agg");
for (Terms.Bucket bucket : brand_agg.getBuckets()) {
SearchResult.BrandVo brandVo = new SearchResult.BrandVo();
//1、得到品牌的id
long brandId = bucket.getKeyAsNumber().longValue();
//2、得到品牌的名
String brandName = ((ParsedStringTerms) bucket.getAggregations().get("brand_name_agg")).getBuckets().get(0).getKeyAsString();
//3、得到品牌的图片
String brandImg = ((ParsedStringTerms) bucket.getAggregations().get("brand_img_agg")).getBuckets().get(0).getKeyAsString();
brandVo.setBrandId(brandId);
brandVo.setBrandName(brandName);
brandVo.setBrandImg(brandImg);
brandVos.add(brandVo);
}
result.setBrands(brandVos);
// //4、当前所有商品涉及到的所有分类信息
ParsedLongTerms catalog_agg = response.getAggregations().get("catalog_agg");
List<SearchResult.CatalogVo> catalogVos = new ArrayList<>();
List<? extends Terms.Bucket> buckets = catalog_agg.getBuckets();
for (Terms.Bucket bucket : buckets) {
SearchResult.CatalogVo catalogVo = new SearchResult.CatalogVo();
//得到分类id
String keyAsString = bucket.getKeyAsString();
catalogVo.setCatalogId(Long.parseLong(keyAsString));
//得到分类名
ParsedStringTerms catalog_name_agg = bucket.getAggregations().get("catalog_name_agg");
String catalog_name = catalog_name_agg.getBuckets().get(0).getKeyAsString();
catalogVo.setCatalogName(catalog_name);
catalogVos.add(catalogVo);
}
result.setCatalogs(catalogVos);
// ========以上从聚合信息中获取======
// //5、分页信息-页码
result.setPageNum(param.getPageNum());
// //5、分页信息-总记录树
long total = hits.getTotalHits().value;
result.setTotal(total);
// //5、分页信息-总页码-计算 11/2 = 5 .. 1
int totalPages = (int) total % EsConstant.PRODUCT_PAGESIZE == 0 ? (int) total / EsConstant.PRODUCT_PAGESIZE : ((int) total / EsConstant.PRODUCT_PAGESIZE + 1);
result.setTotalPages(totalPages);
List<Integer> pageNavs = new ArrayList<>();
for (int i=1;i<=totalPages;i++){
pageNavs.add(i);
}
result.setPageNavs(pageNavs);
//6、构建面包屑导航功能
if(param.getAttrs()!=null && param.getAttrs().size()>0){
List<SearchResult.NavVo> collect = param.getAttrs().stream().map(attr -> {
//1、分析每个attrs传过来的查询参数值。
SearchResult.NavVo navVo = new SearchResult.NavVo();
// attrs=2_5存:6寸
String[] s = attr.split("_");
navVo.setNavValue(s[1]);
R r = productFeignService.attrInfo(Long.parseLong(s[0]));
result.getAttrIds().add(Long.parseLong(s[0]));
if(r.getCode() == 0){
AttrResponseVo data = r.getData("attr", new TypeReference<AttrResponseVo>() {
});
navVo.setNavName( data.getAttrName());
}else{
navVo.setNavName(s[0]);
}
//
//2、取消了这个面包屑以后,我们要跳转到那个地方.将请求地址的url里面的当前置空
//拿到所有的查询条件,去掉当前。
//attrs= 15_海思(Hisilicon)
String replace = replaceQueryString(param, attr,"attrs");
navVo.setLink("http://search.gulimall.com/list.html?"+replace);
return navVo;
}).collect(Collectors.toList());
result.setNavs(collect);
}
//品牌,分类
if(param.getBrandId()!=null && param.getBrandId().size()>0){
List<SearchResult.NavVo> navs = result.getNavs();
SearchResult.NavVo navVo = new SearchResult.NavVo();
navVo.setNavName("品牌");
//TODO 远程查询所有品牌
R r = productFeignService.brandsInfo(param.getBrandId());
if(r.getCode() == 0){
List<BrandVo> brand = r.getData("brand", new TypeReference<List<BrandVo>>() {
});
StringBuffer buffer = new StringBuffer();
String replace = "";
for (BrandVo brandVo : brand) {
buffer.append(brandVo.getBrandName()+";");
replace = replaceQueryString(param, brandVo.getBrandId()+"","brandId");
}
navVo.setNavValue(buffer.toString());
navVo.setLink("http://search.gulimall.com/list.html?"+replace);
}
navs.add(navVo);
}
//TODO 分类:不需要导航取消
return result;
}