Skip to content

Elasticsearch 指南 ​

读者画像:前端转 Java 的新手——会写 JSON、懂 HTTP 请求,Java 刚学完基础语法与 Spring Boot 入门 学习目标:理解 Elasticsearch 为什么快、什么时候用;能写出 ES 8.x Java API Client 的连接、写入、查询、分页排序高亮与聚合代码 适用版本:Elasticsearch 8.x + Java API Client 8.18.3(8.x 系列最新版,2026-11 核实) 范围外:集群部署运维、性能调优、Kibana/Logstash、ES|QL、相关性评分算法推导

范围 ​

本指南用「商品搜索」一个场景贯穿全文:假设你有一个电商项目,用户要在一个搜索框里找商品。前四章回答「ES 是什么、为什么快」——这是看任何 ES 代码之前必须建立的心智模型;第五到九章回答「Java 里怎么用」——每一步都给出最小可读的代码。文中所有代码都是教学示意:为了讲清概念而精简(省略异常处理、真实地址),未在真实 ES 实例上运行验证,不能直接复制到生产。

组织洞察:ES 的本质是把「找数据」这件事从数据库里拆出来的专用系统——写入时先分词、提前建好倒排索引,查询时按词直接命中,再靠评分把最相关的少量结果排在最前面。


目录 ​

  1. 为什么需要 Elasticsearch
  2. 倒排索引与分词
  3. 核心术语对照
  4. 分片与"近实时"
  5. Java 集成:连上 ES
  6. 写入商品
  7. DSL 查询入门
  8. 分页、排序与高亮
  9. 聚合入门
  10. 速查清单
  11. 扩展阅读

1. 为什么需要 Elasticsearch ​

1.1 先说场景:商品搜索 ​

用户打开你的电商网站,在搜索框输入「可乐」,回车。你要返回:

  • 名称或描述里包含「可乐」的商品;
  • 最相关的排前面(比如名称含「可乐」的排在描述含「可乐」的前面);
  • 哪怕用户输入了「可落」这种错别字,最好也能搜出「可乐」。

这个需求叫全文搜索(Full-text Search)。数据库能不能干?能,但干得很痛苦。

1.2 数据库 LIKE 的三大痛点 ​

用 MySQL 做模糊搜索,你只能写:

sql
SELECT * FROM product WHERE name LIKE '%可乐%';

这条 SQL 有三个问题:

痛点 1:慢。LIKE '%可乐%' 以 % 开头,B+Tree 索引完全用不上,
        只能一行一行扫全表。数据量上亿时,查询要几秒甚至更久。

痛点 2:没有相关性排序。搜「可乐」时,「可口可乐」和
        「可乐鸡翅调料」谁更相关?数据库不关心,返回顺序是乱的,
        你得自己再写一套排序逻辑。

痛点 3:零容错。用户输入「可落」就查不到「可乐」;
        输入「cola」也查不到中文名。数据库只会精确比对。

前端出身的话,可以这样理解:数据库 LIKE 就像你在代码里用 str.includes("可乐") 暴力过滤一个大数组——能用,但既慢又傻。

1.3 ES 的三板斧 ​

Elasticsearch(简称 ES)是专门解决上面问题的系统,官方一句话介绍:

Elasticsearch is a real-time, distributed storage, search, and analytics engine. (一个实时的、分布式的存储、搜索与分析引擎。)

对应当前场景,它有三个能力恰好对症下药:

数据库的痛点ES 的能力怎么做到的(后面章节会展开)
LIKE 慢按词快速命中写入时提前分词建好倒排索引(第 2 章)
无相关性排序每条结果带相关性评分(_score),按分排序默认相关度算法 BM25(第 11 章概述)
零容错分词匹配天然支持部分匹配、大小写、拼写近似查询也分词后按词匹配(第 7 章)

1.4 什么时候用 ES,什么时候不用 ​

适合交给 ES 的:

  • 商品、文章、文档的全文搜索;
  • 日志检索(海量日志里按关键字快速定位);
  • 搜索建议、聚合统计(第 9 章)。

不适合硬用 ES 的:

  • 主数据存储:订单、账户这类强事务数据仍应放数据库。ES 是「搜索专用」的补充系统,不是数据库的替代品;
  • 数据量小、只用精确查询:MySQL 的等值查询 + 索引足够,引入 ES 徒增运维成本。

典型架构:数据库负责「写入与事务」,数据同步到 ES 负责「搜索与读取」,ES 扮演从库角色。


2. 倒排索引与分词 ​

第 1 章说 ES 快,是因为「按词直接命中」。这一章讲清楚这句话背后的机制——这是全篇最重要的概念。

2.1 正向索引:一行一行翻 ​

