一、前提

ES作为全文检索引擎


  1. 承担项目中的所有全文检索功能。
    比如京东商城手机检索功能:按照名称或者不同规格属性进行检索
  2. 承担日志的全文检索功能,ELK。
    E:ElasticSearch,存储、分析
    L:LogStash,收集日志,并存储在ES中
    K:Kibana,可视化界面

谷粒商城:15.商城业务 — 商品上架_分页

二、商品上架

上架的商品才可以在网站展示。

上架的商品需要可以被检索。

2.1. 商品Mapping

分析:商品上架在 es 中是存 sku 还是 spu?


  1. 检索的时候输入名字,是需要按照 sku 的 title 进行全文检索的
  2. 检索使用商品规格,规格是 spu 的公共属性,每个 spu 是一样的
  3. 按照分类 id 进去的都是直接列出 spu 的,还可以切换。
  4. 我们如果将 sku 的全量信息保存到 es 中(包括 spu 属性)就太多量字段了。
  5. 我们如果将 spu 以及他包含的 sku 信息保存到 es 中,也可以方便检索。但是 sku 属于spu 的级联对象,在 es 中需要 nested 模型,这种性能差点。
  6. 但是存储与检索我们必须性能折中。
  7. 如果我们分拆存储,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 中可以提供检索和查询功能


  1. hasStock:代表是否有库存。默认上架的商品都有库存。如果库存无货的时候才需要更新一下 es
  2. 库存补上以后,也需要重新更新一下 es
  3. hotScore 是热度值,我们只模拟使用点击率更新热度。点击率增加到一定程度才更新热度值。
  4. 下架就是从 es 中移除检索项,以及修改 mysql 状态

商品上架步骤:


  1. 先在 es 中按照之前的 mapping 信息,建立 product 索引。
  2. 点击上架,查询出所有 sku 的信息,保存到 es 中
  3. es 保存成功返回,更新数据库的上架状态信息。

2.3. 数据一致性


  • 商品无库存的时候需要更新 es 的库存信息
  • 商品有库存也要更新 es 的信息

三、商品检索

3.1. 检索业务分析

商品检索的三个入口:


  1. 选择分类进入商品检索谷粒商城:15.商城业务 — 商品上架_sed_02
  2. 输入检索关键字展示检索页谷粒商城:15.商城业务 — 商品上架_分页_03
  3. 选择筛选条件进入谷粒商城:15.商城业务 — 商品上架_全文检索_04

删选条件&排序条件


  • 全文检索: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;
}