先说结论:SpringBoot 整合 ElasticSearch 这事本身不难,难的是你把 ES 当成数据库用、还是当成搜索引擎用。我见过太多人一上来就写查询代码,结果 mapping 设计一塌糊涂,索引重建了三次,最后被线上数据教做人。这篇内容我会完全从一个没接触过 ES 的 Java 后端视角来写,把环境搭建、依赖引入、核心概念、CRUD、复杂搜索、以及最要命的版本兼容和 mapping 设计全部串起来,最后再给几个我实际踩过、而且你在官方文档里找不到的坑。
1. 为什么要在 SpringBoot 项目里接一个 ElasticSearch
1.1 ES 到底解决了什么问题
先别急着看依赖和注解,你得先搞清楚 ES 在你的项目里扮演什么角色。很多业务系统前期数据量不大,select * from t_product where name like '%手机%'完全够用。但一旦表数据过了百万行,而且 where 条件里同时要处理分词、多字段匹配、拼音搜索、聚合统计、以及“搜索”和“筛选”的混合逻辑,MySQL 的 like 查询基本就废了。
我在实际项目中遇到过一个典型的场景:一个电商后台的商品管理模块,商品表 200 万行。运营想在前端搜索框里输入“红色的苹果手机”,期望结果是把标题里包含“苹果”和“手机”的商品排在最前面,同时“红色”作为规格属性参与过滤,而且能按销量、价格、上架时间做各种排序。这种需求用 SQL 真的写起来非常痛苦,而且查询性能完全不可控。
ElasticSearch 在这个场景下的价值就体现出来了。它是一个基于倒排索引的分布式搜索引擎,文本会被分词、建立索引,查询时直接走索引结构而不是全表扫描。搜索和聚合能力非常强,而且天然支持水平扩展——ES 集群里的索引分片可以分散到多台机器,数据量大了加节点就行,应用层不需要改什么代码。
1.2 关系型数据库做搜索的瓶颈
我这几年帮好几个团队做过技术优化,发现大家一开始对 ES 都有同一个误区:以为 ES 是数据库的替代品。其实不是,ES 更准确的定位是“搜索与聚合层”,和数据库之间是协作关系,数据主存储还是在 MySQL 或者 PostgreSQL 里。
举个具体例子,商品的价格、库存、状态这些强一致性的数据,必须放在数据库里,数据库的事务和持久化机制才是保命的。而商品标题、详情描述、分类路径、品牌名这些文本型数据,需要分词、需要相关性评分、需要高亮命中片段,这些能力才是 ES 的主场。两者通过 MQ 或者定时任务做同步,各司其职。
MySQLLIKE '%keyword%'最大的问题是索引失效,一旦无法命中索引,查询就会退化成全表扫描。哪怕你数据库性能再强,几百万张表的模糊匹配也能把 CPU 打满。更不用说中文分词的问题——MySQL 默认的模糊匹配根本不理解“红苹果”和“苹果红”是同一个意思。
ES 的倒排索引结构我一说你就懂:ES 会把文档通过分词器拆分成一个个词项,比如“红色的苹果手机”会被拆成“红色”“红的”“苹果”“手机”等词项,然后记录每个词项出现在哪些文档里。查询的时候,用户输入的关键词也会被分词,然后去倒排索引里找匹配的文档,再通过相关度评分算法排序返回。这个机制天然就是为搜索设计的。
1.3 结合我的经验:什么时候该上 ES
我的建议是把这类情况归纳成三个特征,你对照一下:
- 查询条件里包含文本分词、模糊匹配、多字段匹配,并且需要相关度排序。
- 需要在大数据量下做实时的聚合分析,比如“统计每个品牌下有多少商品在 100-200 元价位段”。
- 有复杂的组合筛选场景,比如电商筛百家姓、筛选颜色、多层级类目交叉,同时还要按多个字段排序。
如果三个特征都不占,那老老实实用数据库就行,别为了用 ES 而上 ES。ES 是有运维成本的,集群监控、分片调优、冷热数据分离这些都是额外负担。我的习惯是:先用 MySQL 把业务跑起来,等数据量和查询复杂度真的上来了,再在索引数据层引入 ES,把搜索业务隔离出去。
2. 整合前必须搞懂的 ES 核心概念
2.1 索引、文档、映射,你要用数据库思维去类比
如果之前完全没接触过 ES,第一关就是理解它的数据模型。其实你拿关系型数据库做类比就能秒懂:
- 索引(Index)大约等于数据库里的表。
- 文档(Document)大约等于表里的一行记录,但它是 JSON 格式,结构可以比较灵活。
- 映射(Mapping)大约等于表结构定义,声明字段的类型、分词方式等。
- 分片(Shard)是索引数据被物理切分到多个节点的块,这个是你分布式扩展的基础。
把这几条记在心里,后续看 API 就不会懵了。ES 返回的搜索结果里,_index是索引名,_id是文档唯一 id,_source是原始内容的 JSON 结构。你在 SpringBoot 代码里操作 ES,本质上就是在处理 JSON 文档。
2.2 分词器的选择和最容易被坑的 keyword 与 text 区别
很多人写 ES 查询发现结果不对,十有八九是没搞清楚字段类型。ES 里的文本字段最基本的就是text和keyword两种。
text类型会经过分词器处理,存的是词项,搜索时也会先分词,适合全文搜索。keyword类型则不分词,存的是完整字符串,适合精确匹配、聚合、排序。同一个字段值,比如商品标题“全新 iPhone 15 Pro”,如果你把它设置成text,搜“15 Pro”可能命中;设置成keyword,就必须传完整值“全新 iPhone 15 Pro”才能精确匹配。
这里有一个非常隐蔽的坑:有些字段希望两种情况都有。我的做法是定义一个text字段用于搜索,同时给它指定一个keyword子字段用于精准匹配和聚合。这种设计在商品搜索场景非常常用,比如类目筛选、品牌聚合,都需要用 keyword 子字段。
中文项目还有个关键点:ES 自带的标准分词器对中文是按字拆分的,比如“苹果手机”会被拆成“苹”“果”“手”“机”,搜“苹果手机”还算能匹配到,但搜“手机苹果”或“红苹果手机”时,相关度就很差。生产项目里一般会用ik_max_word分词插件。
2.3 ES 的读写响应流程帮你调试时定位问题
还有一个容易让新手傻眼的点:ES 写入是一条文档刚index完,立刻去搜索可能查不到,需要等刷新。ES 默认 refresh 间隔是一秒,意味着文档写入后最多一秒内才能被搜索到。
线上做数据同步时,我从 MySQL 同步到 ES 如果用了批量写,写完立刻查,发现数量对不上,就很容易误以为丢了数据。其实不是丢了,是还没 refresh。ES 的写入是先进内存 buffer,然后定期生成 segment,再让 segment 可见。这个机制意味着 ES 并非强实时搜索,而是“近实时”——通常在一秒级别。
查询时 ES 会把请求广播到索引的所有分片,每个分片本地执行查询并返回结果,再由协调节点合并。如果集群数据量很大,深分页和全字段排序也会变慢,这些后续在性能优化部分细说。
3. 环境准备与版本选型
3.1 安装 ElasticSearch 与 Kibana:别在版本上自找麻烦
做什么事都要先看版本兼容。SpringBoot 2.x 时代和 SpringBoot 3.x 时代,ES 客户端的使用方式有很大区别。我可以直接用最常见的搭配作为示例:SpringBoot 2.7.x + ElasticSearch 7.17.x,这个组合最稳定,资料也最多。
ES 7.x 和 8.x 有个重要差异:8.x 默认开启了安全认证,需要用户名密码才能访问,这对本地测试非常不友好,还得处理 TLS 证书,很多新手在这一步就开始烦躁了。如果你就想先快速跑通流程,我强烈建议先用 7.x。我自己给团队搭的本地开发环境一直是 7.17,稳定,跟 8.x 的 API 差距没有想象中大。
知道为什么我会强调版本吗?因为 ES 的 Java 客户端版本和服务器版本如果不兼容,会出现各种诡异报错,比如java.io.IOException: http://localhost:9200/、Failed to deserialize等。这类问题在本地开发环境尤其明显,明明服务端是 8.11,代码里用的是 7.17 的客户端,连接可能成功,但序列化协议对不上,查数据的时候直接抛异常。
3.2 ElasticSearch 的本地安装步骤
ES 本身依赖 Java 环境,但从 7.x 开始 ES 自带了一个 JDK,不用我们自己配置 JAVA_HOME。我很想提醒大家:ES 不能在 root 用户下启动,否则会直接报错。这个机制是为了安全,但很多初学者第一次都挂在这一步。
我的建议是新建一个普通用户来跑 ES。安装步骤非常简单:下载 tar 包,解压到目录,修改config/elasticsearch.yml配置network.host: 127.0.0.1,然后切换普通用户执行bin/elasticsearch。启动完成后访问http://localhost:9200,如果返回一个包含tagline字段的 JSON,说明启动成功。
Windows 用户直接解压 zip,运行bin/elasticsearch.bat,注意同样不能有中文路径问题。如果启动报错内存不足,修改config/jvm.options里的-Xms和-Xmx,默认 1G,本地测试建议调小到 512m。
Kibana 不是必须的,但强烈建议安装。可视化界面里可以直接调试 ES 查询 DSL,看到返回 JSON,还能看索引 mapping 和分片状态。调通 SpringBoot 的代码之前,先拿 Kibana 验证查询语句,效率能翻一倍。
3.3 SpringBoot 项目里需要哪些依赖
如果你用的是 SpringBoot 2.7,引入依赖时用:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency>这个 starter 会帮助你自动装配ElasticsearchRestTemplate、ElasticsearchOperations等核心 Bean。注意:SpringBoot 2.x 使用的底层客户端是RestHighLevelClient,不过 Spring Data Elasticsearch 已经帮你封装好了,不推荐直接操作底层客户端,除非你有特殊需求。
SpringBoot 3.x 情况不同,它使用的 ES 客户端从RestHighLevelClient改成了ElasticsearchClient,API 风格也变化较大。如果你现在新起一个项目,建议直接上 SpringBoot 3.x + ES 8.x,但如果你以前没接触过,先用 2.7 + 7.17 这条路线更容易理解概念和运行机制。
3.4 application.yml 的核心配置
接好依赖之后,在配置文件里加:
spring: elasticsearch: uris: http://127.0.0.1:9200 connection-timeout: 3s socket-timeout: 5s在 2.x 版本里配置项经常有人写错,注意是spring.elasticsearch.uris,不是spring.elasticsearch.rest.uris(那是 Spring Data 早期版本的用法,后来属性被简化了)。如果你用的版本较老,以你引入的 starter 实际生效的配置项为准,可以在 IDE 里直接看application.yml的自动提示。
如果 ES 开启了安全认证,还需要配置用户名密码。本地测试没开就直接留空。
4. 代码落地:SpringBoot 整合 ElasticSearch 实操
4.1 实体类设计与索引映射定义
代码从哪写起?直接定义一个实体类,对应 ES 索引里的文档。
假设我们要做一个商品搜索模块,实体类这样定义:
import org.springframework.data.annotation.Id; import org.springframework.data.elasticsearch.annotations.Document; import org.springframework.data.elasticsearch.annotations.Field; import org.springframework.data.elasticsearch.annotations.FieldType; @Document(indexName = "product", createIndex = true) public class Product { @Id private String id; @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_max_word") private String title; @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_max_word") private String description; @Field(type = FieldType.Keyword) private String brand; @Field(type = FieldType.Keyword) private String category; @Field(type = FieldType.Double) private Double price; @Field(type = FieldType.Integer) private Integer stock; @Field(type = FieldType.Keyword) private String status; // getter / setter 省略 }这里有几个关键信息要讲透。@Document注解里的indexName是索引名,createIndex = true表示如果索引不存在,Spring Data 会自动创建索引。生产环境我一般会设置为 false,因为生产索引的 mapping 通常需要 DBA 或架构师精心设计,代码自动创建的 mapping 往往不够细致,比如没有为 title 字段添加 keyword 子字段,或者没有天理设置分片数。
@Field注解里的analyzer是写入时使用的分词器,searchAnalyzer是查询时使用的分词器。中文场景下我建议都用ik_max_word,这样文本会被尽可能细粒度地拆词,搜索匹配率更高。
FieldType.Keyword字段用于品牌、类目、状态这些需要精确匹配和聚合的字段。如果商品品牌需要既被搜索又支持聚合,可以设计成:
@Field(type = FieldType.Text, analyzer = "ik_max_word") private String brand; @Field(type = FieldType.Keyword) private String brandKeyword;或者更优雅的方式是使用fields参数,在主字段brand上挂一个子字段brand.keyword:
@Field(type = FieldType.Text, analyzer = "ik_max_word", fields = { @InnerField(suffix = "keyword", type = FieldType.Keyword) }) private String brand;这种方式在 Kibana 里看到的字段名就是brand和brand.keyword,一个用于搜索,一个用于精确匹配和聚合,非常灵活。
4.2 数据访问层:Repository 接口
Spring Data Elasticsearch 最大的爽点在于:你只需要定义一个接口,继承ElasticsearchRepository<T, ID>,就能自动获得一系列 CRUD 方法。比如:
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; import org.springframework.stereotype.Repository; @Repository public interface ProductRepository extends ElasticsearchRepository<Product, String> { List<Product> findByTitle(String title); List<Product> findByBrandAndStatus(String brand, String status); List<Product> findByPriceBetween(Double min, Double max); Page<Product> findByCategory(String category, Pageable pageable); }这些方法是 Spring Data 根据方法名自动生成的查询,约定非常强:findBy后面跟字段名,And、Or、Between、OrderBy等关键字拼接成查询条件。这种语法对简单的场景非常高效,不用手写任何查询 DSL。
但如果你稍微接触一下生产需求就会发现,简单的字段相等或范围查询根本不够,我们需要的是全文搜索、多字段匹配、地理距离排序、布尔组合查询、高亮。这类场景下 Repository 方法就显得不够灵活,你需要一个更强大的工具:ElasticsearchRestTemplate。
4.3 核心搜索功能:用 ElasticsearchRestTemplate 做复杂查询
自定义复杂查询我是直接用ElasticsearchRestTemplate的。它是 Spring Data Elasticsearch 提供的模板类,封装了 ES Rest 客户端的核心操作,让你以 Java API 的方式构建原生查询。简单说,你平时在 Kibana 里写的 JSON 查询 DSL,在 Java 里都可以通过 QueryBuilders 一步步构建。
我们直接用商品搜索这个场景来写一个复杂搜索:用户在前台搜索框里输入“手机”,需要同时匹配商品标题和描述,并且只返回在售商品,价格在 3000-8000 元之间,按销量降序,同时需要高亮标题里的命中关键词。
import org.elasticsearch.index.query.BoolQueryBuilder; import org.elasticsearch.index.query.QueryBuilders; import org.elasticsearch.search.sort.FieldSortBuilder; import org.elasticsearch.search.sort.SortOrder; import org.springframework.data.domain.PageRequest; import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate; import org.springframework.data.elasticsearch.core.SearchHits; import org.springframework.data.elasticsearch.core.query.NativeSearchQuery; import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder; import org.springframework.stereotype.Service; import java.util.List; import java.util.stream.Collectors; @Service public class ProductSearchService { private final ElasticsearchRestTemplate restTemplate; public ProductSearchService(ElasticsearchRestTemplate restTemplate) { this.restTemplate = restTemplate; } public List<Product> searchProduct(String keyword, Double minPrice, Double maxPrice, String brand, int page, int size) { BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); // 多字段匹配关键词 if (keyword != null && !keyword.isEmpty()) { boolQuery.must(QueryBuilders.multiMatchQuery(keyword, "title", "description")); } else { boolQuery.must(QueryBuilders.matchAllQuery()); } // 价格区间过滤 if (minPrice != null || maxPrice != null) { boolQuery.filter(QueryBuilders.rangeQuery("price") .gte(minPrice == null ? 0 : minPrice) .lte(maxPrice == null ? Double.MAX_VALUE : maxPrice)); } // 品牌过滤 if (brand != null && !brand.isEmpty()) { boolQuery.filter(QueryBuilders.termQuery("brand", brand)); } // 只搜索在售商品 boolQuery.filter(QueryBuilders.termQuery("status", "ON_SALE")); NativeSearchQuery searchQuery = new NativeSearchQueryBuilder() .withQuery(boolQuery) .withPageable(PageRequest.of(page, size)) .withSorts(new FieldSortBuilder("salesCount").order(SortOrder.DESC)) .withHighlightBuilder(createHighlightBuilder("title")) .build(); SearchHits<Product> searchHits = restTemplate.search(searchQuery, Product.class); return searchHits.getSearchHits().stream() .map(hit -> { Product product = hit.getContent(); if (hit.getHighlightFields().containsKey("title")) { List<String> highlights = hit.getHighlightFields().get("title"); if (!highlights.isEmpty()) { product.setTitle(highlights.get(0)); } } return product; }) .collect(Collectors.toList()); } private org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder createHighlightBuilder(String field) { org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder builder = new org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder(); builder.field(field); builder.preTags("<font color='red'>"); builder.postTags("</font>"); return builder; } }这段代码还原了我日常写搜索功能的真实流程。很多人可能一开始不理解boolQuery.must和boolQuery.filter的区别,这里有个性能关键点:must会影响相关度评分,filter部分只做过滤、不参与评分,而且可以被缓存,性能更好。所以别什么都塞进must,无关关键词匹配的过滤条件放进filter里是更科学的做法。
这个套路在工作中极其常见:用户输入关键词,系统做多字段全文搜索,然后用筛选条件对价格、品牌、状态做过滤,最后按业务字段排序。这种组合查询用BoolQueryBuilder一个类就搞定了,ES 的灵活之处恰恰在这里。
4.4 数据写入与更新:save 和 bulk 批量操作
数据写入用 Repository 的save方法就够了。如果数据量比较大,比如日志、订单、或商品同步的数据一次性来几千条,一条条 save 性能会很差,必须用批量操作。
Spring Data Elasticsearch 提供了saveAll方法:
public void saveAllProducts(List<Product> products) { productRepository.saveAll(products); }底层会走批量写入,性能比循环单条 save 好很多。但如果数据量达到了百万级,还要考虑用 ES 的bulkAPI 进行分批写入。我通常每次批量 5000 条,加一点延迟,避免对 ES 集群造成太大压力。
商品数据从 MySQL 同步到 ES,我给出一个我自己实践过的同步伪代码流程:
- 从 MySQL 查询出一批需要同步的数据,每次比如 1000 条。
- 把数据库实体对象转换成 ES 文档实体类。
- 调用
saveAll批量写入 ES。 - 记录同步位点,比如商品 ID 或更新时间。
- 定时任务循环执行,直到拉取的数据为空。
这种同步方案非常适合业务和搜索倒排之间的近实时同步。如果需要实时性更高,可以用 MQ 监听 binlog 变更,把增删改事件消费后写入 ES。
4.5 删除和更新操作背后的一个细节
删除文档直接用repository.deleteById(id)。如果是按条件批量删除,可以用deleteBy自定义方法,比如:
@DeleteQuery void deleteByStatus(String status);更新操作我一般不用 update 方法,而是直接 save 一个新文档。ES 底层本身支持 upsert,相同_id的文档写入会覆盖旧文档,所以简单场景下直接 save 就行。但要注意 ES 的更新不是真正意义上的原地更新,新写入的文档版本号会增加,旧版本会被标记删除,之后再后台清理。
5. Mapping 设计与索引维护
5.1 为什么线上环境不能依赖 @Document 自动建索引
我见过好几次线上事故,都是因为生产环境开了createIndex = true,新加了一个字段,结果当前版本的 mapping 里没定义,所有查询结果都缺这个字段。ES 的字段在没有强 mapping 约束时是动态映射的,你写入一个 JSON 字段,它会自动推测类型,比如“2012”会被映射成long,“2012-12-12”会被推测为date。这种自动推测在生产环境是一种隐患。
所以我把代码里的@Document(indexName = "product", createIndex = false)设成 false,然后通过 Kibana 的 Dev Tools 工具手动创建索引:
PUT /product { "settings": { "number_of_shards": 3, "number_of_replicas": 1, "analysis": { "analyzer": { "ik_analyzer": { "type": "custom", "tokenizer": "ik_max_word" } } } }, "mappings": { "properties": { "id": { "type": "keyword" }, "title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_max_word" }, "brand": { "type": "keyword" }, "category": { "type": "keyword" }, "price": { "type": "double" }, "stock": { "type": "integer" }, "status": { "type": "keyword" } } } }手动创建的好处非常明显:你可以精确控制每个字段的 type,可以自己定义分析器,细化分片和副本数。而且一旦创建完成,mapping 中的字段类型不能被修改,除非新建一个索引再 reindex,所以在一开始就设计好,能大幅降低后期维护成本。
5.2 全量重建索引的正确操作
“改字段类型”是所有人最头疼的。ES 不支持直接修改已有字段的 mapping 类型,你需要走“新建索引 -> reindex -> 别名切换”的流程。我在生产环境操作过一次商品索引的字段类型调整,步骤大概是:
- 新建一个索引
product_v2,mapping 按新设计创建。 - 用 POST
_reindex把product的数据复制到product_v2。 - 给
product_v2创建别名product_alias,应用层读取这个别名。 - 确认数据量和搜索效果没问题后,删除旧索引。
尽量别直接改代码里的indexName,因为那会导致旧索引的查询全部失效。专业的做法是使用索引别名。别嫌这个过程繁琐,正是因为 ES 对 mapping 变更非常保守,才保证了线上数据结构的稳定性。
5.3 mapping 设计里最常见的设计误区
关于字段类型选择,我再给一张我脑子里一直有的对照表:
| 字段类型 | 适用场景 | 常见误区 |
|---|---|---|
| text | 全文搜索、分词匹配 | 拿它做精确匹配和聚合,导致结果不对 |
| keyword | 精确匹配、聚合、排序 | 拿它做中文全文搜索,搜不出部分结果 |
| long/integer/double | 数值范围、排序、聚合 | 把金额用 keyword 存,聚合变成字典序 |
| date | 时间范围、排序 | 用字符串存日期,排序结果完全不对 |
| object | 嵌套对象、数组 | 复杂嵌套结构没考虑扁平化,导致查询语义错误 |
拿商品价格举例,如果存成 keyword 类型,范围查询虽然能做(因为 ES 会把它当字符串做字典序比较),但排序出来的结果是“1万”在“2千”之前的荒谬结果。所以数值字段一定要用数值类型。
5.4 字段是否需要 store 和 doc_values
写 mapping 时还有两个别人容易忽略的属性:store和doc_values。
默认情况下 ES 的_source里存了完整文档,查询时可以返回原文。store设置为 true 是把该字段单独存储。如果某字段只需要聚合、排序而不需要原文返回,可以关掉_source级别的字段存储,把doc_values开起来。这些优化在大字段、大索引场景下非常有用,比如日志索引里存了很长的 message 字段,但聚合只用 status 字段,那就可以对 status 开 doc_values,对 message 优化 _source。
低基础的朋友可能记不住doc_values这个概念,我一句话解释:它是在列式存储上构建的排序和聚合数据,不用从原始文档里解析 JSON,速度更快,但会额外占磁盘。开启之后,ES 用该字段做排序、聚合、脚本计算时效率会高很多。
6. 分页、排序与深度分页实战
6.1 普通分页与 from/size
ES 的普通分页和 MySQL 类似,用from和size:
PageRequest pageRequest = PageRequest.of(0, 10);这种分页方式在数据量几百、几千的时候没问题,但一旦页码大了,性能就直线下降。深度分页,比如第 10000 条往后的数据,ES 需要把每个分片上的前 10000 + size 条数据全部取出来,然后在协调节点合并排序,最后才返回有效数据。这个过程对内存的消耗非常大,接口响应时间也会变长。
我在某个后台管理系统里就遇到过这种问题:运营点了第 500 页翻看数据,结果接口直接超时。当时并没有好的解决办法,只能限制最大翻页深度。
6.2 Search After 实现实时深度翻页
如果业务真的需要深度分页,ES 官方推荐的方式是search_after。它的原理是利用上一页最后一条文档的排序值,作为下一页查询的起点,避免每次从零开始扫描。Spring Data Elasticsearch 支持直接传入searchAfter参数。
这里注意一个限制:使用search_after必须至少指定一个确定性的唯一排序字段,一般是_id或者带时间的timestamp,而且排序值必须唯一才能保证不重不漏。
业务的习惯做法是:
- 第一页查询,正常返回数据和本次使用的 search_after 值。
- 请求下一页时,把上一页最后的 search_after 值带上。
这种方式不适用于那种用户跳页的场景,它更像是“无限滚动加载”。适合商品列表的“加载更多”按钮,而不适合页签形式跳页码。
6.3 聚合分析如何写代码
ES 的聚合能力非常强,库存商品按品牌聚合统计、按价格区间分桶统计,都是常用场景。我直接给出一个聚合代码示例,统计商品库中各类目下的数量:
import org.elasticsearch.search.aggregations.AggregationBuilders; import org.elasticsearch.search.aggregations.bucket.terms.TermsAggregationBuilder; import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate; import org.springframework.data.elasticsearch.core.aggregation.AggregatedPage; import org.springframework.data.elasticsearch.core.query.NativeSearchQuery; import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder; public void aggregateCategoryCount() { TermsAggregationBuilder aggregation = AggregationBuilders.terms("category_count") .field("category.keyword") .size(50); NativeSearchQuery query = new NativeSearchQueryBuilder() .withQuery(QueryBuilders.matchAllQuery()) .addAggregation(aggregation) .withPageable(PageRequest.of(0, 1)) .build(); AggregatedPage<Product> page = restTemplate.queryForPage(query, Product.class); // 从 page.getAggregations() 中取出 category_count 聚合结果 }这种聚合查询在电商后台的“商品分布统计”“类目大盘”等需求里非常实用,原先用 SQL 需要 group by 多条语句,ES 一次就出结果。
7. 常见的问题与排查技巧实录
7.1 版本不兼容问题
我接触过的项目里,版本不兼容是高发问题。ES 客户端和服务器之间的兼容性有严格要求。SpringBoot 2.7 默认支持的 ES 版本是 7.15 左右,如果你服务器装的是 7.17,虽能工作,但官方对 major version 不同组合的支持并不完全保证。最让人头疼的是 SpringBoot 2.6 配 ES 7.9 和 SpringBoot 2.7 配 ES 7.17 的序列化细节不一致,排查起异常真的很费劲。
排查方法很简单:先确认 SpringBoot 版本,再确认 Spring Data Elasticsearch 版本,再到官方文档查版本兼容矩阵。如果你发现spring-boot-starter-parent的版本管理里声明的 ES 版本和你本地安装的不一致,需要显式指定。
最让我无语的一个问题是:某个项目里 SpringBoot 2.7 的 starter 自动引入了 ES 7.15 的客户端,但服务端是 7.17,某些查询 DSL 不能使用。后来我用了版本覆盖才解决。再次强调:自行搭建环境前,先核对版本。
7.2 启动时报 Unable to parse response body 或 URI 相关错误
这类错误通常跟连接配置或者依赖版本有关。第一步先确认spring.elasticsearch.uris配置的是否能访问——在浏览器里直接打开http://127.0.0.1:9200,能返回 JSON 就说明服务端正常。如果返回空白或 Connection Refused,就是 ES 服务和端口没起来。
如果 ES 服务正常但应用启动时依然报错,重点检查依赖引入是否完整,是否缺少spring-boot-starter-data-elasticsearch依赖。有时候项目里同时有旧版elasticsearch客户端依赖,和新版 starter 冲突,会导致反序列化失败。
有一种情况是在 SpringBoot 3.x 中,spring-boot-starter-data-elasticsearch改名为spring-boot-starter-data-elasticsearch或者协调方式有变化,需要读取官方文档确认。依赖冲突排查要用mvn dependency:tree查看,别靠猜。
7.3 中文检索不出来或分词结果不符合预期
这是老生常谈的问题。不装 IK 分词插件之前,中文搜索经常只能按单字匹配,搜“手机壳”虽然能通过包含关系匹配到,但排序、相关性、命中率都很差。ES 自带 standard analyzer 对中文是按单个汉字切分的,对于“苹果手机”这种词,它分出来的词项是“苹”“果”“手”“机”,四个独立字。查询时虽然“苹果”能组合命中,但相关度评分会比较低,“苹果手机”和“红苹果”这两个互不相关的词会被拉近关系。
解决方案是安装 IK 分词插件,并确保 mapping 里用的 analyzer 是ik_max_word。IK 插件安装后需要重启 ES,同时要把 ik 插件的版本和 ES 版本匹配好。
如果你只是想在测试环境验证分词效果,可以在 Kibana 里执行:
POST /_analyze { "analyzer": "ik_max_word", "text": "红色的苹果手机" }返回的分词结果可以清晰地看到“红色”“苹果”“手机”“手机”等词项,就知道 mongoose mapping 设计错没错。
7.4 映射字段冲突导致查询报错
在库里更新一个文档,ES 报错mapper [price] cannot be changed from [text] to [double],这就是 mapping 不可变的体现。这类问题有一个快速处理办法:新建一个product_v2索引,reindex 数据,再用别名切换。我会在索引维护部分详细讲。
另外,ES 有个挺有意思的动态映射机制:第一次写入某个字段时,ES 会猜测类型并创建 mapping,所以如果你先写了"price": "99.00",它可能把 price 映射成 text,之后再写入数值就报错。所以正式数据进来之前,先设计 mapping 是对的。
7.5 连接池耗尽和超时问题
ES 客户端有连接池机制,默认连接池大小 100。如果系统并发很高,或者有线程阻塞持有连接,可能导致连接池耗尽。解决办法是调大连接池,并在调用代码里加超时时间。潜在隐患是:ElasticsearchRestTemplate底层使用共享 RestClient,多个线程并发调用时如果单个请求很慢,会很快耗尽连接池。
我的习惯是在配置阶段就对uris、连接超时、socket 超时做规划,超过 5 秒的查询首先反思查询 DSL 是否合理,而不是盲目增加连接池。
8. 从单机到集群:上线前的工程化思考
8.1 你需要多少个节点和分片
上线前大家都会问:分片数量多少合适?我的建议是:
- 单节点测试情况下,1 个分片、0 个副本完全 OK。
- 生产环境的索引,建议按数据规模设置分片数,目标是单个分片大小控制在 30GB 以内。
- 副本数最少 1,保证节点故障时数据不丢。
分片数是索引的“并行单位”,分片过多会导致查询开销变高,分片过少会导致数据倾斜。经验公式是:分片数 = 节点数 * 2 左右,再按数据量微调。这些参数不是拍脑袋定的,是要结合可用内存、磁盘 IO、业务并发测试出来的。
8.2 数据同步方案:从 MySQL 到 ES
本地测试我们直接手动 save 就行,线上却不能这么随意。数据同步是架构设计里最需要谨慎的地方。常用方案有几类,我根据自己的实践对比一下:
| 同步方案 | 延迟性 | 复杂性 | 适用场景 |
|---|---|---|---|
| 定时任务全量/增量同步 | 分钟级到小时级 | 低 | 数据更新不频繁,接受一定延迟 |
| MQ 异步监听 DB 变更 | 毫秒级 | 中 | 需要准实时搜索 |
| 订阅数据库 binlog 同步 | 毫秒级 | 高 | 数据强一致、大规模同步 |
定时任务方案代码简单、容易理解,适合绝大多数中小型项目。如果是电商库存、订单这类数据变更频繁、查询延迟敏感的,可以考虑 MQ 方案。
我见过一个项目,没有做任何同步,直接让前台请求打到 ES,发现数据查不到才意识到 ES 里没有数据,这种低级错误其实很常见。数据库变更之后,一定要触发同步逻辑或定时任务,这是 ES 接入后最基本也是最容易忽略的一步。
8.3 集群监控和容量规划
ES 集群上线后,监控指标至少要看这几个:
- 堆内存使用率:单节点建议不要超过 75%,否则 GC 频繁导致查询抖动。
- 磁盘使用率:超过 85% 时就要考虑扩容或清理数据,ES 在磁盘水位线红色时会禁止写入。
- 分片状态:
cluster health返回红色或黄色都要警惕,黄色表示副本缺失,红色表示主分片丢失。 - 查询延迟:P99 延迟如果持续超过 1 秒,就要查是否存在慢查询语句。
ES 的运维和其他中间件一样,就是靠监控驱动调优。上线前先把基础监控打好,能省掉 90% 的半夜救火。
9. 我最后想说的几点经验
SpringBoot 整合 ElasticSearch 这件事,放到整个搜索与检索体系里看只是第一步。真正决定系统搜索能力上限的是你对分词、mapping、查询 DSL 和分片设计的理解。从零开始学,我个人的建议路径是:先本地起一个 ES,用 Kibana 玩明白常用查询 DSL,再回到 SpringBoot 代码里封装业务,千万别跳过手动建索引这一关。我第一次整合时就是嫌手动建 mapping 麻烦,直接用 @Document 自动生成,后来上线被运营找到几十个搜索不到的商品,排查原因才发现都是 mapping 类型不匹配导致查询语义错了。从那以后,我但凡涉及 ES 上线都是手动 mapping 起步,最多用代码在启动时做 mapping 校验,而不是完全依赖自动创建。
这篇文章里写的所有代码和配置都是我实际跑过、也部署过的方案,版本组合也许跟你本地略有差异,但核心原理不会变。如果你照着搭遇到了报错,优先去检查版本匹配和 mapping 字段类型,八成的问题都出在这两处。踩过几次坑之后你就会发现,SpringBoot 和 ES 的整合,最花时间的地方其实在前期设计和数据建模上,真正的代码反而比想象中少。