多来买:Elasticsearch 搜索实战
多来买:Elasticsearch 搜索实战
事实来源:当前 search-service、search-api 的搜索参数、文档模型、DSL 构造与响应解析源码
证据状态:代码已确认,未启动搜索服务、未连接 Elasticsearch、未读取真实 mapping。
阅读目标:从搜索参数进入 DSL,再到命中、高亮、聚合和分页响应,完整理解搜索读链路。
1. 为什么搜索不直接查询商品表
商城搜索需要同时处理:
- 标题全文检索;
- 一到三级分类过滤;
- 品牌过滤;
- 多个平台属性组合过滤;
- 热度或价格排序;
- 标题高亮;
- 品牌和属性筛选面板;
- 分页。
这些需求适合 Elasticsearch 面向查询构建的 Goods 文档,而不是在商品关系表上临时拼接大量查询。
商品数据库仍是事实源;ES 是通过上架链路构建的搜索读模型。
2. 搜索服务边界
| 组件 | 职责 |
|---|---|
SearchController | 接收搜索参数并返回统一结果 |
SearchServiceImpl#buildDSL | 把参数转换为 ES 查询、排序、高亮和聚合 |
ElasticsearchRestTemplate | 执行查询 |
SearchServiceImpl#parseResponseAsync | 解析命中、高亮、聚合和分页 |
GoodsRepository | 保存、读取或删除 Goods 文档 |
本文聚焦查询;商品如何写入和删除 ES 见本专题第 4 篇。
3. 端到端时序
4. Goods 搜索读模型
源码:duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/model/Goods.java,Goods。
@Data@Document(indexName = "goods", shards = 1, replicas = 0)public class Goods { @Id private Long id;
@Field(type = FieldType.Keyword, index = false) private String defaultImg;
@Field(type = FieldType.Text, analyzer = "ik_max_word") private String title;
@Field(type = FieldType.Double) private Double price;
@Field(type = FieldType.Long) private Long tmId;
@Field(type = FieldType.Keyword) private String tmName;
@Field(type = FieldType.Long) private Long hotScore = 0L;
@Field(type = FieldType.Nested) private List<SearchAttr> attrs;}文档还保存一到三级分类 ID 和名称、品牌 Logo 等字段。
当前只能确认 Java 注解表达的索引意图,不能声称运行中的索引一定已经按这些注解创建,也不能确认 IK 分词器当前可用。
5. Nested 属性子文档
源码:duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/model/SearchAttr.java,SearchAttr。
@Datapublic class SearchAttr { @Field(type = FieldType.Long) private Long attrId;
@Field(type = FieldType.Keyword) private String attrValue;
@Field(type = FieldType.Keyword) private String attrName;}概念数据形态:
{ "id": 101, "title": "某手机 256G 红色", "price": 2999.0, "tmId": 9, "hotScore": 20, "attrs": [ {"attrId": 1, "attrName": "颜色", "attrValue": "红色"}, {"attrId": 2, "attrName": "容量", "attrValue": "256G"} ]}这是帮助理解字段关系的脱敏示例,不是从当前 ES 导出的真实文档。
6. 搜索请求契约
源码:duolaimall-search/search-api/src/main/java/com/cskaoyan/mall/search/param/SearchParam.java,SearchParam。
private Long firstLevelCategoryId;private Long secondLevelCategoryId;private Long thirdLevelCategoryId;private String trademark;private String keyword;private String order = "";private String[] props;private Integer pageNo = 1;private Integer pageSize = 3;| 参数 | 协议 | 当前解释 |
|---|---|---|
keyword | 普通字符串 | 对标题做 match |
| 分类 ID | Long | 对对应字段做 term filter |
trademark | 品牌ID:品牌名 | 过滤只使用品牌 ID |
props | 属性ID:属性值:属性名 数组 | 每项生成一个 nested filter |
order | 1:asc、2:desc 等 | 1 热度、其他代码价格 |
pageNo | 从 1 开始 | 转为 Spring Data 0 基页码 |
pageSize | 正整数 | 默认 3 |
参数采用字符串协议,当前入口没有 Bean Validation。
7. Controller 与执行骨架
源码:duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/controller/SearchController.java,SearchController#list。
@GetMapping("/list")public Result list(SearchParam searchParam) throws IOException { SearchResponseDTO response = searchService.search(searchParam); return Result.ok(response);}源码:duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/service/impl/SearchServiceImpl.java,SearchServiceImpl#search。
NativeSearchQueryBuilder builder = new NativeSearchQueryBuilder();
buildDSL(builder, searchParam);NativeSearchQuery query = builder.build();
SearchHits<Goods> hits = elasticsearchRestTemplate.search(query, Goods.class);
return parseResponseAsync(hits, searchParam);职责分为“构建、执行、解析”三步,Controller 保持薄层。
8. 关键词与结构化过滤
源码:SearchServiceImpl#buildDSL。
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
String keyword = searchParam.getKeyword();if (StringUtils.isNotBlank(keyword)) { boolQuery.must(QueryBuilders.matchQuery("title", keyword));}
Long firstCategoryId = searchParam.getFirstLevelCategoryId();if (firstCategoryId != null) { boolQuery.filter(QueryBuilders.termQuery( "firstLevelCategoryId", firstCategoryId));}二、三级分类使用相同结构。
这里的语义分工:
must + match用于分词全文检索并参与相关性;filter + term用于精确结构化约束,不参与评分;- 关键词为空时不添加标题查询;
- 分类参数传哪个就添加哪个过滤。
9. 品牌协议
源码同上:SearchServiceImpl#buildDSL。
String trademark = searchParam.getTrademark();if (StringUtils.isNotBlank(trademark)) { String[] split = trademark.split(":"); if (split.length == 2) { boolQuery.filter( QueryBuilders.termQuery("tmId", split[0])); }}尽管请求携带品牌名,当前过滤只使用品牌 ID。
格式不是两段时,代码直接不添加品牌过滤;没有返回显式参数错误。
10. 为什么属性必须使用 Nested
源码同上:SearchServiceImpl#buildDSL。
for (String prop : searchParam.getProps()) { String[] split = prop.split(":"); Long attrId = Long.valueOf(split[0]); String attrValue = split[1];
BoolQueryBuilder subQuery = QueryBuilders.boolQuery() .filter(QueryBuilders.termQuery("attrs.attrId", attrId)) .filter(QueryBuilders.termQuery("attrs.attrValue", attrValue));
NestedQueryBuilder nestedQuery = QueryBuilders.nestedQuery( "attrs", subQuery, ScoreMode.None); boolQuery.filter(nestedQuery);}若普通 object 被扁平化:
attrs[0] = { attrId: 1, attrValue: 红色 }attrs[1] = { attrId: 2, attrValue: 256G }查询 attrId=1 AND attrValue=256G 可能跨两个对象错误组合。
Nested 保留数组元素边界,要求 ID 与值出现在同一个属性子文档中。
当前解析直接访问前三段并转换 Long,缺少长度和数字格式校验。
11. 分页转换
源码同上:SearchServiceImpl#buildDSL。
PageRequest pageRequest = PageRequest.of( searchParam.getPageNo() - 1, searchParam.getPageSize());
nativeSearchQueryBuilder.withPageable(pageRequest);前端从 1 开始,Spring Data 从 0 开始,所以 pageNo - 1。
当前没有校验 null、小于 1 或 pageSize 上限;非法值可能在构造 PageRequest 时失败。
12. 排序协议
源码同上:SearchServiceImpl#buildDSL。
String[] split = order.split(":");if (split.length == 2) { String field = split[0].equals("1") ? "hotScore" : "price";
Sort.Direction direction = split[1].equalsIgnoreCase("asc") ? Sort.Direction.ASC : Sort.Direction.DESC;
nativeSearchQueryBuilder.withSort( Sort.by(direction, field));} else { nativeSearchQueryBuilder.withSort( Sort.by(Sort.Direction.DESC, "hotScore"));}真实边界:
- 字段代码不是
1都映射为价格; - 方向不是
asc都映射为降序; - 非两段格式回退到热度降序;
order完全为空时不添加默认排序。
因此无关键词、无排序的纯过滤查询没有代码级稳定顺序保证。
13. 标题高亮
源码同上:SearchServiceImpl#buildDSL。
if (StringUtils.isNotBlank(keyword)) { HighlightBuilder highlight = new HighlightBuilder(); highlight.field("title"); highlight.preTags("<span style='color:red'>"); highlight.postTags("</span>"); nativeSearchQueryBuilder.withHighlightBuilder(highlight);}只有关键词存在时才请求高亮。
后端返回带 HTML 标签的标题;前端如何安全渲染不在本项目后端代码中得到验证。
14. 品牌聚合
源码同上:SearchServiceImpl#buildDSL。
TermsAggregationBuilder tmAgg = AggregationBuilders .terms("tmId_bucket") .field("tmId") .subAggregation(AggregationBuilders .terms("tmName_bucket").field("tmName")) .subAggregation(AggregationBuilders .terms("tmLogoUrl_bucket").field("tmLogoUrl"));
nativeSearchQueryBuilder.withAggregations(tmAgg);先按品牌 ID 分桶,再在每个桶中取得名称和 Logo。
响应因此能根据当前结果集生成品牌筛选项,而不是依赖前端另查所有品牌。
15. 属性 Nested 聚合
源码同上:SearchServiceImpl#buildDSL。
NestedAggregationBuilder attrsAgg = AggregationBuilders .nested("attrsAgg", "attrs") .subAggregation( AggregationBuilders.terms("attrIdAgg") .field("attrs.attrId") .subAggregation(AggregationBuilders .terms("attrNameAgg") .field("attrs.attrName")) .subAggregation(AggregationBuilders .terms("attrValueAgg") .field("attrs.attrValue")) );查询和聚合都必须进入 attrs nested 上下文。
聚合结构是:
attrsAgg -> attrIdAgg -> attrNameAgg -> attrValueAgg16. Source filtering
源码同上:SearchServiceImpl#buildDSL。
FetchSourceFilter sourceFilter = new FetchSourceFilter( new String[]{"id", "defaultImg", "title", "price"}, null);
nativeSearchQueryBuilder.withSourceFilter(sourceFilter);命中列表只取展示所需四个字段,品牌和属性筛选项由聚合返回。
所以 GoodsDTO 即使声明更多字段,列表查询中也不保证全部填充。
17. 响应模型
SearchResponseDTO 的主要结果:
| 字段 | 来源 |
|---|---|
goodsList | 命中与标题高亮 |
trademarkList | 品牌聚合 |
attrsList | 属性 nested 聚合 |
total | ES 命中总数 |
pageNo、pageSize、totalPages | 请求参数和计算结果 |
parseResponseAsync 把四部分放入四个 CompletableFuture 并行解析。
18. 命中与高亮回填
源码:SearchServiceImpl#parseResponseAsync。
List<Goods> goodsList = searchHits.getSearchHits().stream() .map(hit -> { Goods content = hit.getContent(); List<String> titles = hit.getHighlightField("title"); if (CollectionUtils.isNotEmpty(titles)) { content.setTitle(titles.get(0)); } return content; }) .collect(Collectors.toList());
searchResponseDTO.setGoodsList( goodsConverter.goodsPOs2DTOs(goodsList));有高亮就取第一个片段覆盖标题,没有则保留原始标题。
19. 品牌聚合解析
源码同上:SearchServiceImpl#parseResponseAsync。
Terms tmIdAgg = aggregations.get("tmId_bucket");
List<SearchResponseTmDTO> brands = tmIdAgg.getBuckets() .stream() .map(idBucket -> { Terms names = idBucket.getAggregations() .get("tmName_bucket"); Terms logos = idBucket.getAggregations() .get("tmLogoUrl_bucket");
SearchResponseTmDTO dto = new SearchResponseTmDTO(); dto.setTmId(idBucket.getKeyAsNumber().longValue()); dto.setTmName(names.getBuckets().get(0).getKeyAsString()); dto.setTmLogoUrl(logos.getBuckets().get(0).getKeyAsString()); return dto; }) .collect(Collectors.toList());DSL 和解析端的聚合名称必须完全一致。
解析假设每个品牌桶都有至少一个名称和 Logo;若 ES 文档字段不完整,get(0) 存在失败路径。
20. 属性聚合解析
源码同上:SearchServiceImpl#parseResponseAsync。
Nested attrsAgg = aggregations.get("attrsAgg");Terms attrIdAgg = attrsAgg.getAggregations().get("attrIdAgg");
List<String> values = attrValueAgg.getBuckets() .stream() .map(bucket -> bucket.getKeyAsString()) .collect(Collectors.toList());
responseAttrDTO.setAttrId(attrId);responseAttrDTO.setAttrName(attrName);responseAttrDTO.setAttrValueList(values);同一属性 ID 下的多个值被整理为一个列表,供前端绘制筛选面板。
21. 分页响应
源码同上:SearchServiceImpl#parseResponseAsync。
long totalHits = searchHits.getTotalHits();Integer pageSize = searchParam.getPageSize();long totalPages = (long) Math.ceil((double) totalHits / pageSize);
if (totalPages == 0) { totalPages = 1;}
searchResponseDTO.setTotal(totalHits);searchResponseDTO.setPageNo(searchParam.getPageNo());searchResponseDTO.setPageSize(pageSize);searchResponseDTO.setTotalPages(totalPages);零命中时仍返回一页,是当前响应协议,不是 ES 固定规则。
22. 解析线程池边界
parseResponseAsync 每次创建 Executors.newFixedThreadPool(8),等待四个解析任务后返回,但没有调用 shutdown。
解析主要是内存处理,是否值得为每个请求启动多个线程需要用真实响应规模和压测数据判断;当前项目没有这类证据。
23. 参数与响应失败矩阵
| 场景 | 当前静态结果 |
|---|---|
props 缺少三段 | 数组访问可能异常 |
| 属性 ID 非数字 | Long.valueOf 抛异常 |
pageNo < 1 | PageRequest 构造失败 |
pageSize <= 0 | 分页构造或计算失败 |
| 品牌名称、Logo 聚合桶为空 | get(0) 可能异常 |
| 高亮为空 | 使用原始标题 |
| ES 无命中 | goodsList 为空,totalPages 仍为 1 |
| ES 查询异常 | 当前 Service 没有查询降级结果 |
24. 已确认限制与候选风险
24.1 已确认限制
- 请求参数依赖冒号分隔字符串,没有统一校验。
- 未传排序时不会强制默认排序。
- source filter 只返回列表所需四个字段。
- 聚合解析假定名称、Logo 和属性名桶非空。
- 响应解析每请求创建线程池且未关闭。
24.2 候选风险
| 风险 | 触发机制 | 缺失证据 |
|---|---|---|
| 高亮 HTML 渲染边界 | 后端直接返回 <span> | 未检查前端项目 |
| 深分页性能下降 | PageRequest 使用普通 from/size | 未做大页码压测 |
| 大量聚合占用资源 | terms 聚合未展示 size 控制 | 未观察索引规模 |
| 文档字段缺失导致解析失败 | 上架允许部分字段为空 | 未构造 ES 文档复现 |
| 线程持续增长 | 每请求线程池未关闭 | 未运行线程观测 |
25. 如果重做(非当前实现)
- 为
SearchParam建立结构化 DTO 和 Bean Validation; - 为排序代码、方向和分页设置白名单与上限;
- 聚合解析使用空桶保护;
- 复用有界线程池,或直接同步解析轻量结果;
- 对深分页评估
search_after; - 用集成测试固定 nested 查询和聚合名称契约。
26. 关键源码导航
| 阅读问题 | 项目相对路径 | 类/方法 |
|---|---|---|
| 搜索参数 | duolaimall-search/search-api/src/main/java/com/cskaoyan/mall/search/param/SearchParam.java | SearchParam |
| 搜索入口 | duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/controller/SearchController.java | list |
| 文档模型 | duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/model/Goods.java | Goods |
| 属性模型 | duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/model/SearchAttr.java | SearchAttr |
| DSL 与解析 | duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/service/impl/SearchServiceImpl.java | search、buildDSL、parseResponseAsync |
| 响应 DTO | duolaimall-search/search-api/src/main/java/com/cskaoyan/mall/search/dto/SearchResponseDTO.java | SearchResponseDTO |
27. 短复习点
- 关键词使用 match/must,分类和品牌使用 term/filter。
- 属性数组必须用 Nested,防止 ID 与值跨对象错误组合。
- 命中列表靠 source filter,高亮覆盖标题,筛选项来自聚合。
- 参数校验和空聚合保护是当前代码的主要边界。
- 搜索服务读 ES,不替代下单时对商品事实和价格的再次校验。
28. 一句话总结
多来买搜索把商品投影成 Goods 文档,用 bool、nested、排序、高亮和聚合一次返回商品列表与筛选面板;查询链路完整,但协议校验、空桶保护和线程池管理仍停留在练习项目水平。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!