Spring Data Elasticsearch 深度整合

3 阅读45分钟

概述

前文《Elasticsearch 安全与多租户》构建了 ES 的安全防线。但对 Java 开发者而言,直接使用 ElasticsearchClient 构造 JSON 请求、解析响应、处理异常仍然繁琐。Spring Data Elasticsearch 通过自动配置、对象映射和模板模式,将 ES 操作无缝嵌入 Spring 生态——你只需要定义实体类、声明 Repository 接口,就能像操作数据库一样操作搜索引擎。本文将从 Spring Boot 自动配置源码出发,深入 ElasticsearchClientRestHighLevelClient 的架构差异,拆解 NativeSearchQueryCriteriaQueryStringQuery 三种查询构建方式,以及 @Document/@Field 注解的映射原理。

如果你还在为 ES 8.x 升级后 RestHighLevelClient 的废弃而困扰,或者不确定 NativeSearchQueryCriteriaQuery 该选哪个,那么本文就是为你准备的。Spring Data Elasticsearch 5.x 基于新的 ElasticsearchClient 重构,提供了更简洁的 Builder + Lambda API,同时保留了模板方法模式的便利性。本文将从自动配置的源码入口到复杂查询的代码实战,完整拆解 Spring 与 ES 的整合内核,并展示如何在一个项目中优雅地让 MyBatis 管理事务、ES 加速搜索。

核心要点

  • 自动配置原理ElasticsearchDataAutoConfiguration 条件装配与 ClientConfiguration 定制。
  • 新旧客户端对比RestHighLevelClient vs ElasticsearchClient 的架构差异与迁移策略。
  • 对象映射@Document@Field@Id@MultiField 等注解的映射机制与 MappingElasticsearchConverter
  • 三种查询构建NativeSearchQuery(Builder 组合)、CriteriaQuery(链式条件)、StringQuery(原生 JSON)。
  • 混合架构:与 MyBatis/JPA 在同一项目中的协同使用。

文章组织架构图

flowchart TD
    subgraph 1[1. Spring Boot 自动配置原理]
        A1(条件装配)
        A2(属性绑定)
        A3(ClientConfiguration)
    end
    subgraph 整体[整体架构概览]
        Arch(分层架构与组件协作)
    end
    subgraph 2[2. 新旧客户端对比与迁移策略]
        B1(RestHighLevelClient 局限)
        B2(ElasticsearchClient 优势)
        B3(迁移路径)
    end
    subgraph 3[3. 对象映射与实体类设计]
        C1(核心注解)
        C2(MappingElasticsearchConverter)
        C3(自定义转换器)
    end
    subgraph 4[4. 模板操作与三种查询构建方式]
        D1(ElasticsearchOperations)
        D2(NativeSearchQuery)
        D3(CriteriaQuery)
        D4(StringQuery)
    end
    subgraph 5[5. 复杂查询实战]
        E1(Bool组合+聚合)
        E2(分页与search_after)
        E3(高亮与自动补全)
    end
    subgraph 6[6. 与MyBatis/JPA混合使用架构]
        F1(协同架构)
        F2(事务边界)
        F3(异步同步)
    end
    subgraph 7[7. 面试高频专题]
        G1(12+ 题含系统设计)
    end

    1 --> 整体 --> 2 --> 3 --> 4 --> 5 --> 6 --> 7

架构图说明
总览说明:全文 7 个模块加一个整体架构概览,从 Spring Boot 自动配置出发,展示整体分层架构,然后逐步深入客户端选择、对象映射、查询构建和混合架构,最后以面试题收尾。
逐模块说明:模块 1 建立自动配置认知;整体架构概览展示组件全貌与数据流;模块 2 解决新旧版本选择困惑;模块 3-5 是全文核心,深入映射与查询的编码实践;模块 6 展示生产级混合架构;模块 7 面试巩固。
关键结论Spring Data Elasticsearch 的价值在于将 ES 的 RESTful 操作封装为 Spring 风格的模板与 Repository。理解 ElasticsearchClient 的 Builder + Lambda 设计、NativeSearchQuery 的构建链和 @Field 注解的映射规则,是高效使用 Spring Data ES 的基础。


1. Spring Boot 自动配置原理

Spring Data Elasticsearch 的自动配置类是 ElasticsearchDataAutoConfiguration,位于 spring-boot-autoconfigure 模块。其核心职责是根据 classpath 中是否存在 ES 客户端类,自动创建并注册 ElasticsearchClient(或 RestClient/RestHighLevelClient)以及 ElasticsearchOperations 等 Bean。整个流程遵循 Spring Boot 的“条件装配 + 属性绑定”范式,让开发者通过少量配置即可获得完整的 ES 交互能力。

1.1 条件装配流程

ElasticsearchDataAutoConfiguration 类使用了多个条件注解来控制 Bean 的创建。核心逻辑可以简化为以下步骤:

  1. @ConditionalOnClass:检测 classpath 中是否有 co.elastic.clients.elasticsearch.ElasticsearchClient 类。如果存在,说明项目中引入了新的 Java API Client(即 elasticsearch-java 依赖),则激活当前配置。
  2. @EnableConfigurationProperties(ElasticsearchRestClientProperties.class):启用 spring.elasticsearch.* 配置属性绑定。
  3. 内部根据条件判断创建对应的客户端 Bean。在 Spring Boot 3.x 和 Spring Data Elasticsearch 5.x 中,默认聚焦于 ElasticsearchClient(新版本客户端),因为 RestHighLevelClient 已被标记为废弃,且在更高版本的 auto-configuration 中已移除对它的默认支持。
  4. @ConditionalOnMissingBean:如果用户已通过 @Bean 自定义了 ElasticsearchClient,则自动配置的默认 Bean 不会生效,保证用户可以完全接管客户端创建过程。

除了客户端,ElasticsearchDataAutoConfiguration 还会自动装配 ElasticsearchOperations(实际类型为 ElasticsearchRestTemplate),同样使用条件注解,确保一旦有了 ElasticsearchClient,就自动提供一个模板操作 Bean。

自动配置流程图:

flowchart TD
    Start(["Spring Boot 启动"]) --> CheckClass{"@ConditionalOnClass<br/>ElasticsearchClient?"}
    CheckClass -- 否 --> Skip["跳过自动配置"]
    CheckClass -- 是 --> BindProps["绑定ElasticsearchRestClientProperties<br/>读取 spring.elasticsearch.*"]
    BindProps --> CheckUserBean{"@ConditionalOnMissingBean<br/>ElasticsearchClient?"}
    CheckUserBean -- "用户已定义" --> UseUser["使用用户自定义Bean"]
    CheckUserBean -- "未定义" --> CreateDefault["创建默认 ElasticsearchClient<br/>基于 RestClientBuilder"]
    CreateDefault --> CreateTemplate["创建 ElasticsearchRestTemplate"]
    UseUser --> CreateTemplate
    CreateTemplate --> Done(["装配完成"])
    Skip --> Done

    classDef decision fill:#f1f5f9,stroke:#334155,stroke-width:2px,color:#0f172a
    classDef process fill:#f8fafc,stroke:#64748b,stroke-width:2px,color:#1e293b
    classDef endpoint fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#0f172a

    class CheckClass,CheckUserBean decision
    class BindProps,UseUser,CreateDefault,CreateTemplate,Skip process
    class Start,Done endpoint

图 1 四层说明

  • 流程图要素:条件检查(类存在、Bean 缺失)、属性绑定、Bean 创建。
  • 角色与协作ElasticsearchDataAutoConfiguration 作为配置入口,ElasticsearchRestClientProperties 作为属性持有者,RestClientBuilder 作为底层 HTTP 客户端构造器。
  • 关键设计思想:通过 @ConditionalOnMissingBean 预留扩展点,用户只需自定义 ElasticsearchClient Bean 即可完全覆盖默认配置,例如需要添加 SSL 证书、自定义重试策略等。
  • 与前文联系:此处的属性绑定和连接配置与第 8 篇中 ES 集群安全认证(username/password)直接相关,安全证书和用户凭证正是通过此处传入客户端。

1.2 属性绑定

ElasticsearchRestClientProperties 使用 @ConfigurationProperties(prefix = "spring.elasticsearch") 将所有与 ES 连接相关的参数从 application.yml 映射到 Java 对象。主要属性包括:

  • uris:ES 节点的 URI 列表,默认 http://localhost:9200
  • username / password:认证凭证。
  • connection-timeout / socket-timeout:超时时间,分别对应连接建立超时和读取超时。
  • path-prefix:公共路径前缀,常用于反向代理场景。
  • restclient.sniffer:嗅探器相关配置,可动态发现集群节点。

这些属性最终会被 RestClientBuilder 使用,构建出底层的 RestClient,进而被 ElasticsearchClient 包装。

完整配置示例:

spring:
  elasticsearch:
    uris: https://es-node1:9200,https://es-node2:9200
    username: elastic
    password: changeme
    connection-timeout: 5s
    socket-timeout: 60s
    path-prefix: /api
    restclient:
      sniffer:
        interval: 300s
        delay-after-failure: 180s

