Spring Boot整合Elasticsearch 7实战:数据同步、排序高亮与自动补全 简介面向使用 Spring Boot 与 Elasticsearch 7 的 Java 开发人员提供一套可直接落地的搜索服务整合示例覆盖电商商品检索、内容站内搜索等常见场景帮助解决 ES 数据同步、相关度查询排序、高亮显示和自动补全等业务问题既适合开发参考也适合作为学习实践。压缩包内共 17 个文件以 Java 源码为主辅以 Maven 的 XML 配置、application.yml 环境配置和说明文档整体约 29KB目录划分为 common-elasticsearch 与 main-business 两个模块common 侧可复用基础能力business 侧更贴近实际业务组装便于按功能边界阅读和改造。代码内注释具体从数据同步、查询条件封装到高亮片段处理和补全搜索接口都给出了可供参考的组织方式和实现思路读者可结合自身项目架构灵活调整也能借此加深对 ES 查询 DSL 与 Spring Boot 集成方式的理解。当前共有 10194 人学习下载对于正在搭建搜索模块、优化查询相关度或希望了解 ES 7 与 Spring Boot 整合方式的中高级开发者有较好的借鉴价值。 搜索功能是最典型的“看着简单、做起来全是细节”的模块。之前做一个商品库搜索需求的时候数据量几万条并不算大但要求很具体运营端要按关键词搜出目标商品命中标题的要排前面搜索词要在列表里高亮搜索框输入时还要给联想建议。刚开始用 MySQL LIKE 撑着慢查询越拖越明显后来干脆把搜索层切换到 Elasticsearch 7 上。这篇文章就把 Spring Boot 整合 Elasticsearch 7 的完整过程拆开讲覆盖数据同步、相关度排序、高亮显示、自动补全搜索这几个核心环节中间穿插一些真实踩坑记录。1. 版本选型和基础环境先把这个组合玩明白1.1 为什么是 ES 7 原生 RestHighLevelClient很多人一开始会纠结要不要用 Spring Data Elasticsearch我的建议是如果是做复杂搜索直接用原生 RestHighLevelClient。Spring Data Elasticsearch 的注解确实省事但版本捆绑很头疼。Spring Boot 2.6 对应 Spring Data ES 4.3Spring Boot 2.7 对应 4.4每个版本对 ES 服务的兼容范围是有限的一旦服务端升了小版本客户端行为可能有细微变化排查成本全花在框架封装层上。而且搜索场景绕不开高亮、排序、聚合、补全这些操作用 Spring Data 拼 QueryBuilder 最终还是在拼 JSON反而比原生 Client 多了一层理解负担。至于 8.x不是不好而是现有集群和迁移成本摆在那里。ES 7.17 是 7.x 的最后一个版本也是生产环境验证最充分的版本。RestHighLevelClient 在 7.15 被标记废弃但一直能用到 7.17真正的移除是 8.x 之后的事。Spring Boot 2.x ES 7.17 RestHighLevelClient 这套组合到今天依然是中小项目落地的稳妥路线。1.2 依赖、配置类和容器启动参数Maven 依赖不需要引 spring-boot-starter-data-elasticsearch直接加原生客户端就行dependency groupIdorg.elasticsearch.client/groupId artifactIdelasticsearch-rest-high-level-client/artifactId version7.17.9/version /dependency配置类也很简单Configuration public class EsClientConfig { Bean(destroyMethod close) public RestHighLevelClient restHighLevelClient() { return new RestHighLevelClient( RestClient.builder(new HttpHost(127.0.0.1, 9200, http)) .setMaxConnTotal(100) .setMaxConnPerRoute(50) ); } }有几个细节值得单独拎出来说。9200 是 HTTP 接口9300 是旧的 TCP 传输接口ES 7 里客户端统一走 HTTP别再配置 TransportClient那东西早不维护了。ES 不允许 root 用户直接启动Linux 部署时要专门建普通用户并授权数据目录否则启动直接报错。还有 vm.max_map_count 这个内核参数默认 65530 经常不够至少要设到 262144不然内存映射区会被打爆。JVM 堆内存建议 Xms 和 Xmx 设成一样避免运行期动态扩容带来停顿也不要超过物理内存的一半因为 ES 还要留一部分给文件缓存。1.3 安装 IK 分词器版本不一致会直接启动失败中文搜索场景IK 分词器基本是必装的。下载对应 ES 版本的 zip 包解压之后放到plugins/ik目录重启 ES 即可。版本不一致是最高频的启动失败原因报错类似java.lang.IllegalArgumentException: plugin analysis-ik is incompatible with version [7.17.9]看着很直接但网上很多教程给的 IK 下载地址是旧版本稍不注意就踩中。装完之后最好打开 Kibana Dev Tools 验证一下POST _analyze { analyzer: ik_max_word, text: 华为手机 }能看到“华为”、“手机”这类分词结果就说明工作正常。IK 有两种常用模式索引时用ik_max_word尽量切成最细粒度扩大召回搜索时用ik_smart只做粗粒度切分让查询更精确。索引分词器和搜索分词器是可以分开配置的后面查询相关度这块会用到。2. 数据同步定时增量 全量补偿别让搜索数据“缺胳膊少腿”2.1 四种同步方案怎么选数据同步是搜索系统的地基方案选型决定了后续维护成本。我列了四种常见做法的对比同步方案实时性实现成本适用场景业务双写实时低表少、链路简单定时增量 全量补偿分钟级低中小项目首选Logstash JDBC分钟级低不想写代码Canal MQ秒级高强一致性、大流量这个项目最终选了定时增量 全量补偿。原因很简单商品变更频率不高分钟级延迟完全够用不需要额外维护 Canal 集群、消息队列这些组件代码完全可控出问题可以随时手动触发补偿不用依赖外部链路排查。双写看似简单但意味着所有写 MySQL 的地方都要同步写 ES漏一处就是数据不一致而且双写失败时事务很难处理不推荐作为主方案。2.2 增量同步代码示例与 bulk 细节增量同步的核心思路是记录上次同步时间每次只取这个时间点之后变更的数据。MySQL 表里需要有一个update_time字段查询口径大致如下SELECT id, title, brand, content, sales, update_time FROM product WHERE update_time #{lastSyncTime} ORDER BY update_time ASC LIMIT 1000Java 侧代码public void incrementalSync() throws IOException { Instant lastSyncTime getLastSyncTime(); ListProduct products productMapper.selectByUpdateTime(lastSyncTime, 1000); if (products.isEmpty()) { return; } BulkRequest bulkRequest new BulkRequest(); for (Product p : products) { bulkRequest.add(new IndexRequest(INDEX) .id(String.valueOf(p.getId())) .source(JSON.toJSONString(p), XContentType.JSON)); } restHighLevelClient.bulk(bulkRequest, RequestOptions.DEFAULT); saveLastSyncTime(products.stream() .map(Product::getUpdateTime) .max(Instant::compareTo) .orElse(lastSyncTime)); }这里有两个容易忽略的点。第一ES 里文档的_id是字符串MySQL 的 Long 主键必须转成 String否则会出现类型不一致导致覆盖失败的现象。第二能用 bulk 就不要单条 index单条请求一次网络往返1 万条数据就要 1 万次往返bulk 一次打包发过去性能差距是数量级的。批量大小也要控制单批 1000 到 5000 条或者控制总大小在 5MB 到 10MB 之间。太大容易把 ES 的 JVM 内存顶上去太小又体现不出批量优势。2.3 删除同步和漏数据补偿机制增量同步最常见的问题是删不了数据。update_time只能感知到更新感知不到删除。如果你采用了物理删除ES 里那条文档会永远残留。我见过不少项目就是在这上面翻车的搜索结果里出现一堆库里已经查不到的记录。处理方式有两种。一种是 MySQL 里用逻辑删除标记增量查询加条件deleted 0ES 侧通过 upsert 覆盖标记另一种是每天定时跑一次对账任务把 MySQL 主键集合和 ES 主键集合做差集ES 侧多余的文档删掉。增量还有一个坑同一批次里如果两条记录的update_time完全相同而且恰好跨越了游标边界就会出现重复或遗漏。稳妥的做法是游标保存上一批最大update_time下一批条件改成update_time 上一个游标同时在 ES 侧以主键幂等 upsert 兜底。还有有些批量更新工具默认不更新update_time字段导致数据变了但增量拉不到。所以我的建议是增量负责常规更新全量补偿任务每天凌晨固定跑一次兜住所有漏网数据。两套机制配合数据基本不会缺。3. 查询与相关度排序默认 BM25 之上的业务加权3.1 查询体基础term / match / multi_match 的分词差异ES 查询几个最基础的类型用错的概率反而最大。term不分析查询词适合精确匹配 keyword 类型的字段比如状态、品类 ID。如果拿 term 查 text 字段很容易查不到因为 text 字段索引时已经被分词器拆过了term 拿整个词去倒排索引里找找的是完整词元当然匹配不上。match会先分词再匹配适合文本搜索。比如搜“华为手机”分词后变成“华为”、“手机”只要文档里命中其中一个词就能召回。multi_match是多字段版本的 match搜索词会同时对多个字段打分。最常用的是best_fields模式取所有字段中分数最高的那个作为该文档得分。这正好适合商品搜索的场景标题命中比内容命中更说明相关。可以在字段后面直接加权重QueryBuilder titleQuery QueryBuilders.multiMatchQuery(keyword, title^3, brand^2, content) .type(MultiMatchQueryBuilder.Type.BEST_FIELDS);这个^3的意思是标题字段的匹配得分放大 3 倍品牌放大 2 倍。权重不是随便拍的它取决于业务侧对字段重要性的判断标题 品牌 内容正文是商品搜索里的常见认知。3.2 boost 提升标题权重function_score 引入销量因子业务搜索和纯文本检索最大的区别是除了文本相关度还有很多业务指标会影响排序比如销量、点击率、上架时间。默认排序只看_score文本相关度一卷销量高但标题不够贴合的商品可能排到很后面运营端根本没法用。function_score就是用来解决这个问题的。它可以在基础相关度之上叠加各种得分函数FunctionScoreQueryBuilder functionScoreQuery QueryBuilders.functionScoreQuery( titleQuery, new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{ new FunctionScoreQueryBuilder.FilterFunctionBuilder( ScoreFunctionBuilders.fieldValueFactorFunction(sales) .factor(0.2f) .modifier(ScoreFunctionBuilders.Modifier.LOG1P) ) } ).boostMode(CombineFunction.SUM);这里有两个参数需要解释。factor是权重系数不能设太大否则销量分分钟压过文本相关度搜索结果会被爆款完全主宰长尾商品全沉底。modifier用LOG1P是为了对数压缩销量从 10 涨到 1000得分的增长曲线会越来越平缓不至于让头部销量商品一骑绝尘。最终排序仍然走默认的_score降序只是这个_score的基础相关度和业务因子做了叠加。这就是“相关度排序”的完整含义。3.3 高亮显示标签约定和 fragment 控制高亮显示的原理不复杂ES 在搜索时会对命中的词定位到原文位置用前后标签包起来返回。前端拿到带标签的片段直接用 CSS 控制颜色即可。服务端代码HighlightBuilder highlightBuilder new HighlightBuilder(); highlightBuilder.field(new HighlightBuilder.Field(title) .preTags(span class\highlight\) .postTags(/span) .fragmentSize(30) .numOfFragments(1)); sourceBuilder.highlighter(highlightBuilder);fragmentSize控制高亮片段长度numOfFragments控制最多返回几段。如果搜索词命中的是商品标题不建议把 fragmentSize 设得太小否则标题被截断展示效果全无。高亮有几种常见的“不生效”场景。字段是 keyword 类型时高亮粒度是整词中文场景下基本等于没高亮。字段在 mapping 里设置了index: false倒排索引里根本没有分词结果高亮自然无法定位。_source里没有保留原始字段时fragment 也拼不出来。所以高亮依赖三个前提text 类型、可搜索、_source 可读。还要注意一个 XSS 问题。ES 返回的高亮片段如果直接插进前端页面搜索词里如果带了script标签会被浏览器解析。前端必须对非约定的标签做转义只保留highlight这个 class再做展示。4. 自动补全搜索completion 字段结构的从 0 到 14.1 completion 类型与补全数据设计补全搜索用的是 ES 的completion字段类型底层是 FST有限状态转移机专门为前缀匹配设计查询性能很高。但很多人把它理解成“高级模糊查询”这是错误的。它是一个独立的建议器输入源只做前缀匹配不做全文检索也不具备纠错能力。mapping 里定义一个补全字段{ mappings: { properties: { title: { type: text, analyzer: ik_max_word }, suggest: { type: completion, analyzer: ik_max_word, search_analyzer: ik_smart, preserve_separators: true, preserve_position_increments: true, max_input_length: 50 } } } }max_input_length默认是 50超过这个长度的输入不会被索引不是大问题但这个参数值得知道。关键在于补全数据怎么组装。我的建议是把标题、品牌、拼音、缩写别名一起揉进input数组用weight控制建议排序权重MapString, Object suggestField new HashMap(); suggestField.put(input, Arrays.asList( product.getTitle(), product.getBrand(), pinyinUtils.toFullPinyin(product.getTitle()), pinyinUtils.toShortPinyin(product.getTitle()) )); suggestField.put(weight, product.getSales());这样用户输入“华为”、“huawei”、“hw”都能触发补全。weight 直接取销量字段意味着热销商品的建议排名天然靠前这种“以数据驱动排序”的思路在补全场景下效果很好。4.2 补全查询实现查询代码SuggestBuilder suggestBuilder new SuggestBuilder(); suggestBuilder.addSuggestion(product_suggest, SuggestBuilders.completionSuggestion(suggest) .prefix(keyword) .size(10) .skipDuplicates(true)); SearchSourceBuilder sourceBuilder new SearchSourceBuilder(); sourceBuilder.suggest(suggestBuilder); SearchResponse response restHighLevelClient.search( new SearchRequest(INDEX).source(sourceBuilder), RequestOptions.DEFAULT);返回结果里从suggest节点解析 options 数组每个 option 的text就是补全建议。skipDuplicates(true)的作用是去除重复建议因为同一个 title 可能被多条 input 索引命中不去重的话下拉框会出来一堆重复项。prefix就是纯前缀匹配。用户输入“华”能匹配到“华为”输入“手”能匹配到“手机”。但输入“为手”这种中间错位的词是匹配不到的。completion 不是搜索引擎的纠错模块这一点产品沟通时需要提前说清楚。4.3 中文、拼音与“补全不到”的处理做中文补全最大的问题就是拼音。如果不做任何处理“华为手机”的补全只能靠中文前缀用户输入“huawei”完全没有提示。这在小程序、移动端搜索框场景里非常影响体验。一个方案是装 pinyin 分词器插件用它分析 suggest 字段让补全自动吃拼音。但 pinyin 插件也有自己的问题版本匹配又成了新的维护点拼音匹配会过度泛化“记录”的拼音首字母 jl 会匹配到大量无关词补全质量很难控制。所以我更推荐在组装数据阶段就手工生成拼音串放进 input 数组而不是依赖分词器自动派生。这样虽然多写一个工具方法但所有能触发补全的入口都是明确可控的出问题也好排查。全拼、首字母、中文别名都放进去“华为”、“huawei”、“hw”三个入口覆盖了绝大多数输入习惯。做补全字段还有一个容易忽略的时间点如果项目已经上线mapping 里没有 suggest 字段不能直接 PUT 新增字段到已存在的索引。需要重建索引下面这一节专门聊这个。5. 踩坑实录mapping 变更、中文高亮与深分页5.1 mapping 一旦建好就改不了重建索引的正确姿势ES 的 mapping 和数据库表结构完全不一样。数据库可以 ALTER TABLE 加字段ES 里已经建好的字段类型基本改不了因为底层倒排索引已经生成结构写死了。想改怎么办只能重建索引。标准流程分三步创建新索引product_v2mapping 带上 suggest 字段和所有需要的字段配置。用 reindex 把旧索引数据搬过去POST _reindex { source: {index: product_v1}, dest: {index: product_v2} }用 alias 切换读写入口。之前所有业务代码都写死product索引名现在让product这个 alias 指向product_v2再删掉旧索引。这里的关键是在项目一开始就应该让代码读写 alias而不是索引名。同步程序写入product查询程序读product重建索引时只需要切换 alias 指向业务代码零改动。这是个很便宜的架构决策但能省掉大量上线时的协调成本。5.2 高亮不返回和补全不命中的排查链路这两个问题的排查过程很有代表性我遇到过不止一次。先说高亮不返回。第一次遇到时我先查了查询方式发现没问题是 multi_match。又查了 mapping字段确实是 text 类型。最后定位到问题在_source该字段在 mapping 里被设成了enable: false文档源没有保留ES 拿不到原始文本fragment 自然拼不出来。原因是一开始为了省存储空间做的字段裁剪没想到把高亮功能给裁没了。排查高亮问题我习惯按这个顺序走先确认查询是 match/multi_match 而不是 term再确认字段类型是 text 而不是 keyword最后看_source里有没有这个字段。三步走完基本能定位。补全不命中也有一个典型场景用户输入“华为手机”补全没反应。问题往往出在 analyzer 上。completion 字段的 analyzer 用的是ik_max_word华为手机会被切分成“华为”、“手机”两个词元而 completion 的索引结构是按切分后的词元做前缀匹配的不是按完整输入串。用户输入“华为手机”这个完整短语时前缀匹配入口实际上是“华为”而不是“华为手机”看起来就像没命中。解决思路很简单把完整的 title 也放进input数组按完整字符串构建补全索引而不是依赖分词结果。这也是我前面强调“手工组装 input”的另一个原因。5.3 深分页与客户端连接池的参数取舍ES 默认max_result_window是 10000也就是 from size 超过 10000 会直接报错。这不是配置不够的问题而是 ES 的分布式架构决定了深分页非常昂贵每个分片都要先排序取前 from size 条再汇聚到协调节点重新排序越往后翻成本越高。如果产品确实需要翻页用search_after代替 from/size。它的思路是每次带上上一页最后一条记录的排序字段值往下继续取。这个方案适合无限滚动的交互但不适合跳页。搜索场景下用户很少翻到几十页之后所以这个限制实际影响有限。连接池参数方面setMaxConnTotal(100)和setMaxConnPerRoute(50)这两个值是我在商品搜索场景下的起点配置。QPS 不高时可以调低压测发现连接不够再往上加。ES 端的 search 线程池大小一般不用动默认值足够大多数业务使用。最后再说一个我自己的习惯ES 里永远只存要展示和要搜索的字段数据库那一份完整记录别丢。真到了要改 mapping、要深翻页、要对账的时候你会发现留了数据库这条后路比什么骚操作都安心。本文还有配套的精品资源点击获取