数据库的索引是正向索引(Forward Index):根据记录找内容。像翻一本书的目录,从章节名找到对应的页码。

正向索引:按文档找词(数据库的做法)
────────────────────────────────────────────
文档 1: 「冰镇可乐,3 元」
文档 2: 「可乐鸡翅,20 元」
文档 3: 「冰镇雪碧,3 元」

查「可乐」→ 打开每个文档看一遍有没有这个词 → 命中 1、2

记录多的时候,「打开每个文档看一遍」就是慢的根源。

2.2 倒排索引:书末的关键词索引表 ​

**倒排索引(Inverted Index,本指南按惯例简称 index)**反过来:根据词找文档。就像技术书末尾的「关键词 → 页码」索引表——查「泛型」这个词,索引表直接告诉你它在第 3、5、12 页,不用翻整本书。

倒排索引:按词找文档(ES 的做法)
────────────────────────────────────────────
词(Term)          文档编号(Posting List)
可乐        →      [1, 2]
冰镇        →      [1, 3]
鸡翅        →      [2]
雪碧        →      [3]

查「可乐」→ 直接命中文档 1、2,不用看文档 3
对比:
┌──────────────────┬──────────────────────┐
│ 正向索引          │ 倒排索引              │
│ 文档 → 词         │ 词 → 文档             │
│ 相当于书的目录     │ 相当于书末索引表       │
│ 查记录快,查词慢   │ 查词快               │
└──────────────────┴──────────────────────┘

数据库在做模糊匹配时没有「词 → 文档」这张表,所以只能扫全表;ES 在写入时就把这张表建好了,查询时按词直接取——这就是「模糊搜索为什么快」的全部秘密。

术语口径:本指南里,小写 index 指这里讲的倒排索引;大写 Index 指 ES 里存数据的地方(相当于数据库的表,见第 3 章)。二者是不同概念,后文严格按此口径使用。

2.3 写入时分词:倒排索引怎么建出来 ​

「按词命中」的前提是:写入时把一段文字切成一个个词。这个过程叫分词(Tokenization),负责分词的组件叫分词器(Analyzer)。

以写入文档「冰镇可乐」为例(中文分词示意):

「冰镇可乐」 ──分词──▶ [冰镇, 可乐]
                          │
                          ▼
倒排索引新增:
冰镇 → [文档1]
可乐 → [文档1]

查询时,用户输入的「可乐」也经过同样的分词器切成 [可乐],再拿这个词去倒排索引里查——写入和查询用同一套分词逻辑,所以词对得上。

2.4 分词器:英文内置,中文用 IK ​

分词器内部由三部分组成(了解即可,不必深究):

输入文字 ──▶ Character Filter ──▶ Tokenizer ──▶ Token Filter ──▶ 词列表
             (字符预处理:            (按规则切分:    (词再加工:
              去 HTML 标签等)          按空格/标点)     转小写等)

ES 内置的分词器主要面向英文:

分词器规则「Hello-World」的结果
standard(默认)按词切分 + 转小写[hello, world]
simple按非字母过滤 + 转小写[hello, world]
whitespace只按空格切分,不转小写[Hello-World]

中文没有空格分界,内置分词器只能把整句当一个词,检索效果很差。因此中文场景普遍使用 IK 分词器(第三方插件,安装后以 ik_smart/ik_max_word 使用),例如「冰镇可乐」被切成 [冰镇, 可乐]。本指南的教学示意默认按中文分词后的效果理解,不展开 IK 的安装配置。

2.5 通用规则前置:keyword 与 text、match 与 term ​

这是全文反复使用的一组判定依据,先在这里定义,第 7 章会回链到这里。

规则 1:字段类型决定「是否分词」。 ES 里字符串字段分两种核心类型:

text    类型:写入时会分词、建立倒排索引 → 适合全文搜索
keyword 类型:写入时不分词、整个值原样保存 → 适合精确匹配、过滤、排序、聚合

同一个需求里的「名称」通常按 text 存(要搜「可乐」命中「可口可乐」),而「分类」「状态」按 keyword 存(「饮料」就是「饮料」,不要拆成「饮」「料」)。

规则 2:查询类型决定「查询词是否分词」。 两个最常用的查询:

match 查询:先对查询词分词,再拿每个词去倒排索引里匹配 → 全文搜索用
term  查询:不分析查询词,拿原值去精确比对 → 筛选用(配 keyword 字段)

统一判定口径:分词与否,由写入侧(字段类型 text/keyword)和查询侧(match/term)各自独立决定。text 字段必须用 match 才搜得自然;keyword 字段必须用 term 才精确命中。交叉使用(term 查 text、match 查 keyword)得不到预期的搜索结果。第 7 章所有示例都按这条规则选择查询类型,第 10 章速查清单再次固化。

