资讯详情

SpringCloud集成ElasticSearch实现多条件查询实战

📅 2026/10/22 13:53:40 | 华诺云谱 👁 阅读
SpringCloud集成ElasticSearch实现多条件查询实战
做了几年后台系统跟搜索筛选死磕过太多次。列表页上一排筛选条件——状态下拉、类型多选、时间范围、金额区间、关键词模糊搜用户随手点几下数据库就开始喘粗气。后来在SpringCloud项目里接了ElasticSearch做多条件查询算是把这块彻底捋顺了。这篇就把我在真实项目里搭建ES多条件查询的全过程写出来从选型理由到依赖版本、索引设计、查询拼装、踩坑记录一条线讲透。1. 为什么多条件查询最终落在了ElasticSearch上1.1 一个容易被数据库拖垮的筛选场景我接手过一个订单类后台项目查询条件大概有十来个订单号、客户名称、商品名称、订单状态、支付方式、下单时间范围、实付金额区间、收货省份有时候还要按商品标签过滤。这种需求用MySQL硬扛的方式通常是两种第一种是WHERE 11后面疯狂拼AND然后靠联合索引去优化。问题在于联合索引的字段顺序一旦和筛选条件的组合方式不匹配索引就废了。用户可能今天只按状态和金额筛明天只按时间筛后天三个条件一起上你根本没办法给所有组合都建索引。第二种是让前端把筛选条件做成动态SQL传到后端再走MyBatis拼接。这个确实灵活但数据量大到百万级以上时LIKE %关键词%这种模糊查询一定会引发全表扫描几个条件叠上去慢SQL告警邮件天天有。我在那个项目里还遇到过更头疼的情况。商品名称是中文用户经常只记得两三个字客户名可能是张也可能是张三订单状态这种枚举字段反而不需要模糊匹配要的是精确过滤。数据库对这种部分字段模糊、部分字段精确、部分字段区间的混合查询天生就不擅长。而ElasticSearch天生就是干这个的。倒排索引解决模糊搜索的性能问题分词器解决中文搜索的体验问题BoolQuery解决多条件组合的问题。把筛选条件全部交给ES数据库只管事务和基础CRUD整个接口的响应时间从原来的两三秒降到了几百毫秒以内。1.2 ES能解决什么解决不了什么先说能解决的多条件任意组合BoolQuery的must、filter、should、must_not可以自由拼装条件的增删不影响查询结构。中文模糊搜索配合IK分词器商品名称里包含某某词的匹配效率和体验都会好很多。范围查询时间区间、金额区间这类需求ES的RangeQuery非常顺手。排序与分页多字段排序比如先按下单时间倒序再按金额排序、高亮返回这些都是ES的常规能力。聚合统计筛选结果按状态分组统计数量、按省份统计订单量一个聚合请求就完成了。再说解决不了的复杂的事务一致性要求别交给ES。需要多表关联的复杂业务查询ES做起来会很别扭得提前冗余字段。实时性要求极高、数据量又小的场景上ES反而是过度设计。我当时的原则是数据库做主数据存储和事务ES做查询索引数据通过消息队列异步同步。这样既保证了事务的可靠性又让查询性能得到了保障。2. 项目基础准备版本、依赖与索引设计2.1 SpringBoot与ES的版本匹配SpringCloud项目里集成ES第一步不是写代码而是确认版本。ES的客户端版本必须和服务端版本保持一致否则会出现序列化字段对不上、查询报错等莫名其妙的问题。我用的组合是组件版本SpringBoot2.4.xSpringCloud2020.0.xElasticSearch服务端7.14.xRestHighLevelClient7.14.xspring-boot-starter-data-elasticsearch2.4.x这里有个坑要提前说Spring Data Elasticsearch和ES服务端版本有兼容矩阵不是随便配的。比如Spring Data Elasticsearch 4.x对应ES 7.x它内部的RestHighLevelClient依赖版本也需要和服务端一致。如果服务端是7.14而starter里带的是7.6运行时会报版本不兼容或者字段解析异常。我在项目里把ES的版本依赖单独做了管理在pom.xml里覆盖Spring Boot BOM默认的es版本properties elasticsearch.version7.14.0/elasticsearch.version /properties dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-elasticsearch/artifactId /dependency顺便说明一下如果你用的是SpringBoot 2.7以上的版本官方更推荐ElasticsearchClient。但当时项目里已经用RestHighLevelClient做了一部分功能迁移成本大就沿用了。如果是新项目直接用新版客户端也没什么问题查询逻辑大同小异。2.2 索引Mapping设计思路ES的索引Mapping就是表结构设计但设计理念和关系型数据库差别挺大。我一开始习惯性地把索引当成SQL表来设计后来发现很多查询问题都是因为Mapping设计不合理导致的。我的原则是被筛选的字段设计成keyword需要模糊搜索的字段设计成text并配置IK分词器时间字段统一成long或者标准date格式金额字段用double或scaled_float。{ settings: { number_of_shards: 3, number_of_replicas: 1, analysis: { analyzer: { ik_analyzer: { type: custom, tokenizer: ik_max_word } } } }, mappings: { properties: { orderNo: { type: keyword }, goodsName: { type: text, analyzer: ik_max_word, fields: { keyword: { type: keyword } } }, customerName: { type: text, analyzer: ik_max_word }, orderStatus: { type: keyword }, payType: { type: keyword }, createTime: { type: date, format: yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis }, payAmount: { type: double }, province: { type: keyword } } } }这里有个很关键的区分keyword和text的区别。keyword是整体匹配适合订单号、状态、省份这种枚举值或精确值term查询走的就是它。text是分词匹配适合商品名称、客户姓名这种需要模糊搜索的字段match查询走的就是它。如果你把商品名设成了keyword那用户搜索白色T恤就搜不到那条白色T恤男款短袖因为keyword不做分词整个字符串必须一模一样才能匹配。反过来如果你把订单状态设成了text用term查询已支付它会发现分词后已支付可能被拆成已和支付导致精确匹配失效。为了让部分字段同时支持模糊搜索和精确匹配我常用上面那种text keyword子字段的方式goodsName主字段用text支持分词搜索.keyword子字段支持精确排序和聚合。3. BoolQuery组合查询多条件的核心拼装逻辑3.1 must、filter、should、must_not的分工ES的多条件查询核心就是BoolQuery。我把它理解成一个可以装各种子条件的容器里面四个主要部分各有分工子查询含义参与评分使用场景must必须满足的条件参与关键词模糊匹配filter必须满足的条件不参与状态、类型、时间、金额范围should至少满足一个可配置参与多字段匹配、可选条件must_not必须不满足的条件不参与排除指定状态刚开始做的时候我把所有条件全塞进must里结果发现一个性能问题must里的每个子查询都会参与相关度评分计算而筛选条件比如状态已支付时间范围本月根本不需要算分。改到filter之后查询快了不少因为filter的结果会被ES缓存下次同样的筛选条件直接走缓存。我的分配逻辑是这样的用户输入的关键词商品名、客户名——放must因为它影响相关度排序。下拉框和枚举值订单状态、支付方式、省份——放filter它们只有是或否的意义。时间范围、金额区间——放filter不需要评分。排除项比如排除退款订单——放must_not。可选组合条件比如客户名或手机号匹配——放should配合minimum_should_match控制最少命中几个。这个分流做对之后不光查询速度快了代码结构也清晰了——每个条件的拼装都是独立的一块别人看代码时能很快对应上业务含义。3.2 条件动态拼装的前置技巧多条件查询最烦的一点是用户不会每次都把所有条件填满。前端传回来的查询对象里可能状态是空的、时间范围是空的、关键词也是空的。代码里就要做非空判断再拼装不然空字符串查出来的结果肯定不对。我见过的两种常见写法第一种是大量if (xxx ! null !.equals(xxx))嵌套条件一多就变面条代码。第二种是每个条件一个方法像流水线一样往容器里塞。这个思路是对的但如果每个方法里都重复写if (StringUtils.isNotBlank(xxx))看起来还是啰嗦。我个人的偏好是把非空判断封装成一个Builder组件对外提供每个条件的set方法内部统一处理空值最后返回构建好的SearchQuery。这样业务层不用关心哪个字段是空的代码也干净不少。另外一个很重要的点是条件之间的且和或。同一个筛选模块里状态选已支付和待发货是或的关系吗不是。用户选了两个状态业务含义通常是这两种状态的都行那在ES里应该用should而不是两个must或者用一个termsQuery。这个细节查出来很难发现。我刚接ES时把多个状态值用两个must塞进去查出的结果永远为空排查了半天才发现是逻辑关系用错了。多值枚举条件直接用termsQuery一行搞定既简洁又符合业务语义。4. 多条件查询的代码落地与边界处理4.1 实体映射与查询构建器环境配好、索引建好终于可以写代码了。我这里演示的是基于RestHighLevelClientSpring Data Elasticsearch的实现方式。先定义索引对应的实体类Data Document(indexName order_index) public class OrderDoc { Id private String id; Field(type FieldType.Keyword) private String orderNo; Field(type FieldType.Text, analyzer ik_max_word) private String goodsName; Field(type FieldType.Keyword) private String orderStatus; Field(type FieldType.Keyword) private String payType; Field(type FieldType.Date, format DateFormat.date_optional_time) private Date createTime; Field(type FieldType.Double) private Double payAmount; Field(type FieldType.Keyword) private String province; }有人会问Document是和索引相关的如果索引已经存在注解里的analyzer配置影响的是写入时的分词吗实际上索引的Mapping以服务端为准注解里声明的analyzer主要是给框架自动创建索引用的。如果你的索引是手动建的生产环境推荐手动建实体类上的注解简化点也完全没问题但indexName和字段类型最好和Mapping保持一致否则查询时字段类型对不上会报错。查询服务里我用NativeSearchQueryBuilder来构建查询条件Service public class OrderSearchService { private final ElasticsearchRestTemplate restTemplate; public OrderSearchService(ElasticsearchRestTemplate restTemplate) { this.restTemplate restTemplate; } public PageResultOrderDoc search(OrderQuery query) { if (query null) { query new OrderQuery(); } BoolQueryBuilder boolQuery QueryBuilders.boolQuery(); // 1. 关键词多个字段模糊匹配 if (StringUtils.isNotBlank(query.getKeyword())) { boolQuery.must(QueryBuilders.multiMatchQuery(query.getKeyword(), goodsName, customerName)); } // 2. 订单状态多值精确匹配 if (CollectionUtils.isNotEmpty(query.getOrderStatusList())) { boolQuery.filter(QueryBuilders.termsQuery(orderStatus, query.getOrderStatusList())); } // 3. 支付方式单值精确匹配 if (StringUtils.isNotBlank(query.getPayType())) { boolQuery.filter(QueryBuilders.termQuery(payType, query.getPayType())); } // 4. 时间范围支持只给起止之一 if (query.getStartTime() ! null || query.getEndTime() ! null) { RangeQueryBuilder rangeQuery QueryBuilders.rangeQuery(createTime); if (query.getStartTime() ! null) { rangeQuery.gte(query.getStartTime()); } if (query.getEndTime() ! null) { rangeQuery.lte(query.getEndTime()); } boolQuery.filter(rangeQuery); } // 5. 金额区间闭区间 if (query.getMinAmount() ! null || query.getMaxAmount() ! null) { RangeQueryBuilder rangeQuery QueryBuilders.rangeQuery(payAmount); if (query.getMinAmount() ! null) { rangeQuery.gte(query.getMinAmount()); } if (query.getMaxAmount() ! null) { rangeQuery.lte(query.getMaxAmount()); } boolQuery.filter(rangeQuery); } // 6. 排除退款订单 boolQuery.mustNot(QueryBuilders.termQuery(orderStatus, REFUNDED)); // 组装分页、排序 PageRequest pageRequest PageRequest.of(query.getPage(), query.getSize()); NativeSearchQuery searchQuery new NativeSearchQueryBuilder() .withQuery(boolQuery) .withPageable(pageRequest) .build(); SearchHitsOrderDoc searchHits restTemplate.search(searchQuery, OrderDoc.class); ... } }这段代码的要点我标注几个状态字段用termsQuery一次传入多个枚举值不需要多层must语义也更清晰。时间范围和金额区间可以只传一个端点用户可能只选了开始时间没选结束时间代码里要做条件判断不能非空校验未通过直接把整个条件扔掉。mustNot用来做排除如果需求是不包含已退款订单这是典型的负向条件放mustNot里。所有查询条件都是动态拼装的用户传什么就拼什么不传就不拼。4.2 分页、排序与高亮的整合多条件查询通常还需要配合分页、排序和高亮。分页直接用PageRequest就行排序通过Sort指定。排序这里有个细节如果你要对goodsName这种text字段排序必须要用它的keyword子字段否则ES会报fielddata is disabled on text fields之类的错。可以在构建查询时指定Sort sort Sort.by(Sort.Direction.DESC, createTime) .and(Sort.by(Sort.Direction.ASC, goodsName.keyword));高亮需求也很常见。用户在搜索框输入了关键词查询结果里需要把命中的词标红。ES的高亮是在查询时指定的HighlightBuilder highlightBuilder new HighlightBuilder(); highlightBuilder.field(goodsName) .preTags(em classhighlight) .postTags(/em);配合NativeSearchQuery的withHighlightBuilder方法返回结果里就可以拿到高亮片段。高亮的坑在于如果关键词是IK分词后的结果高亮可能只命中部分分词片段比如搜白色T恤返回的高亮可能只有白色被包上了标签。前端拿到高亮片段时得做好兜底——如果没有高亮内容就显示原始字段不然页面上一片空白标签很突兀。4.3 空值、边界与异常处理多条件查询还有一个容易忽略的地方参数的边界校验。分页参数page不能小于0size一般限制在100以内防止有人一次拉取10万条把内存打爆。时间范围如果startTime endTime直接返回空结果或者抛参数异常别让ES去处理这种前后颠倒的范围。关键词长度ES对超长字符串的检索性能不高我一般限制关键词在50字符以内超过就只截取前面部分。特殊字符搜索词里如果出现了ES查询语法的特殊字符比如 - || ! () {} [] ^ ~ * ? : \ /matchQuery本身是全文检索问题不大但如果用了QueryStringQuery就得注意转义。异常处理方面ES查询超时和连接池耗尽是我见过最多的两类问题。在微服务里调用ES一定要设置超时时间我一般配置connectTimeout和socketTimeout避免下游故障时线程被长期占用。在Spring配置里可以这样写spring: elasticsearch: rest: uris: - http://es-node1:9200 - http://es-node2:9200 connection-timeout: 3s read-timeout: 5s超时时间的设置逻辑很简单后端接口的响应时间要求是多少ES的读超时就比这个要求短一点。如果接口要求800ms返回你给ES设5秒超时一旦ES出问题接口就跟着崩。把ES的读超时压到和接口要求相匹配能够快速失败而不是慢性拖垮整个链路。5. 上线后踩过的坑从数据对不上到性能告警5.1 term查不到数据的类型映射坑上线第一周就遇到一个诡异问题。用户按订单号精确查询ES里明明有这条数据但termQuery(orderNo, SO20240110001)死活查不出来。排查过程一步步来先查索引Mapping发现orderNo字段类型是text而不是keyword。这就解释了问题text字段写入时会经过分词器处理订单号可能被分词了term查询是精确值匹配拿完整订单号去匹配一个被拆碎的字段自然匹配不上。这个坑在前期建索引时就应该规避。所有需要精确匹配、排序、聚合的字段必须是keyword类型。如果你不确定字段是干什么用的宁可用text keyword子字段的共存结构这样match查询和term查询都能用。还有一个相似场景数字类型的ID也不要直接映射成long再用来做term查询。ES里long字段做term查询本身没问题但如果前端传过来的是字符串JSON序列化时类型对不上也会出现查询失效。我后来统一把ES的主键和业务标识都设成String类型省掉一堆麻烦。5.2 分页深度过大引发的内存飙升ES默认的from size分页方式有个限制默认只允许查询到第10000条。这个限制是为了保护内存因为每次查找都要在协调节点上汇总所有分片的结果。刚开始没当回事后来运营同事要从导出功能里拉大数据量数据from一下被拉到几万ES直接报了Result window is too large的错误。遇到这种场景有两类解法场景推荐方案要导全量数据需要翻很多页用scrollAPI可以理解成生成一个数据快照游标用户在前端逐页翻每页100条超过前三页之后改用search_after或限定只能查前100页我在项目里给导出功能单独做了scroll处理。核心思路是第一次请求返回一个scrollId后续请求带上scrollId继续取下一页数据直到取完为止。注意scroll上下文会占内存用完一定要清掉。对于前台分页我通过产品规则限制了最多只能翻100页。这不仅仅是ES的限制问题也是用户体验问题——正常用户翻不到100页就放弃了能翻到100页之后的多半是爬虫或干扰访问。5.3 时区问题导致的日期偏差8小时ES默认存储时间用的是UTC而业务上用户选择的时间是东八区。如果你写入时传的是2024-01-15 10:00:00ES理解成10点UTC那你按本地时间8点到20点去查结果对不上。我当时有两个选择写入时统一转换成UTC时间再存查询时也把本地时间转成UTC。不存字符串时间统一存epoch_millis毫秒时间戳。我最终选了方案2。long类型的毫秒时间戳没有时区概念写入时前端传什么就存什么查询时传范围毫秒值即可在数据展示时再转成东八区字符串。有人可能会说那如果服务端或者客户端统一转UTC也行啊。理论上是但一旦团队里有其他服务往同一个索引写数据时忘了转换8小时偏差就会混进数据。时间戳方案从根上消除了这个隐患。5.4 关键词查询结果不如预期分词粒度问题IK分词器有ik_max_word和ik_smart两种粒度。ik_max_word会把文本做最大切分比如中华人民共和国可能切成中华人民共和国中华人民中华华人人民共和国人民等多个词ik_smart则只切出最粗粒度的词。业务上搜索商品名、客户名时我推荐ik_max_word因为它召回率高。代价是索引体积变大、查询可能会有一些无关结果。如果不接受过多的无关命中可以在MatchQuery里加operator(Operator.AND)要求所有分词都命中才算匹配。这个属于搜索体验调校的范畴。没有绝对正确的答案要在召回率和精确率之间反复测试。我的实践是默认用ik_max_word召回结果然后通过排序把匹配度最高的排前面如果用户的反馈是搜出来一堆不相关的再考虑收紧条件。6. 性能优化与运维细节让查询服务长期稳6.1 把可缓存的筛选条件放进filter上下文前面讲BoolQuery的时候已经提到filter上下文不参与相关度评分所以ES可以把它的查询结果缓存起来。这在多条件组合查询里效果非常明显。我做过一个对比实验订单列表页的固定筛选条件状态已支付、时间最近30天用filter构建后相同条件的查询在ES的query cache命中下P99从120ms降到30ms左右。而关键词搜索因为分词后每次的词组不同缓存命中率偏低这部分性能优化主要靠减少额外计算。这个优化几乎是零成本的只需要在代码里注意确定不用计算相关度的条件一律不要放must。这也是我在代码评审时必查的点。6.2 慢查询日志与警告阈值ES慢查询日志是排查线上问题的重要抓手。我习惯在索引配置里把慢查询阈值调低一点方便尽早发现问题{ index.indexing.slowlog.threshold.index.warning: 200ms, index.search.slowlog.threshold.query.warning: 500ms, index.search.slowlog.threshold.fetch.warning: 500ms }调完配置后ELK日志里能看到所有超过阈值的查询语句配合着看哪些查询一下打满CPU、哪些查询扫描文档数异常庞大。有一次接口响应无故变慢我查慢查询日志发现某个条件组合查出了几万条document但是排序字段在text字段上导致sort非常吃力。这跟之前提到的goodsName.keyword排序问题一脉相承。改完字段接口恢复正常。监控比优化更重要。优化做得再好如果没有慢查询日志和指标监控下一次性能恶化的苗头就很难在早期发现。6.3 集群容量与索引分片设计索引分片数设计也是个值得提前想清楚的事。分片数太少单分片数据量过大查询和写入都可能成为瓶颈分片数太多每个分片的开销和协调成本又会增加。经验值是单个分片数据量控制在20-40GB左右。如果你预估索引一年数据量200GB那设置5-8个分片比较合理。这个决策要趁早因为ES虽然支持分片拆分但重建索引的迁移成本不低。副本数方面生产环境至少1既能高可用又能分担读请求。但是副本也是一种磁盘和内存开销测试环境可以设成0。6.4 数据同步别让业务代码直接把数据库数据写进ESES里的数据来源项目里最常见的做法是业务操作MySQL之后通过消息队列异步把数据变更同步到ES。这里要注意同步的延迟和一致性。如果要求写入MySQL后立即能搜到可以通过本地消息表确保最终一致性如果允许秒级延迟简单的MQ监听即可。前者逻辑复杂但数据可靠后者简单但可能在极端情况丢消息。我在项目里用的是MQ广播方案订单在MySQL落库后发一条消息监听器收到后根据订单ID查MySQL组装成OrderDoc写入ES。写入走Bulk批量方式攒了一批再提交降低IO压力。这个方案落地时有一个容易踩的坑ES写入失败后不能直接丢弃要补偿重试。当时的做法是失败消息进入重试队列最多重试三次三次仍然失败就记录到失败表配合告警人工介入。7. 总结下来的一些实际体会整个SpringCloud项目接入ES多条件查询的过程走下来我的感受是ES的门槛不在API使用而在于你是否理解每个字段在检索语义下应该怎么设计。同样一段查询代码在Mapping设计合理的索引上运行很轻松在Mapping设计随意的索引上就会四处踩坑。建议新项目在写任何查询代码之前先花半天时间把索引Mapping推敲一遍哪些字段参与匹配哪些字段参与筛选哪些字段需要排序和聚合哪些字段只做展示。这个设计做扎实了后面的查询代码就会非常顺畅。另外ES查询的调试能力值得花时间掌握。我常用来排查问题的工具是Kibana的Dev Tools直接写DSL验证查询结果。等DSL在Kibana里调试通过再翻译成Java代码出错的概率会小很多。如果反过来先在Java里堆代码出问题后再一点点排查效率会比较低。最后多条件查询这个需求ES并不是唯一的解法。如果你的数据量只有几万条、并发也不高MySQL完全够用。ES最大的价值是在数据量上来之后依然能保持稳定的毫秒级响应但这也意味着你要额外维护一套集群。到底是引入ES还是优化数据库索引建议先拿真实数据量做压测对比别被ES能解决一切搜索问题的说法带偏。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