第02篇:搭建 ES 集群 + Spring Boot 整合实战——从 Docker Compose 到 Java 客户端全覆盖

🚀 引言:搭建方式决定了你后面踩坑的频率

很多人第一次用 ES,往往是直接去官网下一个压缩包,解压、改配置、启动------几分钟就能跑起来,感觉很顺利。但在实际项目里,这种方式埋下了无数隐患:

  • 单节点没有副本,一旦宕机数据丢失;
  • 环境配置和生产差异太大,开发没问题,上线一堆报错;
  • ES 8.x 默认开启了安全认证,不了解就无从下手;
  • 新旧 Java 客户端并存,稍不注意就用了已废弃的 API。

本篇的目标是用一套标准化、可复用的方式把 ES 环境搭起来,同时把 Spring Boot 整合到一个可以直接用于生产参考的状态。中间所有踩过的坑,都会明确标出来。


一、版本选型:ES 8.x 还是 7.x?

在动手之前,先把版本问题说清楚。目前(2024年)主流有两个大版本并存:

对比项 ES 7.x ES 8.x
安全默认 默认关闭,需手动开启 默认开启(HTTPS + 用户认证)
Java 客户端 RestHighLevelClient(官方维护) ElasticsearchClient(新官方推荐)
向量检索 有限支持 成熟支持(dense_vector + kNN)
许可证 Apache 2.0 Elastic License 2.0(核心功能免费)
生产推荐 老项目维护 新项目首选

结论:新项目一律用 ES 8.x。 本系列全程基于 ES 8.13.x(写作时的最新稳定版)。

唯一的心理负担是 ES 8.x 默认强制 HTTPS 和用户认证,初次上手会觉得麻烦。但这恰恰是生产环境的正确姿态,本篇会完整演示如何处理。


二、用 Docker Compose 搭建三节点集群

为什么用 Docker Compose 而不是直接安装二进制包?

原因很简单:环境一致性。Docker Compose 定义的集群配置是版本化的,可以 git 管理,可以一键销毁重建,开发、测试、CI 环境完全一致。直接安装二进制包的方式在多人协作时迟早会出现"我这里好的,你那里不行"的经典问题。

2.1 准备工作:Linux 内核参数

这是 ES 最常见的第一个报错,在启动之前就要处理好。ES 要求虚拟内存映射数量至少 262144,Linux 默认值只有 65530。

bash 复制代码
# 临时生效(重启后失效)
sudo sysctl -w vm.max_map_count=262144

# 永久生效(重启后仍有效)
echo "vm.max_map_count=262144" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p

⚠️ 踩坑 :如果跳过这一步直接启动,ES 节点会立刻报错退出,日志里出现 max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]。在 Docker Desktop(Mac/Windows)下因为 Docker VM 内部已设置过,通常不会报这个错;但在 Linux 服务器上必须手动配置。

2.2 完整的 docker-compose.yml

下面是一个经过生产验证的三节点集群配置。先完整给出,再逐块解释关键点:

yaml 复制代码
# docker-compose.yml
version: "3.9"

# 所有节点共享的公共配置,通过 YAML 锚点复用,避免重复
x-es-common: &es-common
  image: elasticsearch:8.13.0
  environment:
    - cluster.name=es-saas-cluster           # 集群名称,节点靠这个找到彼此
    - discovery.seed_hosts=es-node1,es-node2,es-node3
    - cluster.initial_master_nodes=es-node1,es-node2,es-node3
    - bootstrap.memory_lock=true             # 锁定内存,防止 ES 内存被 swap 到磁盘
    - xpack.security.enabled=true            # 开启安全认证(8.x 默认已开启,显式声明更清晰)
    - xpack.security.http.ssl.enabled=true
    - xpack.security.http.ssl.keystore.path=certs/es-node.p12
    - xpack.security.transport.ssl.enabled=true
    - xpack.security.transport.ssl.keystore.path=certs/es-node.p12
    - xpack.security.transport.ssl.truststore.path=certs/es-node.p12
  ulimits:
    memlock:
      soft: -1
      hard: -1
    nofile:
      soft: 65536
      hard: 65536
  networks:
    - es-net