2.6 到这里你知道了什么 ​

  • 倒排索引 = 「词 → 文档编号」表,写入时建好,查询时按词直取;
  • 分词器让「冰镇可乐」变成 [冰镇, 可乐],写入与查询共用同一套分词;
  • text/keyword 与 match/term 两组规则,决定了分词行为发生在哪一侧。

3. 核心术语对照 ​

ES 有自己的术语体系。对数据库基础不熟没关系,跟着对照表走一遍即可;如果学过 MySQL,下面的类比会让你秒懂。

3.1 Index(索引库)≈ 数据库的表 ​

ES 中数据存放的地方叫 Index(大写,索引库)。一个 Index 里放一类数据,比如商品搜索场景建一个 products Index。

注意区分:第 2 章讲的倒排索引(index)是数据结构;这里的 Index 是存放数据的逻辑容器。可以理解为:每个 Index 内部都维护着多份倒排索引,用来检索这个 Index 里的数据。

3.2 Document(文档)≈ 表中的一行 ​

一个 Document 就是 Index 里的一条记录。它是 JSON 对象——前端同学应该感到亲切:

json
{
  "id": "1001",
  "name": "冰镇可乐",
  "category": "饮料",
  "price": 3.0,
  "description": "500ml 瓶装汽水"
}

3.3 Field(字段)与 Mapping(映射) ​

Document 里的每个键叫 Field,相当于表的列。Mapping 描述每个 Field 的类型和索引规则,相当于表的 Schema(建表语句)。

对新手最友好的事实:ES 默认支持动态映射(Dynamic Mapping)——你写入第一个 Document 时,ES 会根据 JSON 值自动推断字段类型并建好 Index,不用像数据库那样先写 CREATE TABLE。教学示意里我们全程依赖动态映射,省掉手动建 Index 的步骤。

3.4 Query DSL ≈ SQL ​

Query DSL(Domain Specific Language,领域专用语言)是 ES 的查询语言,作用相当于数据库的 SQL。区别在于:SQL 是字符串文本,DSL 本质是结构化 JSON;在 Java 客户端里,DSL 表现为一串链式调用(第 7 章的主角)。

3.5 一图流对照表 ​

┌────────────────┬─────────────────┬──────────────────┐
│ ES             │ 数据库(MySQL)  │ 前端视角          │
├────────────────┼─────────────────┼──────────────────┤
│ Index(索引库) │ Table           │ 一个集合/表       │
│ Document       │ Row             │ 一个 JSON 对象    │
│ Field          │ Column          │ JSON 的 key       │
│ Mapping        │ Schema          │ TypeScript 接口   │
│ Query DSL      │ SQL             │ 一个查询函数      │
│ Type(已废弃)  │ (无对应)       │ —                │
└────────────────┴─────────────────┴──────────────────┘

关于 Type:ES 7 以前的版本里,一个 Index 下还可以分多个 Type。这个设计已被官方移除,看到旧教程里 _doc、Type 相关字样直接跳过即可,本指南不提。


4. 分片与"近实时" ​

官方介绍里的「分布式」和「实时」,这一章用简化模型解释清楚。目标是理解两个常见现象的原因,不展开集群运维细节。

4.1 节点与集群 ​

节点(Node)就是跑着一个 ES 进程的机器;多台节点互相协作组成集群(Cluster)。你从任何一台节点发出的请求,集群都会统一处理,具体落在哪台机器上对调用方透明——就像你把请求打到 Nginx,不关心后面哪台应用服务器响应。

4.2 分片与副本分片 ​

一个 Index 的数据量大到单机放不下(或单机处理不过来)时,ES 会把数据切成多份,分散到不同节点,这就是分片(Shard):

Index: products,3 个主分片(数据各占一份)
┌──────────┐  ┌──────────┐  ┌──────────┐
│ 节点 1    │  │ 节点 2    │  │ 节点 3    │
│ 分片 P0   │  │ 分片 P1   │  │ 分片 P2   │
└──────────┘  └──────────┘  └──────────┘
写入/查询时 3 个分片并行处理 → 吞吐量更高

但「一份数据放在一台机器上」,机器一挂数据就没了。于是每个主分片(Primary Shard)再配一个副本分片(Replica Shard),副本放在不同节点:

节点 1: P0 | R1       节点 2: P1 | R2       节点 3: P2 | R0
写入总是先进主分片,再同步到副本;读取时主副本都能读。
节点 2 挂了 → 它的副本 R2 被提升为主分片 → 数据不丢、服务不断

