简介:面向使用 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.3,Spring 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> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> <version>7.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(); List<Product> 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 = 0,ES 侧通过 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 到 1
4.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控制建议排序权重:
Map<String, 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_v2,mapping 带上 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、要深翻页、要对账的时候,你会发现留了数据库这条后路,比什么骚操作都安心。
本文还有配套的精品资源,点击获取