services:
  # ── 证书生成工具(一次性任务,生成后会自动退出)──────────────────
  es-setup:
    image: elasticsearch:8.13.0
    command: >
      bash -c '
        if [ ! -f /certs/es-node.p12 ]; then
          bin/elasticsearch-certutil cert \
            --silent --pem=false \
            --in /config/instances.yml \
            --out /certs/es-node.zip \
            --pass ""
          cd /certs && unzip es-node.zip
          bin/elasticsearch-certutil cert \
            --silent \
            --name es-node \
            --dns es-node1,es-node2,es-node3,localhost \
            --ip 127.0.0.1 \
            --out /certs/es-node.p12 \
            --pass ""
          chmod 660 /certs/es-node.p12
          echo "证书生成完毕"
        else
          echo "证书已存在,跳过生成"
        fi
      '
    volumes:
      - certs:/certs
    networks:
      - es-net

  # ── 节点1:Master 候选 + Data ─────────────────────────────────────
  es-node1:
    <<: *es-common
    container_name: es-node1
    environment:
      - node.name=es-node1
      - ES_JAVA_OPTS=-Xms1g -Xmx1g    # 开发环境 1g,生产按物理内存50%设置
      # 继承公共环境变量(YAML 合并键)
      - cluster.name=es-saas-cluster
      - discovery.seed_hosts=es-node1,es-node2,es-node3
      - cluster.initial_master_nodes=es-node1,es-node2,es-node3
      - bootstrap.memory_lock=true
      - xpack.security.enabled=true
      - xpack.security.http.ssl.enabled=true
      - xpack.security.http.ssl.keystore.path=certs/es-node.p12
      - xpack.security.transport.ssl.enabled=true
      - xpack.security.transport.ssl.keystore.path=certs/es-node.p12
      - xpack.security.transport.ssl.truststore.path=certs/es-node.p12
    volumes:
      - es-data1:/usr/share/elasticsearch/data
      - certs:/usr/share/elasticsearch/config/certs
    ports:
      - "9200:9200"    # 对外暴露 HTTP 端口(仅 node1,通过 Coordinating 转发其他节点请求)
    depends_on:
      es-setup:
        condition: service_completed_successfully

  # ── 节点2:Master 候选 + Data ─────────────────────────────────────
  es-node2:
    <<: *es-common
    container_name: es-node2
    environment:
      - node.name=es-node2
      - ES_JAVA_OPTS=-Xms1g -Xmx1g
      - cluster.name=es-saas-cluster
      - discovery.seed_hosts=es-node1,es-node2,es-node3
      - cluster.initial_master_nodes=es-node1,es-node2,es-node3
      - bootstrap.memory_lock=true
      - xpack.security.enabled=true
      - xpack.security.http.ssl.enabled=true
      - xpack.security.http.ssl.keystore.path=certs/es-node.p12
      - xpack.security.transport.ssl.enabled=true
      - xpack.security.transport.ssl.keystore.path=certs/es-node.p12
      - xpack.security.transport.ssl.truststore.path=certs/es-node.p12
    volumes:
      - es-data2:/usr/share/elasticsearch/data
      - certs:/usr/share/elasticsearch/config/certs

  # ── 节点3:Master 候选 + Data ─────────────────────────────────────
  es-node3:
    <<: *es-common
    container_name: es-node3
    environment:
      - node.name=es-node3
      - ES_JAVA_OPTS=-Xms1g -Xmx1g
      - cluster.name=es-saas-cluster
      - discovery.seed_hosts=es-node1,es-node2,es-node3
      - cluster.initial_master_nodes=es-node1,es-node2,es-node3
      - bootstrap.memory_lock=true
      - xpack.security.enabled=true
      - xpack.security.http.ssl.enabled=true
      - xpack.security.http.ssl.keystore.path=certs/es-node.p12
      - xpack.security.transport.ssl.enabled=true
      - xpack.security.transport.ssl.keystore.path=certs/es-node.p12
      - xpack.security.transport.ssl.truststore.path=certs/es-node.p12
    volumes:
      - es-data3:/usr/share/elasticsearch/data
      - certs:/usr/share/elasticsearch/config/certs

  # ── Kibana:可视化管理界面 ─────────────────────────────────────────
  kibana:
    image: kibana:8.13.0         # 版本必须与 ES 完全一致,否则会启动失败
    container_name: kibana
    environment:
      - ELASTICSEARCH_HOSTS=https://es-node1:9200
      - ELASTICSEARCH_USERNAME=kibana_system
      - ELASTICSEARCH_PASSWORD=${KIBANA_PASSWORD}   # 从 .env 文件读取
      - ELASTICSEARCH_SSL_CERTIFICATEAUTHORITIES=/certs/ca/ca.crt
    ports:
      - "5601:5601"
    volumes:
      - certs:/certs
    networks:
      - es-net
    depends_on:
      - es-node1