对新手记住两点就够:分片解决「大」的问题,副本解决「挂」的问题;分片和副本的个数都是建 Index 时可配置的(教学场景用默认值即可)。

4.3 近实时:为什么写入后要等约 1 秒才能搜到 ​

ES 的写入不像数据库那样「落盘即见」。简化版写入流程:

写入请求
   │
   ▼
① 写进内存缓冲(Memory Buffer)         ← 到此还不能被搜索到
   │
   │  约每 1 秒执行一次 refresh
   ▼
② 生成 segment 文件(可被搜索的最小单元) ← 此时才能被搜到

因为 refresh 默认每 1 秒执行一次,所以「刚写入的数据」平均要等约 1 秒才能被搜索到。这就是官方说 ES 是 near real-time(近实时,NRT) 而不是强实时的原因。这也解释了第 1.1 节的场景:后台刚上架的商品,搜索框里可能 1 秒后才出现——这是设计取舍,不是 bug。

4.4 translog:防止内存数据丢失 ​

「等 1 秒才可搜索」还能忍,「节点断电内存数据全丢」不能忍。ES 的解法是写一份操作日志(translog):

写入请求 ──▶ 内存缓冲(1 秒后 refresh 可搜索)
      └────▶ translog 日志(按配置定期刷入磁盘)
              节点宕机重启后,从 translog 重放未落盘的数据

简化理解:内存数据负责「快」,translog 负责「稳」。极端情况下(比如两次刷盘之间宕机)仍有极小窗口的数据丢失可能,这也是 ES 不被当作「强一致主库」使用的原因之一。segment 的合并、commit 细节属于进阶话题,本指南止步于此,需要时看第 11 章链接。


5. Java 集成:连上 ES ​

概念打底完毕,开始写代码。第一步:让 Spring Boot 项目里有一个可用的 ES 客户端。

5.1 Maven 依赖 ​

ES 官方为 Java 提供Java API Client(8.x 起的主流客户端)。在 pom.xml 中加入:

xml
<!-- pom.xml(教学示意:只列出 ES 相关依赖,其余省略) -->
<dependency>
    <groupId>co.elastic.clients</groupId>
    <artifactId>elasticsearch-java</artifactId>
    <version>8.18.3</version>
</dependency>

说明:

  • 8.18.3 是 8.x 系列的最新版(2026-11 从 Maven Central 核实;8.x 之后是 9.x,9.x 的写法与本节不同,注意版本边界);
  • JSON 序列化依赖 Jackson:Spring Boot 项目自带 jackson-databind,无需额外添加;
  • 低层 HTTP 客户端由该依赖自动引入,也无需单独添加。

5.2 yml 配置 ​

application.yml:

yaml
# application.yml(教学示意)
elasticsearch:
  host: http://localhost:9200
  username: elastic # 可选:未开启安全认证时留空
  password: changeme # 可选

5.3 配置类:组装 ElasticsearchClient ​

8.x 客户端由三个组件组装而成,结构是固定的三段式:

① RestClient           低层 HTTP 客户端,管网络连接
        │
        ▼
② RestClientTransport  传输层,把 Java 对象和 JSON 互转(配 JacksonJsonpMapper)
        │
        ▼
③ ElasticsearchClient  你业务代码里使用的 API 客户端
java
// ElasticsearchConfig.java(教学示意:省略异常处理与连接池调优)
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.ElasticsearchTransport;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.elasticsearch.client.RestClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ElasticsearchConfig {

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

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

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

    @Bean(destroyMethod = "close")
    public ElasticsearchClient elasticsearchClient() {
        // ① 低层客户端:负责 HTTP 连接
        RestClient.Builder builder = RestClient.builder(HttpHost.create(host));

        // 配置了账号密码时,附上认证信息
        if (username != null && !username.isEmpty()) {
            BasicCredentialsProvider credentials = new BasicCredentialsProvider();
            credentials.setCredentials(
                    AuthScope.ANY,
                    new UsernamePasswordCredentials(username, password));
            builder.setHttpClientConfigCallback(
                    hc -> hc.setDefaultCredentialsProvider(credentials));
        }

        // ② 传输层:负责对象与 JSON 互转
        ElasticsearchTransport transport =
                new RestClientTransport(builder.build(), new JacksonJsonpMapper());

        // ③ 业务代码使用的客户端
        return new ElasticsearchClient(transport);
    }
}

之后在任何类里注入即可:

java
@Service
public class ProductSearchService {

    private final ElasticsearchClient esClient;

    public ProductSearchService(ElasticsearchClient esClient) {
        this.esClient = esClient;
    }
    // 后续章节的方法都写在这个类里
}

5.4 看到旧代码里的 RestHighLevelClient? ​

