Spring Boot 项目使用 Elasticsearch 详细指南(从 0 到上线)
目标:让你在 Spring Boot 里把 ES(Elasticsearch)用到"能跑、好用、可维护、可上线"的程度。
内容包含:选型、依赖与配置、索引与 mapping、CRUD、复杂查询、聚合、分页/排序/高亮、批量、同步 MySQL、性能与坑、生产实践。
1. 你先决定:用 Spring Data 还是直接用 ES Client?
方案 A:Spring Data Elasticsearch(推荐多数业务)
优点
- 代码量少(Repository、
ElasticsearchOperations) - Spring 生态一体化(配置、序列化、审计、测试)
- 更容易做实体化 & 规范化
缺点
- DSL 表达力有时不如原生 client(但一般也够)
- ES/客户端版本升级要跟 Spring Data 兼容矩阵走
方案 B:Elasticsearch 官方 Java Client(更自由)
(现在主流是 Elasticsearch Java API Client ,而老的 RestHighLevelClient 已经是历史包袱)
优点
- DSL 全量能力
- 更贴近 ES 原生概念(mapping、聚合、pipeline 等)
缺点
- 代码多一点
- 你要自己更懂 ES
本文两种都给:你可以先用 Spring Data,遇到复杂聚合/奇怪 DSL 再补一个原生 client。
2. ES 核心概念(别跳过,不懂会踩坑)
- Index(索引):类似数据库的"表"
- Document(文档):类似"行",是 JSON
- Mapping:字段类型定义(相当于 schema)
- Analyzer:分词器(决定"全文检索"怎么切词)
- Inverted Index(倒排索引):搜索快的核心
- keyword vs text
keyword:不分词,适合精确匹配、聚合、排序text:分词,适合全文检索(match)
- refresh:写入后多久可被搜索(实时性 vs 写入吞吐)
- shard/replica:分片和副本(容量、并发、容灾)
3. 本地启动 ES(开发环境建议 Docker)
bash
docker network create esnet
docker run -d --name es \
--net esnet \
-p 9200:9200 -p 9300:9300 \
-e "discovery.type=single-node" \
-e "xpack.security.enabled=false" \
-e "ES_JAVA_OPTS=-Xms1g -Xmx1g" \
docker.elastic.co/elasticsearch/elasticsearch:8.13.4
# 可选:Kibana
docker run -d --name kibana \
--net esnet \
-p 5601:5601 \
-e "ELASTICSEARCH_HOSTS=http://es:9200" \
docker.elastic.co/kibana/kibana:8.13.4
测试:
bash
curl http://localhost:9200
4. Spring Boot 依赖与配置
4.1 Maven 依赖(Spring Data Elasticsearch)
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
4.2 application.yml(最常用)
yaml
spring:
elasticsearch:
uris: http://localhost:9200
# 如果你开了安全认证,再配 username/password
# username: elastic
# password: your_password
# 让日志里能看到请求(开发调试用,生产别开太猛)
logging:
level:
org.springframework.data.elasticsearch.client: DEBUG
说明:不同 Spring Boot 版本的配置项名字可能略有差异,但核心就是 "ES 地址 + 认证"。
5. 索引设计与 mapping(写对一次,省后面 100 次返工)
5.1 一个典型业务索引:商品搜索(product)
需求
- 商品名全文检索(分词)
- 品牌、类目、状态精确过滤(keyword)
- 价格范围过滤
- 热度排序
- 关键词高亮
- 聚合统计:按品牌、类目统计数量
5.2 建议 mapping(关键点:text + keyword 双字段)
你可以用 Kibana DevTools 或代码创建索引。
json
PUT product_v1
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"refresh_interval": "1s"
},
"mappings": {
"properties": {
"id": { "type": "keyword" },
"name": { "type": "text", "analyzer": "standard",
"fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } },
"brand": { "type": "keyword" },
"category": { "type": "keyword" },
"status": { "type": "keyword" },
"price": { "type": "scaled_float", "scaling_factor": 100 },
"hotScore": { "type": "integer" },
"tags": { "type": "keyword" },
"createdAt": { "type": "date" },
"updatedAt": { "type": "date" }
}
}
}
为什么 price 用 scaled_float?
钱最怕浮点误差。scaled_float 用整数存储(比如分),避免各种"0.1 + 0.2 != 0.3"类问题。
6. Spring Data Elasticsearch:实体、Repository、基础 CRUD
6.1 实体类
java
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.*;
import java.time.Instant;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Document(indexName = "product_v1")
public class ProductDoc {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "standard", searchAnalyzer = "standard")
private String name;
@Field(type = FieldType.Keyword)
private String brand;
@Field(type = FieldType.Keyword)
private String category;
@Field(type = FieldType.Keyword)
private String status;
/** 建议用 long 表示分;或者用 scaled_float 但 Java 侧用 BigDecimal 也可 */
@Field(type = FieldType.Scaled_Float, scalingFactor = 100)
private Double price;
@Field(type = FieldType.Integer)
private Integer hotScore;
@Field(type = FieldType.Keyword)
private List<String> tags;
@Field(type = FieldType.Date)
private Instant createdAt;
@Field(type = FieldType.Date)
private Instant updatedAt;
}
6.2 Repository
java
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import java.util.List;
public interface ProductRepository extends ElasticsearchRepository<ProductDoc, String> {
List<ProductDoc> findByBrand(String brand);
}
6.3 基础 CRUD
java
@Service
@RequiredArgsConstructor
public class ProductEsService {
private final ProductRepository repo;
public ProductDoc save(ProductDoc doc) {
return repo.save(doc);
}
public void delete(String id) {
repo.deleteById(id);
}
public ProductDoc get(String id) {
return repo.findById(id).orElse(null);
}
public Iterable<ProductDoc> batchGet(List<String> ids) {
return repo.findAllById(ids);
}
}
7. 复杂查询:用 ElasticsearchOperations(更接近 DSL)
Spring Data 的 Repository 适合简单查询;复杂点的(bool/filter/range/分页/排序/高亮/聚合)建议用 ElasticsearchOperations。
7.1 组合搜索:关键词 + 过滤 + 价格区间 + 排序 + 分页
java
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.*;
import org.springframework.data.elasticsearch.core.*;
import org.springframework.data.elasticsearch.core.query.*;
import org.springframework.stereotype.Service;
import static org.elasticsearch.index.query.QueryBuilders.*;
@Service
@RequiredArgsConstructor
public class ProductSearchService {
private final ElasticsearchOperations ops;
public Page<ProductDoc> search(String keyword,
String brand,
String category,
Double minPrice,
Double maxPrice,
int page,
int size) {
var bool = boolQuery();
// full-text
if (keyword != null && !keyword.isBlank()) {
bool.must(matchQuery("name", keyword));
} else {
bool.must(matchAllQuery());
}
// filters(过滤不参与打分,性能更好)
if (brand != null && !brand.isBlank()) {
bool.filter(termQuery("brand", brand));
}
if (category != null && !category.isBlank()) {
bool.filter(termQuery("category", category));
}
if (minPrice != null || maxPrice != null) {
var range = rangeQuery("price");
if (minPrice != null) range.gte(minPrice);
if (maxPrice != null) range.lte(maxPrice);
bool.filter(range);
}
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Order.desc("hotScore"),
Sort.Order.desc("updatedAt")));
NativeSearchQuery query = new NativeSearchQueryBuilder()
.withQuery(bool)
.withPageable(pageable)
.build();
SearchHits<ProductDoc> hits = ops.search(query, ProductDoc.class);
var content = hits.getSearchHits().stream()
.map(SearchHit::getContent)
.toList();
return new PageImpl<>(content, pageable, hits.getTotalHits());
}
}
8. 高亮(搜索结果把命中的词标出来)
java
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
// ...
HighlightBuilder.Field hlName = new HighlightBuilder.Field("name")
.preTags("<em>")
.postTags("</em>")
.fragmentSize(150)
.numOfFragments(1);
NativeSearchQuery query = new NativeSearchQueryBuilder()
.withQuery(bool)
.withHighlightFields(hlName)
.build();
SearchHits<ProductDoc> hits = ops.search(query, ProductDoc.class);
for (SearchHit<ProductDoc> hit : hits) {
var highlight = hit.getHighlightFields();
if (highlight != null && highlight.containsKey("name")) {
// highlight.get("name") 是 List<String>
String hl = highlight.get("name").get(0);
// 你可以把它塞到 VO 里返回给前端
}
}
9. 聚合(统计类需求:品牌分布、类目分布、价格区间)
9.1 terms 聚合:按品牌统计
java
import org.elasticsearch.search.aggregations.AggregationBuilders;
import org.elasticsearch.search.aggregations.bucket.terms.ParsedStringTerms;
// ...
NativeSearchQuery query = new NativeSearchQueryBuilder()
.withQuery(bool)
.addAggregation(AggregationBuilders.terms("brandAgg").field("brand"))
.withMaxResults(0) // 不要明细,只要聚合
.build();
SearchHits<ProductDoc> hits = ops.search(query, ProductDoc.class);
var agg = hits.getAggregations().get("brandAgg");
ParsedStringTerms terms = (ParsedStringTerms) agg;
terms.getBuckets().forEach(b -> {
String brand = b.getKeyAsString();
long count = b.getDocCount();
});
9.2 range 聚合:价格区间
java
import org.elasticsearch.search.aggregations.bucket.range.ParsedRange;
NativeSearchQuery query = new NativeSearchQueryBuilder()
.withQuery(bool)
.addAggregation(
AggregationBuilders.range("priceRange").field("price")
.addRange("0-100", 0, 100)
.addRange("100-500", 100, 500)
.addUnboundedFrom("500+", 500)
)
.withMaxResults(0)
.build();
var agg = (ParsedRange) ops.search(query, ProductDoc.class).getAggregations().get("priceRange");
agg.getBuckets().forEach(b -> {
String key = b.getKeyAsString();
long cnt = b.getDocCount();
});
10. 批量写入(Bulk)------上量必备
10.1 Spring Data:saveAll(中小批量)
java
repo.saveAll(list);
10.2 更专业:分批 + 控制 refresh
策略:
- 单批 500~2000(看文档大小)
- 关闭/降低 refresh(大导入时把 refresh_interval 调大)
- 导入完再改回 1s,并手动 refresh
生产里大批量导入:建议用离线任务(Job)+ Bulk API(原生 client 更顺手)。
11. MySQL 同步到 ES:三种常见方案(按可靠性排序)
方案 1:业务写入双写(最简单,但最容易不一致)
写 MySQL 后写 ES
问题:ES 写失败怎么办?重试?补偿?幂等?
适合:对一致性要求不高的搜索索引
方案 2:Outbox + MQ(推荐)
核心思路:把"要同步的变更事件"也写到 MySQL(同事务) ,再异步投递到 MQ/消费后写 ES。
好处:不丢,能补偿,天然可追溯。
表结构示例(outbox_event)
sql
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'NEW',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
KEY idx_status_created (status, created_at)
);
流程:
- 更新商品表(MySQL)
- 同事务插入 outbox_event
- 后台任务扫描 NEW 事件,投递 MQ
- 消费者写 ES,成功后把 outbox_event 改为 DONE
- 失败重试(带退避)+ 死信报警
方案 3:CDC(Canal / Debezium)监听 binlog(最解耦)
适合:数据平台化、多个下游
成本:部署复杂一点,但一致性和可维护性非常强
12. 查询性能与坑(这部分最值钱)
12.1 filter 放 bool.filter,别放 must
filter不参与打分,可缓存,速度快must参与打分,没必要就别用
12.2 keyword 字段才能聚合/排序
对 text 排序/聚合会报错(或走 fielddata,巨吃内存)
- 用
name.keyword来排序/聚合 - 用
name来 match 搜索
12.3 深分页不要用 from+size
from很大时,ES 要跳过大量文档,性能崩- 替代方案:
search_after(推荐)- scroll(离线导出/遍历)
12.4 mapping 不能随便改
字段类型一旦建好改不了(除非重建索引 + reindex)
所以要:
- 索引版本化:
product_v1→product_v2 - 用 alias:
product_current指向当前版本 - 重建时切 alias,做到无感迁移
12.5 refresh_interval:实时性 vs 写入吞吐
- 默认 1s 左右
- 写入量大时调大(比如 5s/30s)
- 导入场景可临时关 refresh,导完再开
12.6 写入幂等
文档 id 选择:
- 用业务主键(如商品 id)当
_id - 更新时直接覆盖(upsert)
13. 索引版本化 + alias(生产强烈建议)
13.1 创建 alias
json
POST _aliases
{
"actions": [
{ "add": { "index": "product_v1", "alias": "product_current" } }
]
}
应用侧永远用 product_current。
13.2 迁移到 v2
- 建
product_v2 - 全量导入(reindex / 离线 bulk)
- 切 alias(原子操作)
json
POST _aliases
{
"actions": [
{ "remove": { "index": "product_v1", "alias": "product_current" } },
{ "add": { "index": "product_v2", "alias": "product_current" } }
]
}
14. 用官方 Java API Client(可选增强)
下面是"原生客户端"的典型写法示意(用于你需要更强 DSL/聚合能力时)。
具体依赖版本跟你的 ES 版本要匹配;原则是客户端主版本号要与 ES 主版本号一致。
14.1 核心对象(示意)
java
// 你需要:ElasticsearchClient client;
// 然后:client.search(...), client.index(...), client.bulk(...)
14.2 upsert(示意)
java
// client.update(u -> u
// .index("product_current")
// .id(doc.getId())
// .doc(doc)
// .docAsUpsert(true)
// );
如果你只用 Spring Data,其实也完全够用。原生 client 更适合"重聚合、管道聚合、复杂嵌套查询"等场景。
15. 测试与调试技巧
15.1 用 Kibana DevTools 先写 DSL 再搬到代码里
很多问题不是 Java 问题,是 DSL / mapping 问题。
15.2 把 query 打出来
- 开日志(开发环境)
- 或在构造 query 时把 DSL 存下来,方便复现
15.3 Testcontainers(集成测试推荐)
用容器拉起 ES,跑 CI 也稳。
16. 常见报错速查
- Fielddata is disabled on text fields
- 你在 text 上排序/聚合了 → 改用
.keyword
- 你在 text 上排序/聚合了 → 改用
- mapper_parsing_exception
- 写入字段类型不匹配(比如 date 传了乱七八糟字符串)
- all shards failed
- 多半是 DSL 字段名/类型写错,或脚本报错
17. 一个最小可运行 Demo 结构(建议这样分层)
src/main/java
├── config
│ └── ElasticsearchConfig.java
├── es
│ ├── doc
│ │ └── ProductDoc.java
│ ├── repo
│ │ └── ProductRepository.java
│ └── service
│ ├── ProductEsService.java
│ └── ProductSearchService.java
└── web
└── ProductController.java
18. Controller 示例(把搜索接口暴露出去)
java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/products")
public class ProductController {
private final ProductSearchService searchService;
private final ProductEsService esService;
@GetMapping("/search")
public Page<ProductDoc> search(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String brand,
@RequestParam(required = false) String category,
@RequestParam(required = false) Double minPrice,
@RequestParam(required = false) Double maxPrice,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
return searchService.search(keyword, brand, category, minPrice, maxPrice, page, size);
}
@PostMapping
public ProductDoc save(@RequestBody ProductDoc doc) {
return esService.save(doc);
}
}
19. 你上线前的 checklist(别省这一步)
- mapping 设计过(text/keyword、日期、数值、数组、nested)
- 索引 alias 做了(版本化)
- 写入幂等(_id 用业务 id)
- 深分页方案(search_after 或 scroll)
- 同步方案明确(双写 / outbox+mq / cdc)
- 监控:慢查询、写入失败、队列堆积、集群健康
- 压测:至少测"写入峰值"和"搜索峰值"