networks:
  es-net:
    driver: bridge

volumes:
  certs:
  es-data1:
  es-data2:
  es-data3:

这个配置的核心逻辑,有几点值得解释:

cluster.initial_master_nodes 只用一次。 这个配置只在集群第一次启动时生效,用于告诉 ES "这些节点是初始 master 候选,不要等其他节点"。集群组建完成后,这个配置就没有意义了------但也不会造成问题,保留着就好。很多人看到"initial"误以为每次启动都要配,其实不是。

bootstrap.memory_lock=true 必须配合 ulimits。 锁内存可以防止操作系统把 ES 的堆内存 swap 到磁盘,一旦发生 swap,ES 的延迟会急剧上升甚至宕机。但光设置这个环境变量不够,还必须同时放开 memlock 的 ulimit 限制,两者缺一不可。

Kibana 版本必须与 ES 完全一致。 这是一个非常容易踩的坑。如果 Kibana 是 8.12,ES 是 8.13,Kibana 会一直报 Kibana server is not ready yet,检查日志才发现版本不匹配。永远保持两者版本号字符串完全相同。

2.3 启动集群

bash 复制代码
# 第一步:生成 elastic 用户的初始密码(首次启动后执行)
docker compose up es-setup
docker compose up -d es-node1 es-node2 es-node3

# 等待集群健康(大约 30-60 秒)
# 然后重置 elastic 用户密码
docker exec -it es-node1 bin/elasticsearch-reset-password -u elastic

# 将密码写入 .env 文件(供 Kibana 和应用使用)
echo "ELASTIC_PASSWORD=你的密码" >> .env
echo "KIBANA_PASSWORD=kibana的密码" >> .env

# 启动 Kibana
docker compose up -d kibana

验证集群是否正常:

bash 复制代码
curl -u elastic:你的密码 \
     --cacert ./certs/ca/ca.crt \
     https://localhost:9200/_cluster/health?pretty

期望看到 "status": "green",以及 "number_of_nodes": 3

⚠️ 踩坑 :如果看到 "status": "yellow",不要慌,这通常是因为集群刚启动,.kibana 等系统索引默认有 1 个副本,但副本还没分配完成。等 1-2 分钟自然变绿。如果长时间是 yellow,运行 GET /_cluster/allocation/explain 查看原因。


三、Java 客户端的历史包袱与正确选择

这是本篇另一个重点,也是很多团队在 ES 8.x 升级时卡住的地方。

ES 的 Java 客户端经历了几次大变迁:

bash 复制代码
ES 2.x 时代:TransportClient(基于TCP,ES 7.x废弃,8.x彻底移除)
    ↓
ES 6.x 时代:RestHighLevelClient(基于HTTP,7.x主力,8.x标记废弃)
    ↓
ES 7.15+ :ElasticsearchClient(新官方Java客户端,强类型,8.x主力)
    ↓
Spring Data ES 4.4+:底层换成新客户端,提供更高层的 Repository 抽象

如果你的项目是新项目,直接用 ElasticsearchClient,跳过 RestHighLevelClient 后者虽然在 8.x 里还能用(靠兼容层),但官方已经不再主动维护,迟早要迁移。

如果你维护的是老项目,大量使用了 RestHighLevelClient,也不用急着迁,等到下次大版本升级时一起处理。


四、Spring Boot 3.x 整合 ElasticsearchClient

4.1 依赖配置

xml 复制代码
<!-- pom.xml -->
<dependencies>
    <!-- Spring Boot 3.x 整合 ES(底层使用新版 ElasticsearchClient)-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
    </dependency>

    <!-- Lombok,减少样板代码 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <!-- Jackson,处理 JSON -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
</dependencies>

⚠️ 踩坑 :Spring Boot 3.x 对应 Spring Data ES 5.x,底层已经切换到新的 ElasticsearchClient。但如果你的项目是从 Spring Boot 2.x 升级过来的,老代码里如果有 RestHighLevelClient 的注入,会直接报 Bean 找不到的错误------因为新版 Spring Data ES 不再自动装配 RestHighLevelClient,需要手动引入 elasticsearch-rest-high-level-client 依赖。这种兼容性问题在升级时很常见,建议升级前先读 Spring Data ES 的 Migration Guide。