设计意图解读

  • 安全性:通过 username/password 透明传递安全凭证,底层会自动添加 Authorization: Basic ... 头(或通过 API Key 方式),结合第 8 篇中的 RBAC 模型,确保客户端拥有合适的角色权限。
  • 高可用uris 支持多节点列表,当某个节点不可用时,RestClient 内置的故障转移机制会自动切换到其他节点。配置嗅探器可动态感知集群拓扑变化。
  • 超时控制connection-timeout 避免连接建立时无限等待,socket-timeout 防止慢查询占用资源,这对生产环境的稳定性至关重要。在后续第 14 篇系统设计中,这些参数需要与断路器、重试策略联动。

1.3 自定义客户端配置

虽然自动配置能满足基本需求,但在生产级项目中,经常需要配置 HTTPS 证书、自定义 Header(如 API Key)、重试策略、连接池参数等。Spring Data ES 提供了 AbstractElasticsearchConfiguration 抽象类(位于 spring-data-elasticsearch 模块)作为推荐的自定义入口。

继承该类并覆盖 elasticsearchClient() 方法,通过 ClientConfiguration 创建客户端:

@Configuration
public class ElasticsearchConfig extends AbstractElasticsearchConfiguration {

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

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

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

    @Override
    public ElasticsearchClient elasticsearchClient() {
        // 构建 ClientConfiguration
        ClientConfiguration clientConfiguration = ClientConfiguration.builder()
                .connectedTo(uris.split(","))               // 设置节点列表
                .usingSsl()                                 // 启用 SSL
                .withBasicAuth(username, password)          // 基本认证
                .withConnectTimeout(Duration.ofSeconds(5))  // 连接超时
                .withSocketTimeout(Duration.ofSeconds(60))  // 套接字超时
                .build();

        // 通过 ElasticsearchClients 创建客户端
        return ElasticsearchClients.createClient(clientConfiguration);
    }
}

设计分析

  • ClientConfiguration 提供了 Builder 模式的流畅 API,所有连接参数都可以通过链式调用设置,避免了冗长的配置对象组装。
  • ElasticsearchClients.createClient() 是创建 ElasticsearchClient 的工厂方法,内部会使用 RestClient 作为 HTTP 传输层,并自动集成 Jackson 的 ObjectMapper,使得对象映射与客户端绑定在一起。
  • 这种方式充分利用了 Spring 的 JavaConfig 特性,且与属性绑定解耦,可以硬编码部分不常变动的配置(如 SSL),也可以从 Environment 中读取动态配置。

1.4 整体架构概览

在深入各组件细节之前,有必要从全局视角理解 Spring Data Elasticsearch 的分层架构以及请求从应用层到 ES 集群的完整链路。这不仅有助于定位问题,也利于合理选择抽象层级进行编码。

Spring Data ES 整体分层架构图:

flowchart TB
    subgraph App[应用层]
        Repo[Repository 接口<br/>声明式查询]
        Svc[Service 层<br/>使用 ElasticsearchOperations]
    end
    subgraph SDES[Spring Data Elasticsearch 核心]
        Ops[ElasticsearchOperations<br/>ElasticsearchRestTemplate]
        MapConv[MappingElasticsearchConverter<br/>对象映射]
        QueryBuild[查询构建<br/>NativeSearchQuery / CriteriaQuery / StringQuery]
    end
    subgraph Client[Java 客户端层]
        EC[ElasticsearchClient<br/>新客户端 Builder+Lambda]
        RestC[RestClient<br/>低级 HTTP 客户端]
    end
    subgraph ES[Elasticsearch 集群]
        Node1[Node 1]
        Node2[Node 2]
        Node3[Node 3]
    end

    Repo --> Ops
    Svc --> Ops
    Ops --> MapConv
    Ops --> QueryBuild
    QueryBuild --> EC
    Ops --> EC
    MapConv <--> EC
    EC --> RestC
    RestC --> Node1
    RestC --> Node2
    RestC --> Node3

图 X 四层说明

  • 分层职责:应用层负责业务逻辑,通过 Repository 接口(自动代理)或直接注入 ElasticsearchOperations 操作 ES;Spring Data 核心层提供模板、对象映射、查询构建抽象;Java 客户端层封装了与 ES 的 REST 通信;ES 集群为最终存储与搜索引擎。
  • 数据流:写操作时,实体对象经 MappingElasticsearchConverter 转换为 Document,再通过 ElasticsearchClient 序列化为 JSON 发送;读操作时,JSON 响应经 ElasticsearchClient 反序列化为 SearchResponse,转换器再将其映射为实体。
  • 扩展点:在 ElasticsearchOperations 层面可进行 AOP 拦截(日志、监控);在 MappingElasticsearchConverter 可自定义类型转换;在 RestClient 可配置连接池、拦截器、重试策略。
  • 与 Spring 生态整合ElasticsearchOperations 被 Spring 管理,支持声明式事务(非 ES 事务,而是Spring事务管理器内资源同步),Repository 自动实现依靠 Spring Data 的代理工厂。

这种分层架构使得开发者可以根据需求选择不同的抽象级别:简单查询直接用 Repository 方法命名,复杂查询使用 ElasticsearchOperations 搭配 NativeSearchQuery,极特殊情况直接获取 RestClient 发送原生请求。每一层都在下一层的基础上提供更贴近业务的抽象,同时不屏蔽底层的完整能力。


2. 新旧客户端对比与迁移策略

随着 Elasticsearch 8.0 的发布,官方正式废弃了 RestHighLevelClient,转而全力推进新的 Java API Client(即 co.elastic.clients.elasticsearch.ElasticsearchClient)。Spring Data Elasticsearch 5.x 也顺应这一变化,将 ElasticsearchClient 作为首选客户端。理解两者架构差异是顺利迁移的关键。

2.1 RestHighLevelClient 的废弃原因

RestHighLevelClient 在 7.x 时代是 Java 操作 ES 的主要方式,但其设计上存在多个根本性缺陷:

  1. API 风格割裂:每个操作都是独立的 Request/Response 类(如 IndexRequestSearchRequestGetRequest),没有统一的 Builder 模式,而且不同 API 的参数构造方式差异巨大,学习曲线陡峭。例如,构建查询需要先创建 SearchSourceBuilder,再向其中添加 QueryBuilderAggregationBuilder 等,代码冗长且不直观。

  2. 缺乏类型安全:请求和响应几乎都是面向 JSON 字符串或 Map 的,即使设置了实体类,也需要手动进行序列化/反序列化。错误通常在运行时暴露,而非编译期。

  3. 强耦合 ES 服务端版本RestHighLevelClient 与 ES 服务端的内部协议(如序列化格式)高度耦合,同一个客户端的 minor 版本通常只能对接相同 minor 版本的服务端,导致升级服务端时必须同步升级客户端,且 API 经常发生断裂式变动。

  4. 内部使用 Apache HTTP Client 的私有封装:许多高级特性(如反应式、负载均衡)难以集成,并且其底层依赖的 RestClient 虽然仍在用,但高层 API 的封闭性使得自定义扩展变得困难。

旧代码示例 (RestHighLevelClient):

SearchRequest searchRequest = new SearchRequest("products");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.boolQuery()
        .must(QueryBuilders.matchQuery("name", "手机"))
        .filter(QueryBuilders.rangeQuery("price").gte(1000).lte(5000)));
sourceBuilder.aggregation(
        AggregationBuilders.terms("brand_agg").field("brand.keyword"));
sourceBuilder.sort(new FieldSortBuilder("price").order(SortOrder.DESC));
searchRequest.source(sourceBuilder);

SearchResponse response = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
// 手动解析 SearchResponse...

这段代码暴露出多个问题:Request 与 SourceBuilder 分离,查询构造步骤繁杂,聚合与排序通过不同类型的 Builder 组合,解析结果时需手动处理类型转换。

2.2 ElasticsearchClient 的 Builder + Lambda 设计优势

新的 ElasticsearchClient 基于 Jackson 进行 JSON 序列化,采用 Builder + Lambda 的流式 API,从根本解决了上述问题:

  • 统一且类型安全的 API:所有请求都是泛型的,例如 IndexRequest<Product> 明确了索引的文档类型,SearchResponse<Product> 可以自动将命中的 _source 反序列化为 Product 对象,编译期就能发现类型不匹配。
  • Lambda 表达式构建查询:查询条件通过函数式接口传递,例如 b -> b.bool(bo -> bo.must(m -> m.match(ma -> ma.field("name").query("手机")))),代码紧凑、可读性高,IDE 自动补全大大降低编写错误。
  • 底层依赖统一为 RestClient:新客户端完全基于低级 RestClient 构建,去掉了高层私有封装,更利于扩展与性能优化。同一 RestClient 实例可同时供 RestHighLevelClient(如果仍需要)和新客户端使用,为平滑迁移提供可能。
  • 与 Jackson 深度整合:通过配置 ObjectMapper 可灵活控制字段命名、日期格式、空值处理等,映射逻辑集中在序列化层,与 ES 交互无缝衔接。

等价新代码 (ElasticsearchClient):

// 构建查询:Lambda 风格,类型安全
SearchResponse<Product> response = elasticsearchClient.search(s -> s
        .index("products")
        .query(q -> q
                .bool(b -> b
                        .must(m -> m.match(ma -> ma.field("name").query("手机")))
                        .filter(f -> f.range(r -> r.field("price").gte("1000").lte("5000")))
                )
        )
        .aggregations("brand_agg", agg -> agg
                .terms(t -> t.field("brand.keyword"))
        )
        .sort(sort -> sort
                .field(f -> f.field("price").order(org.opensearch.client.opensearch._types.SortOrder.Desc))
        ),
        Product.class  // 指定响应反序列化的目标类型
);

