尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
SpringBoot整合ES 7:基于RestHighLevelClient封装分页高亮查询工具类
在 SpringBoot 项目里跟 Elasticsearch 打交道的时间越久越觉得查询这一块的代码特别容易写成一团乱麻。每个业务需求都从new SearchRequest开始自己拼BoolQueryBuilder再处理分页和高亮翻来覆去就是那十几个 API 的组合。一旦这套逻辑被复制到十几个接口上后面想改一个高亮标签都得全局搜索替换稍不留神还容易出现字段解析不一致。今天我们聊的就是一个很实际的话题SpringBoot 整合 Elasticsearch 7基于 RestHighLevelClient 封装一个带分页和高亮能力的工具类。整套方案适合不想引入 Spring Data Elasticsearch 那一层重抽象、想自己掌握查询细节的团队也适合正在学 ES 客户端分层设计的开发者。我会把从依赖引入、配置类编写、分页高亮实现到踩坑记录完整讲一遍。1. 为什么自己封装而不直接用现成框架1.1 先说项目里的真实痛点我之前在某个后台管理项目里做过一批检索功能那时最头疼的不是 ES 查不出数据而是每个接口都在重复写一样的请求构建和响应解析逻辑。比如商品模块要查名称、描述、标签文章模块要查标题、正文、摘要它们技术点完全一样拼布尔查询、设置 from/size、做高亮、解析SearchResponse。开始觉得没什么无非是复制粘贴等迭代到二十多个接口时问题就暴露了一次高亮标签样式调整要把所有复制过的代码全部找出来改一遍漏掉一个页面上的效果就不统一。后面我意识到必须把搜索逻辑抽到一个公共工具类里统一处理分页、高亮、结果转换。这样业务方只需要关心自己的查询条件是什么返回什么 DTO至于底层怎么拼 DSL、怎么解析高亮字段全部收敛在一个地方。这个工具类后来也被用到消息中心的检索、日志平台的模糊匹配等场景只要把索引名、查询条件、分页参数传进来就能拿到统一结构的结果。这也是我要写这篇文章的直接原因如果你也在项目里反复写 ES 查询代码强烈建议先把这套工具类沉淀下来。1.2 技术选型RestHighLevelClient 到底行不行先回答一个问题为什么不用 Spring Data Elasticsearch我对它的评价是团队里大家都是 ES 新手时它确实方便一旦需求复杂起来抽象层反而会成为阻碍。比如官方文档里教你Query注解写 DSL但实际业务里很多查询条件都是动态拼接的会退到底层用NativeSearchQueryBuilder。而高亮这块Spring Data ES 的封装在高亮结果回填到实体类这一步有点别扭调试起来远不如直接操作HighlightField来得痛快。再看 RestHighLevelClient它是 Elasticsearch 官方在 7.x 时代主推的高级 REST 客户端API 跟 REST API 几乎是镜像对应的你在 Kibana 里用Dev Tools调通的 DSL换成 Java 代码思路基本一样迁移成本很低。虽然官方在 8.x 已经把它标记为废弃转而推荐新的 Elasticsearch Java API Client但目前市面上大量生产环境还停在 7.x7.17 是 7.x 最后一个版本RestHighLevelClient 依然是大多数团队能用、用得稳的选择。如果项目还没准备升 8.x就别纠结7.x 用 RestHighLevelClient 完全够用。版本匹配上也要提个醒RestHighLevelClient 的小版本最好和集群版本保持同大版本比如 ES 是 7.15 或 7.17客户端就用对应的 7.x 版本。我见过有人用 6.x 客户端连 7.x 集群直接抛IncompatibleElasticsearchClientVersionException排查了半天才发现是版本问题。1.3 工具类需要满足哪些设计目标动手写代码前我先给自己定了几个目标后面所有实现都围绕这几条展开调用简单业务方只传索引名、查询条件、分页参数和高亮字段不感知SearchRequest、SearchSourceBuilder这些细节。返回统一不管查的什么索引都返回统一的EsPageResultT里面包含总数、页码、每页条数、数据列表结构稳定。泛型友好支持把SearchHit的_source转成任意 DTO而不是每个业务自己写解析。高亮可配置能指定哪些字段需要高亮高亮标签可以自定义这样前端样式调整不用改 Java 代码。可扩展分页大小、排序、超时时间这些参数尽量给默认值但也允许业务方覆盖。这些目标看起来很朴素实际写下来发现还是有不少细节需要打磨。接下来我从环境准备开始一步步拆。2. 先搭好环境依赖、配置类和索引流程2.1 依赖坐标与版本锁定我项目里用的是 Spring Boot 2.5.12Elasticsearch 集群是 7.15.2所以引入的是elasticsearch-rest-high-level-client。依赖就一个但有个坑要注意Spring Boot 的依赖管理里并没有默认管理这个库的版本直接写容易拉到最新 8.x 版本所以必须显式锁定版本号。properties java.version1.8/java.version elasticsearch.version7.15.2/elasticsearch.version /properties dependency groupIdorg.elasticsearch.client/groupId artifactIdelasticsearch-rest-high-level-client/artifactId version${elasticsearch.version}/version /dependency如果项目用的是 Spring Boot 2.7.x可以试 7.17.x 客户端因为 Spring Boot 2.7 内置的 Spring Data Elasticsearch 4.4.x 对应 ES 7.17版本兼容性更好。我这里为了保守一直用 7.15.2线上跑了一年多没有遇到兼容性问题。建议不要混合用不同版本的客户端和集群产品版本号不一致是很多人第一次连接失败的原因。2.2 客户端配置类的细节RestHighLevelClient 是一个线程安全的对象正常情况整个 JVM 启动时初始化一次就够了不需要每次查询都 new。我们用Configuration配合Bean做单例管理并且显式设置连接超时、socket 超时和重试超时。Configuration public class EsClientConfig { Value(${elasticsearch.host:127.0.0.1}) private String host; Value(${elasticsearch.port:9200}) private int port; Value(${elasticsearch.scheme:http}) private String scheme; Bean(destroyMethod close) public RestHighLevelClient restHighLevelClient() { RestClientBuilder builder RestClient.builder( new HttpHost(host, port, scheme) ); builder.setRequestConfigCallback(config - config .setConnectTimeout(5000) .setSocketTimeout(60000) .setConnectionRequestTimeout(1000) ); builder.setMaxRetryTimeoutMillis(60000); return new RestHighLevelClient(builder); } }配置里的Bean(destroyMethod close)一定不要漏它能在 Spring 容器关闭时帮我们释放线程池和连接资源否则应用重启时连接池可能残留。超时参数这里也给个解释setConnectTimeout是建立 TCP 连接的超时setSocketTimeout是每次请求等待响应的超时setConnectionRequestTimeout是从连接池获取连接的超时。ES 大查询或者聚合很慢时socket 超时设太小会直接超时所以 60 秒是偏稳妥的值。2.3 准备一个可用的索引为了后面的演示不那么空对空我约定一个简单的商品索引goods字段包括public class GoodsDTO { private Long id; private String name; private String description; private String category; private Integer price; private String publishTime; }索引 mapping 里要注意需要被高亮的字段最好设置type: text并且指定一个适合中文的分词器。ES 默认的standard分词器对中文是按单字拆的高亮出来的片段经常是几个孤零零的字体验很差。我这边装的是 IK 分词器mapping 简化后长这样PUT /goods { mappings: { properties: { name: { type: text, analyzer: ik_max_word, search_analyzer: ik_smart }, description: { type: text, analyzer: ik_max_word, search_analyzer: ik_smart } } } }这里的analyzer是写入时分词search_analyzer是查询时对查询词分词。ik_max_word粒度更细索引更全ik_smart更偏向智能切分两者配合是我的常用姿势。如果没有 IK 也没关系工具类本身不依赖分词器只是高亮效果会差一些。3. 分页查询从搭建请求到解析结果3.1 核心骨架SearchRequest 和 SearchSourceBuilderES 的 Java 客户端查询流程说白了就是三步构造 SearchRequest往 SearchSourceBuilder 里塞查询条件和分页参数再把 SearchRequest 交给 client 执行。所有复杂查询都是在这个骨架上叠 buff。我第一次看这个 API 时觉得类有点多但理清楚之后就很简单。SearchRequest searchRequest new SearchRequest(goods); SearchSourceBuilder sourceBuilder new SearchSourceBuilder(); BoolQueryBuilder boolQuery QueryBuilders.boolQuery(); boolQuery.must(QueryBuilders.matchQuery(name, 手机)); boolQuery.must(QueryBuilders.termQuery(category, 数码)); sourceBuilder.query(boolQuery); sourceBuilder.from(0); sourceBuilder.size(10); sourceBuilder.sort(publishTime, SortOrder.DESC); searchRequest.source(sourceBuilder); SearchResponse response restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);这段代码里from表示跳过多少条size表示返回多少条二者配合就是分页。很多人刚接触时会搞混这两个参数的位置总想把pageNum直接传给 ES其实 ES 根本不认识页码它认的是偏移量。页码和偏移量的换算公式是from (pageNum - 1) * pageSize这个换算逻辑应该收在工具类里业务方永远只传第几页。3.2 分页参数校验和排序设计分页看着简单实际要处理的边界情况不少。我在工具类里加了几个基础校验pageNum最小为 1小于 1 直接置为 1。pageSize设置一个上限比如最多 500防止有人一次要 20 万条把内存打爆。from size不能超过索引的index.max_result_window默认是 10000超过会报Result window is too large错误。排序字段可以为空如果为空就不调用sort让 ES 自己按相关度算分排序。排序这块单独抽了个方法处理因为实际业务里排序条件不止一个并且还要兼容字符串数值和数字字段。简化后的工具方法private void buildSort(SearchSourceBuilder sourceBuilder, ListEsSortParam sortParams) { if (CollectionUtils.isEmpty(sortParams)) { return; } for (EsSortParam sortParam : sortParams) { sourceBuilder.sort(sortParam.getField(), SortOrder.valueOf(sortParam.getOrder().toUpperCase())); } }这里EsSortParam就是一个包含字段名和排序方式的简单对象。有一点要注意text 类型的字段默认是不允许直接 sort 的会报Text fields are not optimised for operations that require per-document field data。如果你确实要按照名称排序mapping 里需要给这个字段加一个fielddata: true或者建一个keyword子字段否则就等着报错吧。我一般都是在 mapping 里给需要排序的字段配一个.keyword子字段然后排序时排name.keyword。3.3 结果解析把 SearchResponse 变成可用的 DTOES 返回的SearchResponse结构其实不复杂但嵌套层级深手写解析容易漏掉字段。核心就两个东西response.getHits().getTotalHits()拿总数response.getHits().getHits()拿命中的SearchHit数组。每个SearchHit可以通过getSourceAsString()拿到_source的 JSON 字符串然后用 Jackson 反序列化成对应 DTO。long total response.getHits().getTotalHits().value; ListT list new ArrayList(); for (SearchHit hit : response.getHits().getHits()) { String sourceJson hit.getSourceAsString(); if (sourceJson null) { continue; } T item objectMapper.readValue(sourceJson, clazz); list.add(item); }这里我故意没有直接贴泛型new TypeReferenceT(){}的写法因为泛型方法里直接 new TypeReference 拿到的类型信息往往是空的要么老老实实传ClassT要么传一个TypeReferenceT给工具方法。实际项目中 80% 的场景 DTO 都是纯数据类没有嵌套泛型所以用ClassT最省心。后面我会在完整代码里给出参数设计。EsPageResultGoodsDTO result esSearchUtil.search( goods, boolQuery, 1, 10, null, GoodsDTO.class);调用起来就很干净了业务方写了一大半查询条件都从方法参数传进来。4. 高亮功能配置、解析与坑点4.1 高亮为什么不能只看查询结果搜索场景里用户输入一个关键词最理想的结果是返回的列表里能清晰地看到关键词命中的位置也就是我们常说的高亮。ES 的高亮原理简单说就是查询时不仅返回文档内容还会返回一段经过标记处理的文本。拿match查询命中手机这个词默认会把文档里包含手机的地方截取一段片段然后用你指定的标签把它包起来前端拿到这段 HTML 直接渲染关键词就以特殊样式展示了。所以高亮并不只是给SearchSourceBuilder设置一个highlight字段那么简单还涉及两个关键环节请求时声明高亮配置返回时解析高亮片段。很多新手只做了第一步发现返回的 DTO 里根本没有高亮内容就是因为第二步没写。4.2 高亮器怎么选unified、plain 还是 fvhES 7.x 里常见的高亮器有三种unified、plain和fvh。默认高亮器是unified我大部分场景都用它它对多字段、跨字段查询的支持更均衡内存消耗也相对可控。plain适合字段内容很短、又要求高亮片段必须精确的场景但性能一般。fvh需要索引里存了term_vector它胜在速度快适合大文本、多字段高亮。工具类里我用了默认的 unified没做切换因为大多数业务用 unified 都不会出大问题。如果后续遇到超大字段聚合高亮耗时太长再考虑单独给某个字段指定 fvh。高亮配置的核心参数是三个preTags和postTags决定关键词前后包裹的标签fragmentSize决定每个高亮片段返回多长。我这里统一用em classhighlight和/em包裹前端 CSS 写一个样式就能全局生效。HighlightBuilder highlightBuilder new HighlightBuilder(); for (String field : highlightFields) { HighlightBuilder.Field highlightField new HighlightBuilder.Field(field); highlightField.preTags(em class\highlight\); highlightField.postTags(/em); highlightField.fragmentSize(80); highlightField.numOfFragments(1); highlightBuilder.field(highlightField); } sourceBuilder.highlighter(highlightBuilder);numOfFragments是每个文档最多返回多少段高亮片段设置为 1 表示只回传第一段。如果你的字段很长又想展示多个命中片断可以调大这个值。4.3 高亮解析和回填不能把 DTO 原样返回就完事高亮配置完之后SearchResponse里的每个SearchHit会多出一个getHighlightFields()方法返回值是MapString, HighlightField。这里有个非常容易踩的坑高亮后的文本和原始字段内容是两套数据你必须手动把它们替换进 DTO而不是期望 ES 把原始_source改了。我见过有人查完高亮直接把hit.getSourceAsString()反序列化返回结果前端页面一丁点高亮效果都没有。正确做法是if (hit.getHighlightFields() ! null !hit.getHighlightFields().isEmpty()) { for (Map.EntryString, HighlightField entry : hit.getHighlightFields().entrySet()) { String fieldName entry.getKey(); HighlightField highlightField entry.getValue(); if (highlightField.fragments() ! null highlightField.fragments().length 0) { StringBuilder sb new StringBuilder(); for (Text fragment : highlightField.fragments()) { sb.append(fragment.string()); } setFieldValue(item, fieldName, sb.toString()); } } }这里我用了反射的setFieldValue来把高亮文本写回 DTO 对应的属性。因为工具类不知道业务 DTO 的类型只能用字段名反射赋值。如果嫌反射麻烦也可以在解析时不改 DTO而是把高亮结果放在一个独立的MapString, String字段里返回。两种方案各有利弊反射回填业务方拿到的 DTO 是完整的但反射有性能开销独立 Map 更清晰但前端要额外拼接。我最终选了反射回填理由是这个工具类的使用频率不算特别高一次 10 条文档的反射开销完全可以忽略而业务代码会简洁很多。4.4 高亮字段偶发为空的排查思路这是我最开始使用高亮时遇到的问题某些文档明明包含关键词但highlightFields里就是没有这个字段。排查后发现两个常见原因第一个是跨字段查询下的requireFieldMatch。默认情况下高亮要求查询词命中的字段必须和高亮字段一致。如果用multi_match同时查name和description命中词出现在name而你高亮配置里只写了description那description就不会有高亮。解决办法是把highlightField.requireFieldMatch(false)显式设成 false让高亮器在全部字段里找匹配词。第二个是查询词和字段存储分词不一致。如果这个字段在索引时用ik_max_word拆词而高亮器默认用的是unified加原来的查询条件某些长词的高亮就是会不稳定。遇到这种情况我建议在高亮器里也显式指定分词器例如highlightField.analyzer(ik_smart)让高亮切词更接近真实查询。5. 完整工具类封装和业务接入5.1 先定好请求与返回结构前面说了这么多现在该把工具类完整串起来了。为了让调用方用起来舒服我定义了一个请求对象和一个返回对象。请求对象里除了分页和查询条件还加了可选的排序列表和源字段筛选public class EsSearchRequest { private String index; private BoolQueryBuilder query; private int pageNum 1; private int pageSize 10; private ListEsSortParam sorts; private MapString, Boolean highlightFields; // 字段 - 是否需要requireFieldMatch }返回对象就比较固定了public class EsPageResultT { private long total; private int pageNum; private int pageSize; private ListT list; public static T EsPageResultT of(long total, int pageNum, int pageSize, ListT list) { EsPageResultT result new EsPageResult(); result.setTotal(total); result.setPageNum(pageNum); result.setPageSize(pageSize); result.setList(list); return result; } }这里我把高亮字段定义成MapString, Booleanvalue 表示是否要求字段匹配如果为 false 就调用requireFieldMatch(false)。稍微复杂一点的高亮参数比如每个字段想要不同的 fragmentSize其实也可以扩展成一个对象 Map但大多数场景用不到所以没过度设计。5.2 工具类核心代码工具类本身我用 Spring 的Component管理构造函数注入RestHighLevelClient和ObjectMapper。注意ObjectMapper 用了 Spring Boot 自动配置的那个 Bean而不是new ObjectMapper()这样能保证兼容 Spring 对时间格式的全局配置。Component public class EsSearchUtil { private final RestHighLevelClient client; private final ObjectMapper objectMapper; public EsSearchUtil(RestHighLevelClient client, ObjectMapper objectMapper) { this.client client; this.objectMapper objectMapper; } public T EsPageResultT search(EsSearchRequest request, ClassT clazz) throws IOException { int pageNum Math.max(request.getPageNum(), 1); int pageSize Math.min(Math.max(request.getPageSize(), 1), 500); SearchRequest searchRequest new SearchRequest(request.getIndex()); SearchSourceBuilder sourceBuilder new SearchSourceBuilder(); if (request.getQuery() ! null) { sourceBuilder.query(request.getQuery()); } else { sourceBuilder.query(QueryBuilders.matchAllQuery()); } sourceBuilder.from((pageNum - 1) * pageSize); sourceBuilder.size(pageSize); if (CollectionUtils.isNotEmpty(request.getSorts())) { for (EsSortParam sort : request.getSorts()) { sourceBuilder.sort(sort.getField(), SortOrder.valueOf(sort.getOrder().toUpperCase())); } } if (MapUtils.isNotEmpty(request.getHighlightFields())) { HighlightBuilder highlightBuilder new HighlightBuilder(); for (Map.EntryString, Boolean entry : request.getHighlightFields().entrySet()) { HighlightBuilder.Field field new HighlightBuilder.Field(entry.getKey()); field.preTags(em class\highlight\); field.postTags(/em); field.fragmentSize(80); field.numOfFragments(1); field.requireFieldMatch(entry.getValue()); highlightBuilder.field(field); } sourceBuilder.highlighter(highlightBuilder); } searchRequest.source(sourceBuilder); SearchResponse response client.search(searchRequest, RequestOptions.DEFAULT); return parseResponse(response, clazz, pageNum, pageSize); } private T EsPageResultT parseResponse(SearchResponse response, ClassT clazz, int pageNum, int pageSize) throws IOException { long total response.getHits().getTotalHits() null ? 0 : response.getHits().getTotalHits().value; ListT list new ArrayList(); for (SearchHit hit : response.getHits().getHits()) { String sourceJson hit.getSourceAsString(); if (sourceJson null) { continue; } T item objectMapper.readValue(sourceJson, clazz); fillHighlight(item, hit); list.add(item); } return EsPageResult.of(total, pageNum, pageSize, list); } private T void fillHighlight(T item, SearchHit hit) { if (hit.getHighlightFields() null || hit.getHighlightFields().isEmpty()) { return; } for (Map.EntryString, HighlightField entry : hit.getHighlightFields().entrySet()) { String fieldName entry.getKey(); HighlightField value entry.getValue(); if (value.fragments() null || value.fragments().length 0) { continue; } StringBuilder sb new StringBuilder(); for (Text fragment : value.fragments()) { sb.append(fragment.string()); } try { Field targetField item.getClass().getDeclaredField(fieldName); targetField.setAccessible(true); targetField.set(item, sb.toString()); } catch (NoSuchFieldException | IllegalAccessException ignored) { // DTO里没有对应字段时忽略不影响正常数据返回 } } } }代码看起来不复杂但有几个细节值得说明。第一查询条件为 null 时兜底成了 matchAllQuery避免业务方漏传参数直接空指针。第二分页参数做了夹逼处理pageSize 最大 500防止单次查询过大拖垮集群。第三反射回填高亮时如果 DTO 里没有对应字段我选择静默忽略而不是抛异常否则一个字段名对不上整个查询就挂了。反射虽然慢一点但在这种低频工具方法里完全不是瓶颈。5.3 业务层调用示例工具类写完后业务层的代码缩减得非常干净。我随便贴一段真实业务里的调用public EsPageResultGoodsDTO searchGoods(String keyword, int pageNum, int pageSize) throws IOException { BoolQueryBuilder boolQuery QueryBuilders.boolQuery(); if (StringUtils.isNotBlank(keyword)) { boolQuery.must(QueryBuilders.multiMatchQuery(keyword, name, description)); } EsSearchRequest request new EsSearchRequest(); request.setIndex(goods); request.setQuery(boolQuery); request.setPageNum(pageNum); request.setPageSize(pageSize); MapString, Boolean highlightFields new HashMap(); highlightFields.put(name, false); highlightFields.put(description, false); request.setHighlightFields(highlightFields); request.setSorts(Collections.singletonList(new EsSortParam(publishTime, desc))); return esSearchUtil.search(request, GoodsDTO.class); }这段代码一眼就能看懂业务方只关心要查什么索引、什么条件、第几页、哪些字段高亮剩下的分页偏移计算、DSL 组装、高亮回填全部被工具类吃掉了。我后来接手另一个系统时直接把整个工具类和请求对象拷贝过去连配置都不用改效果非常好。6. 我踩过的坑和排查思路6.1 客户端版本和集群版本不一致导致连接失败刚接入 ES 那年我犯过一个低级错误集群是 7.4.0客户端依赖写的是 7.15.2。启动时没有任何报错但一旦执行client.search()服务端直接返回 HTTP 400错误信息大概意思是 product version mismatch。后来查了官方才知道RestHighLevelClient 会校验服务端版本大版本超过一个版本号就拒绝执行。排查思路其实很简单先看异常堆栈里有没有IncompatibleElasticsearchClientVersionException没有的话再对比本地依赖版本和集群版本。这个问题在多个 ES 集群混用的项目里特别容易出现因为不同的集群可能是不同版本。建议把版本号统一提到配置中心管理并且在建客户端时打一行启动日志把当前客户端版本打出来方便事后核对。6.2 高亮字段偶发为空这个前面讲了一部分这里补充一个我在日志平台场景里遇到的典型案例。当时我们按关键字搜日志正文日志正文很长ES 默认只返回第一个匹配片段。结果用户反馈搜索结果里有些日志整条都看不到关键词很像是数据丢了其实只是高亮片段没截取到关键词所在段落。排查发现是对长文本用默认 fragmentSize 时高亮片段经常从文档开头截如果关键词在文档偏后的位置截出来的第一段就没包含关键词。解决方法是增大 fragmentSize或者设置fragmentSize(200)同时numOfFragments(3)让返回的高亮片段包含更长的上下文。高亮看起来是小事真正调起来却直接影响用户的搜索体验不能只看 DSL 能跑通就完事。6.3 泛型反序列化把 List 搞丢我在第一版工具类里用的是objectMapper.readValue(sourceJson, clazz)这个写法对纯 DTO 没什么问题。但某天有个同事传了一个带泛型的 DTO 进来比如ResultDTOListItemDTO结果解析出来list字段变成了ArrayListLinkedHashMap往下 get 属性直接ClassCastException。原因很简单Java 泛型在运行时会被擦除ClassT拿不到完整的泛型参数。解决方式是在工具方法里额外接收一个TypeReferenceT参数让调用方传入完整类型结构。这样虽然调用稍微麻烦一丢丢但泛型嵌套复杂时就安全多了。public T EsPageResultT search(EsSearchRequest request, TypeReferenceT typeReference) throws IOException { // ... T item objectMapper.readValue(hit.getSourceAsString(), typeReference); // ... }6.4 超过 10000 条的深度分页怎么办ES 的 from size 分页有个硬限制默认max_result_window 10000。也就是说页面深到一定程度就查不了了。我们之前做过一个翻页到第 80 页的列表页直接报Result window is too large。这个报错很短但第一次看到确实容易懵。解决思路有三种第一种是调大max_result_window比如一个索引一个索引地单独配但只适合有把握的场景第二种是换成scroll适合一次性拉全量数据比如数据导出但不适合用户随时翻页第三种是search_after它通过上一页最后一条数据的排序值来定位下一页性能比深分页好很多。工具类里我暂时只实现了基础分页但已经在查询对象里预留了字段后续有深分页需求可以平滑扩展。6.5 空 source 时的 NPE 隐患解析SearchHit时getSourceAsString()在某些情况下会返回 null比如文档没有_source或者查询设置了fetchSource(false)。我一开始没判空严谨测试就发现了。所以工具类里解析数据的循环体最前面直接if (sourceJson null) continue;宁可少返回一条也不能让一个 null 拖垮整个查询。这种细节平时看不到出了问题往往都是线上环境里偶发触发写代码时养成习惯就能少踩很多坑。7. 工具类的后续扩展方向这套工具类目前支撑了我这边好几个项目的检索功能回过头看其实还可以继续扩展。比如加入搜索后排序的二次纠偏比如先按相关度排再按时间排或者加入source 字段过滤让大字段不参与传输减少 IO。更推荐做的是把EsSearchRequest再优化一下支持传入更复杂的嵌套布尔查询结构让工具类不局限于单层多条件的组合。我后来还做了个很简单的查询日志切面在工具方法入口和出口打印索引名、查询耗时和命中数。这些日志在排查慢查询时非常给力直接能看到哪个索引的哪个查询慢不用再靠猜。做搜索引擎相关开发的小伙伴可以加一个类似的统计逻辑。有一点要说明上面所有代码都以 RestHighLevelClient 为底座如果你后续要升级到 Elasticsearch 8.x官方推荐的新客户端 API 差别比较大但工具类的设计思路依然可以沿用换的只是底层执行和解析的部分。这也是我为什么坚持把上层请求对象和返回对象独立出来的原因它们不依赖具体客户端实现未来迁移时能省不少事。如果你正在做项目里的搜索模块我的建议是先不要急着把功能铺开先把这一层工具类写好把分页、高亮、排序这些基础能力沉淀住后面所有业务接入都会很轻松。我自己的体会是写好一个工具类不只是消灭重复代码更重要的是让团队对 ES 的使用方式有了统一规范新同事上手时不需要再翻几十个接口看各自的查询写法。希望这篇分享能帮到你也欢迎你在实际改造中摸索出更适合自己业务的优化方案。
RELATED

相关推荐

STM32空气质量监测系统:嵌入式IoT闭环设计与实战避坑指南

STM32空气质量监测系统:嵌入式IoT闭环设计与实战避坑指南

1. 项目概述:这不是一个“玩具级”空气质量盒子,而是一套可落地的嵌入式物联网闭环系统你手上看到的这个标题——“【开源】基于STM32单片机室内空气质量检测系统 WIFI 机智云”,表面看是几个关键词堆砌,但背后藏着一条完整的工业…

📅 2026/10/14 16:44:42
Windows上搭建SonarQube与Sonar Scanner完整指南

Windows上搭建SonarQube与Sonar Scanner完整指南

在 Windows 上搭 SonarQube,再用 Sonar Scanner 把代码分析跑起来,听起来就是“下载、解压、执行”三步的事。但真动起手来,你会发现版本匹配、数据库选型、扫描器执行路径、项目排除规则这些环节,每一个都能卡你一下。我这篇从零…

📅 2026/10/14 16:44:42
CActor 7.0 完全指南:基于仓颉语言的高性能 Actor 系统架构、API 与实战

CActor 7.0 完全指南:基于仓颉语言的高性能 Actor 系统架构、API 与实战

后端并发编程分布式 【免费下载链接】cactor 项目地址: https://gitcode.com/Cangjie-SIG/cactor 点击查看 免费下载 CActor 是基于仓颉(Cangjie)语言实现的 Actor 并发框架,参考 Akka / Akka Typed 的设计理念,为高并…

📅 2026/10/14 16:44:42
MORE NEWS

更多资讯

📰

数据结构与算法分析Java版习题答案详解:从链表到图的备考攻略

简介:《数据结构与算法分析(Java语言描述)》(第三版)的配套习题答案文档,面向正在学习数据结构、Java 版算法分析与计算理论基础的本科学生及自学者。全文共 1 个 docx 文件,压缩包约 1.52MB&am…

📰

AR开发避坑指南:图像处理、位姿估计与SLAM如何撑起增强现实

拿着一张手机拍的照片,你想把一个3D咖啡杯放到桌面上。听起来只是“放上去”一个动作,但如果你真从零实现一次,会发现整个AR系统至少有八成时间在处理图像:找桌面、估计相机角度、算虚拟物体的透视关系、判断要不要让杯子被手挡住…

📰

GrantForge 2026.1.0 正式发布:开源统一权限管理平台,新增 HDFS、ZooKeeper、MySQL 权限控制与数据脱敏!

最近一直在持续开发和完善 GrantForge,今天正式发布 2026.1.0 版本! GrantForge 是一款基于 Spring Boot、Spring Security 和 Vue 3 构建的开源权限管理平台。 项目最初主要围绕 RBAC(基于角色的访问控制)展开,希望提…

📰

STM32F4 SPI+DMA+FATFS嵌入式SD卡稳定写入方案

简介:本资源是面向嵌入式开发初学者与进阶工程师的STM32F4平台SD卡文件系统实战项目,聚焦SPIDMA高效驱动MicroSD卡并集成FatFS实现标准文件操作,解决嵌入式设备中大容量、可靠存储与读写管理的核心痛点,适用于智能仪表、数据记录仪…

📰

rizzo-pii训练配方全公开:mmBERT微调74.5万行数据,单卡16GB两小时跑通0.3B PII检测模型

【免费下载链接】rizzo-pii Local-first privacy guard: anonymize your documents before sharing with LLMs. 项目地址: https://gitcode.com/gh_mirrors/ri/rizzo-pii 点击查看 免费下载 做本地隐私保护的 rizzo-pii 把它的 PII 检测模型训练配方完整公开了&…

📰

SQL窗口函数速查表:从排名到累计的实战避坑指南

简介:这份《SQL窗口函数速查表》PDF面向数据库管理员、数据分析师、数据科学家及开发人员,尤其适合希望提升复杂数据集查询能力的技术人员。内容按功能与用途分类,系统梳理窗口函数的基本概念、语法结构与参数说明,涵盖ROW_NUMBER…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