简介:面向基于Spring Boot的Java开发者和大数据工程师,这份压缩包提供了一套已集成Elasticsearch的可直接落地项目。项目以ElasticsearchTemplate为核心,覆盖索引管理、CRUD、批处理、结果排序、分页查询、检索与关键字查询、高亮显示、逻辑查询、过滤查询、分组查询等常见ES操作,且经过生产环境验证,拿来即可用。压缩包共25个文件,整体仅29KB,包含22个Java源文件、1个XML配置、1个properties配置和1个README文档,结构简洁,便于按功能快速定位到对应示例。已有4052人学习下载,适合需要快速上手ES操作或希望基于Spring Boot构建搜索服务的开发者。参考其中的实现,可减少从零搭建ES集成的时间,对照示例即可梳理各查询与聚合逻辑,并平滑迁移到自有项目中。
1. SpringBoot 集成 Elasticsearch 真正卡人的地方,从来不是查询 DSL
SpringBoot 集成 Elasticsearch 真正卡人的地方,不是 ES 查询 DSL 学不会,而是 Spring Boot、Spring Data Elasticsearch、ES 服务端三者版本对不齐:动一个,另两个跟着报错。这个“已实现各种 ES 操作,上手即可用”的标题,本质是把索引管理、文档增删改、批量写入、组合检索、聚合统计封装成独立 Service,调用方不碰底层连接细节,改配置就能切换环境。
文章按一条工程路径推进:先定版本组合和依赖装配,把连接自检放到启动期;再给出一套可复制的 CRUD、搜索、聚合代码;最后落在深翻页验证,以及 Windows 本机装完 ES 9.x 后最容易踩的坑上。写过 Spring Boot CRUD、还没系统整理 ES 操作层的后端,可以直接按这套结构落地。
2. SpringBoot 集成 Elasticsearch 的版本对齐与客户端装配
先选对组件,再谈操作。SpringBoot 集成 Elasticsearch 现在有两条成熟路线:一条是 Spring Data Elasticsearch 基于 Repository 的接口派生查询,适合 CRUD 占比高、查询条件固定的业务;另一条是直接使用官方 Elasticsearch Java API Client,DSL 的 Java 写法和 ES 请求体几乎一一对应,适合条件嵌套深、聚合逻辑复杂的场景。
这两个方案不是互斥的。常见做法是混用:Spring Data Elasticsearch 负责索引实体绑定和单文档操作,ElasticsearchClient 负责条件删除、批量导入、复杂聚合。在 Spring Boot 3.x 里,spring-boot-starter-data-elasticsearch会自动装配出ElasticsearchClient和ElasticsearchTemplate两个 Bean,不需要手工创建连接。
2.1 用版本矩阵先锁死 SpringBoot、Spring Data ES 与 ES 服务端
版本乱是 SpringBoot 集成 Elasticsearch 的第一事故源。Spring Data Elasticsearch 的维护分支和 Spring Boot 版本强绑定,而它兼容的 Elasticsearch 服务端又有自己的独立窗口。选型时以 Spring Boot 为主轴,倒推另外两个版本才不容易翻车。
| Spring Boot 版本 | 附带 Spring Data ES 版本 | 兼容 ES 服务端推荐 |
|---|---|---|
| 2.7.x | 4.4.x | 7.17.x |
| 3.1.x | 5.1.x | 8.7 ~ 8.10 |
| 3.2.x | 5.2.x | 8.11 ~ 8.13 |
| 3.3.x | 5.3.x | 8.13 ~ 8.16 |
原则很简单:ES 服务端大版本不要高于 Spring Data ES 兼容窗口。比如表里 Spring Boot 3.2 对 ES 8.11~8.13 是稳定窗口,直接配 ES 8.16 就会在查询阶段出现无法解析响应之类的问题。至于 Elasticsearch 9.x,先查 Spring Data Elasticsearch 官方兼容矩阵里有没有对应维护分支,再决定要不要升级,不要先升 starter 再拿生产环境试错。
提示:Spring Data Elasticsearch 的版本由
spring-boot-starter-parent的 BOM 统一管理,不要在 pom 里单独写版本号,否则会和 Spring Boot 自动配置的装配逻辑脱节。
2.2 Maven 依赖与 yml 配置:把连接参数收敛到配置中心
依赖声明保持最小化,只加三个 starter 就够支撑后续的 CRUD、搜索和健康检查:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> </dependencies>spring-boot-starter-data-elasticsearch已经带上了 elasticsearch-java 客户端以及 Jackson 映射相关依赖;actuator 是为了后面做健康检查。如果发现编译期缺了 jackson-databind 的某个类,多半是手动加了别的 ES 版本依赖把 BOM 的依赖树搞乱了。
application.yml 里的连接参数直接决定线上能撑住多大的查询压力:
spring: elasticsearch: uris: - http://10.0.1.21:9200 - http://10.0.1.22:9200 connection-timeout: 5s socket-timeout: 30s max-conn-total: 100 max-conn-per-route: 20 username: ${ES_USERNAME:elastic} password: ${ES_PASSWORD:changeme}参数含义按优先级排:uris配多个节点时,客户端按轮询方式分发请求,不是传统负载均衡,但已经够撑住单集群多节点场景;socket-timeout对聚合查询尤其关键,ES 在做大范围 terms 聚合时经常超过默认 10 秒,调成 30 秒能少踩很多SocketTimeoutException;max-conn-total是连接池总连接数,max-conn-per-route是单个节点的连接上限。用户名密码从环境变量读取,而不是把生产密码写死在 yml 里,这个习惯在 ES 8 开启安全特性后就是刚需。
2.3 启动期连接自检:让环境问题暴露在部署前
ES 地址配错、集群未就绪、版本不兼容这三类问题,要是等第一条业务查询报 500 才发现就太晚了。做法是在 Spring 容器启动完成后,主动调一次client.info()探活。
@Slf4j @Component @RequiredArgsConstructor public class EsStartupChecker implements ApplicationRunner { private final ElasticsearchClient client; @Override public void run(ApplicationArguments args) { try { InfoResponse info = client.info(); log.info("ES 集群已连接,clusterName={},version={}", info.clusterName(), info.version().number()); } catch (IOException e) { log.error("ES 启动自检失败,拒绝启动:{}", e.getMessage()); throw new IllegalStateException("ES 连接不可用", e); } } }ApplicationRunner.run()在任何业务逻辑执行前被调用,这里抛出异常会直接中断 Spring Boot 启动。为什么不写在@PostConstruct?因为ElasticsearchClient的自动配置完成时机在 bean 初始化之后,用 ApplicationRunner 能保证所有依赖注入完全到位。拿到InfoResponse后把集群名和版本号打出来,排障时一眼就能对账。
3. 索引管理、文档 CRUD 与批量写入的落地代码
有了客户端,接下来的操作层按“索引 → 单条 → 批量 → 条件删除”的顺序封装。这部分代码是“各种 ES 操作”里最常被复制粘贴的部分,但有几个细节不点破,抄过去照样踩坑。
3.1 先创建索引和 mapping,别依赖自动建索引
ES 允许在写入第一条数据时自动创建索引并推断 mapping,但推断出来的字段类型往往不符合业务预期:一个看起来像数字的字符串,在 8.x 自动 mapping 里可能被识别成 text,后续要按数值范围过滤时就拿不到结果。所以索引必须显式创建,mapping 应该作为静态文件维护。
把 mapping 放在 classpath 的es/product_mapping.json下:
{ "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "id": { "type": "keyword" }, "title": { "type": "text", "analyzer": "standard" }, "category": { "type": "keyword" }, "price": { "type": "double" }, "stock": { "type": "integer" }, "createdAt": { "type": "date", "format": "strict_date_optional_time" } } } }然后写一个幂等创建方法:
public boolean createIndexIfAbsent(String indexName, String mappingClasspath) throws IOException { boolean exists = client.indices().exists( e -> e.index(indexName) ).value(); if (exists) { return false; } try (InputStream is = getClass().getResourceAsStream(mappingClasspath)) { client.indices().create(c -> c .index(indexName) .withJson(is) ); return true; } }.exists().value()拿的是布尔响应体,比捕获异常再判断更干净;.withJson(is)直接吞掉整个 mapping 文件,省去把 JSON 字符串拼进代码的麻烦。注意getResourceAsStream如果路径写错会返回 null,调用withJson之前要判空。
提示:索引一旦创建,mapping 里的字段类型就不能改了,只能新增字段。测试环境图省事可以删除重建,生产环境必须先确认字段变更对线上查询的影响面。
3.2 单条 upsert、删除和条件删除
单条写入最稳的姿势是显式指定业务主键,这样重复写入就变成覆盖更新而不是无限新增:
public String upsertDoc(String indexName, String id, Map<String, Object> body) throws IOException { IndexResponse resp = client.index(i -> i .index(indexName) .id(id) .document(body) ); return resp.result().jsonValue(); }resp.result()返回的是Result枚举,.jsonValue()会得到created、updated或deleted字符串。日志里记录这个值,能直接看出当前请求是新增还是覆盖。删除单条文档时,业务上经常遇到“删完立刻查”的场景:
public boolean deleteById(String indexName, String id) throws IOException { DeleteResponse resp = client.delete(d -> d .index(indexName) .id(id) .refresh(Refresh.WaitFor) ); return resp.result() == Result.Deleted; }.refresh(Refresh.WaitFor)让删除操作在返回前先刷新分片,保证后续查询立刻能看到结果。这个参数只适合低频单条操作,如果用到高频路径上,每次删除都要等一次 refresh,写入吞吐会明显下降。
按条件删除走deleteByQuery,它在服务端是按批次执行删除的,返回的是实际删除数:
public long deleteByCategory(String indexName, String category) throws IOException { DeleteByQueryResponse resp = client.deleteByQuery(d -> d .index(indexName) .query(q -> q.term(t -> t .field("category").value(category))) ); return resp.deleted(); }这里 category 在 mapping 里是 keyword 类型,所以用term做精确匹配。如果换成 text 字段,term 查的就是分词后的结果,大概率删不到数据。
3.3 批量写入:批次大小、错误定位与失败兜底
批量写入用 Bulk API,一次网络请求带上百条操作,是导入场景的最优解:
@Slf4j @Service public class EsBulkService { private final ElasticsearchClient client; public int bulkIndex(String indexName, List<ProductDoc> docs) throws IOException { BulkRequest.Builder builder = new BulkRequest.Builder(); for (ProductDoc doc : docs) { builder.operations(op -> op .index(idx -> idx .index(indexName) .id(doc.getId()) .document(doc))); } BulkResponse response = client.bulk(builder.build()); if (response.errors()) { List<String> reasons = response.items().stream() .filter(item -> item.error() != null) .map(item -> item.error().reason()) .toList(); throw new IOException("批量写入失败: " + reasons); } return response.items().size(); } }response.errors()为 true 时,BulkResponse 里仍然返回所有 items,所以需要遍历找出error()不为 null 的那几条,把reason()拼进异常信息。这个细节非常关键,否则只能看到“批量失败”四个字,不知道具体哪条数据、什么原因。
批次大小不是越大越好,结合常见场景的经验值如下:
| 数据形态 | 建议批次 | 说明 |
|---|---|---|
| 单行小 JSON | 500~1000 | 网络往返是瓶颈,尽量压满 |
| 含大文本字段 | 100~200 | 单次 HTTP 载荷控制在 1MB~5MB |
| 全量历史数据导入 | 分片数 × 8~16 | 避免单个分片写入排队 |
BulkRequest内部的BulkableOperation是按传入顺序排列的,如果其中一条失败,ES 默认不会中断整个批次,只会在对应 item 上打错误标记。项目中要保证幂等,最稳妥的办法就是显式传业务 id,重复执行同一批次只会覆盖相同 id 的文档,不会产生重复数据。
4. 组合检索、高亮与常用聚合的落地写法
“各种 ES 操作”里,搜索和聚合是最能拉开实用性的部分。搜索的关键不是会写 match,而是知道什么字段用精确匹配、什么字段走全文检索、什么条件放 filter 缓存。
4.1 bool 组合查询:term 与 match 的职责边界
term 和 match 的区别,是 ES 新手最容易弄反的一对。term 对 keyword 字段做精确匹配,不做分词;match 对 text 字段做全文检索,会先经过分析器。用反的结果是:keyword 字段用 match 查不到完整值,text 字段用 term 经常只命中单个分词。
实际业务里更多的场景是多个条件叠加,组合查询几乎都落在 bool 上:
public SearchResponse<ProductDoc> searchProducts(String keyword, String category, Double minPrice, Double maxPrice, int page, int size) throws IOException { return client.search(s -> s .index("product") .query(q -> q.bool(b -> b .must(m -> m.match(t -> t.field("title") .query(keyword) .fuzziness("AUTO"))) .filter(f -> f.term(t -> t.field("category") .value(category))) .filter(f -> f.range(r -> r.number(n -> n .field("price").gte(minPrice).lte(maxPrice)))))) .from((page - 1) * size) .size(size), ProductDoc.class); }bool 查询里的四种子句职责要分清:must参与相关性打分,filter只做条件过滤、不影响评分,should是可选匹配,must_not是排除。上面示例把 category 和 price 放在 filter 里,是因为这两个条件不需要影响搜索排序,filter子句在 ES 节点上还会自动做查询结果缓存,命中率越高性能越好。
keyword 参数同时带进了fuzziness("AUTO"),它允许搜索词有一定程度的字符误差,比如“iphone”能匹配到“ipone”。这个参数会带来轻微的性能损耗,搜索词很短时基本无感,但如果字段本身是标准分词,不建议在长文本上开 fuzzy。
| 场景 | 字段类型 | 用的查询 |
|---|---|---|
| 类目筛选 | keyword | term / filter |
| 标题搜索 | text | match + fuzziness |
| 价格范围 | double | range |
| 多条件叠加 | 混合 | bool 组合 |
4.2 高亮与深翻页:from-size 上限与 search_after
搜索结果里的关键词高亮,是搜索类系统几乎必做的交互。高亮本质上是在返回结果里附加一段带标记的字段片段:
public SearchResponse<ProductDoc> searchWithHighlight( String indexName, String keyword, int page, int size) throws IOException { return client.search(s -> s .index(indexName) .query(q -> q.match(t -> t.field("title").query(keyword))) .highlight(h -> h .fields("title", f -> f .preTags("<mark>") .postTags("</mark>")) .fragmentSize(60)) .from((page - 1) * size) .size(size), ProductDoc.class); }高亮配置里fields("title", ...)指定对哪个字段做高亮,preTags和postTags定义高亮标记,fragmentSize(60)表示抽取 60 个字符作为摘要片段。前端拿到响应后把<mark>标签渲染成高亮样式即可。这里有个性能细节:高亮会对命中文档的_source重新走一遍分析流程,字段内容越长开销越大,线上如果只要高亮片段而不需要全文,可以同时关闭_source加载。
深翻页是另一个容易踩雷的点。ES 的from + size翻页深度默认上限是 10000,超过这个值会直接抛异常。业务里要做无限滚动采集、导出全量数据时,正确做法是 search_after:
public SearchResponse<ProductDoc> searchAfter(String indexName, String keyword, List<FieldValue> lastSortValues, int size) throws IOException { List<SortOptions> sorts = Arrays.asList( SortOptions.of(so -> so.field(f -> f .field("createdAt").order(SortOrder.Desc))), SortOptions.of(so -> so.field(f -> f .field("_id").order(SortOrder.Asc))) ); return client.search(s -> s .index(indexName) .query(q -> q.match(t -> t.field("title").query(keyword))) .sort(sorts) .size(size) .searchAfter(lastSortValues), ProductDoc.class); }search_after 的原理是记住当前页最后一条的排序值,下一页从这里继续取。所以排序条件里必须有一个唯一值保证顺序稳定——createdAt可能重复,加上_id作 tie-breaker 就是标准做法。从上一页响应里取出最后一条的.sort()字段,传给下一次请求。
List<FieldValue> lastSortValues = previousPage.hits().hits() .get(previousPage.hits().hits().size() - 1) .sort();4.3 聚合统计:terms 分组与 date_histogram 时间桶
聚合是“各种 ES 操作”里和生产报表最贴近的一块。最简单的需求是“按某个字段分组计数”,对应 terms 聚合:
public Map<String, Long> aggregateByCategory(String indexName) throws IOException { SearchResponse<Void> resp = client.search(s -> s .index(indexName) .size(0) .aggregations("byCategory", a -> a .terms(t -> t.field("category").size(20))), Void.class); return resp.aggregations().get("byCategory") .sterms().buckets().array().stream() .collect(Collectors.toMap( b -> b.key().stringValue(), b -> b.docCount(), (x, y) -> x, LinkedHashMap::new)); }size(0)让服务端只返回聚合结果,不返回文档数据,省下大量传输开销。泛型传Void.class表示不需要反序列化_source。sterms()是字符串 terms 聚合的类型强转,buckets().array()拿到桶列表,每个桶的key()就是分组字段的值。注意terms聚合的精确计数在数据量大时有误差,这是 ES 分布式聚合的固有行为,报表场景通常可以接受。
时间维度的聚合用date_histogram,固定时间桶比自己写日期取整再 group by 可靠得多:
.aggregations("ordersPerDay", a -> a .dateHistogram(d -> d .field("createdAt") .calendarInterval(CalendarInterval.Day)))calendarInterval里 Day 是按自然日切桶,自动处理时区偏移,比固定毫秒间隔更适合业务报表。聚合出来的桶 key 是 epoch 毫秒,前端展示前要按配置时区做一次格式化。
5. 集成质量的三个验证手段:健康检查、深翻页自测、Windows 环境排查
把代码写完只是第一步,验证整个 SpringBoot 集成 Elasticsearch 链路是否真正可用,我一般会用下面三个手段把问题前置。
5.1 Actuator 健康检查与启动自检配合
首先把启动自检和 Spring Boot Actuator 串联起来。定义一个自定义 HealthIndicator,让/actuator/health直接反映 ES 集群的连接状态,运维探活和本地调试都能复用同一个端点:
@Component public class EsHealthIndicator extends AbstractHealthIndicator { private final ElasticsearchClient client; public EsHealthIndicator(ElasticsearchClient client) { super("esHealthCheck"); this.client = client; } @Override protected void doHealthCheck(Health.Builder builder) throws Exception { InfoResponse info = client.info(); builder.up() .withDetail("cluster", info.clusterName()) .withDetail("version", info.version().number()); } }这样/actuator/health返回的 JSON 里会多出esHealthCheck一节,集群名和 ES 版本都带上了。相比文档里常见的只探 TCP 端口,client.info()能同时验证 HTTP 协议层和 ES 版本兼容性,链接打通但版本不匹配的情况会在这里暴露。
5.2 search_after 翻页一致性自测
深翻页代码写完要验证,最直接的方式是对比 from-size 和 search_after 两种翻页方式的前若干条结果是否一致。在测试环境执行一段临时脚本,从第一页开始连续翻 20 页,把每次返回的文档 id 列表用 hash 做比对。如果前面页一致、后面开始错位,基本可以确定是排序字段不稳定——检查是否漏了_idtie-breaker。
自测脚本里还要验证一件事:search_after 只能在当前查询上下文内翻页,不能跳到任意页,也不能跨查询复用 sort 值。搜索条件一变,上一批 sortValues 就已经失效。
5.3 Windows 本机安装 ES 9.x 后最容易忽视的环境问题
最后说 Windows 本机环境排查。如果你在 Windows 上刚装好 Elasticsearch 9.5.3 准备连 SpringBoot 项目,有三个点先确认:ES 9.x 要求 JDK 17 以上,先跑java --version确认默认 JDK;Windows 下 ES 默认不推荐用 root 启动,但更常见的问题是安装目录权限不足导致 data 目录写入失败;启动后立刻访问http://localhost:9200看返回的version.number,和 Spring Data Elasticsearch 兼容矩阵先对齐再写代码。
提示:ES 9.x 的很多 8.x REST 接口仍然可用,但 Spring Data Elasticsearch 是否覆盖对应版本窗口,必须以官方兼容矩阵为准。版本没对上,任何代码层的排查都没有意义。
这三件事做完,SpringBoot 集成 Elasticsearch 的“上手即可用”才算闭环:启动期自检保证部署环境正确,HealthIndicator 给运行时探活兜底,search_after 自测证明深翻页逻辑没有排序隐患。剩下的就是根据业务字段不断调整 mapping 和查询条件了。
本文还有配套的精品资源,点击获取