// 结果解析已内建,直接访问
List<Hit<Product>> hits = response.hits().hits();

对比总结:新 API 去掉了所有 Builder 的显式实例化,利用 Lambda 内联构建,链式调用的每一步都受到类型检查,且最终查询结构直接映射到 ES REST API 的 JSON 体,与 ES 版本解耦,更加健壮。

新旧客户端架构对比图:

flowchart LR
    subgraph Old[RestHighLevelClient 旧架构]
        direction TB
        AppOld[应用程序] --> RHL[RestHighLevelClient]
        RHL --> RC1[RestClient 底层]
        RHL --> Req1[SearchRequest]
        Req1 --> SSB[SearchSourceBuilder]
        SSB --> QB[QueryBuilder]
        SSB --> AB[AggregationBuilder]
        RHL --> Resp1[SearchResponse]
        Resp1 --> Parse1[手动解析 JSON]
    end
    subgraph New[ElasticsearchClient 新架构]
        direction TB
        AppNew[应用程序] --> EC[ElasticsearchClient]
        EC --> RC2[RestClient 底层]
        EC --> Lambda[Lambda Builder]
        Lambda --> QueryDSL[类型安全查询DSL]
        EC --> Resp2[SearchResponse<Product>]
        Resp2 --> AutoMap[自动 Jackson 映射]
    end
    Old -.->|废弃, 迁移| New

图 2 四层说明

  • 结构对比:旧架构中请求构建和响应解析完全分离,需要开发者操作多种 Builder;新架构中请求构建、执行、反序列化全部融合在一条 Lambda 链中。
  • 类型安全:新架构通过泛型 SearchResponse<Product> 提供编译期类型检查,旧架构只能通过 GetResponse.getSourceAsString() 或 Map 访问。
  • 底层复用:两者都依赖 RestClient,但新客户端不再创建额外的抽象层,直接基于该底层客户端通信,避免了重复封装。
  • 迁移启示:可以在项目中同时保留两个客户端 Bean,通过 RestClient 实例共享,逐步将查询代码从旧 API 切换到新 API。

2.3 迁移路径与共存策略

由于 RestHighLevelClient 已在 ES 8.x 中废弃,并将在未来版本中移除,生产项目的迁移是必然的。推荐采用逐步替换策略:

  1. 第一步:共享底层 RestClient
    在配置类中构建一个 RestClient Bean,然后分别用它创建旧的 RestHighLevelClient(如果需要保持部分业务)和新的 ElasticsearchClient。这样可以确保连接池、节点嗅探等底层配置共享,避免资源浪费。

  2. 第二步:按模块迁移查询代码
    对于新功能直接使用新客户端;对于存量功能,可以按 Repository 或 Service 逐个重写。由于 Spring Data Elasticsearch 5.x 的 ElasticsearchRestTemplate 已经全面支持新客户端,可以在同一个项目中混用两种 Repository(一个基于旧 API 的 ElasticsearchRestTemplate(旧) 和新的 ElasticsearchOperations),通过不同的 Bean 名注入。

  3. 第三步:移除旧客户端依赖
    当所有查询迁移完成后,删除旧客户端 Bean 和相关 Maven 依赖(elasticsearch-rest-high-level-client),全面切换至新客户端。

共存配置示例:

@Configuration
public class MixedClientConfig {

    @Bean
    public RestClient restClient() {
        // 底层 RestClient 共享
        return RestClient.builder(
                new HttpHost("es-node1", 9200, "https"),
                new HttpHost("es-node2", 9200, "https"))
                .setHttpClientConfigCallback(h -> h
                        .setDefaultCredentialsProvider(credsProvider()))
                .build();
    }

    @Bean
    public ElasticsearchClient elasticsearchClient(RestClient restClient) {
        // 新客户端包装同一个 RestClient
        ElasticsearchTransport transport = new RestClientTransport(
                restClient, new JacksonJsonpMapper());
        return new ElasticsearchClient(transport);
    }

    @Bean
    @Deprecated
    public RestHighLevelClient restHighLevelClient(RestClient restClient) {
        // 旧客户端仍然可用,但标记废弃以警示
        return new RestHighLevelClient(restClient);
    }
}

迁移过程中的注意事项

  • 响应解析变化:旧 API 的 SearchHit.getSourceAsString() 需替换为新 API 的 hit.source()(直接反序列化为对象)。
  • 聚合解析变化:新 API 中聚合的解析更加严格,需通过 response.aggregations().get("brand_agg") 等具名方法获取,且需要根据聚合类型强转。
  • 异常处理:旧客户端的 ElasticsearchException 与新客户端的异常体系不完全一致,迁移时需要统一异常捕获。

3. 对象映射与实体类设计

Spring Data Elasticsearch 通过注解和 MappingElasticsearchConverter 实现 Java 对象与 ES 文档的双向转换。实体类上的元数据定义了索引的结构(Mapping),也决定了 JSON 序列化的规则。这一层是 Spring Data 价值的重要体现——开发者无需手动构造 IndexRequest 的 JSON 体,只需关注领域模型。