网上大量教程用 RestHighLevelClient,包括很多公司 7.x 时代的存量代码。请注意:该客户端已被官方弃用,其构件版本停在 7.17.x(Maven Central 已无 8.x 版本),8.x 起一律使用本节的 ElasticsearchClient。遇到旧代码时按本章模式改写成新客户端即可,不要在 8.x 项目里继续引入它。


6. 写入商品 ​

6.1 商品模型 ​

先定义一个最简单的商品类(教学示意:无 Lombok,普通 POJO):

java
// Product.java(教学示意:字段精简的普通 POJO)
public class Product {

    private String id;          // 商品编号,同时用作 Document 的 _id
    private String name;        // 名称 → text,全文搜索
    private String category;    // 分类 → keyword,精确过滤
    private Double price;       // 价格
    private String description; // 描述 → text,全文搜索

    public Product() {}

    public Product(String id, String name, String category, Double price, String description) {
        this.id = id;
        this.name = name;
        this.category = category;
        this.price = price;
        this.description = description;
    }

    public String getId() { return id; }
    public void setId(String id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
}

按第 2.5 节规则:name、description 用于搜索,属于 text;category 用于筛选和聚合,属于 keyword。依赖动态映射时 ES 会按 JSON 类型自动推断,教学示意里不手动建 Mapping。

6.2 单条写入 ​

写入一条数据在 ES 里叫索引(index)——注意这里的 index 是动词(把文档放进 Index 的动作),与名词 Index(索引库)、名词 index(倒排索引)是三种不同用法,本指南统一用「写入」表达这个动作,避免歧义。

java
// ProductSearchService 内(教学示意:省略异常处理)
public String save(Product product) throws IOException {
    IndexResponse response = esClient.index(i -> i
            .index("products")      // 目标 Index 名(不存在会自动创建)
            .id(product.getId())    // 指定 Document 的 _id,相同 _id 覆盖旧数据
            .document(product)      // Java 对象自动序列化为 JSON
    );
    return response.result().name();  // 如 "Created"(新建)或 "Updated"(覆盖)
}

写入成功后(约 1 秒,见第 4.3 节),这条商品就能被搜索到。

6.3 批量写入 ​

逐条调用 index() 意味着每条数据一次 HTTP 往返,上万条商品初始化时会非常慢。批量接口 Bulk 把多条操作打包成一个请求:

java
// ProductSearchService 内(教学示意:省略异常处理)
public void saveAll(List<Product> products) throws IOException {
    BulkRequest.Builder bulk = new BulkRequest.Builder();
    for (Product p : products) {
        bulk.operations(op -> op
                .index(idx -> idx
                        .index("products")
                        .id(p.getId())
                        .document(p)));
    }
    BulkResponse response = esClient.bulk(bulk.build());

    // 批量请求整体成功,不代表每条都成功,需要逐条检查
    if (response.errors()) {
        for (BulkResponseItem item : response.items()) {
            if (item.error() != null) {
                // item.error().reason() 是该条失败的原因
                System.out.println("写入失败: " + item.error().reason());
            }
        }
    }
}

关键点:response.errors() 为 true 时,说明这批里至少有一条失败,成功和失败会混在一起,生产代码必须像上面这样逐条检查。

6.4 什么时候用批量 ​

单条写入:业务实时写入(用户提交一条商品)→ save()
批量写入:初始化导入、定时同步(几万条起步)→ saveAll()

比 BulkRequest 更进一步的 BulkIngester(自动攒批、自动刷盘)适合持续大流量场景,教学阶段了解存在即可,见第 11 章链接。


7. DSL 查询入门 ​

查询是 ES 的精华。这一章逐个认识最常用的四种查询,判定依据已在第 2.5 节定义,选型时回链到那里。

7.1 match:全文搜索的主力 ​

match 会把查询词分词后拿去匹配 text 字段,支持「搜『可乐』命中『冰镇可乐』」:

java
// 搜索名称或描述里包含关键词的商品
public List<Product> searchByName(String keyword) throws IOException {
    SearchResponse<Product> response = esClient.search(s -> s
                    .index("products")
                    .query(q -> q
                            .match(m -> m
                                    .field("name")    // 在 name 字段上做全文匹配
                                    .query(keyword))),// 查询词会先被分词
            Product.class);                            // 命中结果反序列化为 Product

    List<Product> result = new ArrayList<>();
    for (Hit<Product> hit : response.hits().hits()) {
        result.add(hit.source());  // hit.source() 是命中的商品对象
    }
    return result;
}

几个新手必懂的响应结构:

SearchResponse
└── hits                   命中信息
    ├── total              总命中条数(可能带 relation 标记:eq 精确 / gte 至少)
    └── hits[]             本页结果列表
        ├── source()       商品对象(你想要的业务数据)
        └── score()        相关性评分,越高越靠前(第 1.3 节)

match 的典型行为(教学示意,按第 2.3 节分词规则):

查询词行为
可乐分词成 [可乐],命中名称含「可乐」的所有商品
可乐 雪碧分词成 [可乐, 雪碧],命中含任一词的商品,两个都含的评分更高
COLA分词时转小写,能否命中中文名取决于分词器配置

7.2 term:精确匹配的筛子 ​

term 不分词,拿原值精确比对,配 keyword 字段使用(回链第 2.5 节规则 2):

java
// 筛选 category 精确等于「饮料」的商品
SearchResponse<Product> response = esClient.search(s -> s
                .index("products")
                .query(q -> q
                        .term(t -> t
                                .field("category")
                                .value(v -> v.stringValue("饮料")))), // 原样比对,不分词
        Product.class);

用错时的现象很典型:如果拿 term 去查 text 字段的「冰镇可乐」,会查不到——因为 text 字段在倒排索引里存的是分词后的 冰镇、可乐,不存在完整的「冰镇可乐」这个词。反过来,拿 match 查 keyword 字段时,只有当整个查询词与字段值完全相等时才命中(等于绕了个弯的 term),「部分匹配」能力丢失。这就是 2.5 节「交叉使用得不到预期的搜索结果」的含义。

7.3 range:范围查询 ​

价格区间、时间范围用 range:

java
// 价格在 [1, 10) 之间的商品
SearchResponse<Product> response = esClient.search(s -> s
                .index("products")
                .query(q -> q
                        .range(r -> r
                                .number(n -> n
                                        .field("price")
                                        .gte(1.0)   // >= 1
                                        .lt(10.0)))),// < 10
        Product.class);

gt/gte/lt/lte 四个边界参数 = 大于 / 大于等于 / 小于 / 小于等于,和 SQL 的 >/>=/</<= 一一对应。

7.4 bool:把查询组合起来 ​

真实搜索几乎都是多条件:「名称含关键词 并且 分类是饮料 并且 价格低于 10」。bool 查询就是用来组合的,它有三个常用位置:

must   必须满足(类似 AND),参与评分        → 影响「排前面」的条件放这里
filter 必须满足(类似 AND),不参与评分      → 纯筛选条件放这里,性能更好
should 应该满足(类似 OR / 加分项)          → 「最好满足」的条件放这里
java
// 名称含「可乐」,分类必须是饮料,价格低于 10
SearchResponse<Product> response = esClient.search(s -> s
                .index("products")
                .query(q -> q
                        .bool(b -> b
                                .must(m -> m      // ① 全文搜索 + 参与评分
                                        .match(mm -> mm
                                                .field("name")
                                                .query("可乐")))
                                .filter(f -> f    // ② 精确筛选 + 不评分(可缓存)
                                        .term(t -> t
                                                .field("category")
                                                .value(v -> v.stringValue("饮料"))))
                                .filter(f -> f    // ③ 范围筛选
                                        .range(r -> r
                                                .number(n -> n
                                                        .field("price")
                                                        .lt(10.0))))))
        Product.class);

must 和 filter 结果相同,但 filter 不影响评分且能被缓存——「参与排名」用 must,「只要满足」用 filter,是新手最容易内化的取舍。

7.5 查询类型怎么选(速记表) ​

需求查询判定依据(回链)
关键词搜商品,容忍部分匹配match查询词分词(2.5 规则 2)
分类等于「饮料」term + keyword字段不分词 + 查询不分词(2.5 规则 1、2)
价格/时间区间range数值/日期字段
多个条件组合boolmust/filter/should 语义(7.4)

8. 分页、排序与高亮 ​

上一章查出了结果,这一章让结果「好看」:翻页、按价格排、把命中词标红。最后用一个完整示例收束 5~8 章。

8.1 from/size 分页 ​

ES 分页用 from(跳过前 N 条)+ size(返回条数):

java
// 第 2 页:跳过前 10 条,取 10 条
SearchResponse<Product> response = esClient.search(s -> s
                .index("products")
                .query(q -> q.match(m -> m.field("name").query("可乐")))
                .from(10)   // (页码 - 1) * 每页条数
                .size(10),  // 每页条数
        Product.class);

深分页提示:from 越大,ES 要在各分片上跳过越多的数据再汇总,性能越差。翻到几百页之后会明显变慢,且「from + size」合计默认上限 10000 条。真实系统的深翻页需求应改用 search_after 等方案(进阶话题,见第 11 章链接),教学场景记住「别让用户翻太深」即可。

8.2 排序 ​

sort 支持按多个字段排序,并可用 SortOrder.Asc / SortOrder.Desc 指定方向:

java
SearchResponse<Product> response = esClient.search(s -> s
                .index("products")
                .query(q -> q.match(m -> m.field("name").query("可乐")))
                .sort(so -> so                           // ① 价格升序
                        .field(f -> f
                                .field("price")
                                .order(SortOrder.Asc)))
                .sort(so -> so                           // ② 同价按评分降序(默认)
                        .score(sc -> sc.order(SortOrder.Desc))),
        Product.class);

注意:不写 sort 时 ES 默认按相关性评分降序返回;一旦指定了排序字段,评分排序就被取代,上面的例子因此显式把评分作为第二排序条件。

8.3 高亮 ​

高亮让命中词包上 HTML 标签(如 <em>),前端直接渲染即可:

java
SearchResponse<Product> response = esClient.search(s -> s
                .index("products")
                .query(q -> q.match(m -> m.field("name").query("可乐")))
                .highlight(h -> h
                        .fields("name", hf -> hf      // 对 name 字段高亮
                                .preTags("<em>")      // 命中词前插入
                                .postTags("</em>"))), // 命中词后插入
        Product.class);

for (Hit<Product> hit : response.hits().hits()) {
    // highlight() 返回 Map<字段名, 高亮片段列表>;未命中的字段不在 Map 里
    List<String> fragments = hit.highlight().get("name");
    if (fragments != null) {
        // 例如:["冰镇<em>可乐</em>,500ml"]
        System.out.println(fragments.get(0));
    }
}

8.4 完整示例:一个真正的搜索接口 ​

把 5~8 章串起来——关键词搜索 + 筛选 + 分页 + 排序 + 高亮:

java
// ProductSearchService 内(教学示意:省略参数校验与异常处理)
public List<SearchResult> search(SearchCondition condition) throws IOException {
    SearchResponse<Product> response = esClient.search(s -> s
                    .index("products")
                    .query(q -> q
                            .bool(b -> b
                                    .must(m -> m
                                            .match(mm -> mm
                                                    .field("name")
                                                    .query(condition.getKeyword())))
                                    .filter(f -> f
                                            .term(t -> t
                                                    .field("category")
                                                    .value(v -> v.stringValue(condition.getCategory()))))
                                    .filter(f -> f
                                            .range(r -> r
                                                    .number(n -> n
                                                            .field("price")
                                                            .lte(condition.getMaxPrice()))))))
                    .from(condition.getFrom())
                    .size(condition.getSize())
                    .sort(so -> so.field(f -> f.field("price").order(SortOrder.Asc)))
                    .highlight(h -> h
                            .fields("name", hf -> hf
                                    .preTags("<em>")
                                    .postTags("</em>"))),
            Product.class);

    List<SearchResult> results = new ArrayList<>();
    for (Hit<Product> hit : response.hits().hits()) {
        Product product = hit.source();
        SearchResult result = new SearchResult(product);
        List<String> fragments = hit.highlight().get("name");
        result.setHighlightedName(
                fragments != null ? fragments.get(0) : product.getName());
        results.add(result);
    }
    return results;
}

到这里,「为什么 + 怎么做」的主线已经闭环:概念解释了机制,代码完成了商品搜索的完整链路。


9. 聚合入门 ​

搜索之外,ES 的另一个看家本领是聚合(Aggregation):对命中数据做分组统计,类似 SQL 的 GROUP BY。

9.1 需求:按分类统计商品数 ​

搜索「可乐」时,侧边栏想展示「饮料 23 个、零食 5 个」这样的分类计数,方便用户继续筛选。用 terms 聚合实现「按某字段分组计数」:

java
// 按 category 分组统计「可乐」命中商品的个数
SearchResponse<Void> response = esClient.search(s -> s
                .index("products")
                .size(0)                                  // 只要统计,不要商品明细
                .query(q -> q.match(m -> m.field("name").query("可乐")))
                .aggregations("by_category", a -> a       // 聚合名自定义,响应里用它取值
                        .terms(t -> t
                                .field("category")
                                .size(10))),              // 只取计数最多的前 10 个分类
        Void.class);                                      // 不关心明细时用 Void.class

9.2 解析聚合结果 ​

java
Aggregate aggregate = response.aggregations().get("by_category");
for (StringTermsBucket bucket : aggregate.sterms().buckets().array()) {
    System.out.println(bucket.key() + " 有 " + bucket.docCount() + " 个");
}
// 输出示意:
// 饮料 有 23 个
// 零食 有 5 个

读响应时记住两个约定:aggregations().get(聚合名) 取结果;sterms() 对应 terms 聚合(s 开头表示字符串分组)。不同聚合类型的取法不同,但模式一致。

9.3 聚合与查询的关系 ​

搜索请求 = query(先缩小范围) + aggregation(再对命中数据统计)

上面例子的语义是「在名称含『可乐』的商品里,按分类统计」。query 和 aggregation 是先后关系,不是二选一——这也正是电商筛选侧边栏的标准做法。

9.4 常见聚合一览 ​

聚合用途类比 SQL
terms按字段值分组计数GROUP BY + COUNT(*)
avg / max / min / sum数值统计AVG() / MAX() / MIN() / SUM()
histogram按区间分桶(如价格每 50 元一档)分组区间统计

10. 速查清单 ​

10.1 术语表(本指南单一口径) ​

术语含义易混点
index(小写)倒排索引:词 → 文档编号的数据结构不是存数据的地方
Index(大写)索引库:存数据的容器,相当于表内部靠倒排索引实现检索
写入(index 动词)把文档放进 Index 的动作本指南统一说「写入」
Document一条记录,JSON 对象相当于行
Field文档里的键相当于列
Mapping字段类型与索引规则相当于 Schema
Query DSLES 的查询语言相当于 SQL
Shard / Replica分片 / 副本分片解决「大」/ 解决「挂」

10.2 查询类型选择表 ​

触发条件选择
用户输入的搜索词,容忍部分匹配match + text 字段
分类、状态等固定枚举值精确匹配term + keyword 字段
价格、时间区间range
多条件组合bool:参与排名用 must,纯筛选用 filter
只统计不取明细查询 + aggregations,size(0) + Void.class

10.3 常见坑 ​

① match 与 term 混用:拿 term 查 text 字段查不到结果;拿 match 查 keyword
   会丢失部分匹配能力。
   → 记住 2.5 节:字段类型与查询类型要同侧对齐。

② 忘记「近实时」:写入后立刻搜,发现没有,以为写丢了。
   → refresh 默认约 1 秒,稍等再搜(4.3 节)。

③ 深分页:from 越翻越大,越来越慢,且 from+size 默认上限 10000。
   → 教学场景限制翻页深度;生产深翻页研究 search_after(8.1 节)。

④ 用旧客户端:照着旧教程抄 RestHighLevelClient,8.x 项目里找不到类。
   → 8.x 一律使用 Java API Client 的 ElasticsearchClient(5.4 节)。

⑤ 错把 ES 当主库:用 ES 存强事务数据,出了事故怪 ES。
   → ES 是搜索专用系统,主数据仍在数据库(1.4 节)。

11. 扩展阅读 ​

11.1 为什么还能更快:倒排索引的工程优化(概述) ​

第 2 章的倒排索引模型解释了「为什么快」,ES 内部还叠加了三层工程优化,让「词 → 文档」的查询从快变得极快(了解即可):