4.2 application.yml 配置

yaml 复制代码
# application.yml
spring:
  elasticsearch:
    uris: https://localhost:9200      # 注意:ES 8.x 默认 HTTPS,不要写 http
    username: elastic
    password: ${ELASTIC_PASSWORD}     # 从环境变量读取,不要硬编码在配置文件里
    socket-timeout: 30s
    connection-timeout: 5s

  # 如果是开发环境,可以临时信任所有证书(生产环境绝不能这样做!)
  # 正确做法见下方 JavaConfig

仅靠 application.yml 还不够,因为 ES 8.x 的 HTTPS 证书是自签名的,Java 的 SSL 默认不信任自签名证书。有两种处理方式:

方式一(开发推荐):禁用 SSL 验证

这种方式只在本地开发环境使用,绝对不要带到生产:

java 复制代码
// EsConfig.java
@Configuration
public class EsConfig {

    @Value("${spring.elasticsearch.uris}")
    private String esUri;

    @Value("${spring.elasticsearch.username}")
    private String username;

    @Value("${spring.elasticsearch.password}")
    private String password;

    /**
     * 开发环境:绕过 SSL 验证。
     * 生产环境请使用方式二,加载真实证书。
     *
     * 为什么要这么做?
     * ES 8.x 默认生成的是自签名证书,Java 的 TrustManager 默认只信任
     * CA 签发的证书,自签名证书会导致 SSLHandshakeException。
     * 在开发阶段,绕过 SSL 验证是最简单的解决方案。
     */
    @Bean
    @Profile("dev")
    public ElasticsearchClient elasticsearchClientDev() throws Exception {
        // 创建信任所有证书的 SSLContext(仅开发)
        SSLContext sslContext = SSLContextBuilder.create()
                .loadTrustMaterial(null, (chain, authType) -> true)  // 信任所有证书
                .build();

        RestClient restClient = RestClient.builder(HttpHost.create(esUri))
                .setHttpClientConfigCallback(httpClientBuilder ->
                        httpClientBuilder
                                .setSSLContext(sslContext)
                                .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
                                .setDefaultCredentialsProvider(credentialsProvider())
                )
                .build();

        ElasticsearchTransport transport = new RestClientTransport(
                restClient, new JacksonJsonpMapper());
        return new ElasticsearchClient(transport);
    }

    private CredentialsProvider credentialsProvider() {
        BasicCredentialsProvider provider = new BasicCredentialsProvider();
        provider.setCredentials(AuthScope.ANY,
                new UsernamePasswordCredentials(username, password));
        return provider;
    }
}

方式二(生产推荐):加载 CA 证书

java 复制代码
@Bean
@Profile("prod")
public ElasticsearchClient elasticsearchClientProd() throws Exception {
    // 从 Docker volume 挂载路径加载 CA 证书
    Path caCertPath = Paths.get("/app/certs/ca/ca.crt");
    SSLContext sslContext = SSLContextBuilder.create()
            .loadTrustMaterial(caCertPath.toFile(), null, TrustAllStrategy.INSTANCE)
            .build();

    // 或者使用 KeyStore 加载 p12 格式
    KeyStore keyStore = KeyStore.getInstance("PKCS12");
    try (InputStream is = Files.newInputStream(Paths.get("/app/certs/es-node.p12"))) {
        keyStore.load(is, "".toCharArray());  // 我们生成证书时密码为空
    }

    RestClient restClient = RestClient.builder(HttpHost.create(esUri))
            .setHttpClientConfigCallback(httpClientBuilder ->
                    httpClientBuilder
                            .setSSLContext(sslContext)
                            .setDefaultCredentialsProvider(credentialsProvider())
            )
            .build();

    ElasticsearchTransport transport = new RestClientTransport(
            restClient, new JacksonJsonpMapper());
    return new ElasticsearchClient(transport);
}

4.3 定义实体类

java 复制代码
// Product.java
// 这是 ES 文档对应的 Java 实体类
// @Document 注解告诉 Spring Data ES 这个类对应哪个索引
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Document(indexName = "products")
public class Product {

    @Id
    private String id;

