管理主页分类

拖动分类调整顺序,并选择是否在主页显示。设置仅保存在当前浏览器。

  • 微服务课件 31
  • Java 基础 28
  • Java Web 开发 25
  • 多来买 18
  • LeetCode 题解 8
  • 开发工具 8
  • 网络工具 2
  • 黑马头条 2
  • Java 记忆恢复 1

多来买:Elasticsearch 搜索实战

2723 字
14 分钟
多来买: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. 端到端时序#

sequenceDiagram participant C as 客户端 participant SC as SearchController participant SS as SearchServiceImpl participant ES as Elasticsearch C->>SC: GET /list + SearchParam SC->>SS: search(searchParam) SS->>SS: buildDSL(builder, param) SS->>ES: search(NativeSearchQuery, Goods.class) ES-->>SS: SearchHits<Goods> par 解析命中与高亮 SS->>SS: goodsList and 解析品牌聚合 SS->>SS: trademarkList and 解析属性聚合 SS->>SS: attrsList and 解析分页 SS->>SS: total / totalPages end SS-->>SC: SearchResponseDTO SC-->>C: Result.ok(response)

4. Goods 搜索读模型#

源码:duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/model/Goods.javaGoods

@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.javaSearchAttr

@Data
public 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.javaSearchParam

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
分类 IDLong对对应字段做 term filter
trademark品牌ID:品牌名过滤只使用品牌 ID
props属性ID:属性值:属性名 数组每项生成一个 nested filter
order1:asc2:desc1 热度、其他代码价格
pageNo从 1 开始转为 Spring Data 0 基页码
pageSize正整数默认 3

参数采用字符串协议,当前入口没有 Bean Validation。

7. Controller 与执行骨架#

源码:duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/controller/SearchController.javaSearchController#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.javaSearchServiceImpl#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
-> attrValueAgg

16. 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 聚合
totalES 命中总数
pageNopageSizetotalPages请求参数和计算结果

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 < 1PageRequest 构造失败
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. 如果重做(非当前实现)#

  1. SearchParam 建立结构化 DTO 和 Bean Validation;
  2. 为排序代码、方向和分页设置白名单与上限;
  3. 聚合解析使用空桶保护;
  4. 复用有界线程池,或直接同步解析轻量结果;
  5. 对深分页评估 search_after
  6. 用集成测试固定 nested 查询和聚合名称契约。

26. 关键源码导航#

阅读问题项目相对路径类/方法
搜索参数duolaimall-search/search-api/src/main/java/com/cskaoyan/mall/search/param/SearchParam.javaSearchParam
搜索入口duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/controller/SearchController.javalist
文档模型duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/model/Goods.javaGoods
属性模型duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/model/SearchAttr.javaSearchAttr
DSL 与解析duolaimall-search/search-service/src/main/java/com/cskaoyan/mall/search/service/impl/SearchServiceImpl.javasearchbuildDSLparseResponseAsync
响应 DTOduolaimall-search/search-api/src/main/java/com/cskaoyan/mall/search/dto/SearchResponseDTO.javaSearchResponseDTO

27. 短复习点#

  1. 关键词使用 match/must,分类和品牌使用 term/filter。
  2. 属性数组必须用 Nested,防止 ID 与值跨对象错误组合。
  3. 命中列表靠 source filter,高亮覆盖标题,筛选项来自聚合。
  4. 参数校验和空聚合保护是当前代码的主要边界。
  5. 搜索服务读 ES,不替代下单时对商品事实和价格的再次校验。

28. 一句话总结#

多来买搜索把商品投影成 Goods 文档,用 bool、nested、排序、高亮和聚合一次返回商品列表与筛选面板;查询链路完整,但协议校验、空桶保护和线程池管理仍停留在练习项目水平。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

多来买:Elasticsearch 搜索实战
https://firefly-mu-weld.vercel.app/posts/duolaimai-elasticsearch-product-search/
作者
Daisy
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录