3.1 核心注解与映射规则

  • @Document:标记一个类为 ES 文档,并指定索引信息。
    • indexName:索引名称,支持 SpEL 表达式动态指定(如 #{@indexNameProvider.getIndexName()})。
    • createIndex:是否在应用启动时自动创建索引,默认 true。生产环境中建议关闭,使用手动 Mapping 管理(详见第 4 篇)。
    • shardsreplicasrefreshInterval:索引创建时的设置,仅在 createIndex=true 时生效。这些设置直接映射到 ES 索引的 settings
  • @Id:标注文档 ID 字段,支持 StringLongUUID 等类型。如果值为 null,ES 会自增生成 ID。
  • @Field:定义字段的 Mapping 属性,核心参数包括:
    • type:ES 字段类型,使用 FieldType 枚举,如 FieldType.TextFieldType.KeywordFieldType.IntegerFieldType.Nested 等。
    • name:ES 中字段名称,默认与 Java 字段名一致(但可根据命名策略转换,如驼峰转下划线)。
    • analyzersearchAnalyzer:分别指定索引时分词器和搜索时分词器,实现搜索与索引分词的分离。
    • indexdocValuesstore:控制是否索引、是否存储列式数据、是否单独存储原始值。
  • @MultiField:将一个字段映射为多个子字段,通常用于 text 类型字段同时需要 keyword 子字段(用于精确匹配、排序、聚合)。
  • @Transient:排除该字段,不持久化到 ES。
  • @GeoPointField:专门标注地理位置字段。

完整实体类示例:

@Document(indexName = "products", createIndex = true, shards = 3, replicas = 1)
public class Product {

    @Id
    private String id;  // 文档ID

    @MultiField(
        mainField = @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart"),
        otherFields = {
            @InnerField(suffix = "keyword", type = FieldType.Keyword)
        }
    )
    private String name;  // 商品名称:text 用于全文搜索,keyword 子字段用于精确匹配与排序

    @Field(type = FieldType.Keyword)
    private String category;  // 品类

    @Field(type = FieldType.Double)
    private BigDecimal price;  // 价格,通过自定义转换器使用 scaled_float

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

    @Field(type = FieldType.Date, format = {}, pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;  // 创建时间

    @Transient
    private String tempData;  // 临时字段,不存入 ES
}

映射解析

  • @MultiField 使得 name 字段在 ES 中以 text 主字段索引,并有一个 name.keyword 子字段,类型为 keyword。这对于既要全文搜索又要按名称排序/聚合的场景非常实用,与前文第 4 篇中 Mapping 设计的多字段策略一致。
  • @Field(type = FieldType.Double) 在默认情况下映射为 double 类型,但如果我们希望存储为 scaled_float 以节省磁盘(如价格精确到分),则需要自定义转换器,后文会展示。
  • @Transient 确保了某些计算属性或业务辅助属性不会污染 ES 文档,保持了文档的干净。

3.2 MappingElasticsearchConverter 映射流程

当调用 ElasticsearchRestTemplate.save(product) 时,幕后执行的是 MappingElasticsearchConverter 的映射流程。该类实现了 ElasticsearchConverter 接口,负责将 Java 对象转换为可以被 ElasticsearchClient 序列化的 Document 对象(Spring Data 内部的通用文档表示),以及将查询结果反序列化回 Java 对象。

映射过程

  1. 元数据读取:Spring Data 在启动时通过反射扫描 @Document 注解的类,生成 ElasticsearchPersistentEntityElasticsearchPersistentProperty 元数据模型,包含索引名、字段类型、转换器等信息。
  2. 写映射 (Java -> ES)
    • 遍历实体的所有属性,根据 @Field 的类型和 Converter 将值写入一个 Document(类似 Map<String, Object>)。
    • 如果字段标注 @Id,则将其值作为文档 ID,单独处理。
    • 如果存在自定义的 PropertyValueConverter,则先进行转换再写入。
    • Document 连同索引名、ID 等传递给 ElasticsearchClient,后者通过 Jackson 序列化为 JSON 发送给 ES。
  3. 读映射 (ES -> Java)
    • ES 返回的 JSON 命中结果,先由 Jackson 反序列化为 Document
    • MappingElasticsearchConverter 根据元数据,将 Document 中的字段值读取出来,进行必要的类型转换(如 LongLocalDateTimeDoubleBigDecimal),最后调用实体类的构造函数或 setter 填充对象。

深入源码来看,MappingElasticsearchConverter 内部维护了一个 ElasticsearchPersistentEntityIndex,该索引缓存了所有 @Document 实体类的元信息。写操作的核心方法 write(Object source, Document sink, MappingContext context) 会迭代实体的 PersistentProperty,并调用 writeProperty(source, sink, property, context)。对于简单属性,直接使用 Spring 的 ConversionService 进行类型转换;对于复杂对象或带注解的属性,会根据 @Fieldtype 决定如何写入,例如 Nested 类型会递归调用写映射。

对象映射流程图:

flowchart TD
    subgraph Write ["写操作"]
        A1["Java实体对象"] --> B1{"MappingElasticsearchConverter"}
        B1 --> C1["读取注解元数据<br/>ElasticsearchPersistentEntity"]
        C1 --> D1["遍历属性,PropertyValueConverter转换"]
        D1 --> E1["生成 Document Map"]
        E1 --> F1["ElasticsearchClient"]
        F1 --> G1["Jackson 序列化为 JSON"]
        G1 --> H1["ES 索引"]
    end

    subgraph Read ["读操作"]
        A2["ES 搜索结果 JSON"] --> B2["ElasticsearchClient"]
        B2 --> C2["Jackson 反序列化为 Document"]
        C2 --> D2{"MappingElasticsearchConverter"}
        D2 --> E2["根据元数据读取字段<br/>使用 ConversionService 转换类型"]
        E2 --> F2["对象填充(构造器或setter)"]
        F2 --> G2["Java实体对象"]
    end

    classDef writeStyle fill:#f1f5f9,stroke:#334155,stroke-width:2px,color:#0f172a
    classDef readStyle fill:#f8fafc,stroke:#475569,stroke-width:2px,color:#1e293b
    classDef converter fill:#e2e8f0,stroke:#475569,stroke-width:2px,color:#0f172a

    class A1,C1,D1,E1,F1,G1,H1 writeStyle
    class B1,D2 converter
    class A2,B2,C2,E2,F2,G2 readStyle

图 3 四层说明

  • 参与者:Java 实体、MappingElasticsearchConverterElasticsearchClient、Jackson、ES 索引。
  • 关键转换点:注解元数据缓存(读一次,重复使用)保证了性能;Document 作为中间媒介解耦了实体类与 JSON 的直接依赖。
  • 扩展点:可通过 CustomConversionsPropertyConverter 插入自定义逻辑,影响 Document 的生成和读取。
  • 与前文关联:最终的 Mapping 定义(字段类型、分词器等)源自第 4 篇的规划,而 @Document 的设置直接影响索引创建参数,需与第 8 篇的权限模型配合(如 createIndex 需要管理权限)。

3.3 自定义 PropertyConverter

Spring Data ES 提供 PropertyValueConverter 接口,允许自定义单个字段的 ES 存储格式与 Java 类型的转换。典型场景包括 BigDecimal 转为 scaled_float、枚举转为自定义编码、多个字段合并或拆分等。

示例:价格使用 scaled_float 存储

scaled_float 是将浮点数乘以一个缩放因子后以 long 存储,可显著减少磁盘空间。假设价格以分为单位,缩放因子为 100。

public class PriceConverter implements PropertyValueConverter {

    @Override
    public Object write(Object value, MappingContext<? extends PersistentEntity<?, ?>, ? extends PersistentProperty<?>> context) {
        if (value instanceof BigDecimal) {
            // 元 -> 分,缩放因子100
            return ((BigDecimal) value).multiply(BigDecimal.valueOf(100)).longValue();
        }
        return value;
    }

    @Override
    public Object read(Object value, MappingContext<? extends PersistentEntity<?, ?>, ? extends PersistentProperty<?>> context) {
        if (value instanceof Long) {
            // 分 -> 元
            return BigDecimal.valueOf((Long) value, 2);
        }
        return value;
    }
}

在实体字段上声明:

@Field(type = FieldType.Long)  // ES 中实际存储为 long
@ValueConverter(PriceConverter.class)
private BigDecimal price;

同时需要在索引 Mapping 中指定 scaling_factor,这无法通过注解直接生成,需要手动更新 Mapping(或通过 IndexOperations.putMapping())。这种转换器完全透明,业务代码依旧使用 BigDecimal,而 ES 底层存储为长整型,兼顾精度与存储效率。


4. 模板操作与三种查询构建方式

Spring Data Elasticsearch 的操作接口体系围绕 ElasticsearchOperations 展开,其实现类为 ElasticsearchRestTemplate。它封装了通用的 CRUD、索引管理、搜索等功能,是 Repository 背后的执行引擎。开发者也可以直接注入 ElasticsearchOperations 来编写更灵活的查询。

4.1 ElasticsearchOperations 接口体系

核心子接口分工明确:

  • IndexOperations:索引生命周期管理,包括创建索引、更新 Mapping、设置别名、刷新、删除索引等。可通过 operations.indexOps(Product.class) 获取。
  • DocumentOperations:提供 savesaveAllgetdeletebulkIndex 等单个或批量文档操作。
  • SearchOperations:核心搜索入口,定义多种 search 方法重载,接受 Query 对象(如 NativeSearchQueryCriteriaQueryStringQuery)并返回 SearchHits<T>,同时支持 countsuggest 等。

ElasticsearchRestTemplate 实现以上所有接口,通过组合 ElasticsearchClientMappingElasticsearchConverter 完成任务。理解其架构有助于在需要高度定制的场景中,绕过 Repository,直接使用模板编写复杂逻辑。

4.2 NativeSearchQuery – Builder 组合模式

NativeSearchQuery 是最强大的查询构建方式,它直接使用 ES 的 QueryAggregationSortHighlight 等原生 Builder 对象(新客户端中的对应类)。通过 NativeSearchQueryBuilder 将各种条件组装起来,可以构建出与 ES REST API 完全一致的 DSL。其优势在于表达力最强、无抽象泄漏,适合复杂查询场景。

构建示例:组合 Bool 查询、聚合、排序、高亮

@Service
public class ProductSearchService {

    private final ElasticsearchOperations operations;

    public ProductSearchService(ElasticsearchOperations operations) {
        this.operations = operations;
    }

    public SearchHits<Product> advancedSearch(String keyword, BigDecimal minPrice, BigDecimal maxPrice) {
        // 1. 构建 Bool 查询
        Query boolQuery = BoolQuery.of(b -> b
                .must(m -> m
                        .match(ma -> ma
                                .field("name")
                                .query(keyword)
                        )
                )
                .filter(f -> f
                        .range(r -> r
                                .field("price")
                                .gte(minPrice.toString())
                                .lte(maxPrice.toString())
                        )
                )
        );

        // 2. 构建聚合:按品类统计
        Aggregation categoryAgg = Aggregation.of(a -> a
                .terms(t -> t.field("category"))
        );

        // 3. 构建排序:按价格降序,评分升序
        List<SortOptions> sorts = Arrays.asList(
                SortOptions.of(s -> s.field(f -> f.field("price").order(SortOrder.Desc))),
                SortOptions.of(s -> s.field(f -> f.field("_score").order(SortOrder.Asc)))
        );

        // 4. 构建高亮
        Highlight highlight = Highlight.of(h -> h
                .fields("name", f -> f.numberOfFragments(0)) // 0 表示返回整个字段
        );

        // 5. 通过 NativeSearchQueryBuilder 组装
        NativeSearchQuery query = new NativeSearchQueryBuilder()
                .withQuery(boolQuery)
                .withAggregation("category_agg", categoryAgg)
                .withSorts(sorts)
                .withHighlight(highlight)
                .withPageable(PageRequest.of(0, 20))
                .build();

        // 6. 执行查询
        return operations.search(query, Product.class);
    }
}

设计意图解读

  • 组合模式NativeSearchQueryBuilder 将 Query、Aggregation、Sort 等独立构建的对象组合在一起,完全遵循 ES DSL 的语法结构,学习曲线即是 ES 查询本身。
  • 与 ES 版本解耦:虽然使用了新客户端中的 BoolQueryAggregation 类,但这些都是基于 elasticsearch-java 库提供的强类型 DSL,与第 5 篇介绍的 JSON DSL 一一对应。
  • 灵活性:任何 ES 支持的特性都可以通过此方式使用,不受 Spring Data 抽象限制,比如复杂的 nested 查询、pipeline aggregation 等。

执行路径解析ElasticsearchRestTemplatesearch 方法内部,会先调用 QueryMapperNativeSearchQuery 转换成 co.elastic.clients.elasticsearch.core.SearchRequest,然后通过 ElasticsearchClient 发送请求。转换过程中,QueryAggregationSort 对象几乎原样设置到请求中,仅对 Pageable 进行 from/size 转换,并对 highlight 等做适配。这意味着开发者使用 NativeSearchQuery 时,几乎是在直接操作 ES 客户端 API,Spring Data 只提供了生命周期管理和结果反序列化。

4.3 CriteriaQuery – 面向对象链式条件

CriteriaQuery 提供了一种完全面向对象的查询构建方式,无需直接编写 ES 的 Query DSL。通过 CriteriaCriteriaQuery 构建链式条件,适合简单到中等复杂度的查询,尤其是对 ES DSL 不熟悉的开发者,或者希望查询代码更接近业务语义的场景。

链式条件构造示例:

Criteria criteria = Criteria.where("name").is("手机")
        .and("price").between(1000, 5000)
        .and("category").in("电子产品", "配件")
        .or("stock").greaterThan(0);

CriteriaQuery query = new CriteriaQuery(criteria)
        .setPageable(PageRequest.of(0, 20))
        .addSort(Sort.by("price").descending());

SearchHits<Product> hits = operations.search(query, Product.class);

转换原理CriteriaQuery 在执行时,会通过内部的 CriteriaQueryProcessorCriteria 对象翻译为 ES 的 Query DSL(新客户端使用的 Query 对象)。其翻译规则大致为:每个 Criteria 条件链映射为一个 bool 查询,and 对应 mustor 对应 should。但复杂嵌套和某些高级查询(如 match_phrasespan 查询)无法完全表达,因此它的能力是 NativeSearchQuery 的子集。

NativeSearchQuery vs CriteriaQuery 对比:

特性NativeSearchQueryCriteriaQuery
构建方式Builder 组合 ES 原生 Query 对象面向对象链式 Criteria
表达能力完全,支持所有 ES 特性受限,仅支持常见条件
可读性与 ES 文档一致,有一定学习曲线业务语义强,更接近 SQL 风格
适用场景复杂搜索、聚合、高亮简单过滤、快速原型
性能无额外开销,直接序列化需额外转换,有微小开销

4.4 StringQuery – 原生 JSON

StringQuery 允许直接传入 ES 查询的 JSON 字符串,提供最高灵活性,但完全失去类型安全。适用于需要动态构造查询模板、调用 ES 特有但 Spring Data 尚未支持的新特性,或从 Kibana Dev Tools 复制过来的现成 DSL。

示例:

String jsonQuery = """
    {
        "bool": {
            "must": [
                { "match": { "name": "手机" } }
            ],
            "filter": [
                { "range": { "price": { "gte": 1000, "lte": 5000 } } }
            ]
        }
    }
    """;

StringQuery query = new StringQuery(jsonQuery);
SearchHits<Product> hits = operations.search(query, Product.class);

使用注意StringQuery 需要开发者自行保证 JSON 的正确性,且字段名必须与 ES 中的实际字段名一致(考虑命名策略)。通常用于无法通过 NativeSearchQuery 合理构造的极端场景(如包含复杂的 script 查询或需要精确控制 JSON 结构的场景)。

三种查询构建方式对比序列图:

sequenceDiagram
    participant Dev as 开发者
    participant NSQ as NativeSearchQuery
    participant CQ as CriteriaQuery
    participant SQ as StringQuery
    participant Template as ElasticsearchRestTemplate
    participant Client as ElasticsearchClient
    participant ES as Elasticsearch

    Dev->>NSQ: 构建 BoolQuery/Aggregation/Sort 等原生对象
    NSQ->>Template: search(query, class)
    Template->>Client: 发送 REST 请求 (JSON)
    Client->>ES: 执行查询
    ES-->>Client: 响应
    Client-->>Template: SearchResponse
    Template-->>Dev: SearchHits<Product>

    Dev->>CQ: 链式 Criteria 条件
    CQ->>Template: search(query, class)
    Template->>CQ: 内部转换 Criteria -> ES DSL
    Template->>Client: 发送 REST 请求
    Client->>ES: 执行查询
    ES-->>Client: 响应
    Client-->>Template: SearchResponse
    Template-->>Dev: SearchHits<Product>

    Dev->>SQ: 直接提供 JSON 字符串
    SQ->>Template: search(query, class)
    Template->>Client: 发送原始 JSON
    Client->>ES: 执行查询
    ES-->>Client: 响应
    Client-->>Template: SearchResponse
    Template-->>Dev: SearchHits<Product>

图 4 四层说明

  • 流程差异NativeSearchQuery 由开发者直接使用 ES 客户端类构建 DSL;CriteriaQuery 经过一次内部转换;StringQuery 跳过构建步骤,直接传递 JSON。
  • 模板角色ElasticsearchRestTemplate 是统一的执行入口,负责序列化配置、异常处理、结果映射,屏蔽底层 HTTP 通信细节。
  • 调试考量NativeSearchQuery 可以在日志中看到与手写 REST API 一致的 JSON,易于调试;CriteriaQuery 生成的 JSON 可能稍显冗余;StringQuery 需要开发者完全负责 JSON 正确性。
  • 选择策略:复杂生产查询首选 NativeSearchQuery;简单管理界面查询可用 CriteriaQuery;临时调试或调用新特性用 StringQuery

4.5 SearchHits<T> 结果解析

所有 search 方法均返回 SearchHits<T>,它是查询结果的高层抽象,包含如下核心信息:

  • getTotalHits():返回匹配文档的总数,用于分页计算。
  • getSearchHits():命中的 SearchHit<T> 列表,每个 SearchHit 包含:
    • getContent():映射后的实体对象 T
    • getScore():相关性评分。
    • getSortValues():排序值数组,用于 search_after 深度分页。
    • getHighlightFields():高亮片段 Map,键为字段名,值为 List<String>
  • getAggregations():聚合结果容器,通过 aggregations.get("aggName") 获取具体聚合,再根据聚合类型强转解析。

解析示例:

SearchHits<Product> searchHits = operations.search(query, Product.class);
long total = searchHits.getTotalHits();  // 总数

for (SearchHit<Product> hit : searchHits.getSearchHits()) {
    Product product = hit.getContent();          // 实体对象
    float score = hit.getScore();                // 评分
    List<String> nameFragments = hit.getHighlightFields().get("name"); // 高亮
    Object[] sortVals = hit.getSortValues();     // 排序值
}

SearchHits 的出现使得结果处理完全对象化,避免了旧 API 中手动遍历 SearchResponse.getHits() 的繁琐过程。同时,通过 SearchHit.getSortValues() 可以无缝衔接 search_after 深度分页,后文将进一步演示。


5. 复杂查询实战

基于 NativeSearchQueryElasticsearchClient 的新 API,我们可以构建出几乎所有生产中需要的复杂查询。本节展示几个经典组合,并对聚合解析与深度分页做深入展开。

5.1 Bool 组合查询 + 聚合 + 排序 + 高亮完整示例

已在 4.2 中给出,此处不再重复。该示例足以覆盖电商搜索的常见需求:关键词搜索、价格范围过滤、分类聚合、多字段排序、名称高亮。

5.2 分页与 search_after 深度分页

from+size 方式在深度分页时性能极差(详见第 5 篇)。Spring Data ES 提供 search_after 支持,通过 SearchHit.getSortValues() 获取上一页的最后一条排序值,传递给下一页查询。

实现 search_after 示例:

public SearchHits<Product> searchAfter(String keyword, int size, Object[] lastSortValues) {
    Query boolQuery = BoolQuery.of(b -> b
            .must(m -> m.match(ma -> ma.field("name").query(keyword)))
    );

    NativeSearchQueryBuilder builder = new NativeSearchQueryBuilder()
            .withQuery(boolQuery)
            .withSorts(Collections.singletonList(
                    SortOptions.of(s -> s.field(f -> f.field("price").order(SortOrder.Desc)))
            ))
            .withPageable(PageRequest.of(0, size));

    // 如果不是第一页,设置 search_after
    if (lastSortValues != null) {
        builder.withSearchAfter(Arrays.asList(lastSortValues));
    }

    return operations.search(builder.build(), Product.class);
}

在 Controller 中维护游标时,可将最后一条记录的 sortValues 返回给前端或缓存,下次请求时传入。注意 search_after 要求排序字段必须唯一,通常结合 _id 防止重复。

聚合结果解析详解:一个完整聚合查询的解析往往被忽略。假设我们需要获取按“品类”聚合的桶以及每个品类下的平均价格,查询构建如下:

// 聚合定义:按 category 分桶,子聚合计算平均价格
Aggregation categoryAgg = Aggregation.of(a -> a
        .terms(t -> t.field("category"))
        .aggregations("avg_price", sub -> sub
                .avg(av -> av.field("price")))
);

NativeSearchQuery query = new NativeSearchQueryBuilder()
        .withQuery(buildMatchAll()) // 或任意过滤
        .withAggregation("category_agg", categoryAgg)
        .build();

SearchHits<Product> result = operations.search(query, Product.class);
// 解析聚合
Aggregations aggregations = result.getAggregations();
if (aggregations != null) {
    // 获取 category_agg 聚合,它是 StringTermsAggregate 类型
    StringTermsAggregate categoryTerms = aggregations.get("category_agg");
    for (StringTermsBucket bucket : categoryTerms.buckets().array()) {
        String category = bucket.key();
        long docCount = bucket.docCount();
        // 获取子聚合 avg_price
        AvgAggregate avgPrice = bucket.aggregations().get("avg_price");
        double avg = avgPrice.value();
        // 业务处理...
    }
}

解析聚合时需要根据聚合类型(termsavgnested 等)强转为对应的 Aggregate 子类,这是新 API 类型安全的一部分,但也要求开发者清楚聚合的返回结构。

5.3 高亮与自动补全

高亮已在 4.2 展示,Spring Data 的高亮构建与 ES 原生高亮完全一致,结果通过 SearchHit.getHighlightFields() 获取。

自动补全(Completion Suggester):ES 的 completion 类型用于实时前缀建议。Spring Data 通过 @CompletionField 注解支持:

@Document(indexName = "suggestions")
public class SuggestionDoc {
    @Id
    private String id;

    @CompletionField(maxInputLength = 100)
    private Completion suggest;
}

其中 Completion 对象包含 input(输入词)和可选的 weight(权重)、contexts。搜索建议通过 operations.suggest()NativeSearchQuery 结合 Suggestion 构建,结果可转换为实体。

SearchHits<SuggestionDoc> hits = operations.search(
    new NativeSearchQueryBuilder()
        .withQuery(buildCompletionQuery("手机"))
        .build(),
    SuggestionDoc.class);

具体构建 Suggestion 可使用 ES 客户端的 Suggester Builder,与普通查询组合在同一请求中,减少网络往返。


6. 与 MyBatis/JPA 的混合使用架构

在微服务或单体应用中,常见的设计模式是“数据库主存储 + ES 搜索加速”。MySQL/PostgreSQL 负责事务写入和全量数据落盘,ES 负责高速搜索和分析。Spring Data Elasticsearch 与 MyBatis/JPA 可以在同一个 Spring 项目中和谐共存。

6.1 协同架构概览

架构模型:Service 层同时注入 MyBatis 的 Mapper(或 JPA Repository)和 ES 的 Repository 或 ElasticsearchOperations。写操作针对主数据库,并使用异步机制同步至 ES;读操作直接查询 ES,获取高性能搜索体验。

关键点

  • 事务边界@Transactional 仅作用于主数据源的事务管理器(JDBC),不包含 ES 操作。ES 的写入通过 @Async、消息队列或事件驱动异步完成,避免 ES 的延迟或故障影响核心业务。
  • 数据一致性:采用最终一致性模型。通常使用 CDC(Change Data Capture)+ 消息队列(如 Canal/Debezium → Kafka)将数据变更同步至 ES,详见第 14 篇系统设计。简单场景也可在 Service 中手动调用 ES 保存。
  • 读写分离:查询接口专走 ES,保证搜索性能;管理后台或需精确主键查询的场景可以直接读数据库(或通过 ES 的 get 请求)。

6.2 Service 层混合注入示例

@Service
public class ProductService {

    private final ProductMapper productMapper;           // MyBatis Mapper
    private final ProductSearchRepository productSearchRepo; // Spring Data ES Repository
    private final ElasticsearchOperations esOperations;  // 模板,用于复杂操作

    public ProductService(ProductMapper productMapper,
                          ProductSearchRepository productSearchRepo,
                          ElasticsearchOperations esOperations) {
        this.productMapper = productMapper;
        this.productSearchRepo = productSearchRepo;
        this.esOperations = esOperations;
    }

    @Transactional(transactionManager = "mysqlTransactionManager") // 明确指定事务管理器
    public Product createProduct(Product product) {
        // 1. 写入 MySQL
        productMapper.insert(product);
        // 2. 异步同步至 ES(推荐使用 ApplicationEvent 或 消息队列)
        syncToElasticsearch(product);
        return product;
    }

    @Async
    public void syncToElasticsearch(Product product) {
        // 将 Product 转为 ES 文档实体(可能是同一个类,也可能不同)
        ProductDocument doc = ProductDocument.from(product);
        productSearchRepo.save(doc);
    }

    @Transactional(readOnly = true)
    public SearchHits<ProductDocument> search(String keyword, int page, int size) {
        // 直接从 ES 搜索
        NativeSearchQuery query = new NativeSearchQueryBuilder()
                .withQuery(q -> q.match(m -> m.field("name").query(keyword)))
                .withPageable(PageRequest.of(page, size))
                .build();
        return esOperations.search(query, ProductDocument.class);
    }
}

设计要点

  • @Async 使同步操作不阻塞主事务,保证接口响应速度。同时需要配置异步线程池与异常处理策略。
  • 实体分离:可以定义独立的 ES 文档类 ProductDocument,只包含搜索需要的字段(去掉了大量业务表关联字段),ES 索引更轻量,符合第 4 篇中 Mapping 优化的“按需索引”原则。
  • 事务管理器:如果项目中存在多个数据源,必须显式指定事务管理器,避免 Spring 误管理 ES 操作。

6.3 异步同步的可靠模式

仅仅使用 @Async + 直接保存 ES 存在风险:如果 ES 保存失败,数据库已提交,数据会丢失。生产环境推荐结合 Spring 的事务事件机制,在数据库事务提交之后再触发 ES 同步,并加入重试和死信处理。

使用 @TransactionalEventListener 示例:

// 1. 定义事件
public class ProductCreatedEvent {
    private final Product product;
    // constructor, getter...
}

// 2. 在 Service 中发布事件
@Transactional
public Product createProduct(Product product) {
    productMapper.insert(product);
    // 发布事件,事务提交后监听器处理
    applicationEventPublisher.publishEvent(new ProductCreatedEvent(product));
    return product;
}

// 3. 异步监听器,绑定 AFTER_COMMIT 阶段
@Component
public class ProductSyncListener {

    private final ProductSearchRepository repository;

    public ProductSyncListener(ProductSearchRepository repository) {
        this.repository = repository;
    }

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleProductCreated(ProductCreatedEvent event) {
        ProductDocument doc = ProductDocument.from(event.getProduct());
        try {
            repository.save(doc);
        } catch (Exception e) {
            // 记录失败日志,发送到重试队列或人工处理
            log.error("ES sync failed for product: {}", event.getProduct().getId(), e);
            // 可选:写入本地失败表,定时补偿
        }
    }
}

这种模式确保只有在 MySQL 事务成功提交后才尝试同步 ES,且 ES 同步失败不会回滚主事务。配合重试、死信队列或定时补偿任务(如每隔 5 分钟扫描失败记录重试),可以达到较高的最终一致性。


7. 面试高频专题

本部分独立于正文,涵盖面试中 Spring Data Elasticsearch 相关的重点问题,每题包含回答、详解、追问与加分回答,所有解答均为深度剖析,力求覆盖面试官的连续追问。

1. Spring Data Elasticsearch 是如何通过自动配置创建 ElasticsearchClient 的?

  • 一句话回答:通过 ElasticsearchDataAutoConfiguration 的条件装配,读取 spring.elasticsearch.* 属性,创建 RestClient 并包装为 ElasticsearchClient
  • 详细解释:Spring Boot 启动时,spring-boot-autoconfigure 模块中的 ElasticsearchDataAutoConfiguration 类会在 ElasticsearchClient 存在于 classpath 的条件下生效。该配置类通过 @EnableConfigurationProperties(ElasticsearchRestClientProperties.class) 激活属性绑定,将 application.yml 中的 spring.elasticsearch.urisusernamepassword、超时等配置注入到 ElasticsearchRestClientProperties 对象。接着,它会通过 ElasticsearchRestClientConfigurations 内部类,根据已绑定的属性构建一个 RestClientBuilder,然后创建 RestClient。最后,使用 RestClient 构造 RestClientTransport 并传入 JacksonJsonpMapper,创建出 ElasticsearchClient Bean。整个过程完全由 @ConditionalOnMissingBean 保护,如果用户自定义了同类型的 Bean,则自动配置退让。
  • 多角度追问
    • 如何自定义 Jackson 的 ObjectMapper 以影响映射? 可以通过 JacksonJsonpMapper 的构造函数传入自定义 ObjectMapper,或者在 AbstractElasticsearchConfiguration 覆盖 elasticsearchClient() 时,手动创建 ElasticsearchClient 时注入。
    • 如果要连接多个 ES 集群怎么办? 自动配置只支持一个默认客户端,多集群需要手动配置多个 ElasticsearchClient Bean,并通过 @Qualifier 区分,且不能依赖自动配置。
    • 底层 RestClient 的连接池如何调整? RestClientBuilder 允许设置 HttpClientConfigCallback,可以传入自定义的 HttpAsyncClientBuilder 来配置连接池大小、连接超时、SSL 等。
  • 加分回答:Spring Boot 3.x 中还新增了 ElasticsearchRestClientAutoConfiguration,对 RestClient 进行更细粒度的自动配置,支持嗅探器、SSL 绑定、路径前缀等,使客户端在生产环境就绪度更高。

2. RestHighLevelClient 为什么被废弃?ElasticsearchClient 有哪些改进?

  • 一句话回答:旧客户端 API 风格不一、缺乏类型安全、与 ES 版本强耦合;新客户端基于 Jackson + Builder + Lambda,提供统一、类型安全的流式 API,并与 ES 版本解耦。
  • 详细解释RestHighLevelClient 最初作为低级客户端的便捷封装,随着 ES 功能的膨胀,其 API 迅速变得庞大且不一致。例如,索引请求是 IndexRequest,搜索请求是 SearchRequest,而 SQL 又是另一套。请求和响应的序列化依赖 ES 内部的 XContent,导致 Java 对象与 JSON 的转换受限于内部格式,错误信息难以理解。此外,客户端版本需与 ES 服务端版本严格对齐,升级 ES 时常伴随大量编译错误。新客户端(elasticsearch-java)抛弃了这些遗留设计,采用 Jackson 作为 JSON 库,所有请求和响应均为强类型且带有 Builder + Lambda 的流式构造方式,IDE 友好。同时它直接通过 RestClient 通信,不与服务端内部协议耦合,理论上可以跨多个 minor 版本工作。
  • 多角度追问
    • 旧客户端的 SearchTemplateRequest 如何迁移? 新 API 中有 SearchTemplateRequest,同样支持脚本模板和参数,构造方式更简洁。
    • 聚合类型解析在旧 API 中需要强制转型,新 API 呢? 新 API 同样需要根据聚合类型强转,但泛型和 Builder 使得类型更明确,例如 StringTermsAggregate
    • 迁移过程中,如何处理大量已存在的 QueryBuilder 代码? 可先共用 RestClient,逐步将查询逻辑重构为新 API,Spring Data ES 5.x 同时兼容两种客户端,允许灰度替换。
  • 加分回答:新客户端还支持异步请求(返回 CompletableFuture),通过 ElasticsearchAsyncClient 实现,为反应式编程和高吞吐场景提供了原生支持。

3. @Document@Field 注解是如何映射到 ES 的 Mapping 的?

  • 一句话回答:通过 MappingElasticsearchConverter 在启动时或索引创建时扫描注解,生成 IndexSettingsMapping JSON,调用 ES REST API 创建索引。
  • 详细解释:Spring Data ES 会在启动时为每一个标注 @Document 的实体类生成 ElasticsearchPersistentEntity 元数据,其中包含从注解提取的索引名、分片数、字段列表等。当调用 IndexOperations.createIndex() 或设置 createIndex=true 时,MappingElasticsearchConverter 会遍历实体的 PersistentProperty,为每个 @Field 注解构造对应的 Mapping 键值对。例如,@Field(type = FieldType.Keyword) 会生成 {"type": "keyword"}@MultiField 会生成带 fields 的结构。这些 Mapping 属性被收集到一个 Document 对象中,最终通过 ElasticsearchClientindices().create() 发送。值得留意的是,某些高级设置(如 scaling_factor)无法通过注解表达,需手动补充。
  • 多角度追问
    • 如何禁用自动创建索引? 设置 @Document(createIndex = false),生产环境推荐此方式,索引由运维手动管理。
    • 字段名驼峰转下划线在哪里配置? 可以通过全局配置 spring.data.elasticsearch.naming-strategy 或在 @Field 中显式指定 name
    • 如果想自定义整个 Mapping JSON,而不使用注解生成? 可使用 @Mapping 注解,提供完整 Mapping JSON 字符串,IndexOperations.putMapping() 时会直接使用该值。
  • 加分回答:元数据模型 ElasticsearchPersistentEntity 会被缓存,避免每次操作都反射解析,这使得即便注解元数据较复杂,性能也无损。

4. NativeSearchQueryCriteriaQueryStringQuery 有什么区别?分别适用什么场景?

  • 一句话回答NativeSearchQuery 使用 ES 原生 DSL Builder,能力最全;CriteriaQuery 面向对象链式条件,简单易读;StringQuery 直接传入 JSON,最灵活但无类型安全。
  • 详细解释NativeSearchQuery 需要开发者使用 elasticsearch-javaQueryAggregation 等对象构建 DSL,表达能力与 ES REST API 完全对等,适合复杂的搜索、聚合、嵌套查询等。CriteriaQuery 内部将 Criteria 条件链翻译为 DSL,表达能力受限于翻译器,只支持 equalsinbetween 等常见操作,不适合 match_phrasenested 查询等,优点是面向对象,代码可读性高,适合快速迭代。StringQuery 直接保持 JSON 字符串,可以覆盖所有 ES 特性,但失去了 Java 类型系统的检查,容易被运行时错误击中,通常用作“逃生舱”。
  • 多角度追问
    • 能否在一个查询中混用这些方式? 不能混用在同一个 Query 实例中,但可以在一个 Service 方法中根据条件分支选择不同构建方式。
    • CriteriaQuery 可以支持 or 条件吗? 可以,criteria.or(...) 会转换为 boolshould 子句,并自动设置 minimum_should_match 为 1。
    • StringQuery 的分页如何实现? 仍可以通过 StringQuery 构造时传入 Pageable,Spring Data 会将其拼接为 from/size 加入请求,但需确保 JSON 中没有冲突的 from/size
  • 加分回答:Spring Data ES 的 Repository @Query 注解本质上使用了 StringQuery,并支持 JSON 模板和 SpEL 占位符,可以说是 StringQuery 的声明式衍生。

5. 如何通过 NativeSearchQueryBuilder 构建一个包含 Bool 查询、聚合和排序的复杂查询?

  • 一句话回答:分别构造 BoolQueryAggregationSortOptions 对象,通过 NativeSearchQueryBuilderwithQuerywithAggregationwithSorts 方法组合。
  • 详细解释NativeSearchQueryBuilder 是建造者模式的体现,所有构造方法返回自身,允许链式调用。首先创建 BoolQuery,使用 BoolQuery.of(b -> b.must(...).filter(...)) 定义逻辑;然后创建聚合,如 Aggregation.of(a -> a.terms(...)),通过 withAggregation("name", agg) 添加并命名;排序通过 SortOptions.of(s -> s.field(...)) 构建,可使用 withSorts(list) 添加多个排序规则。最后调用 build() 得到不可变的 NativeSearchQuery。该对象传到 ElasticsearchRestTemplate.search() 时,内部几乎原封不动地映射到 SearchRequest
  • 多角度追问
    • 如何实现聚合的嵌套?Aggregation.of 内部继续调用 .aggregations("sub", subAgg) 即可。
    • 聚合结果如何按名称取回? SearchHits.getAggregations().get("name") 并强转为对应的聚合结果类型。
    • 如果某次查询不需要聚合或排序,Builder 如何处理? Builder 允许不调用对应方法,最终这些部分不会出现在请求中。
  • 加分回答NativeSearchQueryBuilder 还提供了 withTrackTotalHits(true) 来精确计算总数,withSearchAfter(list) 实现深度分页,withSourceFilter 控制返回字段。

6. SearchHits 如何解析?如何实现分页?

  • 一句话回答SearchHits 提供 getTotalHits()getSearchHits() 等方法;分页通过构建时 withPageable(PageRequest.of(page, size)) 实现,解析后可根据总数和命中列表构建自定义分页对象。
  • 详细解释SearchHits.getTotalHits() 返回总匹配数,注意当结果集超过 10000 时,ES 默认不精确,需设置 trackTotalHits(true)getSearchHits() 返回 List<SearchHit<T>>,每个 SearchHit 封装了源实体、评分、排序值、高亮。分页时,NativeSearchQueryBuilder.withPageable(PageRequest.of(page, size)) 会设置 fromsize。如果需要暴露给前端一个标准的分页对象,可以将 SearchHits 的数据填充到 Spring Data 的 PageImpl 中,提供页码、总页数等。
  • 多角度追问
    • 深度分页性能问题怎么解决? 使用 search_after,通过 SearchHit.getSortValues() 获取游标,下页查询传入。
    • getTotalHits 在聚合查询中有效吗? 有效,聚合与文档计数独立,但 totalHits 仍代表满足查询的文档数。
    • 高亮片段为空是什么原因? 确认 HighlightBuilder 的字段名与 ES 一致,且字段存储了 term_vector 或使用了 plain 高亮器。
  • 加分回答:可以直接使用 SearchPageSearchHitSupport.searchPageFor()SearchHits 转为带分页信息的 SearchPage,与前端交互更方便。

7. 如何在同一个项目中同时使用 MyBatis 和 Spring Data Elasticsearch?

  • 一句话回答:分别注入 MyBatis Mapper 和 ES Repository/Operations,事务管理明确区分,ES 写入异步化。
  • 详细解释:两种持久化方式在 Spring 中都是普通的 Bean,可以共存。关键在于事务边界的划分:MyBatis(或 JPA)的事务由 DataSourceTransactionManager 管理,而 ES 操作通常不参与该事务。创建商品时,先通过 MyBatis 写入 DB,然后调用异步方法或发送事件来同步 ES。异步方法应使用 @Async 标记,并配置单独的线程池,确保 ES 延迟不影响主流程。读取时,搜索服务完全走 ES,与 MyBatis 无直接耦合。
  • 多角度追问
    • 异步同步失败了怎么办? 结合本地消息表或事务事件,记录失败并定时补偿。
    • 如何保证 ES 与 DB 数据的最终一致性? 使用 CDC 工具或基于事件的幂等更新,允许秒级延迟。
    • 同一个 Service 里既有 DB 又有 ES 调用,能加 @Transactional 吗? 可以,但只能管理 DB 事务,ES 调用不会被 Spring 事务管理,且若同步失败不会回滚 DB,需要独立处理。
  • 加分回答:更稳健的方案是通过 Canal 监听 binlog,完全解耦业务代码与 ES 同步,实现无侵入的同步架构。

8. MappingElasticsearchConverter 的作用是什么?如何自定义类型转换?

  • 一句话回答:负责 Java 对象与 ES Document 的双向转换;通过 PropertyValueConverterConverter 注册到 ElasticsearchCustomConversions 实现自定义转换。
  • 详细解释MappingElasticsearchConverter 实现了 ElasticsearchConverter,其核心是读写的两个方法:write(Object, Document)read(Class<T>, Document)。写过程将 Java 对象转换为由 Map 组成的 Document 结构,读过程反之。自定义转换可以从两个层次介入:属性级别使用 @ValueConverter 指定一个 PropertyValueConverter 实现,对整个属性的读写进行加工;全局级别可以通过 ElasticsearchCustomConversions 注册 Spring 的 Converter,改变特定 Java 类型在 ES 中的默认存储格式(例如将 Date 转为 long)。
  • 多角度追问
    • 默认支持哪些类型转换? 基本类型、StringDateBigDecimal(默认转 double)、枚举(可通过配置转为名字或序号)。
    • 如何在转换时获取其他字段信息? PropertyValueConverterwrite/read 提供了 MappingContext,但无法直接访问其他字段,复杂逻辑建议在实体层解决。
    • 能否将对象属性扁平化存入 ES? 通过自定义 EntityConverter 或使用 @Fieldvalue 属性配合转换器,但不推荐过度破坏对象结构。
  • 加分回答MappingElasticsearchConverter 内部组合了 DefaultConversionService,可动态添加 Converter,Spring Data 的通用 CustomConversions 机制在此完全适用。

9. search_after 在 Spring Data ES 中如何实现?

  • 一句话回答:通过 NativeSearchQueryBuilder.withSearchAfter(lastSortValues) 传入上一页最后一条的排序值。
  • 详细解释search_after 是 ES 提供的高效深度分页方式,避免 from 的性能问题。Spring Data ES 在 NativeSearchQuery 中直接支持。首先确保查询包含至少一个排序字段(通常加上 _id 保证唯一),获取首页结果后,记录最后一条 SearchHit.getSortValues(),它是一个 Object[]。当请求下一页时,将该数组传入 withSearchAfter(Arrays.asList(sortValues)),并保持排序不变。执行后得到的 SearchHits 即为下一页数据。Pageable 在此场景中主要用于控制 sizefrom 会被忽略。
  • 多角度追问
    • search_afterscroll 如何选择? scroll 适合遍历所有数据,有状态且占用资源;search_after 适合实时分页,无状态,推荐用于用户分页。
    • 第一次请求没有 sortValues 怎么办? 传 null,Builder 会忽略 search_after,执行正常查询。
    • 能否与 PIT(Point In Time)结合? 可以,先通过 OpenPointInTimeRequest 创建 PIT,在 NativeSearchQueryBuilder 中通过 withPointInTime 设置,保证分页过程中索引不会变更。
  • 加分回答:Spring Data ES 5.x 提供了 withPointInTimewithSearchAfter 的组合支持,对于实时性要求高且数据频繁变动的场景特别有用。

10. @MultiField 注解的作用和使用场景?

  • 一句话回答:将一个字段映射为多个子字段(如 text + keyword),满足同一字段既可全文搜索又可精确匹配、排序、聚合的需求。
  • 详细解释:ES 中一个字符串字段经常需要同时作为全文搜索字段和聚合字段。@MultiField 通过定义 mainFieldotherFields,在索引时创建一个多字段映射。主字段通常是 text 类型(带分词),子字段指定 keyword 或其他类型。例如商品名称,搜索时用 name 进行分词匹配,排序时用 name.keyword 精确排序。@MultiField 生成对应的 Mapping JSON,使得开发者在实体类中只需一个 Java 字段,查询时通过字段路径区分。
  • 多角度追问
    • 子字段可以指定分词器吗? 可以,@InnerField 支持 analyzersearchAnalyzer 等属性。
    • 最多可以定义多少子字段? ES 限制一个字段的 fields 映射深度为 5,但一般定义 1-2 个足够。
    • 查询时如何动态决定用哪个子字段? 根据查询条件选择,例如 Criteria.where("name.keyword").is(...) 或 Lambda 中指定字段名为 name.keyword
  • 加分回答@MultiField 还可以用于定义不同分词器的子字段,例如一个字段需要 standardik_max_word 两种索引方式,以满足不同业务场景。

11. Spring Data ES 的 Repository 接口是如何自动生成查询的?findByName 这样的方法名是如何解析的?

  • 一句话回答:通过 Spring Data 的查询构建机制,在运行时解析方法名,根据关键词(findByAndOrBetween 等)创建 CriteriaQuery,底层再转换为 ES DSL。
  • 详细解释:Spring Data 通用模块的 QueryLookupStrategy 负责解析 Repository 方法。对于 ES,默认策略是 CREATE_IF_NOT_FOUND,即优先使用方法上的 @Query 注解,若没有则尝试从方法名派生查询。派生由 ElasticsearchPartQuery 实现,它将方法名按 By 分割,后面的部分拆解成树形条件(如 NamePriceBetween),每个部分对应实体类的属性,构建 Criteria 对象。最终由 CriteriaQueryProcessorCriteria 转为 Query 执行。该方法名派生支持 IsEqualsBetweenLessThanLikeInOrderBy 等大量关键字,也支持 TopFirst 限制返回数量。
  • 多角度追问
    • 方法名太复杂怎么办? 建议使用 @Query 注解写 JSON 或 DSL,避免长方法名。
    • 派生的查询会缓存吗? 会,第一次解析后,ElasticsearchQueryMethod 被缓存,后续直接使用。
    • 如何实现忽略大小写的 like 查询? 使用 findByNameLikeIgnoreCase,Spring Data 会尝试适配 ES 的 matchwildcard
  • 加分回答:Repository 还支持 @Highlight 注解自动高亮,@SourceFilters 控制返回字段,这些特性通过 AOP 在查询执行前后进行增强。

12. 系统设计题:设计一个电商项目的数据库与搜索架构,要求 MySQL 负责事务写入、ES 负责商品搜索,给出完整的实体设计、Repository 定义、Service 层代码和同步策略。

  • 回答
    • 实体设计
      • MySQL 实体:Product 包含 id, name, category, price, stock, create_time, description 等,使用 JPA/MyBatis 管理。
      • ES 文档:ProductDocument 仅保留搜索相关字段,如 nametext+keyword)、categorykeyword)、pricescaled_float)、tagskeyword 数组)、createTimedate),通过 @Document(indexName="products") 映射。
    • Repository 定义
      • MyBatis Mapper:ProductMapper.insertupdateselectById
      • ES Repository:interface ProductSearchRepository extends ElasticsearchRepository<ProductDocument, String>,自定义方法 List<ProductDocument> findByNameContaining(String keyword);
    • Service 层
      • ProductService.create(product)@Transactional 写入 MySQL,然后发布 ProductCreatedEvent
      • ProductSyncListener 通过 @TransactionalEventListener(phase = AFTER_COMMIT) 异步消费事件,将 ProductDocument 存入 ES。
      • ProductSearchService.search(keyword) 直接调用 ProductSearchRepositoryElasticsearchOperations 构建复杂查询。
    • 同步策略
      • 初期:事件 + @Async + 失败记录到 sync_failed_log 表,定时任务重试。
      • 规模增大:引入 Canal/Debezium 监听 MySQL binlog → Kafka → ES 同步服务,实现准实时同步,完全解耦业务代码。
      • 补偿:每日凌晨全量对账,将最近更新的数据重新索引,确保 ES 与 DB 最终一致。
    • 额外考虑:ES 索引按时间(月)分割成 products-2026-05,使用别名 products 指向可写索引,方便数据管理和重建。查询使用 search_after 深度分页,高亮商品名,聚合商品品类。
  • 加分回答:在 Service 中增加缓存(Caffeine/Redis),热点搜索词的结果可以缓存,降低 ES 压力;监控 ES 同步延迟,通过 Micrometer 暴露指标,设置告警阈值。