    // @Field 指定 ES 中的字段类型和分词器
    // text 类型用于全文检索,keyword 子字段用于精确匹配和排序
    @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.Double)
    private Double price;

    @Field(type = FieldType.Text)
    private String description;

    @Field(type = FieldType.Keyword)
    private List<String> tags;

    @Field(type = FieldType.Integer)
    private Integer stock;

    // 日期字段必须显式声明格式,否则 ES 和 Java 的日期格式可能不兼容
    @Field(type = FieldType.Date,
           format = DateFormat.date_hour_minute_second)
    private LocalDateTime createdAt;
}

⚠️ 踩坑LocalDateTime 与 ES 的 date 类型序列化是个经典坑。ES 的日期字段默认接受 ISO 8601 格式,但 Jackson 默认会把 LocalDateTime 序列化成数组 [2024,1,15,8,0,0],ES 无法识别。解决方案是在 @Field 注解里明确指定 format,同时确保 Jackson 的 JavaTimeModule 已注册。Spring Boot 自动配置通常已经处理了这个问题,但如果自定义了 ObjectMapper,要记得重新注册时间模块。

4.4 Repository 接口------最简单的 CRUD

java 复制代码
// ProductRepository.java
// 继承 ElasticsearchRepository 即可得到基础 CRUD 和分页能力
// 这是 Spring Data ES 提供的高层抽象,底层调用 ElasticsearchClient
@Repository
public interface ProductRepository
        extends ElasticsearchRepository<Product, String> {

    // Spring Data 根据方法名自动生成查询,类似 JPA
    // 这里会生成 term 查询(精确匹配 brand 字段)
    List<Product> findByBrand(String brand);

    // 价格范围查询
    List<Product> findByPriceBetween(Double minPrice, Double maxPrice);

    // 多条件组合(and 连接)
    Page<Product> findByCategoryAndPriceLessThan(
            String category, Double maxPrice, Pageable pageable);
}

Repository 方式虽然简洁,但有个明显局限:复杂的 bool 查询、聚合、高亮等都无法通过方法名表达。这时候需要直接使用 ElasticsearchClient

4.5 直接使用 ElasticsearchClient------处理复杂场景

java 复制代码
// ProductSearchService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductSearchService {

    private final ElasticsearchClient esClient;

    /**
     * 综合搜索接口:关键词全文检索 + 品牌过滤 + 价格范围
     *
     * 为什么要把 keyword 过滤放在 filter 而不是 must 里?
     * filter 不参与相关性计算,结果会被缓存,性能比 must 好很多。
     * must 用于需要影响评分的条件,filter 用于纯粹的过滤条件。
     */
    public SearchResult<Product> search(ProductSearchRequest request) throws IOException {
        SearchResponse<Product> response = esClient.search(s -> s
                .index("products")
                // 分页
                .from(request.getPage() * request.getSize())
                .size(request.getSize())
                // 查询体
                .query(q -> q
                    .bool(b -> {
                        // must:全文检索(影响相关性评分)
                        if (StringUtils.hasText(request.getKeyword())) {
                            b.must(m -> m
                                .multiMatch(mm -> mm
                                    .query(request.getKeyword())
                                    .fields("name^3", "description", "brand^2")
                                    // ^3 表示 name 字段权重是其他字段的3倍
                                    // 这样相同关键词出现在商品名里的结果排在前面
                                )
                            );
                        }
                        // filter:精确过滤(不影响评分,走缓存)
                        if (StringUtils.hasText(request.getBrand())) {
                            b.filter(f -> f
                                .term(t -> t
                                    .field("brand")
                                    .value(request.getBrand())
                                )
                            );
                        }
                        if (request.getMinPrice() != null || request.getMaxPrice() != null) {
                            b.filter(f -> f
                                .range(r -> {
                                    r.field("price");
                                    if (request.getMinPrice() != null) {
                                        r.gte(JsonData.of(request.getMinPrice()));
                                    }
                                    if (request.getMaxPrice() != null) {
                                        r.lte(JsonData.of(request.getMaxPrice()));
                                    }
                                    return r;
                                })
                            );
                        }
                        // filter:只展示有库存的商品
                        b.filter(f -> f
                            .range(r -> r
                                .field("stock")
                                .gt(JsonData.of(0))
                            )
                        );
                        return b;
                    })
                )
                // 高亮:搜索结果中关键词飘红
                .highlight(h -> h
                    .fields("name", hf -> hf
                        .preTags("<em class='highlight'>")
                        .postTags("</em>")
                        .numberOfFragments(0)  // 0 表示返回整个字段,不截断
                    )
                    .fields("description", hf -> hf
                        .preTags("<em class='highlight'>")
                        .postTags("</em>")
                        .numberOfFragments(3)
                        .fragmentSize(150)
                    )
                )
                // 排序
                .sort(sort -> sort
                    .field(f -> f
                        .field("_score")
                        .order(SortOrder.Desc)
                    )
                ),
                Product.class
        );

        // 解析响应,把高亮内容合并到结果里
        List<ProductVO> hits = response.hits().hits().stream()
                .map(hit -> {
                    Product product = hit.source();
                    ProductVO vo = ProductVO.fromProduct(product);
                    // 如果有高亮,用高亮内容替换原始内容
                    if (hit.highlight().containsKey("name")) {
                        vo.setNameHighlight(hit.highlight().get("name").get(0));
                    }
                    return vo;
                })
                .collect(Collectors.toList());

        long total = response.hits().total().value();
        return new SearchResult<>(hits, total, request.getPage(), request.getSize());
    }

    /**
     * 索引单个商品文档
     *
     * 注意:ES 的"更新"操作本质是删除旧文档 + 写入新文档。
     * 如果你只需要更新部分字段,用 updateProduct 方法(内部调用 _update API),
     * 比整个文档重新写入要高效得多,因为不需要重新索引所有字段。
     */
    public String indexProduct(Product product) throws IOException {
        IndexResponse response = esClient.index(i -> i
                .index("products")
                .id(product.getId())
                .document(product)
        );
        log.info("商品 [{}] 索引完成,版本号: {}", product.getId(), response.version());
        return response.id();
    }

    /**
     * 部分更新:只更新价格和库存
     *
     * 为什么要用 _update 而不是整个文档重写?
     * 因为商品的 name、description 等字段需要分词索引,重建成本高。
     * _update 只更新指定字段,Lucene 层面依然是删除+重写,
     * 但网络传输的数据量小很多,对实时性要求高的场景(如库存更新)更合适。
     */
    public void updateProductPrice(String productId, Double newPrice) throws IOException {
        esClient.update(u -> u
                .index("products")
                .id(productId)
                .doc(Map.of("price", newPrice)),
                Product.class
        );
    }
}

