Spring Boot 项目使用 Elasticsearch 详细指南

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)
);

流程:

  1. 更新商品表(MySQL)
  2. 同事务插入 outbox_event
  3. 后台任务扫描 NEW 事件,投递 MQ
  4. 消费者写 ES,成功后把 outbox_event 改为 DONE
  5. 失败重试(带退避)+ 死信报警

方案 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_v1product_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

  1. product_v2
  2. 全量导入(reindex / 离线 bulk)
  3. 切 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
  • 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)
  • 监控:慢查询、写入失败、队列堆积、集群健康
  • 压测:至少测"写入峰值"和"搜索峰值"

相关推荐
彭于晏Yan2 小时前
Springboot集成Hutool导出CSV
java·spring boot·后端
万小猿2 小时前
互联网大厂Java求职面试模拟实战:谢飞机的三轮提问与详细解答
java·大数据·spring boot·微服务·面试·技术解析·互联网大厂
Coder_Boy_2 小时前
基于SpringAI企业级智能教学考试平台试卷管理模块全业务闭环方案
java·大数据·人工智能·spring boot·springboot
wanghowie2 小时前
02.01 Spring Boot|自动配置机制深度解析
android·spring boot·后端
yuuki2332332 小时前
【C++】掌握list:C++链表容器的核心奥秘
c++·后端·链表·list
Coder_Boy_2 小时前
基于SpringAI的智能AIOps项目:部署相关容器化部署管理技术图解版
人工智能·spring boot·算法·贪心算法·aiops
wanghowie2 小时前
01.03 Spring核心|事务管理实战
java·后端·spring
千寻技术帮2 小时前
10356_基于Springboot的老年人管理系统
java·spring boot·后端·vue·老年人
最贪吃的虎2 小时前
Redis 除了缓存,还能干什么?
java·数据库·redis·后端·缓存