Spring Data ES 速查表

常用注解

注解作用关键属性
@Document标记 ES 文档,指定索引信息indexName, createIndex, shards, replicas
@Id文档 ID 字段支持 String, Long, UUID
@Field字段 Mapping 定义type, name, analyzer, searchAnalyzer, index
@MultiField单字段多类型映射mainField, otherFields
@Transient排除字段-
@CompletionField自动补全字段maxInputLength
@GeoPointField地理位置字段-
@ValueConverter绑定自定义属性转换器value 指定转换器类

配置项 (application.yml)

spring:
  elasticsearch:
    uris: http://localhost:9200
    username: elastic
    password: secret
    connection-timeout: 5s
    socket-timeout: 60s
    restclient:
      sniffer:
        interval: 300s
        delay-after-failure: 180s

查询构建类

构建方式适用场景
NativeSearchQuery + NativeSearchQueryBuilder组合 ES 原生 Query 对象复杂查询、聚合、高亮
CriteriaQuery链式 Criteria.where("field").is(value)简单到中等过滤条件
StringQuery直接传入 JSON 字符串特殊 DSL、调试

Repository 方法命名规则

  • 前缀:findBy, readBy, getBy, queryBy, searchBy
  • 条件关键词:And, Or, Is, Equals, Between, LessThan, GreaterThan, Like, In, NotIn, OrderBy
  • 示例:List<Product> findByNameAndPriceBetween(String name, BigDecimal low, BigDecimal high);
  • 分页与排序:Page<Product> findByCategory(String category, Pageable pageable);

常用操作接口

接口用途
ElasticsearchOperations / ElasticsearchRestTemplate通用 CRUD、索引管理、搜索
ElasticsearchRepository<T, ID>声明式 Repository
IndexOperations索引映射管理

延伸阅读

本文至此已完整覆盖 Spring Data Elasticsearch 的核心原理与实践。通过自动配置、客户端演进、对象映射和灵活查询的深度拆解,以及整体架构概览和详细的面试解析,读者能够自信地在 Spring 项目中构建高性能的搜索功能,并为后续实战项目打下坚实基础。