4.6 集群健康检查接口

这是上线前必须做的基础设施验证,建议加入应用的 Actuator 或者 HealthCheck 接口:

java 复制代码
// EsHealthController.java
@RestController
@RequestMapping("/actuator/es")
@RequiredArgsConstructor
public class EsHealthController {

    private final ElasticsearchClient esClient;

    @GetMapping("/health")
    public ResponseEntity<Map<String, Object>> health() {
        try {
            HealthResponse health = esClient.cluster().health(h -> h);
            Map<String, Object> result = new HashMap<>();
            result.put("status", health.status().jsonValue());   // green/yellow/red
            result.put("numberOfNodes", health.numberOfNodes());
            result.put("numberOfDataNodes", health.numberOfDataNodes());
            result.put("activePrimaryShards", health.activePrimaryShards());
            result.put("unassignedShards", health.unassignedShards());
            
            // 如果集群是 red,返回 503
            if (health.status() == HealthStatus.Red) {
                return ResponseEntity.status(503).body(result);
            }
            return ResponseEntity.ok(result);
        } catch (Exception e) {
            return ResponseEntity.status(503)
                    .body(Map.of("error", "无法连接到 ES 集群: " + e.getMessage()));
        }
    }
}

五、SaaS 视角:多租户场景下的客户端设计

在 SaaS 应用里,所有租户共用同一个 ES 集群(通常情况下),客户端设计需要考虑以下问题:

是否需要多个 ElasticsearchClient 实例?

一般情况下,一个 ElasticsearchClient 实例就够了。它内部维护了一个连接池,线程安全,可以并发使用。只有在以下场景才需要多实例:

  • 不同租户对应不同的 ES 集群(强隔离方案)
  • 需要用不同的 ES 账号访问(行级权限场景)

连接池与超时配置:

java 复制代码
RestClient restClient = RestClient.builder(
        HttpHost.create("https://es-node1:9200"),
        HttpHost.create("https://es-node2:9200"),
        HttpHost.create("https://es-node3:9200")  // 多节点,自动负载均衡
    )
    .setHttpClientConfigCallback(httpClientBuilder ->
            httpClientBuilder
                    .setMaxConnTotal(200)            // 连接池最大连接数
                    .setMaxConnPerRoute(50)           // 每个节点最大连接数
                    .setDefaultCredentialsProvider(credentialsProvider())
                    // 连接超时:建立 TCP 连接的最大等待时间
                    .setDefaultIOReactorConfig(
                            IOReactorConfig.custom()
                                    .setConnectTimeout(Timeout.ofSeconds(5))
                                    .build()
                    )
    )
    .setRequestConfigCallback(requestConfigBuilder ->
            requestConfigBuilder
                    .setSocketTimeout(30000)   // 等待响应的最大时间(ms),慢查询要适当放大
                    .setConnectTimeout(5000)
    )
    .build();

六、常见问题 FAQ

Q: 为什么 Spring Boot 启动时报 SSLHandshakeException

A: ES 8.x 使用自签名证书,Java 默认不信任。解决方案有三种:(1)按本文 4.2 节加载 CA 证书;(2)开发环境使用 TrustAllStrategy 绕过验证;(3)将 ES 的 HTTP SSL 关闭(仅用于内网开发,xpack.security.http.ssl.enabled=false,但 Transport 层 SSL 仍建议保留)。

Q: RestHighLevelClientElasticsearchClient 能同时存在吗?

A: 可以。在升级过渡期,可以通过手动引入 elasticsearch-rest-high-level-client 依赖来保留老客户端,同时使用新客户端开发新功能。但要注意两者的 Bean 名称可能冲突,需要手动命名区分。

Q: ES 的版本和 Spring Boot 的版本如何对应?

A: Spring Boot 3.2.x → Spring Data ES 5.2.x → 支持 ES 8.x。Spring Boot 2.7.x → Spring Data ES 4.4.x → 支持 ES 7.x/8.x(有限)。建议查阅 Spring Data ES 官方兼容矩阵

Q: 本地开发时,能不能直接关闭 ES 8.x 的安全认证?

A: 可以,但不推荐。关闭方式是设置 xpack.security.enabled=false。不推荐的原因是这样你的开发代码与生产环境配置差异太大,容易在上线时出现认证相关的问题。更好的做法是接受学习曲线,把安全配置从一开始就做好。


📋 本篇小结

知识点 关键结论
版本选型 新项目用 ES 8.x,拥抱默认安全配置
Docker Compose 标准化环境,version 必须全组件一致
内核参数 vm.max_map_count=262144 必须提前设置
Java 客户端 新项目用 ElasticsearchClient,弃用 RestHighLevelClient
SSL 处理 开发绕过验证,生产加载 CA 证书
连接池 单实例足够,配置超时和最大连接数
查询 vs 过滤 精确条件放 filter,全文检索放 must

🔗 上一篇

第01篇《Elasticsearch 核心概念与架构全解析------写给每一个想搞懂搜索引擎的开发者》

🔗 下一篇

第03篇《深入 Mapping 与数据类型设计------ES Schema 设计避坑指南》

Mapping 设计是 ES 项目中最容易"一错定终身"的环节。textkeyword 到底有什么区别?objectnested 什么时候该用哪个?主分片数设错了怎么不停机迁移?下一篇全部讲透。


💬 遇到启动报错? 欢迎在评论区贴出错误日志,大概率是证书或内核参数问题,一起排查。

相关推荐
Jinkxs3 小时前
LoadBalancer- 简单限流策略:Nginx 基于连接 / 请求的限流实现
java·运维·nginx
fenglllle3 小时前
JDK8升级JDK17使用CompletableFuture在线程中classloader的变化
java·开发语言·jvm
计算机安禾3 小时前
【c++面向对象编程】第44篇:typename与class的区别,依赖类型名与template消除歧义
java·jvm·c++
JAVA面经实录9173 小时前
Java+SpringAI企业级实战项目完整官方文档(生产终版)
java·开发语言·spring·ai编程
梵得儿SHI3 小时前
Java IO 流进阶:Buffer 与 Channel 核心概念解析及与传统 IO 的本质区别
java·开发语言·高并发·nio·channel·buffer·提升io效率
斯特凡今天也很帅3 小时前
Spring Boot+mybatis项目切换sql为传参成无参
spring boot·sql·mybatis
2301_789015623 小时前
C++_string增删查改模拟实现
java·开发语言·c++
没有逆称3 小时前
Java OOM 问题全解析
java·jvm
星河耀银海3 小时前
JAVA 注解(Annotation):从原理到实战应用
java·开发语言·数据库