做搜索服务这件事我一开始其实是拒绝的。业务表里就几千条数据一个LIKE %关键词%就能搞定的事何必引入一套搜索引擎后来数据量涨到几十万、上百万用户开始要求“搜得快”“搜得准”“搜出来能按价格销量排序”LIKE查询直接慢到怀疑人生我才意识到搜索不是查询搜索是一项独立的技术活。而这个技术活在Java后端落地时最常见的姿势就是标题里写的这个组合——基于Spring Boot构建Elasticsearch搜索服务。这次就聊聊我用Elasticsearch 6.8版本在Spring Boot项目里搭搜索服务的完整过程包括版本怎么选、索引怎么设计、搜索链路怎么写、同步怎么搞以及那些不跑几次线上环境根本发现不了的坑。1. 技术选型解析为什么是ES 6.8 Spring Boot1.1 版本选择的利弊权衡先说版本。标题里写的是Elasticsearch 6.8这个版本选得其实很有讲究。ES 6.8是6.x系列的最后一个版本处于“维护期”内很多生产环境团队在7.0刚出来那会儿是不敢直接升的。6.8带来的一个关键变化是它内置了免费的基础安全特性虽然我们用得不多但对一些有内网隔离需求的项目来说这个版本比早期6.0-6.5更像一个“可以安心收尾”的版本。另外Spring Boot这边也要对上号。6.8配套的Java客户端是elasticsearch-rest-high-level-client:6.8.x对应的Spring Boot版本通常用2.2.x或2.3.xJDK用8。这一套组合在当前的存量项目里非常常见——老项目要升级搜索能力但又不想连Spring Boot一起升级那么6.8就是那个“改造成本最低”的版本。你非要在一套Spring Boot 2.7的项目里强行用ES 6.8的high-level client也不是完全不行但客户端和ES服务端的版本必须严格对应不然请求序列化会出各种匪夷所思的错。选6.8而不是7.x还有个现实原因团队成员对6.x的API更熟。SearchRequest、SearchSourceBuilder、BoolQueryBuilder这些核心类在6.8和7.x之间的使用方式几乎没变但6.8里还可以用TYPE概念去兼容老代码习惯。虽然官方已经建议大家一个索引只保留一个type了而且后续版本会移除type但在存量系统过渡期6.8给了你足够的缓冲时间。1.2 三种Java客户端选型对比Spring Boot项目接ES常见的选择有三条路老牌的TransportClient、第三方封装的Jest、官方主推的RestHighLevelClient。我直接说结论6.8时代就选RestHighLevelClient不要犹豫。拿TransportClient来说它走的是TCP协议早期很多项目用它是因为API原生、性能好、可以直接传QueryBuilder对象。但它在6.8已经被标记为deprecated官方计划在7.0直接移除。为什么会废弃TransportClient要维护一套跟服务端完全一致的版本协议ES版本一升级客户端就得跟着升升级成本极高而且它和集群节点之间是长连接节点挂了还要自己做故障转移这些底层逻辑交给框架往往比自己做靠谱得多。Jest是第三方HTTP客户端当初流行是因为它不依赖ES版本、底层用HttpClient代码写起来也接近REST风格。但Jest的更新节奏始终慢于官方API6.8时期的新特性、新的RestHighLevelClient已经能完全覆盖Jest的全部功能而且官方客户端的异步支持和连接管理是经过官方团队验证的没必要用第三方再包一层。RestHighLevelClient的优势在于官方维护、基于HTTP协议、天然跨语言无版本耦合严格上还是需要版本匹配的、支持同步异步、内部封装了连接池。它把ES的REST API封装成了一个一个的Java方法我们在代码里写的SearchRequest、SearchSourceBuilder本质上就是在拼一个JSON格式的查询DSL只是通过对象的方式表达出来而已。1.3 搜索服务的整体数据流与职责边界在动手写代码前还有一件很重要的事搞清楚搜索服务在整个系统里的位置。典型的架构是这样的业务数据落在MySQL或其它主存储同步程序把需要搜索的字段搬到ES里搜索服务只负责“读”ES的数据并返回结果给前端不负责写主库、不负责事务、不负责数据一致性。数据同步可以是定时任务的全量重建也可以是监听binlog的增量同步。搜索服务本身是无状态的可以水平扩展承受高并发搜索请求。这种“主存储 检索副本”的模式和缓存服务的定位非常像。ES承担的不是数据权威而是检索能力。它解决的核心问题是在千万级数据里实现毫秒级的召回、排序和聚合。想清楚了这条链路后续的索引设计、查询优化、同步策略就都有了依据。2. 索引设计与工程落地2.1 Mapping规划字段类型与分词策略ES里的“索引”可以理解成关系型数据库里的“表”而Mapping就是表的“Schema定义”。我习惯在设计阶段就把Mapping写出来而不是让ES靠第一次写入数据时自动生成——自动生成的字段类型经常不合预期比如把价格映射成了text把时间映射成了keyword后面再改Mapping要重建索引代价极大。以一个典型的商品搜索场景为例我会这么规划字段{ mappings: { _doc: { properties: { id: { type: keyword }, title: { type: text, analyzer: ik_max_word, search_analyzer: ik_smart }, category: { type: keyword }, brand: { type: keyword }, price: { type: double }, stock: { type: integer }, sales: { type: integer }, tags: { type: keyword }, create_time: { type: date, format: yyyy-MM-dd HH:mm:ss||epoch_millis } } } } }这里有几个关键选择id用keyword而不是long。搜索服务的id往往作为业务主键回查远端服务用keyword避免了隐式类型转换而且ES内部对keyword的索引和查询更可控。title用text但单独设置分词器。text字段会被分词这样搜索“手机壳”时命中的文档更多。但注意aggregation和sort不能直接作用于text所以如果需要对标题做排序或聚合要么加一个title.keyword子字段要么用其它keyword字段来排序。category、brand、tags用keyword。这些字段不参与全文检索只做精确过滤和分组聚合keyword类型天然适合这种场景而且底层用doc_values优化聚合性能比text快得多。2.2 中文分词IK分词器的接入与实战在中文场景下ES内置的标准分词器基本不能用。它会按单个汉字或者按空格分搜“苹果手机”可能被拆成“苹”“果”“手”“机”召回率是高了但精准度惨不忍睹。所以中文项目基本都会引入IK分词器。IK分词器是ES的一个插件安装方式极其简单下载对应版本的elasticsearch-analysis-ik包解压到ES的plugins/ik目录下重启ES即可。但这里有一个常见的版本坑ES和IK插件的版本必须严格一致6.8.0对应6.8.06.8.1对应6.8.1差一个小版本都可能加载失败。接入IK后在Mapping里指定分词器{ settings: { analysis: { analyzer: { default: { type: ik_max_word } } } } }一般建议索引分词组用ik_max_word最细粒度拆分尽可能召回搜索分词用ik_smart最粗粒度提高命中精准度。所以我在Mapping里为title字段设置title: { type: text, analyzer: ik_max_word, search_analyzer: ik_smart }这样做的原因是同样搜“中华人民共和国”索引时拆成“中华人民共和国/中华人民/中华/华人/人民共和国/人民/共和国”等一堆词而查询时只拆成“中华人民共和国”一个词能够在召回率和精准度之间取得平衡。IK还支持自定义词典把行业内的专有名词、品牌词、人名加进去避免被误拆。比如“小米手机”如果不加词典可能被拆成“小米”和“手机”这其实没问题但如果你有“小米之家”这种专有名字就得手动加词典了。2.3 Spring Boot中的客户端配置与Bean管理服务端装好了、Mapping想清楚了接着是Spring Boot这边的工程接入。我在项目里的做法是在application.yml里维护ES连接信息然后写一个配置类构建RestHighLevelClient的Bean。spring: elasticsearch: uris: http://192.168.1.10:9200,http://192.168.1.11:9200 socket-timeout: 30s max-conn-per-route: 100 max-conn-total: 300Configuration public class ElasticsearchConfig { Bean public RestHighLevelClient restHighLevelClient(Value(${spring.elasticsearch.uris}) String uris) { HttpHost[] hosts Arrays.stream(uris.split(,)) .map(String::trim) .map(host - host.split(:)) .map(parts - new HttpHost(parts[0], Integer.parseInt(parts[1]), http)) .toArray(HttpHost[]::new); RestClientBuilder builder RestClient.builder(hosts); builder.setRequestConfigCallback(requestConfig - requestConfig.setConnectTimeout(30000).setSocketTimeout(30000)); builder.setHttpClientConfigCallback(httpClientBuilder - { httpClientBuilder.setMaxConnTotal(300); httpClientBuilder.setMaxConnPerRoute(100); return httpClientBuilder; }); return new RestHighLevelClient(builder); } }为什么要把连接池参数调出来而不是用默认值因为ES的_search接口在Java服务端高并发消费场景下默认连接数很容易不够导致大量请求排队等待。我在一个项目里就把max-conn-per-route从默认50提到了100高峰期查询丢弃率直接降了一个量级。还有一点RestHighLevelClient内部用的是Apache HttpClient所以如果你项目里也依赖了HttpClient注意版本冲突。6.8对应的RestHighLevelClient依赖的是httpclient 4.5.x如果Spring Boot里被别的依赖拉低了版本建议显式指定版本。3. 核心搜索功能实现细节3.1 多字段综合查询与布尔组合搜索服务的核心功能肯定不是简单地调一个matchQuery就完事。实际业务基本都是“关键词 多个过滤条件 排序规则”的组合。这里最常用的是BoolQueryBuilder。我来展示一个综合搜索商品的方法public SearchResponse searchProducts(String keyword, String category, Double minPrice, Double maxPrice, String brand, int page, int size) throws IOException { BoolQueryBuilder boolQuery QueryBuilders.boolQuery(); // 1. 多字段全文检索 if (StringUtils.hasText(keyword)) { boolQuery.must(QueryBuilders.multiMatchQuery(keyword) .field(title, 3.0f) .field(tags, 1.5f) .field(brand, 1.0f) .type(MultiMatchQueryBuilder.Type.BEST_FIELDS)); } else { boolQuery.must(QueryBuilders.matchAllQuery()); } // 2. 过滤条件走 filter不计分 if (StringUtils.hasText(category)) { boolQuery.filter(QueryBuilders.termQuery(category, category)); } if (StringUtils.hasText(brand)) { boolQuery.filter(QueryBuilders.termQuery(brand, brand)); } if (minPrice ! null || maxPrice ! null) { boolQuery.filter(QueryBuilders.rangeQuery(price) .gte(minPrice null ? 0 : minPrice) .lte(maxPrice null ? 999999 : maxPrice)); } SearchSourceBuilder sourceBuilder new SearchSourceBuilder(); sourceBuilder.query(boolQuery); sourceBuilder.from((page - 1) * size); sourceBuilder.size(size); sourceBuilder.sort(sales, SortOrder.DESC); sourceBuilder.sort(_score, SortOrder.DESC); SearchRequest request new SearchRequest(product_index); request.source(sourceBuilder); request.types(_doc); // 6.8兼容写法 return highLevelClient.search(request, RequestOptions.DEFAULT); }这里有几个点值得展开。multiMatchQuery的field权重可以大幅影响搜索结果。比如标题字段权重是3标签是1.5品牌是1那么一条标题里包含“苹果手机”的文档比分标签里命中“苹果手机”的文档得分更高。这比单纯在title里搜索要灵活得多。boolQuery.must和boolQuery.filter的区别是must会参与相关性打分filter只做过滤、不参与打分。过滤条件数量多的时候尽量把范围查询、term查询放到filter里ES对filter查询有缓存机制性能能提升一个档次。这一点非常实用一定要养成习惯。request.types(_doc)在6.8里是必需的。ES 6.x一个索引只能有一个type通常统一叫_doc如果你不指定type官方客户端虽然能默认补上但明确写出来在排查问题时更清晰。3.2 高亮显示的正确姿势电商搜索、文章搜索基本都要求关键词高亮。ES原生支持高亮不需要另外的组件。HighlightBuilder highlightBuilder new HighlightBuilder(); highlightBuilder.field(title) .preTags(em class\highlight\) .postTags(/em) .fragmentSize(100) .noMatchSize(50); sourceBuilder.highlighter(highlightBuilder);代码倒是简单但实际返回的结果里highlight字段里的内容是HTML片段而且因为分词粒度问题经常出现高亮片段被截断、显示不完整的情况。我的调优经验是fragmentSize设得太小比如30会导致高亮内容太短用户看不清上下文设得太大比如200在移动端展示又太长。我一般取100左右。noMatchSize是当匹配内容不足fragmentSize时把完整原文片段返回避免高亮区域空着。这个值建议设置成和fragmentSize一样大。高亮默认会对命中的词做各种HTML转义如果你的标题本身包含、、这类字符返回的高亮内容最好统一走一遍HtmlUtils.htmlEscape再输出到前端防止XSS。我踩过一次很深的坑前端传过来的关键词带了特殊符号ES分词之后命中了标题里的关联词结果高亮内容里出现了未闭合的HTML标签前端直接解析崩溃。后来我在服务端统一对高亮片段做了消毒处理才解决。3.3 排序方案相关性分数与业务字段的取舍ES默认按_score相关性排序。但实际业务里“卖得好的东西排前面”往往比“文本匹配最像的排前面”更能满足用户预期。所以真实项目中通常会混合排序先用业务字段排序再用相关性分数做次级排序。我常用方案是sourceBuilder.sort(SortBuilders.fieldSort(sales).order(SortOrder.DESC)); sourceBuilder.sort(SortBuilders.scoreSort().order(SortOrder.DESC));这样搜索结果先按销量倒序销量相同再按相关度排序。如果希望相关度优先就反过来。还可以用function_score做更复杂的加权计算销量高的加权价格太低的降权上架时间近的加权等等。QueryBuilder boostedQuery QueryBuilders.functionScoreQuery(boolQuery) .add(QueryBuilders.termQuery(sales, 0), ScoreFunctionBuilders.weightFactorFunction(0.2f)) .boostMode(CombineFunction.SUM);比如我们希望搜索结果“卖过0件的商品权重降低”就可以用上面的方式把销量为0的商品得分乘以0.2。函数打分功能很强大但也要注意不要让算法过于复杂否则线上排查问题和调优都很难动手。3.4 分页方案别只盯着from sizeES的from size分页在数据量小的时候没问题但一旦from size超过10000ES会直接报错Result window is too large。这是ES 6.x版本的默认设置。原因很简单ES需要在多个分片上把每个分片的前fromsize条数据全部取出来做全局排序from太大会导致内存和CPU暴涨。所以分页方案要根据业务场景选择分页方式适用场景注意事项from size结果集小于10000的页面简单直接超过10000会被拒scroll大批量数据导出、后台处理维护scroll上下文有额外开销不适合用户交互search_after无限深度分页、实时性要求高的页面不支持随机跳页只能一页页往下翻我做用户端搜索时一般直接限死用户只能翻前200页每页20条也就是4000条以内这完全在from size的安全范围内。但如果做管理后台的“全部商品导出”一定要用scroll否则导到一半就报错。scroll的用法是先用SearchRequest设置scroll参数拿到第一次结果后得到一个scrollId然后循环用scrollId取下一页结果取完主动clearScroll释放资源。search_after的原理是记录上一页最后一条数据的排序值下一页查询时只取“排序值大于这个值”的数据。它比scroll更轻量但需要排序字段唯一或者加一个_id做tie-breaker否则同排序值的记录会漏掉或重复。4. 数据同步与索引维护4.1 全量同步与增量同步方案搜索服务里的数据归根到底是主数据库的“备份”所以怎么保持两边数据一致性是搜索服务能不能上线跑的关键。我在项目里用了两套方案并行。全量同步通过定时任务每天凌晨跑一次把MySQL里的全量数据拉到ES重建索引。这个任务的本质就是写一个批量读取 批量写入的跑批作业注意控制每批写入的量我一般设置1000条为一个batch避免单次bulk请求过大。增量同步用时间戳实现业务表里维护一个update_time字段定时任务每隔几分钟捞一次“update_time大于上次同步时间”的数据然后写入或更新到ES对应文档。这个方案的核心是每次同步完记录这次的最大update_time作为下一次同步的起点。如果在业务里直接对每次同步的结束时间做记录可能会漏掉同步过程中新产生的数据。有没有更实时、更准的方案有就是监听MySQL binlog。通过binlog获取到增删改事件的原始数据再同步到ES。这个方案实时性最好秒级延迟但引入的成本也很高需要额外的消息队列、监听组件比如类似canal这样的中间件或者自研解析binlog模块。如果业务对搜索的“新鲜度”要求极高比如动态、新闻类产品那值得投入如果只是普通商品、文章搜索采用“定时全量 短周期增量”就够了。4.2 索引别名与平滑重建索引设计上有一个我强烈推荐的好习惯对外永远不直接访问索引名而是通过别名访问。这么做的原因非常简单一旦你想调整Mapping比如给字段换分词器、加字段类型直接改现有索引通常是不可行的。正确的方式是重建一个新索引老索引不删通过“原子切换别名”的方式让搜索请求从老索引瞬间切到新索引。具体流程是创建一个新索引比如product_index_v2把新的Mapping写入。全量导数据到product_index_v2。数据核对没问题后执行一个“删除别名 添加别名”的原子操作把别名product_search从旧索引切到新索引。确认运行稳定后再删除旧索引。Java端用IndicesAliasesRequest实现IndicesAliasesRequest request new IndicesAliasesRequest(); AliasAction addAction new AliasAction.Add(product_search, product_index_v2); AliasAction removeAction new AliasAction.Remove(product_search, product_index_v1); request.addAliasAction(removeAction); request.addAliasAction(addAction); highLevelClient.indices().updateAliases(request, RequestOptions.DEFAULT);这样做的最大价值是搜索服务代码里永远只需要写别名product_search至于背后是v1还是v2与业务代码无关。升级索引对线上用户来说完全无感知。我每次调整搜索相关配置都用这套流程从没出过岔子。5. 性能调优与常见问题排查5.1 写入性能优化bulk批量导入与refresh间隔很多人在开发环境里一条一条写入数据到了生产环境还这么写那就完蛋了。ES写入的性能关键在于批量。BulkRequest可以一次性发送多条写请求Java端实现也简单BulkRequest bulkRequest new BulkRequest(); for (ProductDoc doc : batchDocs) { bulkRequest.add(new IndexRequest(product_index, _doc, doc.getId()) .source(toJson(doc), XContentType.JSON)); } BulkResponse response highLevelClient.bulk(bulkRequest, RequestOptions.DEFAULT);如果对写入的实时性要求没那么高比如做全量同步可以把索引的refresh_interval调大比如从默认的1s改到30s或-1禁用。这样索引在写入期间不会反复refresh磁盘IO开销大幅下降bulk导入速度能明显提升。同步任务结束后再把它调回默认值。5.2 内存、GC与堆设置ES是个吃内存的组件这里有一个核心原则给ES分配的内存不要超过32G而且尽量不要超过物理内存的一半。为什么ES底层用LuceneLucene会用堆外内存做文件缓存。如果给ES JVM堆配置过大反而会挤压系统文件缓存的空间。JVM堆超过32G后JVM会放弃指针压缩对象引用会多占内存性能反而下降。所以一个常见的配置是机器32G内存给ES堆分配16G到31G之间剩余内存留给OS文件缓存。ES节点在启动时会自动读取ES_HEAP_SIZE环境变量或jvm.options里配置的-Xms和-Xmx。注意这两个值必须设置成一样避免JVM运行时动态扩展堆导致Full GC。另外如果搜索服务的查询QPS很高建议对ES节点开启mlockall防止内存被换出。5.3 连接池与超时报错排查RestHighLevelClient这边连接池和超时时间配置是首查项。我在实践里见过很多“搜索服务偶尔超时”的问题排查到最后基本都是HttpClient的socketTimeout设置过短默认的30秒在高负载状态下确实可能不够用导致请求还没等到ES响应就被客户端掐断了。超时时间也不是越长越好。生产环境我一般设置为连接超时10秒请求超时30秒如果30秒还没响应说明ES端查询太重了这时候真正要做的不是加大超时而是优化查询或扩容节点。socketTimeout设太长会大量堆积等待中的线程最终拖垮整个Java应用。实际排查超时问题时建议先看ES节点的thread_pool.search队列。ES 6.x中每个节点默认的搜索线程池队列是1000如果队列堆积明显说明查询处理不过来这时优先考虑优化查询复杂度、增加副本、或降低单次查询的size。5.4 分页、聚合与字段类型的连环坑最后整理几个高频踩坑做成速查表给各位参考问题现象根因解决方案查询报Result window is too largefromsize超过默认1万改用search_after或调大max_result_window对text字段做聚合或排序报错text默认不支持sort/aggregation改用keyword子字段或设置fielddatatrue搜索时中文单词被拆得七零八落没有用IK分词或词典缺失安装IK加载自定义词典查询term搜不到数据字段被分词器拆碎了精确查询对keyword字段或用keyword子字段分页深了耗内存fromsize全局排序开销大改用search_after方案bulk写入大量失败mapping字段类型不匹配查看error里的cause逐个修正数据后再重跑刚开始用的时候最容易迷惑的就是term和match的区别。term是不分词精确查询match会先把查询词分词再匹配。我见过太多新同事拿term查text字段搜出0条结果然后把锅甩给ES。记住text走matchkeyword走term这个原则能帮你避免一半的搜索事故。从我自己的实践看基于ES 6.8 Spring Boot做搜索服务真正难的并不是把它们接在一起而是把“如何规划索引”“如何设计查询”“如何保持同步”“如何排查性能问题”这一整套搜索思维建立起来。先把最基础的一个索引、一个multi_match查询跑通再一步步把过滤、排序、高亮、聚合加进去最后补上同步和运维你就能在Spring Boot体系里撑起一个实战级的搜索服务了。最后分享一个小技巧也是我后来一直沿用的所有搜索引擎相关的配置包括索引名、别名、分析器名都统一整理成一个常量类。命名不要随手写万一前后端对接或排查问题时对不上“那个索引到底叫什么”你会花上一下午的时间去抓狂。建搜索服务时麻烦一点用起来才会趁手很多。
阅读完成 · 觉得有帮助?