  • Term Index(FST):词典里的词太多不可能全放内存,用有限状态转换器(Finite State Transducers)压缩词的前缀放在内存,空间小、查询快;
  • Posting List 压缩(FOR):文档编号列表用 Frame Of Reference 编码压缩,省磁盘;
  • Roaring Bitmap:多条件查询要做文档编号的交集/并集,用位图结构快速计算。

11.2 相关性评分 BM25(概述) ​

第 1.3 节说结果按 _score 排序。8.x 默认评分算法是 BM25,直觉理解:一个词在越少的文档里出现、在当前文档里出现次数越多,说明它对这篇文档越重要——「可乐」出现在 100 个商品里权重低,只出现在 2 个商品里权重就高。评分调试属于进阶话题,入门阶段先知道「有评分、可排序」即可。

11.3 进阶路线 ​

  • 手动 Mapping:控制 text/keyword 类型与 IK 分词器;
  • search_after 深分页、BulkIngester 流式批量写入;
  • 数据库 → ES 的数据同步方案(如 Logstash、Canal);
  • 集群规划与调优。

References ​

本指南的版本敏感事实(客户端版本、API 形态、弃用状态)在 2026-11 撰写时核实自以下官方主源:

  • Java API Client 8.19 官方文档:安装(installation)、连接(connecting)、单条写入(indexing)、批量写入(indexing-bulk)、搜索(searching)、聚合(aggregations)
  • elasticsearch-java 8.19 分支生成源码(GitHub):FieldSort.java(sort 的 field/order 方法)、HighlightBase.java(preTags/postTags 方法)
  • Maven Central 构件查询:co.elastic.clients:elasticsearch-java 8.x 最新版为 8.18.3;org.elasticsearch.client:elasticsearch-rest-high-level-client 最高版为 7.17.29(无 8.x 版本)
  • 概念章节的动机与结构参考:掘金《「扫盲」Elasticsearch》(Java3y,2020)