ElasticSearch快速入门

1. 初识ElasticSearch

[!tip]

ElasticSearch各个版本的文档:ElasticSearch各个版本文档

学习参考文档:

1.1 什么是ElasticSearch

ElasticSearch是一个开源的分布式搜索引擎,可以用来实现搜索、日志统计、分析、系统监控等功能。

  1. 基于Apache Lucene: Elasticsearch 的核心搜索功能建立在强大的Apache Lucene库之上。Lucene 是一个顶级的、高性能的全文搜索引擎库。Elasticsearch 在 Lucene 的基础上,提供了分布式架构、RESTful API 等,使其更易用、更强大、更适合企业级应用。
  2. 分布式: 这是 Elasticsearch 的核心优势之一。数据被自动分割成多个部分(称为分片),并分布在集群中的多个服务器(称为节点)上。这带来了:
    • 高可用性: 即使某个节点宕机,数据副本(副本分片)也能在其他节点上提供服务。
    • 水平扩展性: 当数据量或查询压力增大时,只需添加更多节点到集群中,Elasticsearch 就能自动重新分配数据和负载。
    • 巨大的存储和处理能力: 可以处理 PB 级别的数据。
  3. 近实时 (Near Real-Time - NRT): 写入的数据通常在1 秒内即可被搜索到。虽然不如内存数据库那样绝对实时,但对于绝大多数搜索和分析场景来说,这个延迟已经非常低。
  4. 文档导向: Elasticsearch 存储和操作的基本单位是JSON 文档。一个文档代表一条数据记录(比如一个产品信息、一篇日志、一条用户资料)。
  5. Schema-Free / Dynamic Mapping: 你不需要像关系型数据库那样预先严格定义表结构(Schema)。当你索引(写入)一个包含新字段的文档时,Elasticsearch 会自动检测字段类型并创建映射(Mapping)。当然,你也可以显式定义映射以获得更精确的控制和优化性能。

1.2 核心概念

1.2.1 倒排索引

倒排索引的概念是基于MySQL这样的正向索引而言的。例如给下表(tb_goods)中的id创建索引:

MySQL表查询

如果是根据id查询,那么直接走索引,查询速度非常快。

但如果是基于title做模糊查询,只能是逐行扫描数据,流程如下:用户搜索数据,条件是title符合"%手机%"-->逐行获取数据,比如id为1的数据-->判断数据中的title是否符合用户搜索条件-->如果符合则放入结果集,不符合则丢弃。回到最开始。

逐行扫描,也就是全表扫描,随着数据量增加,其查询效率也会越来越低。当数据量达到数百万时,就是一场灾难。


与正排索引相反的就是倒排索引。

倒排索引中有两个非常重要的概念:

  • 文档(Document:用来搜索的数据,其中的每一条数据就是一个文档。例如一个网页、一个商品信息
  • 词条(Term:对文档数据或用户搜索数据,利用某种算法分词,得到的具备含义的词语就是词条。例如:我是中国人,就可以分为:我、是、中国人、中国、国人这样的几个词条

创建倒排索引是对正向索引的一种特殊处理,流程如下:

  • 将每一个文档的数据利用算法分词,得到一个个词条
  • 创建表,每行数据包括词条、词条所在文档id、位置等信息
  • 因为词条唯一性,可以给词条创建索引,例如hash表结构索引

倒排索引

倒排索引的搜索流程如下(以搜索"华为手机"为例):用户输入条件"华为手机"进行搜索--->对用户输入内容分词,得到词条:华为手机--->拿着词条在倒排索引中查找,可以得到包含词条的文档id:1、2、3--->拿着文档id到正向索引中查找具体文档。

倒排索引搜索过程

虽然要先查询倒排索引,再查询倒排索引,但是无论是词条、还是文档id都建立了索引,查询速度非常快!无需全表扫描。

1.2.1.1 正向和倒排对比

概念区别:

  • 正向索引是最传统的,根据id索引的方式。但根据词条查询时,必须先逐条获取每个文档,然后判断文档中是否包含所需要的词条,是根据文档找词条的过程
  • 倒排索引则相反,是先找到用户要搜索的词条,根据词条得到保护词条的文档的id,然后根据id获取文档。是根据词条找文档的过程

优缺点:

正向索引

  • 优点:可以给多个字段创建索引;根据索引字段搜索、排序速度非常快。
  • 缺点:根据非索引字段,或者索引字段中的部分词条查找时,只能全表扫描。

倒排索引

  • 优点:根据词条搜索、模糊搜索时,速度非常快。
  • 缺点:只能给词条创建索引,而不是字段;无法根据字段做排序。

1.2.1 ES数据库基本概念

ElasticSearch中有很多独有的概念,与MySQL中略有差别,但也有相似之处。

1.2.1.1 文档(Document)和字段(Field)

[!note]

一个文档就像数据库里的一条数据,字段就像数据库里的列。

ElasticSearch是面向文档(Document)存储的,可以是数据库中的一条商品数据,一个订单信息。文档数据会被序列化为json格式后存储在ElasticSearch中:

数据库到JSON

而JSON文档(Document)中往往包含很多的字段(Field),类似于MySQL数据库中的列。

1.2.1.2 索引(Index)和映射(Mapping)

[!note]

索引就像数据库里的表,映射就像数据库中定义的表结构。

索引(Index),就是相同类型的文档的集合类似mysql中的表

例如:

  • 所有用户文档,就可以组织在一起,称为用户的索引;
  • 所有商品的文档,可以组织在一起,称为商品的索引;
  • 所有订单的文档,可以组织在一起,称为订单的索引;

索引

因此,我们可以把索引当做是数据库中的表。

数据库的表会有约束信息,用来定义表的结构、字段的名称、类型等信息。因此,索引库中就有映射(Mapping),是索引中文档的字段约束信息,类似表的结构约束。

1.2.1.3 MySQL与ElasticSearch

MySQL与ElasticSearch各有长处:

  • Mysql:擅长事务类型操作,可以确保数据的安全和一致性。
  • Elasticsearch:擅长海量数据的搜索、分析、计算。

我们统一的把MySQL与ElasticSearch的重要概念做一下对比

MySQL Elasticsearch 说明
Table Index 索引(index),就是文档的集合,类似数据库的表(table)
Row Document 文档(Document),就是一条条的数据,类似数据库中的行(Row),文档都是JSON格式
Column Field 字段(Field),就是JSON文档中的字段,类似数据库中的列(Column)
Schema Mapping Mapping(映射)是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema)
SQL DSL DSL是Elasticsearch提供的JSON风格的请求语句,用来操作Elasticsearch,实现CRUD

在企业中,往往是两者结合使用:

  • 对安全性要求较高的写操作,使用MySQL实现。
  • 对查询性能要求较高的搜索需求,使用Elasticsearch实现
  • 两者再基于某种方式,实现数据的同步,保证一致性。

企业中的架构

1.2.1.4 索引生命周期(ILM)

索引生命周期就是:定义一个索引从创建到删除的整个过程,分阶段管理(热 / 温 / 冷 / 删除),并让Elasticsearch自动执行这些动作

这背后的逻辑是:数据不是所有时候都同样重要、同样热。

  • 新数据:读写频繁,需要快速响应 → 放在“热”阶段
  • 老数据:查询很少,但仍需保留 → 放在“温/冷”阶段
  • 过期数据:没人再用 → 自动删除

ILM 就是帮你 自动做这一系列动作


ILM 支持四个阶段,每个阶段可以定义动作(actions):

  1. Hot(热阶段)

    特点:频繁写入和查询。

    动作:rollover(滚动新索引)、force_merge(强制合并 segment)、shrink(缩小分片数)。

    场景:比如logs-2025-09-13当天日志。

  2. Warm(温阶段)

    特点:数据不再写入,但仍会被查。

    动作:reduce replicas(减少副本)、allocate(分配到便宜节点)、shrink(减少分片数)。

    场景:一周前的日志,查得不多了。

  3. Cold(冷阶段)

    特点:几乎不查,但必须存。

    动作:move到低性能存储节点、冻结(frozen index)。

    场景:一个月前的日志。

  4. Delete(删除阶段)

    特点:数据彻底过期。

    动作:delete index(索引删除)。

    场景:超过 90 天的日志,直接删。


ILM的作用

  1. 节省资源:旧数据自动降级到低性能节点 / 低存储成本。
  2. 自动化管理:不需要人工写定时任务去删老索引。
  3. 提升查询性能:热节点只存活跃索引,查询速度更快。
  4. 零停机切换:结合 rollover + alias,可以平滑地从旧索引切换到新索引。

例如现在试着定义索引生命周期策略:

PUT _ilm/policy/logs_policy
{
  "policy": {
    "phases": { // 生命周期分为多个阶段(hot/warm/cold/delete)
      "hot": { // 热阶段:数据正在频繁写入和查询
        "actions": {
          "rollover": { // rollover 动作:滚动索引
            "max_size": "50gb", // 当索引大小超过 50GB
            "max_age": "7d" //或者当索引的时间超过 7 天
          }
        }
      },
      "warm": { // 温阶段:数据不再写入,但仍然会被查询
        "min_age": "7d", // 进入 warm 阶段的条件:索引至少 7 天
        "actions": {
          "allocate": { "require": { "box_type": "warm" }}, // 把索引迁移到 warm 节点
          "shrink": { "number_of_shards": 1 }, // 缩减分片数到 1(节省资源)
          "forcemerge": { "max_num_segments": 1 } // 强制合并 segment,减少文件句柄
        }
      },
      "cold": { // 冷阶段:数据几乎不查,但要保留
        "min_age": "30d", // 索引 30 天以后进入冷阶段
        "actions": {
          "allocate": { "require": { "box_type": "cold" }} // 移动到冷节点(低性能存储)
        }
      },
      "delete": { // 删除阶段:数据彻底过期
        "min_age": "90d", // 索引存活 90 天后删除
        "actions": { "delete": {} } // 执行删除索引动作
      }
    }
  }
}

索引生命周期定义结果


索引生命周期定义好了,现在我们来创建索引模版并且使用自定义的生命周期策略:

PUT _index_template/logs_template
{
  "index_patterns": ["logs-*"],       // 匹配所有以 logs- 开头的索引
  "template": {
    "settings": {
      "index.lifecycle.name": "logs_policy",         // 指定上面定义的 ILM 策略
      "index.lifecycle.rollover_alias": "logs_write" // rollover 时使用的别名
    }
  }
}

执行结果

1.3 安装ElasticSearch、kibana、分词器

[!tip]

这里的部署安装我为了方便,都是采用的Docker安装,且都是单节点。

1.3.1 部署安装单节点ElasticSearch与kibana

[!tip]

Kibana 是Elastic Stack(ELK Stack)的核心成员之一,由 Elastic 公司开发。它本质上是一个开源的数据可视化和探索平台,专门为 Elasticsearch 设计。你可以把它想象成Elasticsearch 的图形化操作界面和数据分析中心

在正式安装之前,我们首先创建出一个ELK内部通讯的网络,保证ElasticSearch与kibana两个容器能够内部通信:

docker network create es-net

之后,编写安装ElasticSearch和kibana的docker-compose文件:

version: '3.1'
services:
  elasticsearch:
    image: elasticsearch:7.12.1
    container_name: elasticsearch
    privileged: true
    environment:
      - "cluster.name=elasticsearch" #设置集群名称为elasticsearch
      - "discovery.type=single-node" #以单一节点模式启动
      - "ES_JAVA_OPTS=-Xms512m -Xmx1096m" #设置使用jvm内存大小
      - "bootstrap.memory_lock=true"  #尝试将进程地址空间锁定到 RAM 中,防止任何 Elasticsearch 堆内存被换出
      - "TAKE_FILE_OWNERSHIP=true" # 自动修复文件所有权,防止容器启动报“权限不足”错误
    volumes:
      - ./es/plugins:/usr/share/elasticsearch/plugins #插件文件挂载
      - ./es/data:/usr/share/elasticsearch/data #数据文件挂载
      - ./es/logs:/usr/share/elasticsearch/logs #日志文件挂载
    ports:
      - 9200:9200
      - 9300:9300
    #指定网络,保证elastic search与kibana在同一网络中通信
    networks:
      - es-net
    deploy:
     resources:
        limits:
           cpus: "2"
           memory: 1000M
        reservations:
           memory: 200M
    restart: unless-stopped

  kibana:
    image: kibana:7.12.1
    container_name: kibana
    depends_on:
      - elasticsearch #kibana在elasticsearch启动之后再启动
    environment:
      ELASTICSEARCH_HOSTS: http://elasticsearch:9200 #设置访问elasticsearch的地址
      I18N_LOCALE: zh-CN
    ports:
      - 5601:5601
    #指定网络,保证elastic search与kibana在同一网络中通信
    networks:
      - es-net
    restart: unless-stopped
# 网络配置
networks:
  es-net:
    external: true # 使用已存在的外部网络

之后使用命令启动:

docker-compose up -d

启动之后,使用IP+端口即可访问。比如你要访问ElasticSearch,那么就是http://192.168.3.69:9200/,如图:

ElasticSearch访问

同样的,访问kibana,那么就是http://192.168.3.69:5601/,如图:

kibana访问

kibana中提供了一个DevTools界面:

kibana的DevTools界面

这个界面中可以编写DSL来操作ElasticSearch。并且对DSL语句有自动补全功能。


上面安装的是7.12.1版本的,该版本对于系统资源要求不是这么高,如果你的系统资源不够,那么你可以使用7.12.1版本,但是如果你的系统资源充足(剩余内存大于10G),那么你可以使用最新版,当前版本最新版为9.1.2。

services:
  elasticsearch:
    image: elastic/elasticsearch:9.1.2
    container_name: elasticsearch
    privileged: true
    environment:
      - "cluster.name=elasticsearch" #设置集群名称为elasticsearch
      - "discovery.type=single-node" #以单一节点模式启动
      - "ES_JAVA_OPTS=-Xms8g -Xmx10g" #设置使用jvm内存大小
      - "bootstrap.memory_lock=true"  #尝试将进程地址空间锁定到 RAM 中,防止任何 Elasticsearch 堆内存被换出
    #   - "xpack.security.enabled=false"  # 禁用安全功能,如果是开发环境,可以禁用,生产环境最好还是启用
    #   - "xpack.security.enrollment.enabled=false"
    volumes:
      - ./es/plugins:/usr/share/elasticsearch/plugins #插件文件挂载
      - ./es/data:/usr/share/elasticsearch/data #数据文件挂载
      - ./es/logs:/usr/share/elasticsearch/logs #日志文件挂载
    ports:
      - 9200:9200
      - 9300:9300
    #指定网络,保证elastic search与kibana在同一网络中通信
    networks:
      - es-net
    restart: unless-stopped

  kibana:
    image: kibana:9.1.2
    container_name: kibana
    depends_on:
      - elasticsearch #kibana在elasticsearch启动之后再启动
    environment:
      ELASTICSEARCH_HOSTS: http://elasticsearch:9200 #设置访问elasticsearch的地址
      I18N_LOCALE: zh-CN
      
      # 专用账号密码生成之前,要求先仅启动es容器,命令:docker-compose up elasticsearch
      # kibana内部账号生成命令:docker exec -it es容器名称 /usr/share/elasticsearch/bin/elasticsearch-reset-password -u kibana_system
      ELASTICSEARCH_USERNAME: ${KIBANA_ACCOUNT} # kibana连接es的内部专用账号,不可用来登陆kibana面板
      ELASTICSEARCH_PASSWORD: ${KIBANA_PASSWORD} # kibana连接es的内部专用密码,不可用来登陆kibana面板
    ports:
      - 5601:5601
    #指定网络,保证elastic search与kibana在同一网络中通信
    networks:
      - es-net
    restart: unless-stopped
# 网络配置
networks:
  es-net:
    external: true # 使用已存在的外部网络

在启动之前,你应该调整以下es目录下的文件权限,否则启动会报权限不足错误:

sudo mkdir -p ./es/{data,logs,plugins}
sudo chown -R 1000:1000 ./es/
sudo chmod -R 755 ./es/

之后使用命令启动即可:

docker-compose up -d

[!tip]

为啥这里我要提一下最新版本,因为现在7.x版本已经很老了,同时如果你要从7.x升级到9.x,你不能够直接升级过来,你要先用8.x过渡一下,也就是说你应该先从7.x升级到8.x,然后再从8.x升级到9.x。

后续所有讲解我都按照9.x的版本来讲解。

1.3.2 IK分词器

在Elasticsearch(ES)中,分词器(Analyzer)是一个核心组件,负责在索引文档(Indexing)搜索查询(Searching)时处理文本数据。它的核心任务是将原始文本转换成适合搜索的词项(Terms)

简单来说,分词器的工作就是:将一大段文本拆分成独立的、有意义的单词或词组,并对其进行标准化处理,以便后续高效匹配。

那为什么是IK分词器呢?简单来说就是对汉字分词友好

1.3.2.1 安装IK分词器

下载网址:IK分词器下载

IK分词器下载

IK分词器属于Elasticsearch的一个插件,所以,我们需要将下载好了的分词器文件上传到挂载的Elasticsearch的plugins目录中。在挂载的plugins目录中,首先创建一个ik文件夹,将分词器上传到ik文件夹中并且解压。

分词器目录

之后重启ElasticSearch容器即可:

docker-compose down
docker-compose up -d

重启容器之后,在正式测试IK分词器之前,我们先来测试一下ElasticSearch中标准的分词器(analyzer为standard),我们来到kibana的开发工具这里,执行如下命令:

GET /_analyze
{
  "analyzer": "standard",
  "text": "全民制作人们大家好,我是练习时长两年半的个人练习生蔡徐坤。喜欢唱、跳、rap、篮球,music!"
}

标准的分词器分词结果:

{
  "tokens" : [
    {
      "token" : "全",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "<IDEOGRAPHIC>",
      "position" : 0
    },
    {
      "token" : "民",
      "start_offset" : 1,
      "end_offset" : 2,
      "type" : "<IDEOGRAPHIC>",
      "position" : 1
    },
    {
      "token" : "制",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "<IDEOGRAPHIC>",
      "position" : 2
    },
    {
      "token" : "作",
      "start_offset" : 3,
      "end_offset" : 4,
      "type" : "<IDEOGRAPHIC>",
      "position" : 3
    },
    {
      "token" : "人",
      "start_offset" : 4,
      "end_offset" : 5,
      "type" : "<IDEOGRAPHIC>",
      "position" : 4
    },
    {
      "token" : "们",
      "start_offset" : 5,
      "end_offset" : 6,
      "type" : "<IDEOGRAPHIC>",
      "position" : 5
    },
    {
      "token" : "大",
      "start_offset" : 6,
      "end_offset" : 7,
      "type" : "<IDEOGRAPHIC>",
      "position" : 6
    },
    {
      "token" : "家",
      "start_offset" : 7,
      "end_offset" : 8,
      "type" : "<IDEOGRAPHIC>",
      "position" : 7
    },
    {
      "token" : "好",
      "start_offset" : 8,
      "end_offset" : 9,
      "type" : "<IDEOGRAPHIC>",
      "position" : 8
    },
    {
      "token" : "我",
      "start_offset" : 10,
      "end_offset" : 11,
      "type" : "<IDEOGRAPHIC>",
      "position" : 9
    },
    {
      "token" : "是",
      "start_offset" : 11,
      "end_offset" : 12,
      "type" : "<IDEOGRAPHIC>",
      "position" : 10
    },
    {
      "token" : "练",
      "start_offset" : 12,
      "end_offset" : 13,
      "type" : "<IDEOGRAPHIC>",
      "position" : 11
    },
    {
      "token" : "习",
      "start_offset" : 13,
      "end_offset" : 14,
      "type" : "<IDEOGRAPHIC>",
      "position" : 12
    },
    {
      "token" : "时",
      "start_offset" : 14,
      "end_offset" : 15,
      "type" : "<IDEOGRAPHIC>",
      "position" : 13
    },
    {
      "token" : "长",
      "start_offset" : 15,
      "end_offset" : 16,
      "type" : "<IDEOGRAPHIC>",
      "position" : 14
    },
    {
      "token" : "两",
      "start_offset" : 16,
      "end_offset" : 17,
      "type" : "<IDEOGRAPHIC>",
      "position" : 15
    },
    {
      "token" : "年",
      "start_offset" : 17,
      "end_offset" : 18,
      "type" : "<IDEOGRAPHIC>",
      "position" : 16
    },
    {
      "token" : "半",
      "start_offset" : 18,
      "end_offset" : 19,
      "type" : "<IDEOGRAPHIC>",
      "position" : 17
    },
    {
      "token" : "的",
      "start_offset" : 19,
      "end_offset" : 20,
      "type" : "<IDEOGRAPHIC>",
      "position" : 18
    },
    {
      "token" : "个",
      "start_offset" : 20,
      "end_offset" : 21,
      "type" : "<IDEOGRAPHIC>",
      "position" : 19
    },
    {
      "token" : "人",
      "start_offset" : 21,
      "end_offset" : 22,
      "type" : "<IDEOGRAPHIC>",
      "position" : 20
    },
    {
      "token" : "练",
      "start_offset" : 22,
      "end_offset" : 23,
      "type" : "<IDEOGRAPHIC>",
      "position" : 21
    },
    {
      "token" : "习",
      "start_offset" : 23,
      "end_offset" : 24,
      "type" : "<IDEOGRAPHIC>",
      "position" : 22
    },
    {
      "token" : "生",
      "start_offset" : 24,
      "end_offset" : 25,
      "type" : "<IDEOGRAPHIC>",
      "position" : 23
    },
    {
      "token" : "蔡",
      "start_offset" : 25,
      "end_offset" : 26,
      "type" : "<IDEOGRAPHIC>",
      "position" : 24
    },
    {
      "token" : "徐",
      "start_offset" : 26,
      "end_offset" : 27,
      "type" : "<IDEOGRAPHIC>",
      "position" : 25
    },
    {
      "token" : "坤",
      "start_offset" : 27,
      "end_offset" : 28,
      "type" : "<IDEOGRAPHIC>",
      "position" : 26
    },
    {
      "token" : "喜",
      "start_offset" : 29,
      "end_offset" : 30,
      "type" : "<IDEOGRAPHIC>",
      "position" : 27
    },
    {
      "token" : "欢",
      "start_offset" : 30,
      "end_offset" : 31,
      "type" : "<IDEOGRAPHIC>",
      "position" : 28
    },
    {
      "token" : "唱",
      "start_offset" : 31,
      "end_offset" : 32,
      "type" : "<IDEOGRAPHIC>",
      "position" : 29
    },
    {
      "token" : "跳",
      "start_offset" : 33,
      "end_offset" : 34,
      "type" : "<IDEOGRAPHIC>",
      "position" : 30
    },
    {
      "token" : "rap",
      "start_offset" : 35,
      "end_offset" : 38,
      "type" : "<ALPHANUM>",
      "position" : 31
    },
    {
      "token" : "篮",
      "start_offset" : 39,
      "end_offset" : 40,
      "type" : "<IDEOGRAPHIC>",
      "position" : 32
    },
    {
      "token" : "球",
      "start_offset" : 40,
      "end_offset" : 41,
      "type" : "<IDEOGRAPHIC>",
      "position" : 33
    },
    {
      "token" : "music",
      "start_offset" : 42,
      "end_offset" : 47,
      "type" : "<ALPHANUM>",
      "position" : 34
    }
  ]
}

标准分词器分词结果

可见ElasticSearch的标准分词器的分词结果效果并不好,尤其是针对中文,它会把中文的每一个汉字单独作为一个词。

现在我们来看看IK分词器:

[!tip]

IK分词器提供了两种分词模式,它们分别是智能模式(ik_smart)细粒度模式(ik_max_word)。这里先以智能模式进行测试,后面再具体介绍二者区别。

GET /_analyze
{
  "analyzer": "ik_smart",
  "text": "全民制作人们大家好,我是练习时长两年半的个人练习生蔡徐坤。喜欢唱、跳、rap、篮球,music!"
}

IK分词器智能模式分词结果:

{
  "tokens" : [
    {
      "token" : "全民",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "制作",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 1
    },
    {
      "token" : "人们",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "CN_WORD",
      "position" : 2
    },
    {
      "token" : "大家好",
      "start_offset" : 6,
      "end_offset" : 9,
      "type" : "CN_WORD",
      "position" : 3
    },
    {
      "token" : "我",
      "start_offset" : 10,
      "end_offset" : 11,
      "type" : "CN_CHAR",
      "position" : 4
    },
    {
      "token" : "是",
      "start_offset" : 11,
      "end_offset" : 12,
      "type" : "CN_CHAR",
      "position" : 5
    },
    {
      "token" : "练习",
      "start_offset" : 12,
      "end_offset" : 14,
      "type" : "CN_WORD",
      "position" : 6
    },
    {
      "token" : "时长",
      "start_offset" : 14,
      "end_offset" : 16,
      "type" : "CN_WORD",
      "position" : 7
    },
    {
      "token" : "两",
      "start_offset" : 16,
      "end_offset" : 17,
      "type" : "COUNT",
      "position" : 8
    },
    {
      "token" : "年半",
      "start_offset" : 17,
      "end_offset" : 19,
      "type" : "CN_WORD",
      "position" : 9
    },
    {
      "token" : "的",
      "start_offset" : 19,
      "end_offset" : 20,
      "type" : "CN_CHAR",
      "position" : 10
    },
    {
      "token" : "个人",
      "start_offset" : 20,
      "end_offset" : 22,
      "type" : "CN_WORD",
      "position" : 11
    },
    {
      "token" : "练习生",
      "start_offset" : 22,
      "end_offset" : 25,
      "type" : "CN_WORD",
      "position" : 12
    },
    {
      "token" : "蔡",
      "start_offset" : 25,
      "end_offset" : 26,
      "type" : "CN_CHAR",
      "position" : 13
    },
    {
      "token" : "徐",
      "start_offset" : 26,
      "end_offset" : 27,
      "type" : "CN_CHAR",
      "position" : 14
    },
    {
      "token" : "坤",
      "start_offset" : 27,
      "end_offset" : 28,
      "type" : "CN_CHAR",
      "position" : 15
    },
    {
      "token" : "喜",
      "start_offset" : 29,
      "end_offset" : 30,
      "type" : "CN_CHAR",
      "position" : 16
    },
    {
      "token" : "欢唱",
      "start_offset" : 30,
      "end_offset" : 32,
      "type" : "CN_WORD",
      "position" : 17
    },
    {
      "token" : "跳",
      "start_offset" : 33,
      "end_offset" : 34,
      "type" : "CN_CHAR",
      "position" : 18
    },
    {
      "token" : "rap",
      "start_offset" : 35,
      "end_offset" : 38,
      "type" : "ENGLISH",
      "position" : 19
    },
    {
      "token" : "篮球",
      "start_offset" : 39,
      "end_offset" : 41,
      "type" : "CN_WORD",
      "position" : 20
    },
    {
      "token" : "music",
      "start_offset" : 42,
      "end_offset" : 47,
      "type" : "ENGLISH",
      "position" : 21
    }
  ]
}

IK分词器智能模式分词结果

虽然看起来还是有点怪怪的,但是你至少发现了词语的存在吧。

想要知道为什么会出现这样的现象,我们还得简单了解一下分词的原理。

1.3.2.2 IK分词器两种模式的区别

为举例方便,我将以“中华人民共和国”这个词举例。

  • 智能模式:采用最粗粒度拆分,仅输出最可能的词语组合。这个模式下,直接输出“中华人民共和国”这个最大的词,其内部的小词它是不会再继续分的,也就是最粗粒度。
  • 细粒度模式:将文本进行最细粒度拆分,穷尽所有可能的词语组合。这个模式下还可以继续拆分“中华人民共和国”内部的小词,比如:“中华”、“中华人民”、“人民”、“共和国”等等词语。

由此可见:前者产生的倒排索引占用的空间要小一些,但是会降低搜索到文档的概率;后者则是恰好反过来的。

如何选择呢?

如果是对文档建立倒排索引这个场景,那么用细粒度模式最为合适,这样文档被搜到的概率也就越大。如果是用户通过关键词搜索文档这个场景,那么用智能模式最为合适,可以提高搜索结果的相关性。

1.3.2.3 分词的原理

标准分词器会把“中国”这个词傻傻地拆分成“中”、“国”这两个词。而IK分词器就比较聪明了,它能正确地将“中国”识别成为一个词。

其实IK分词器内置了一个汉语词典(你可以类比为我们小时候用过的《现代汉语词典》),这里面记录了所有可能的词语。

其实一开始IK分词器不知道“中国”是一个词,在它眼里这只是一个普通的由“中”和“国”两个字符组成的字符串而已。然后它会拿着这个字符串去汉语词典里面查,如果查到了,这时IK分词器才会把这个字符串当作一个词语。 怎么样?其实也不是很复杂对吧~

当然,如果某个字符串要是没被收录到这个词典之中,IK分词器还是会采用和标准分词器一样的策略,即单字分词。例如如“奥力给”这个网络词语,我相信经常在互联网冲浪的大伙肯定不陌生,但是7.12.1这个版本的IK分词器的汉语词典里并没有收录这个词。

GET /_analyze
{
  "analyzer": "ik_smart",
  "text": "奥里给"
}

IK分词器智能模式分词结果:

{
  "tokens" : [
    {
      "token" : "奥",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "CN_CHAR",
      "position" : 0
    },
    {
      "token" : "里",
      "start_offset" : 1,
      "end_offset" : 2,
      "type" : "CN_CHAR",
      "position" : 1
    },
    {
      "token" : "给",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "CN_CHAR",
      "position" : 2
    }
  ]
}

IK分词器智能模式分词结果

现在你能猜到为什么之前用IK分词器来给鸽鸽的名言分词的结果为什么怪怪的了吧?本质是因为里面的好多词并没有收录到IK分词器内置的汉语词典中。

1.3.2.4 为IK分词器内置的词典添砖加瓦

我们都知道互联网上的信息是爆炸式增长的。当初设计IK分词器内置词典的那批人怎么可能预料到这么多的网络词语的诞生呢?

还好他们给我们留了一手,使得我们可以往这个汉语词典当中添加一些自定义的词。

我们现在移步到IK分词器的配置文件所在目录下:

IK分词器配置文件目录

我们随便点进去一个dic文件看看里面的内容吧:

.dic文件内容

然后再来看看IK分词器的核心配置文件:

IK分词器的核心配置文件

现在我们自己拓展一个词典,存放网络用语:

自定义拓展词典

之后修改核心配置文件,将我们的自定义拓展词典加入进去:

加入自定义拓展词典

之后重启ElasticSearch服务,即可生效。

[!tip]

IK分词器其实支持词典的热更新功能,即修改词典后无需重启ES服务器,但是默认是不开启的。喜欢折腾的同学可以自行尝试。

IK分词器智能模式分词结果

从IK分词器的核心配置文件可以看出,上面有拓展词典和停止拓展词典,这里需要说明一下拓展词典拓展停止字典这两个概念:

  • 拓展词典:用于补充内置词典未收录的词(拓展词)。
  • 拓展停止字典:用于存放需要被过滤掉的词汇(停用词)。它们不会被建立倒排索引,也就是说不会被搜索到。这类词汇一般是介词、语气词、敏感词等等。其中常见的介词、语气词等已经被包括了,但是敏感词需要我们自己指定。

2. 索引库操作

索引库就类似数据库表,Mapping映射就类似表的结构。我们要向ES中存储数据,必须先创建“库”和“表”。

2.1 Mapping映射属性

[!tip]

参考文档:Mapping官方文档

Mapping是对索引库中文档的约束,常见的mapping属性包括:

  • type:字段数据类型,常见的简单类型有:

    • 字符串:text(可分词的文本)、keyword(精确值,例如:品牌、国家、ip地址)。

      [!important]

      keyword类型只能整体搜索,不支持搜索部分内容

    • 数值:long、integer、short、byte、double、float。

    • 布尔:boolean。

    • 日期:date。

    • 对象:object。

  • index:是否创建索引,默认为true。

  • analyzer:使用哪种分词器。

  • properties:该字段的子字段。

例如下面的JSON文档:

{
    "age": 21,
    "weight": 52.1,
    "isMarried": false,
    "info": "真相只有一个!",
    "score": [99.1, 99.5, 98.9],
    "name": {
        "firstName": "柯",
        "lastName": "南"
    }
}

对应的每个字段映射(mapping):

  • age:类型为 integer;参与搜索,因此需要index为true;无需分词器。
  • weight:类型为float;参与搜索,因此需要index为true;无需分词器。
  • isMarried:类型为boolean;参与搜索,因此需要index为true;无需分词器。
  • info:类型为字符串,需要分词,因此是text;参与搜索,因此需要index为true;分词器可以用ik_smart。
  • score:虽然是数组,但是我们只看元素的类型,类型为float;参与搜索,因此需要index为true;无需分词器。
  • name:类型为object,需要定义多个子属性
    • name.firstName;类型为字符串,但是不需要分词,因此是keyword;参与搜索,因此需要index为true;无需分词器。
    • name.lastName;类型为字符串,但是不需要分词,因此是keyword;参与搜索,因此需要index为true;无需分词器。

[!important]

定义映射的时候,有两种方式:

  1. 显示映射:显式映射是在创建索引时或之后手动定义字段类型和设置的方式。
  2. 动态映射:是Elasticsearch自动推断字段类型的机制,当遇到未定义的字段时自动创建映射。

2.2 索引库的CRUD

[!note]

CRUD简单描述:

  • 创建索引库:PUT /索引库名
  • 查询索引库:GET /索引库名
  • 删除索引库:DELETE /索引库名
  • 修改索引库(添加字段):PUT /索引库名/_mapping

这里统一使用Kibana编写DSL的方式来演示。

2.2.1 创建索引库和映射

基本语法:

  • 请求方式:PUT
  • 请求路径:/索引库名,可以自定义
  • 请求参数:Mapping映射

格式:

PUT /索引库名称
{
  "mappings": {
    "properties": {
      "字段名":{
        "type": "text",
        "analyzer": "ik_smart"
      },
      "字段名2":{
        "type": "keyword",
        "index": "false"
      },
      "字段名3":{
        "properties": {
          "子字段": {
            "type": "keyword"
          }
        }
      },
      // ...略
    }
  }
}

针对之前柯南的例子,这里的示例:

PUT /conan
{
  "mappings": {
    "properties": {
      "age":{
        "type": "integer"
      },
      "weight":{
        "type": "integer"
      },
      "isMarried":{
        "type": "boolean"
      },
      "info":{
        "type": "text",
        "analyzer": "ik_smart"
      },
      "score":{
        "type": "float"
      },
      "name":{
        "properties": {
          "firstName": {
            "type": "keyword"
          },
          "lastName": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

[!tip]

这里其实就是一个显示映射的使用。我在创建索引的时候,手动定义了字段类型和设置的方式。

执行结果:

{
  //表示Elasticsearch集群已经成功接收并确认了这个索引创建请求,如果为false: 说明请求可能超时或集群状态更新失败
  "acknowledged" : true,
  //表示索引的分片(shards)已经在集群中成功创建和分配,如果为false: 可能表示分片分配还在进行中,或者集群资源不足
  "shards_acknowledged" : true,
  //返回刚刚创建的索引名称
  "index" : "conan"
}

创建索引执行结果

2.2.2 查询索引库

基本语法

  • 请求方式:GET
  • 请求路径:/索引库名
  • 请求参数:无

格式:

GET /索引库名

针对之前柯南的例子,这里的示例:

GET /conan

执行结果:

{
  "conan": {
    "aliases": {},
    "mappings": {
      "properties": {
        "age": {
          "type": "integer"
        },
        "info": {
          "type": "text",
          "analyzer": "ik_smart"
        },
        "isMarried": {
          "type": "boolean"
        },
        "name": {
          "properties": {
            "firstName": {
              "type": "keyword"
            },
            "lastName": {
              "type": "keyword"
            }
          }
        },
        "score": {
          "type": "float"
        },
        "weight": {
          "type": "integer"
        }
      }
    },
    "settings": {
      "index": {
        "routing": {
          "allocation": {
            "include": {
              "_tier_preference": "data_content"
            }
          }
        },
        "number_of_shards": "1",
        "provided_name": "conan",
        "creation_date": "1755660885934",
        "number_of_replicas": "1",
        "uuid": "HrJ7AhHTRQemyjfW1q4MQQ",
        "version": {
          "created": "9033000"
        }
      }
    }
  }
}

查询结果

2.2.3 修改索引库

[!warning]

这里的修改是只能增加新的字段到Mapping中。

倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。因此索引库一旦创建,无法修改Mapping

虽然无法修改Mapping中已有的字段,但是却允许添加新的字段到Mapping中,因为不会对倒排索引产生影响。

语法说明

PUT /索引库名/_mapping
{
  "properties": {
    "新字段名":{
      "type": "integer"
    }
  }
}

针对之前柯南的例子,这里的示例:

PUT /conan/_mapping
{
  "properties": {
    "email":{
      "type": "keyword"
    }
  }
}

执行结果

2.2.4 删除索引库

语法:

  • 请求方式:DELETE
  • 请求路径:/索引库名
  • 请求参数:无

格式:

DELETE /索引库名

针对之前柯南的例子,这里的示例:

DELETE /conan

删除索引执行结果

3. 文档操作

在Elasticsearch中,文档(Document) 是最基本的信息存储单元,类似于关系型数据库中的一行记录。

文档是一个可以被索引的基本信息单元,以 JSON 格式存储。每个文档都包含一组键值对(字段和值)。

例如如下JSON数据就是一个文档:

{
  "_index": "my-first-elasticsearch-index",
  "_id": "DyFpo5EBxE8fzbb95DOa",
  "_version": 1,
  "_seq_no": 0,
  "_primary_term": 1,
  "found": true,
  "_source": {
    "email": "john@smith.com",
    "first_name": "John",
    "last_name": "Smith",
    "info": {
      "bio": "Eco-warrior and defender of the weak",
      "age": 25,
      "interests": [
        "dolphins",
        "whales"
      ]
    },
    "join_date": "2024/05/01"
  }
}

可见上面的字段还有很多以下划线开头的,比如:_index_id_version等,这些字段都称为元数据

上诉元数据的含义如下:

字段名 含义 作用 备注
_index 文档所属的索引名称 标识文档存储位置 类似数据库表名
_id 文档的唯一标识符 用于唯一标识和检索文档 自动生成的 Base64 格式
_version 文档版本号 乐观并发控制,防止更新冲突 新文档为1,每次更新递增
_seq_no 序列号 确保操作顺序性,支持复制恢复 从0开始递增
_primary_term 主分片任期号 处理分片故障转移,确保一致性 主分片变化时递增
_source 存储文档的原始JSON内容 保存完整的原始数据,支持检索和操作 ES核心存储字段

3.1 文档的CRUD

文档操作有哪些?

  • 创建文档:POST /{索引库名}/_doc/文档id
  • 查询文档:GET /{索引库名}/_doc/文档id
  • 删除文档:DELETE /{索引库名}/_doc/文档id
  • 修改文档:
    • 全量修改:PUT /{索引库名}/_doc/文档id
    • 增量修改:POST /{索引库名}/_update/文档id { "doc": {字段}}

3.1.1 新增文档

语法:

POST /索引库名/_doc/[可选 文档id]
{
    "字段1": "值1",
    "字段2": "值2",
    "字段3": {
        "子属性1": "值3",
        "子属性2": "值4"
    },
    // ...
}

针对之前柯南的例子,这里的示例:

POST /conan/_doc
{
  "age": 21,
  "weight": 52.1,
  "isMarried": false,
  "info": "真相只有一个!",
  "score": [99.1, 99.5, 98.9],
  "name": {
    "firstName": "柯",
    "lastName": "南"
  }
}

执行结果:

{
  "_index" : "conan",
  "_type" : "_doc",
  "_id" : "aEw-wZgBH3R7jdF0tikG",
  "_version" : 1,
  "result" : "created",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 0,
  "_primary_term" : 1
}

执行结果

操作结果字段解释:

字段名称 值示例 含义说明
_index "conan" 文档所在的索引名称。 文档被存储在名为 "conan" 的索引中。
_type "_doc" 文档的类型。 在 Elasticsearch 7.x 及更高版本中,类型概念已被废弃。"_doc" 是统一使用的占位符类型名。
_id "aEw-wZgBH3R7jdF0tikG" 文档的唯一标识符 (ID)。 可以是创建请求中指定的 ID,也可以是 Elasticsearch 自动生成的 UUID(如此例所示)。
_version 1 文档的版本号。 初始创建时为 1。每次文档更新(包括删除后再创建同名 ID)都会递增。用于乐观并发控制
result "created" 操作结果。 "created" 表示这是一个新文档,成功创建。如果是更新现有文档,结果会是 "updated"
_shards { ... } 分片操作信息对象。 包含操作在分片副本上执行情况的统计。
_shards.total 2 总分片副本数。 表示操作应该执行在多少个分片副本上(包括主分片和所有副本分片)。这里 2 通常意味着索引配置了 1 个主分片和 1 个副本分片。
_shards.successful 1 成功执行的分片副本数。 这里 1 表示主分片写入成功。副本分片的写入是异步进行的(最终一致性),可能稍后完成,所以成功数可能暂时少于总数。
_shards.failed 0 执行失败的分片副本数。 0 表示没有失败。
_seq_no 0 序列号 (Sequence Number)。 一个严格递增的唯一编号,分配给该索引操作(写入、删除)。用于在特定分片内对操作进行排序和追踪。
_primary_term 1 主分片任期 (Primary Term)。 每当一个分片的主分片发生重新分配(例如节点故障、重启导致主分片切换)时,这个值就会递增。

关键点说明:

  1. _shards 解释:

    • total = 2:通常对应索引设置 number_of_shards=1 (1 个主分片) 和 number_of_replicas=1 (1 个副本分片)。1 (主) + 1 (副) = 2。
    • successful = 1:主分片写入成功。副本分片 (replica) 的写入是异步进行的,以保证写入性能。客户端会立即收到主分片写入成功的响应,副本分片由 ES 在后台同步。这体现了 ES 的最终一致性模型。稍后检查文档或索引状态时,successful 可能会变为 2
  2. 并发控制 (_version, _seq_no, _primary_term):

    • _version:基本的文档级别版本控制,适用于简单场景。
    • _seq_no + _primary_term:这两个字段共同构成了更强大、更精确的主分片级别并发控制机制。它们能正确处理主分片切换(_primary_term 增加)和同一主分片上的操作顺序(_seq_no 递增)。在进行文档更新或删除操作时,指定这两个值可以确保你修改的是你之前读取到的确切版本,防止并发冲突。这比仅使用 _version 更可靠,尤其是在发生故障转移的情况下。
  3. _type:在 Elasticsearch 6.x 之前,一个索引可以包含多种类型(类似数据库表)。从 7.x 开始,类型被废弃,官方推荐一个索引只存放一种类型的数据,并统一使用 "_doc" 作为类型名。在 8.x 中,类型 (_type) 已被完全移除。

3.1.2 查询文档

根据Rest风格,新增是POST,查询应该是GET,不过查询一般都需要条件,这里我们把文档id带上。

语法:

GET /{索引库名称}/_doc/{id}
//批量查询:查询该索引库下的全部文档
GET /{索引库名称}/_search

针对之前柯南的例子,这里的示例:

GET /conan/_doc/aEw-wZgBH3R7jdF0tikG

执行结果:

{
  "_index" : "conan",
  "_type" : "_doc",
  "_id" : "aEw-wZgBH3R7jdF0tikG",
  "_version" : 1,
  "_seq_no" : 0,
  "_primary_term" : 1,
  "found" : true,
  "_source" : {
    "age" : 21,
    "weight" : 52.1,
    "isMarried" : false,
    "info" : "真相只有一个!",
    "score" : [
      99.1,
      99.5,
      98.9
    ],
    "name" : {
      "firstName" : "柯",
      "lastName" : "南"
    }
  }
}

执行结果

3.1.3 修改文档

修改有两种方式:

  1. 全量修改:直接覆盖原来的文档。
  2. 增量修改:修改文档中的部分字段。

3.1.3.1 全量修改

全量修改是覆盖原来的文档,其本质是:

  • 根据指定的id删除文档
  • 新增一个相同id的文档

[!caution]

如果根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了。

语法:

PUT /{索引库名}/_doc/文档id
{
    "字段1": "值1",
    "字段2": "值2",
    // ... 略
}

针对之前柯南的例子,这里的示例:

PUT /conan/_doc/aEw-wZgBH3R7jdF0tikG
{
  "age": 21,
  "weight": 52.1,
  "isMarried": false,
  "info": "真相只有一个!",
  "email": "123@example.com",
  "score": [99.1, 99.5, 98.9],
  "name": {
    "firstName": "柯",
    "lastName": "南"
  }
}

执行结果:

{
  "_index" : "conan",
  "_type" : "_doc",
  "_id" : "aEw-wZgBH3R7jdF0tikG",
  "_version" : 2,
  "result" : "updated",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 1,
  "_primary_term" : 1
}

执行结果

注意上图中的数据变动。

3.1.3.2 增量修改

增量修改是只修改指定id匹配的文档中的部分字段。

语法:

POST /{索引库名}/_update/文档id
{
  "doc": {
    "字段名": "新的值",
  }
}

针对之前柯南的例子,这里的示例:

POST conan/_update/aEw-wZgBH3R7jdF0tikG
{
  "doc": {
      "email":"456@example.com"
    }
}

执行结果:

{
  "_index" : "conan",
  "_type" : "_doc",
  "_id" : "aEw-wZgBH3R7jdF0tikG",
  "_version" : 3,
  "result" : "updated",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 2,
  "_primary_term" : 1
}

执行结果

3.14 删除文档

删除使用DELETE请求,同样,需要根据id进行删除。

语法:

DELETE /{索引库名}/_doc/id值

针对之前柯南的例子,这里的示例:

DELETE /conan/_doc/aEw-wZgBH3R7jdF0tikG

执行结果:

{
  "_index" : "conan",
  "_type" : "_doc",
  "_id" : "aEw-wZgBH3R7jdF0tikG",
  "_version" : 4,
  "result" : "deleted",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 3,
  "_primary_term" : 1
}

执行结果

我们在执行一下查询,看看是否真的删除成功:

查询结果

可见确实是删除成功了的。

4. ES搜索引擎

本节主要来学习一下ES中查用的查询DSL语句。

4.1 DSL设置查询条件

4.1.1 DSL查询分类

在分类之前,必须理解一个最根本的概念:查询(Query)上下文过滤(Filter)上下文。这直接关系到搜索的性能和相关性算分。

  1. 查询上下文 (Query Context)
    • 问题:这个文档与查询子句的匹配程度如何?
    • 核心:在此上下文中,查询子句会询问文档的相关性,并为每个文档计算一个 _score(相关性分数)。分数越高,匹配度越高。
    • 性能:相对较慢,因为需要计算分数并排序。
    • 使用场景:全文搜索、需要排名的场景(如搜索“最佳匹配”的产品)。
  2. 过滤上下文 (Filter Context)
    • 问题:这个文档是否匹配这个查询子句?
    • 核心:答案是一个简单的“是”或“否”。它不计算分数,只关心文档是否包含该条件。过滤上下文会被 Elasticsearch 自动缓存,以加速性能。
    • 性能:非常快,尤其对重复查询,因为它可以利用缓存且无需计算分数。
    • 使用场景:精确值匹配(如状态、时间范围、标签、类型等)、需要频繁过滤的场景。

关键区别总结:

特性 查询上下文 (Query) 过滤上下文 (Filter)
目的 计算相关性并排序 筛选出匹配/不匹配的文档
评分 ,计算 _score _score 为 0
性能 较慢 极快(可缓存)
缓存 不缓存 自动缓存
使用场景 全文搜索 精确值、范围过滤

在实际查询中,bool查询的mustshould子句属于查询上下文,而filtermust_not子句属于过滤上下文

所有的查询基本上都是GET请求,且查询语法基本一致:

GET /索引名称/_search
{
  "query": {
    "查询类型": {
      "查询条件": "条件值"
    }
  }
}

Elasticsearch提供了基于JSON的DSL(Domain Specific Language)来定义查询。常见的查询类型包括:

  1. 全文查询(Full Text Queries)。
  2. 词级查询(Term Level Queries)。
  3. 复合查询(Compound Queries)。
  4. 地理查询(Geo Queries)。
  5. 特殊查询(Specialized Queries)。
  6. 连接查询(Joining Queries)。

4.1.2 全文查询(Full Text Queries)

全文查询专门用于在分析过的文本字段(如text类型)上进行高级、灵活的搜索。

[!tip]

这里的分析过指的是被分词器处理过的字段。绝大多数都是text类型。

其中全文查询又有细分。

4.1.2.1 Match(标准全文查询)

这是进行全文搜索的首选和标准查询。它是一个高级查询,理解字段的映射类型,非常灵活。

适合单字段普通关键词检索;可控制匹配严格度、错拼容忍等。

例如为现在有文档如图:

现有文档

现有Match查询:

GET /conan/_search
{
  "query": {
    "match": {
      "info": { // 指定要搜索的字段为 "info"
        "query": "真相 两个", // 查询字符串
        "operator": "or", // 布尔操作符
        "minimum_should_match": "75%", // 最低匹配条款数
        "fuzziness": "AUTO", // 模糊匹配容错度
        "prefix_length": 1, // 模糊查询时,前缀必须匹配的长度
        "max_expansions": 50, // 模糊查询时,最大扩展项数
        "zero_terms_query": "none" // 当分析器移除所有词条后的行为
      }
    }
  }
}

Match查询结果

Match查询详细解释:

  1. query:该参数指定要搜索的原始文本字符串。比如这里的“真相 两个”;查询处理器会将它传递给分析器。默认情况下(使用标准分析器),中文文本通常会被分词为两个独立的词条:["真相", "两个"]。后续的所有操作都是基于这两个分析后的词条进行的。

  2. operator:该参数定义多个搜索词条之间的布尔逻辑关系。可选值为orand其中or为match的默认选项。

    当为and的时候,文档必须包含所有分析后的词条才能被视为匹配。在这个例子中,文档的info字段必须同时包含 “真相”和“两个”这两个词。

    当为or的时候,文档仅需包含分析后的任意一条词条即可视为匹配。在这个例子中,文档的info字段只要包含 “真相”或“两个”这两个词中的任意一个即可。

  3. minimum_should_match:该参数的含义为至少需要匹配多少个词条(或者百分比),文档才能算作匹配。它比operator提供了更灵活的控制。在这个例子中,我们有两个词条。"75%"是1.5,Elasticsearch会向下取整1。这意味着它要求匹配到1个词条即可。

    这里我同时指定了operatorminimum_should_match,如果此时我的minimum_should_match给值为100%或者2时,相当于要求两个词条都匹配才能查出数据。

  4. fuzziness:该参数的作用为是否启用模糊匹配,允许搜索与查询词条相似而非完全相同的词,用于容错(如拼写错误、笔误)。

    AUTO是智能模式。它会根据词条的长度自动决定允许的编辑距离:

    • 词条长度12:必须精确匹配(fuzziness: 0)。
    • 词条长度35:允许一个字符的差异(fuzziness: 1)。
    • 词条长度>5:允许两个字符的差异(fuzziness: 2)。

    对于中文,模糊匹配通常基于编辑距离,但效果不如英文明显。它可能对同音字、形近字或少量增删改字符有效。例如,“真相” 可能匹配到“真像”(同音字)或“真像你”(新增字)。

  5. prefix_length:该数与fuzziness配合使用,限制模糊匹配的条件,以提升性能和结果质量。它规定了在进行模糊匹配时,词条的前 N 个字符必须完全匹配

    这里"prefix_length": 1表示词条的第一个字符不能有错误

  6. max_expansions:该参数与fuzziness配合使用,限制为每个词条生成的模糊变体的最大数量,以防止性能开销过大。

    模糊查询的工作原理是为原始词条生成一系列可能的错误变体(如 “apple” -> “apple”, “appl”, “pple”等)。max_expansions限制了这份变体列表的长度。当生成了50个变体后,即使还有更多可能,生成过程也会停止。这是一个安全阀,防止一个非常短的词(如 “abc”)产生成千上万个模糊变体,从而导致查询缓慢甚至崩溃。默认值通常是 50,所以这里明确写出来可能是为了强调或确保行为一致。

  7. zero_terms_query:该参数指定当分析器(如停用词过滤器)移除了查询字符串中的所有词条后,搜索引擎应该做什么。

    假设有一个分析器配置了停用词过滤器,会移除“的”、“和”、“是”等词。如果你搜索"的与是",分析后可能一个词条都不剩。

    "zero_terms_query": "none" 表示:如果分析后没有产生任何词条,则不返回任何文档(返回 hits 为空)。另一个选项是 "all",表示如果分析后没有词条,则返回索引中的所有文档(这通常不是想要的行为)。

    在这个例子中,中文查询“真相 两个”被分析后几乎不可能变成零词条,所以这个参数在此场景下更像是一个保障措施,确保查询行为的确定性。

[!tip]

由于篇幅有限,还有一些其他的参数配置,这里就不一一讲解了。

4.1.2.2 match_phrase(词条与词序)

match_phrasematch相比,查询的时候不仅仅要求词条匹配,同时词序也要匹配。

match_phrase查询要求满足如下条件:

  1. 所有词条必须都出现
  2. 词条出现的顺序必须与查询字符串中完全一致
  3. 词条之间的位置必须是紧邻的(默认情况下,可以通过 slop 参数调整)。

通常用它寻找的是一个完整的、连续的短语,而不仅仅是一堆散落的单词。

要理解match_phrase,首先需要知道Elasticsearch在索引text类型字段时,不仅存储了分词后的词条(terms),还会存储每个词条的位置(position) 信息:

词条位置

现在,我们执行一个match_phrase查询:

GET /conan/_search
{
  "query": {
    "match_phrase": {
      "info": {
        "query": "只有一个"
      }
      
    }
  }
}

match_phrase查询结果

查询过程解释:

  1. 分词:分词的时候,是根据你的Mapping定义时的分词器进行分词,在我的Mapping定义中,info字段采用的是IK分词器的智能模式(ik_smart),所以,这里查询字符串"只有一个",会被分词为两个词条:"只有"、"一个"。
  2. 查找词条:在info字段的倒排索引中,查找词条,可见本小节的第一个图。
  3. 检查词条和位置顺序:查询字符串"只有一个",会被分词为两个词条:"只有"、"一个"。在完整的info内容词条中,"只有"的position为1,"一个"position为2,刚好能够找到这两个词条,并且顺序也是合规的。

如果此时我的查询条件字符串变为:"真相一个",那么结果又是啥呢?

match_phrase查询结果

可见本次就直接查询不出来了,因为顺序不合规,“真相“的position为0,"一个"position为2,可见中间跳了一个position为1的词条,所以此处无法查询出来,当然,这种情况可以通过参数slop解决。

slop参数是match_phrase查询的灵魂,它允许短语中的词条之间存在“间隔”。

  • 作用:指定查询词条之间最多可以间隔多少个其他词条仍被视为匹配。
  • 默认值0。意味着必须紧邻,毫无灵活性。
  • 工作原理slop表示为了让短语匹配,词条需要移动的次数(交换相邻词条的位置或跳过中间的词)。

match_phrase查询搭配slop参数执行结果

4.1.2.3 match_phrase_prefix(前缀短语之输入法联想)

类似match_phrase,但最后一个词允许前缀匹配,适合搜索建议/边打边搜的场景。

现在我们执行一个match_phrase_prefix查询:

GET /conan/_search
{
  "query": {
    "match_phrase_prefix": {
      "info": {
        "query": "真相只"
      }
    }
  }
}

match_phrase_prefix查询结果

这里有两个关键参数:

  1. max_expansions:限制最后一个前缀词条所能匹配到的唯一词条的数量。因为前缀查询可能有会多都能匹配到,比如“只”,可以匹配到“只能”、“只有”、“只是”等等词语,如果不加以限制,查询可能会变得非常缓慢且消耗大量资源。当你指定了max_expansions参数之后,Elasticsearch会在倒排索引中遍历所有以"只"开头的词条,收集它们直到达到max_expansions设定的数量,然后停止。
  2. slop:这个参数和之前的match_phrase中的slop一致,不做过多解释。

4.1.2.4 multi_match(多字段一起搜)

它允许你将同一个查询字符串多个字段上进行搜索。

想象一下一个产品文档,它有 title, description, tags, manufacturer 等字段。用户通常在搜索框中只输入一次关键词(如 "wireless charger"),但期望搜索引擎能智能地在所有这些可能相关的字段中查找。为每个字段写一个 match 查询并组合起来会非常繁琐。multi_match 查询就是为了优雅地解决这个问题而设计的。

简单来说:一个查询,多个目标字段。

例如我现在执行multi_match查询:

GET /conan/_search
{
  "query": {
    "multi_match": {
      "query": "真相 柯",
      "fields": ["info^3", "name.firstName^1.5", "name.lastName"] // 需要匹配的字段
    }
  }
}

[!note]

提升权重 (Boosting):你可以使用^符号后跟一个数字来提升某个字段的权重。例如"info^3"表示在info字段中匹配到的结果,其相关性分数 (_score) 会是原来的 3 倍。这通常意味着匹配了info的结果会比匹配了正文的结果排名更靠前。

multi_match查询结果

[!warning]

这里multi_match查询要注意一下,在查询的过程中,你可以指定分词器参数:analyzer,如果对于某些你查询不出来的文档,有可能你分词器没有进行合适的指定。

例如name.firstName="柯"、name.lastName="南",如果你的查询字符串为:“柯南”,但是如果你mapping映射时设置的分词器如果为ik_smart或者ik_max_word,那么很有可能是查询不出来的,此时你可以在查询的时候手动指定分词器为:standard,就可以查询出来了。

使用Mapping默认的分词器查询

手动指定分词器

那我用ik_smart、ik_max_word是否能够查询出来呢?其实你提前用这两个分词器分析一下就知道:

ik_max_word分词结果

ik_smart分词结果

[!important]

上述所说的查询中指定分词器是对所有类型的查询都有效的,并不是multi_match这一个查询独有的。

在multi_match查询中,还有一些比较重要的参数:

  1. type这是核心参数,决定分数如何合并。multi_match查询的真正威力和复杂性来自于它的type参数。它定义了在多个字段上执行查询时,如何合并每个字段的得分以生成最终的文档得分。

    以下是最重要和最常用的几种类型:

    • best_fields:这是type的默认类型。它是 “单项冠军”模式。文档的最终得分等于匹配字段中得分最高的那个字段的分数。它认为文档的某个字段匹配得非常好,比它在多个字段上都勉强匹配要更重要。

      适用于当你期望查询词在同一个字段中紧密匹配时。例如,在titleabstract中搜索 "quantum physics",你更希望找到title中完整匹配这个短语的文档,而不是title中有 "quantum" 且abstract中有 "physics" 的文档。

    • most_fields:它是 “总分”模式。文档的最终得分是所有匹配字段的得分之和

      适用于相同文本,不同分析器的场景,例如,一个主字段text被标准分析器处理,另一个子字段text.english被英文分析器(会做词干还原)处理。搜索 "running" 时,text字段可能匹配 "running",而text.english字段会匹配 "run"。most_fields会将这两个匹配的分数相加,从而提升包含相关词汇的文档的排名。

      但是它也有隐患:由于Lucene的评分机制(TF/IDF),一个词在多个字段中出现可能会导致分数被不合理地抬高。

    • cross_fields:它是 “一个大字段”模式。它将所有指定的字段视为一个虚拟的大字段,然后在这个大字段上执行查询。这对于处理跨多个字段的标识信息至关重要。一般配合operator: AND使用。

      适用于搜索人名、地址等逻辑上是一个整体但被存储在多个字段中的数据。就比如我之前的name.firstName、和name.lastName,它本质上都属于人名,但却被存储到了两个字段中。

    • phrasephrase_prefix:这两种类型的行为分别类似于best_fields,但它们不是在内部使用match查询,而是使用match_phrasematch_phrase_prefix查询。

      适用于你需要对多个字段进行精确的短语或前缀短语搜索时

  2. tie_breaker:仅在type: "best_fields"时有效,用于调整最终得分的计算方式。默认情况下,best_fields类型只使用匹配字段中得分最高的那个字段的分数作为文档的最终得分。当设置tie_breaker(介于0.0和1.0之间)时,最终得分 = 最高分 + (tie_breaker * 其他匹配字段的分数之和)。

    tie_breaker为 0.0(默认):只取最高分。tie_breaker为1.0时,其实就是各个字段得分总合。

4.1.2.5 query_string/simple_query_string(用户可写查询语法)

它们允许你使用一种特殊的“迷你语言”在查询字符串中直接表达复杂的搜索逻辑。

与使用 JSON 结构来构建查询(如bool查询)不同,query_stringsimple_query_string允许你在一个字符串中,通过特定的操作符(如 AND, OR, +, -, * 等)来构建整个查询。这对于从搜索引擎迁移过来的用户或者喜欢简洁语法的用户来说非常熟悉。

[!tip]

query_string语法强但容易被未转义的特殊字符弄崩;而simple_query_stringquery_string的安全版,它不会抛解析异常(忽略非法语法)。前台检索建议首选match/ multi_match,或simple_query_string

4.1.2.5.1 query_string查询

query_string查询非常强大,但“能力越大,责任越大”。它支持丰富的功能,但语法错误会导致查询失败,并且如果暴露给用户,可能有安全性和性能风险。

query_string的重要参数:

  • default_field:如果查询字符串中没有指定字段,则默认在此字段中搜索。默认为 *,即所有符合查询的字段(由 index.query.default_field设置控制,通常为所有textkeyword字段)。
  • default_operator:指定默认的布尔运算符。可以是OR(默认)或ANDquery: "quick brown"default_operator: AND下意味着必须同时包含quickbrown
  • allow_leading_wildcard:是否允许通配符*?在开头。出于性能考虑,默认为true,但生产环境中通常建议设置为 false
  • fuzzy_max_expansions:控制模糊查询的扩展数量,用于限制性能开销。
  • analyze_wildcard:是否对通配符模式进行分析。通常保持默认的false

query_string的缺点:

  1. 极其严格:查询字符串中有一个语法错误(例如未闭合的括号、无效的转义符),整个查询就会抛出错误,返回4xx
  2. 性能风险:用户很容易输入性能开销巨大的查询,如*:*(匹配所有文档)、开头通配符*abc、或范围过大的正则表达式,可能拖慢甚至击垮集群。
  3. 安全性风险:如果直接暴露给用户,恶意用户可能通过构造复杂的查询来消耗大量系统资源(一种 DoS 攻击向量)。

适用场景内部工具、管理员后台、或受信任的、受过培训的用户。绝不直接暴露给不可控的终端用户。


字段选择查询

  • field:value:在指定字段中搜索值。
  • _exists_:field:查找包含该字段的文档。
GET /conan/_search
{
  "query": {
    "query_string": {
      "query": "info:一个"
    }
  }
}

query_string字段选择查询结果


布尔逻辑查询

  • AND / && / +:必须同时满足。apple AND orange(两者都必须有)。
  • OR / || / (空格):至少满足一个。apple OR orange(有其中一个即可)。空格默认就是 OR
  • NOT / ! / -:必须不满足。apple NOT orange(要有 apple,但不能有 orange)。-orange(排除所有包含 orange 的文档)。

[!caution]

这里的都算是关键字,大小写是敏感的

GET /conan/_search
{
  "query": {
    "query_string": {
      "default_field": "info", // 指定默认查询的字段
      "query": "一个 AND 你好" // 查询info中既要包含 “一个” 又要包含 “你好“的文档
    }
  }
}

query_string布尔逻辑查询结果

可见info字段中并没有“一个”和“你好”这两个内容同时包含的文档,我们现在把布尔逻辑调整为OR试一试,或者把“你好”调整为“真相”试一试:

query_string布尔逻辑查询结果

query_string布尔逻辑查询结果


分组查询: 使用括号()来组合子句,构建复杂的逻辑。你可以理解为优先级的问题。

GET /conan/_search
{
  "query": {
    "query_string": {
      "default_field": "info", // 指定默认查询的字段
      "query": "(一个 OR 苹果) AND 你好"
    }
  }
}

我的info当前为:“真相只有一个!”;上述查询语句,“(一个 OR 苹果)”表示info要么包含“一个”,要么包含“苹果”,显然是满足的,返回TRUE,后面的“AND 你好”,info中显然没有“你好”,用AND表示两者都要满足,即TRUE AND FlASE,所以,这里是查不出来结果的:

query_string分组查询结果

当我这里调整一下,使得整个query为TRUE就可以了:

query_string分组查询结果


其他还有一些,我就不一一举例了,这里只是说一下概念:

  • 通配符查询

    • ?匹配任意单个字符,例如qu?ck 匹配quick, quack
    • *匹配零个或多个字符。el*tric匹配elastic, electric, electrific

    [!warning]

    通配符查询,尤其是开头的通配符(如*ric),性能开销极大,应尽量避免。

  • 正则表达式:使用/包裹正则表达式模式。name:/joh?n(ath[oa]n)/

  • 模糊匹配:使用~在词条后进行模糊匹配。quikc~会匹配quick。可以指定编辑距离quikc~2

  • 范围查询:数值/日期范围:age:[18 TO 30], date:{2020-01-01 TO 2020-12-31};其[ ]表示包含端点,{ }表示不包含端点。也有简写形式,例如简写:age:>18, date:<2020-01-01

  • 短语搜索:使用引号""来搜索精确短语。"quick brown fox" 会进行短语匹配(类似 match_phrase)。

  • 提升:使用^来提升某个词条或短语的权重。quick^2 brown表示quick的重要程度是brown的两倍。


4.1.2.5.2 simple_query_string查询

simple_query_string查询是query_string的一个更安全、更健壮的版本。它被设计用来直接处理用户输入

核心特性:它支持query_string的一个子集运算符,但其核心特点是:它会忽略掉它不识别或无效的语法部分,而不是抛出错误

支持的运算符simple_query_string的运算符含义与 query_string 相同,但你需要通过 flags 参数来显式启用你希望支持的运算符:

  • flags 参数:指定支持的运算符列表。例如flags: "OR|AND|PREFIX"
  • 常用运算符及其对应的符号:
    • |代表OR (默认)
    • +& 代表AND
    • -代表NOT
    • *代表前缀通配符
    • "代表短语
    • ()代表优先级
    • ~N代表单词后的模糊匹配
    • ~N代表短语后的邻近度(slop)

simple_query_string的优点:

  1. 容错性强:不会因为用户的无效输入而崩溃,提供了更稳健的搜索体验。
  2. 更安全:你可以通过flags参数严格控制允许使用的运算符,避免用户使用性能开销大的操作(如通配符*,如果你不启用 PREFIX 的话)。
  3. 专为用户输入设计:它的所有设计初衷都是为了安全地处理不可预测的用户输入。

适用场景任何直接面向终端用户的搜索框。你应该始终使用simple_query_string而不是query_string来处理用户输入。


4.1.2.5.3 对比
特性 query_string simple_query_string
核心定位 功能强大的专家工具 安全稳健的用户输入处理器
语法错误 抛出异常,查询失败 忽略无效部分,继续执行
性能控制 难控制,用户可能写出重查询 可通过 flags 精细控制允许的操作
安全性 低,易受DoS攻击
运算符支持 非常丰富(通配、正则、范围等) 有限子集(通过 flags 指定)
适用场景 内部系统、受信任环境 面向用户的搜索框

4.1.3 词级查询(Term Level Queries)

词级查询(Term-Level Queries)是Elasticsearch中用于精确匹配结构化/非分词值的一组查询。它们不依赖搜索时再分词(不像 match),而是直接在倒排索引中按原子词项(term) 匹配。因此常用于:精确过滤、聚合、排序、范围查询、ID 查询等场景。

使用场景:

  • 你要做精确匹配(状态、枚举、标签、id、布尔值等)。
  • 要做聚合/排序/脚本计算(这些通常要求keyword/数值/日期字段)。
  • 想把条件放在filter context(不算分、可被优化)以提高查询性能。

[!tip]

词级查询不做搜索分析(analyzer),因此要确保你查询的值与索引时存入倒排索引的 term 对应(对text字段通常是分词结果,对keyword字段就是原文)。

其中词级查询又有细分。

4.1.3.1 term查询

term查询用于在未分析的精确值(如数字、日期、枚举值、未分词的字符串)上进行搜索。它的工作方式可以类比为 SQL 中的 WHERE column = 'value'

最重要的概念:它不会对查询的输入进行任何分析(分词)。

语法格式:

GET /索引名称/_search
{
  "query": {
    "term": {
      "FIELD": {
        "value": "VALUE"
      }
    }
  }
}

term查询结果

term查询有几个关键参数:

  1. value:这个值是必须的,表示要匹配的精确值。
  2. case_insensitive:(可选,布尔值) 表示是否大小写不敏感,它允许你在不改变映射的情况下进行大小写不敏感的匹配。
  3. boost:(可选,浮点数) 用于调整此查询条件的相关性权重。

总结:

  • term查询是精确匹配查询,用于搜索数字、日期、关键字等未分析的精确值。
  • 它不会对查询输入进行任何分析
  • 永远不要对 text 字段直接使用 term 查询,要搜索 text 字段的确切值,请使用 .keyword 子字段。
  • 它是实现过滤器的理想选择,性能极高。
  • 使用 case_insensitive: true 可以在不改变映射的情况下实现大小写不敏感的匹配。

4.1.3.2 terms查询

terms是term查询的“多值”版本,用于匹配包含指定字段中任意一个或多个精确值的文档。

一个最贴切的类比是 SQL 中的IN操作符:

  • SQL: SELECT * FROM products WHERE category_id IN (1024, 2048, 4096);
  • Elasticsearch:"terms": { "category_id": [1024, 2048, 4096] }

它的核心机制与term查询一致:不分析查询输入,直接与倒排索引中的精确词条进行匹配

语法:

GET /索引名称/_search
{
  "query": {
    "terms": {
      "FIELD": [
        "VALUE1",
        "VALUE2"
      ]
    }
  }
}

例如我有如下索引与文档:

produts索引与所有文档

我现在的查询请求与结果如图:

terms查询结果

对于terms查询,还有一个高级功能:Terms Lookup机制:允许你从另一个Elasticsearch文档中获取terms列表。这对于动态列表非常有用。

例如有时需要匹配的值列表非常长,比如需要匹配上千个用户ID:

{
  "query": {
    "terms": {
      "user_id": {
        "index": "users_lists_index",
        "id": "list_of_vip_users",
        "path": "user_ids"
      }
    }
  }
}

这个查询会从users_lists_index索引中ID为list_of_vip_users的文档的user_ids字段里获取值列表,然后用它来查询。

4.1.3.3 ids查询

ids查询是通过文档的唯一标识符 _id (元数据)来检索一个或多个文档。你可以把它想象成数据库中的主键查询。

SQL 类比:SELECT * FROM table_name WHERE id IN ('id1', 'id2', 'id3');

语法:

GET /索引名称/_search
{
    "query": {
        "ids": {
            "values": []
        }
    }
}

ids查询请求与结果

4.1.3.4 range查询

range查询的核心功能是匹配字段值在某个上下限之间的文档。它可以应用于数值、日期、甚至字符串(按字典序)类型的字段。

它支持gt/gte/lt/lte格式与now表达式。

语法:

GET /索引名称/_search
{
    "query": {
        "range": {
          "FIELD": {
            "gte": ...,
            "gt": ...,
            "lte": ...,
            "lt": ...,
            // ... 其他参数
          }
        }
    }
}
参数 含义 全称 说明
gt 大于 (Greater than) 匹配字段值大于指定值的文档。不包含边界本身。
gte 大于等于 Greater than or equal 匹配字段值大于或等于指定值的文档。包含边界本身。
lt 小于 (Less than) 匹配字段值小于指定值的文档。不包含边界本身。
lte 小于等于 Less than or equal 匹配字段值小于或等于指定值的文档。包含边界本身。

range查询可以应用到不同的数据类型上:

  1. 数值范围:用于integer, long, float, double等数值类型字段。这是最直观的用法。

    // 匹配 20 <= price < 50 的商品
    {
      "range": {
        "price": {
          "gte": 20,
          "lt": 50
        }
      }
    }
    
  2. 日期范围:用于date类型字段。这是 range 查询最强大的功能之一。Elasticsearch的日期处理非常灵活,支持多种格式。

    日期数学(Date Math)是其精髓所在:Elasticsearch允许使用特殊的表达式来表示相对时间。

    • 锚定日期now(当前时间)或一个具体的日期字符串2023-01-01
    • 数学操作+1h(加1小时), -1d(减1天), +2M(加2个月), /d(取整到天)。
    • 时间单位y(年), M(月), w(周), d(天), h(小时), m(分钟), s(秒)。
    {
      "range": {
        "timestamp": {
          "gte": "now-7d/d", // 7天前,并取整到00:00:00
          "lte": "now"       // 当前时间
        }
      }
    }
    

    这个查询匹配过去7天内(直到现在)的所有文档。now-7d/d是一个非常常用的模式,用于获取“最近7天”的数据。

  3. 字符串范围:用于keyword类型的字段。范围基于字典序。

    // 匹配所有姓氏从 "A" 到 "L" 的用户(例如 Anderson, Lee, but not Zhang)
    {
      "range": {
        "last_name.keyword": { // 注意:必须对 text 字段使用 .keyword
          "gte": "A",
          "lt": "M" // 小于 "M",即不包含以 M 开头的
        }
      }
    }
    

    [!warning]

    text字段直接使用range查询会遇到和term查询相同的问题(需要搜索分析后的词条,结果不可预测)。应始终在 .keyword子字段上执行字符串范围查询。

对于range查询,还有一些关键参数:

  1. format:如果你的日期格式不是标准的yyyy-MM-ddepoch_millis,你需要用此参数指定格式。

    例如,你存储的日期是"dd/MM/yyyy"格式:

    {
      "range": {
        "timestamp": {
          "gte": "01/01/2023",
          "lte": "31/12/2023",
          "format": "dd/MM/yyyy" // 指定输入日期的格式
        }
      }
    }
    
  2. time_zone:日期查询默认使用UTC时间。如果你的数据涉及时区,这个参数可以强制将查询中的日期转换到特定时区。这对于处理按“天”统计的日志等场景非常有用。

    // 查询北京时间 (UTC+8) 2023年1月1日当天的所有日志
    {
      "range": {
        "timestamp": {
          "gte": "2023-01-01T00:00:00", // 这行代码在 UTC 环境下是 2023-01-01T00:00:00Z
          "lte": "2023-01-01T23:59:59",
          "time_zone": "+08:00" // 这个参数会让 Elasticsearch 将上面的时间理解为北京时区的时间
        }
      }
    }
    // 等价于在 UTC 时间范围内查询:["2022-12-31T16:00:00Z" TO "2023-01-01T15:59:59Z"]
    

这里针对之前的商品数据,我就列举一个数值范围查询的例子即可:

range数值范围查询请求与响应

4.1.3.5 exists查询

exists查询是查找那些包含指定字段的文档。它只关心一个字段是否存在,而完全不管该字段的值是什么。

语法:

GET /索引名称/_search
{
    "query": {
        "exists": {
            "field":"field_name"
        }
    }
}

什么情况下文档算作“存在”? 理解exists查询的行为,关键在于理解Elasticsearch如何索引值。下表总结了各种情况:

字段值示例 是否被索引? exists 查询是否匹配? 说明与常见场景
{ "field": "value" } 标准情况。字段包含一个明确的、非空的值。
{ "field": "" } 常见陷阱。空字符串也是一个有效的字符串值,会被索引。
{ "field": null } 显式的 null 值不会被索引,等同于字段缺失。
{ "field": [] } 空数组不包含任何需要索引的实际值。
{ "field": [null] } 虽然是一个数组,但其内含的所有元素都是 null,没有可索引的有效值。
{ "field": ["value1", "value2"] } 只要数组中包含至少一个有效(非null)的值,该字段就会被索引。
{ } (字段缺失) 文档的 JSON 结构中根本不存在该字段。

核心结论:exists查询只关心一个字段是否有一个已被索引的有效值。空字符串""是一个有效值,而null或空数组则不是。

常用场景:

  1. 数据质量检查与清洗:这是exists查询最典型的用途。用于找出缺失关键字段的文档,以便进行数据清洗或补充。
  2. 确保聚合准确性:在对某个字段进行聚合(如terms聚合)前,先用exists过滤,可以确保聚合结果只基于拥有该字段的文档,避免统计误差。
  3. 构建健壮的搜索过滤器:例如在用户进行筛选时,确保只筛选那些真正拥有该属性的商品。
  4. 处理动态映射的未知字段:在采用动态映射的索引中,某些文档可能因为从未被赋值而缺少某个字段。exists查询可以可靠地识别出这些文档。

exists查询请求与响应

4.1.3.6 prefix查询

prefix查询用于查找在指定字段中包含以给定前缀开头的term的文档。

一个最贴切的类比是SQL中的LIKE 'prefix%'

  • SQL:SELECT * FROM products WHERE name LIKE 'Pro%';
  • Elasticsearch:"prefix": { "name.keyword": "Pro" }

它的核心机制是:不分析查询输入,直接与倒排索引中的精确词条进行前缀匹配

语法:

GET /索引名称/_search
{
  "query": {
    "prefix": {
      "FIELD": {
        "value": ""
      }
    }
  }
}

prefix查询有结果情况

prefix查询无结果情况

4.1.3.7 wildcard查询

wildcard查询让你可以使用*?等通配符来匹配字段的值。它提供了比prefix查询更大的灵活性,但是性能开销比较大

它只对keyword类型比较合适。

通配符 含义 示例 匹配示例
* 匹配零个或多个任意字符 *phone* " smartphone", "telephone", "phone_case"
? 匹配一个任意字符 Pro?ect "Project", "Protect" (但不匹配 "Prospect")

语法:

GET /索引名称/_search
{
  "query": {
    "wildcard": {
      "FIELD": {
        "value": "VALUE"
      }
    }
  }
}

wildcard查询使用?匹配单个字符

wildcard查询使用*匹配单个字符

4.1.3.8 regexp查询

regexp查询让你可以使用完整的正则表达式语法来匹配字段的值。它提供了无与伦比的灵活性,但是也很大,是模糊查询中(prefix、wildcard、regexp)开销最大的。

它的核心机制是:不分析查询输入,直接与倒排索引中的精确词条进行正则表达式匹配

正则表达式语法:

运算符 含义 示例 匹配示例
. 匹配任意单个字符 a.c "abc", "aac", "a c"
* 前一个字符出现0次或多次 ab*c "ac", "abc", "abbc"
+ 前一个字符出现1次或多次 ab+c "abc", "abbc" (不匹配 "ac")
? 前一个字符出现0次或1次 ab?c "ac", "abc"
{n,m} 前一个字符出现n到m次 a{2,4}b "aab", "aaab", "aaaab"
[...] 字符组,匹配其中任意一个字符 [abc]d "ad", "bd", "cd"
[^...] 否定字符组,匹配不在其中的字符 [^abc]d "dd", "ed" (不匹配 "ad", "bd", "cd")
(...) 分组,将多个字符作为一个整体 (ab)+c "abc", "ababc"
^ 匹配字符串开始 ^abc "abc" (不匹配 "aabc")
$ 匹配字符串结束 abc$ "abc" (不匹配 "abcd")

语法:

GET /索引名称/_search
{
  "query": {
    "regexp": {
      "FIELD": "REGEXP"
    }
  }
}

这里就不多讲了,来对比一下词级查询中的模糊查询:

特性 prefix 查询 wildcard 查询 regexp 查询
功能 只能前缀匹配 (prefix*) 通配符匹配 (*, ?) 完整正则表达式匹配
灵活性
性能开销 非常高
适用模式 Pro* *phone*, Pro?ect `Pro(ject

简单来说:prefixwildcard的功能子集,而regexp是它的功能超集。性能和灵活性成反比。

4.1.3.9 fuzzy查询

fuzzy查询用于查找与搜索词条相似而非完全相同的词条。它的核心是基于编辑距离(Edit Distance),也称为 Levenshtein 距离

编辑距离是指将一个词条转换为另一个词条所需的最少单字符编辑操作次数。允许的编辑操作包括:

  1. 插入 (Insertion):abc -> abec (插入e)
  2. 删除 (Deletion):abec -> abc (删除e)
  3. 替换 (Substitution):abc -> azc (将b替换为z)
  4. 换位 (Transposition):abc -> acb (将bc交换位置) (通常可选)

一个简单的类比:如果你搜索"quikc",一个优秀的模糊搜索应该能匹配到"quick",因为它们的编辑距离为 2(一次插入 c,一次换位 kc?或者两次替换?实际上,quikc->quick可以通过换位kc来实现,编辑距离为 1)。

语法:

GET /索引名称/_search
{
  "query": {
    "fuzzy": {
      "FIELD": {
        "value":"VALUE"
      }
    }
  }
}

例如我现在有请求:

GET /products/_search
{
  "query": {
    "fuzzy": {
      "product.name": {
        "value": "商品003",
        "prefix_length":4,
        "fuzziness": "AUTO"
      }
    }
  }
}

在fuzzy查询中,有一些关键参数:

  1. fuzziness: (可选,字符串或整数)允许的最大编辑距离。它决定了搜索的“模糊度”或“宽容度”。

    取值:

    • 0, 1, 2:具体的编辑距离。0相当于term查询(精确匹配)。
    • "AUTO" (推荐):智能模式。根据词条长度自动决定距离。

    例如上述例子中,"value": "商品003", "fuzziness": "AUTO"能匹配 "商品001", "商品002"等。

  2. prefix_length:(可选,整数)最重要的性能优化参数。指定开头多少个字符必须完全匹配。默认值为0。

    在上述例子中,"prefix_length":4表示开头前4个字符必须完全匹配。

示例请求与结果

4.1.3.10 term_set查询

它是对基础terms查询的重大增强,引入了“动态阈值”的概念。普通的terms查询要求文档匹配词条列表中的至少一个词条(OR 逻辑)。如果你需要匹配至少N个,通常需要借助bool查询组合多个term查询,但这需要在查询时硬编码N的值

terms_set查询的强大之处在于:文档需要匹配的最小词条数(minimum_should_match)不是固定的,而是可以根据每个文档的内容动态计算出来的。

我们先看一个场景:

假设你有一个商品索引,每个商品都有一些标签 (tags)。你想搜索“包含一系列标签”的商品,但不同的商品类别需要匹配的标签数量不同:

  • 对于“电子产品”,可能需要匹配所有 3 个标签才算相关。
  • 对于“服装”,可能只需要匹配 2 个标签就算相关。

普通terms查询无法实现这种动态需求。而terms_set查询通过两种方式解决了这个问题:

  1. 基于另一个字段的值:从当前文档的某个字段中读取minimum_should_match的值。
  2. 基于脚本:通过一个脚本动态计算minimum_should_match的值。

这里暂时不做过多讲解。

4.1.4 复合查询(Compound Queries)

复合查询用来组合其他查询控制打分方式。常见成员:

  • bool:布尔组合王者(must/should/must_not/filter)。
  • dis_max:多子查询取最高分,适合同义字段/多索引副本。
  • constant_score:把一组过滤当“命中即给固定分”,不参与相关性计算
  • function_score:在原 _score 基础上用函数加权/衰减
  • boosting:命中负面条件的文档降权而非剔除。

[!tip]

记忆法:布尔组合(bool)择优取高(dis_max)固定得分(constant_score)函数加权(function_score)负样降权(boosting)

4.1.4.1 bool查询

bool查询是Elasticsearch功能最强大、使用最频繁的查询。绝大多数复杂的搜索需求最终都可以通过组合bool查询来实现。它就像SQL中的WHERE子句加上ANDORNOT这些逻辑运算符的组合,但更加强大和灵活。

bool查询允许你将多个独立的查询子句以布尔逻辑的方式组合起来,形成一个复杂的查询。它基于“匹配与否”的二值逻辑,但其强大的相关性评分(_score)系统使得结果可以精准排序。

bool查询通过四个主要的子句来组合其他查询。理解每个子句的行为,尤其是它们在查询上下文(Query Context)过滤上下文(Filter Context) 下的区别,是掌握 bool 查询的关键:

子句 逻辑等价 贡献分数? 上下文类型 描述
must AND (&&) 查询上下文 必须满足的所有子句。文档必须匹配这些子句,并且每个子句的分数都会计入总分。
filter AND (&&) 过滤上下文 必须满足的所有子句。文档必须匹配这些子句,但不计算分数,且子句会被缓存。
should OR (` `)
must_not NOT (!) 过滤上下文 必须不满足的所有子句。文档不能匹配这些子句,不计算分数,且子句会被缓存。
  1. must子句 (必须满足)

    作用:所有must子句必须匹配。这相当于逻辑运算中的AND

    贡献分数。每个must子句的得分都会计入文档的总分 (_score)。

    用途:用于定义搜索的核心要求

    例如我想查询一个商品,要求他的描述内容中有“升级款”这个数据,并且商品的价格要小于等于1000:

    GET /products/_search
    {
        "query": {
            "bool": {
                "must": [
                  {
                    // 查询描述中带有“升级款的产品”
                    "match": {
                      "product.description": "升级款"
                    }
                  },
                  {
                    // 查询商品的价格小于等于1000的产品
                    "range": {
                      "product.price": {
                        "lte": 1000
                      }
                    }
                  }
                ]
            }
        }
    }
    

    must查询结果

  2. filter子句(必须满足,但不评分)

    作用:所有filter子句必须匹配(也是 AND 逻辑)。

    贡献分数。这是它与must最关键的区别。filter子句不参与评分,_score为 0 或不受影响。

    性能极佳。因为它不计算分数,而且Elasticsearch会自动缓存常用的filter子句结果,这使得后续相同的过滤操作速度极快。

    用途:用于精确过滤。例如:范围(日期、价格)、状态值、标签等一切“是/否”的是非判断。

    还是must子句中的例子,例如我想查询一个商品,要求他的描述内容中有“升级款”这个数据,并且商品的价格要小于等于1000:

    filter查询结果

  3. should子句(应该满足 - 可选项/加分项)

    作用should子句是“加分项”或“可选项”。匹配的should子句会增加文档的相关性分数。

    贡献分数。匹配的should子句会使其分数计入总分,匹配的越多,分数越高。

    特殊行为:其具体行为取决于它是否与mustfilter子句同时存在

    • 如果bool查询中包含了mustfiltershould子句就变成了纯粹的加分项。文档可以匹配 0 个或多个 should 子句。不匹配不会导致文档被排除,但匹配了会提高其排名。
    • 如果bool查询中只有should:那么文档至少需要匹配其中一条 should 子句。这时的行为类似于逻辑 OR

    情况一:与must或者与filter组合,作为加分项

    GET /products/_search
    {
      "query": {
        "bool": {
          "must": [
            {
              "match": {
                "product.description": "升级款"
              }
            },
            {
              "range": {
                "product.price": {
                  "lte": 1000
                }
              }
            }
          ],
          "should": {
            "term": {
              "product.name": {
                "value": "商品002"
              }
            }
          }
        }
      }
    }
    

    should与must组合且匹配的结果

    should与must的结果不匹配时

    情况二:bool查询中只有should,多个should相当于就是OR关系:

    GET /products/_search
    {
      "query": {
        "bool": {
          "should": [
            {
              "term": {
                "product.name": {
                  "value": "商品002"
                }
              }
            },
            {
              "term": {
                "product.name": {
                  "value": "商品003"
                }
              }
            }
          ]
        }
      }
    }
    

    多个should查询结果

  4. must_not子句(必须不满足)

    作用:所有must_not子句必须不匹配。这相当于逻辑运算中的 NOT

    贡献分数。与filter一样,它不计算分数,且会被缓存。

    用途:用于排除不需要的文档。

    例如我查询的商品ID必须不能是2的:

    GET /products/_search
    {
      "query": {
        "bool": {
          "must_not": [
            {
              "ids": {
                "values": [2]
              }
            }
          ]
        }
      }
    }
    

    must_not查询示例

对于bool查询,还有一个核心参数:minimum_should_match,专门用于控制至少需要匹配多少个 should 子句

使用场景:

  1. bool查询中只有should子句时,它用于控制文档被匹配的最低标准
  2. bool查询中同时有mustshould 时,它用于控制至少需要匹配多少个should子句才能获得“加分”

性能最佳实践:

  1. 优先使用filter上下文:将对相关性评分没有要求的精确匹配(termrangeexists 等)条件全部放入 filter 子句。这是提升查询性能最有效的手段。
  2. 合理使用must_not:它和filter一样高效,用于排除文档。
  3. must用于核心全文搜索:将需要计算相关性的全文搜索(matchmulti_match)放在must中。
  4. should用于优化排名:使用should来引入业务逻辑,提升重要文档的排名。
  5. 嵌套bool查询bool查询本身也可以作为子句嵌入到另一个bool查询中,从而实现极其复杂的逻辑。

4.1.4.2 dis_max查询

dis_max查询是为解决一个非常具体的问题而设计的:当同一个查询词在多个字段中匹配时,如何避免相关性评分(_score)的失真。

它的核心逻辑是:文档的最终得分等于其所有匹配的子查询中得分最高的那个分数,而不是所有子查询得分的总和。

该查询主要是为了解决 "分数稀释" 问题。

为了更好地理解dis_max,我们先看一个使用普通bool查询时可能遇到的问题。

假设场景:我们有两个文档,在titlecontent字段中搜索 "apple"。

  • 文档A (非常相关):
    • title:"Apple iPhone 13 Pro" (得分: 2.5)
    • content:"A review of the latest smartphone from apple." (得分: 1.0)
  • 文档B (不太相关):
    • title:"My Favorite Fruits"
    • content:"I love to eat apples and bananas. An apple a day keeps the doctor away. ... (提到 10 次 apple)" (得分: 2.4)

如果使用bool查询with should

{
  "query": {
    "bool": {
      "should": [
        { "match": { "title": "apple" } },
        { "match": { "content": "apple" } }
      ]
    }
  }
}

评分计算:

  • 文档A总分:2.5 (title) + 1.0 (content) = 3.5
  • 文档B总分:0.0 (title) + 2.4 (content) = 2.4

结果:文档A排名第一。这符合预期。

但现在,我们稍微修改一下文档B:

文档B (修改后):

  • title:"My Favorite Fruits - Apple Recipes" (得分: 1.8)
  • content:"I love to eat apples and bananas... (提到 10 次 apple)" (得分: 2.4)

新的评分计算:

  • 文档A总分:2.5 + 1.0 = 3.5
  • 文档B总分:1.8 + 2.4 = 4.2

结果:文档B的排名超过了文档A!

这显然是不合理的。文档A的标题完美匹配了用户搜索的“Apple”品牌,是最相关的结果。而文档B只是在标题中轻微提及,在正文中频繁出现“apple”水果。bool查询将分数累加的机制,导致一个在多个字段上都只是“还行”的匹配,其总分超过了另一个在单个字段上“极佳”的匹配。

想要解决这个问题,你只使用dis_max查询就可以了,它不再将分数相加,而是只取匹配的子查询中的最高分作为文档的最终得分。

dis_max查询语法:

GET /索引名称/_search
{
  "query": {
    "dis_max": {
      "tie_breaker": 0.7, //默认0.7
      "boost": 1.2, // 默认1.2
      "queries": []
    }
  }
}

[!tip]

这里的tie_breaker参数与boost参数就不重复讲了,在之前的已经讲过了,你可以搜索一下。

使用示例:

GET /products/_search
{
  "query": {
    "dis_max": {
      "tie_breaker": 0.7,
      "boost": 1.2,
      "queries": [
        {
          "match": {
            "product.description": {
              "analyzer":"standard",
              "query":"001"
            }
          }
        },
        {
          "term": {
            "product.description.keyword": {
              "value":"商品"
            }
          }
        }
      ]
    }
  }
}

在实际使用中,dis_max查询一般很少使用,因为它要解决的问题是如此常见,所以Elasticsearch将其作为了 multi_match查询中type: best_fields的底层实现

比如如下两个查询其实是等效的:

{
  "query": {
    "dis_max": {
      "queries": [
        { "match": { "title": "apple" } },
        { "match": { "content": "apple" } }
      ],
      "tie_breaker": 0.3
    }
  }
}
{
  "query": {
    "multi_match": {
      "query": "apple",
      "type": "best_fields", // 默认就是 best_fields,底层使用 dis_max
      "fields": ["title", "content"],
      "tie_breaker": 0.3
    }
  }
}

4.1.4.3 constant_score查询

Elasticsearch的查询上下文(Query Context)和过滤上下文(Filter Context)的根本区别在于是否计算相关性分数

  • 查询上下文:计算_score,用于排序。
  • 过滤上下文:不计算_score(结果为 0 或不影响原分数),用于精确的“是/否”筛选,性能极高且可缓存。

constant_score查询巧妙地将两者结合:它允许你在过滤上下文中执行一个子查询(享受高性能),但同时为所有匹配的文档指定一个固定的分数(使其可以参与排序)。

constant_score查询包装另一个查询,并为其所有匹配的文档设置一个相同的分数。

语法:

GET /索引名称/_search
{
  "query": {
    "constant_score": {
      "filter": {}, // 在 filter 上下文中执行子查询,任何一个查询子句,通常是 term, range, exists 等
      "boost": 1.2 //(可选)为所有匹配的文档设置的固定分数
    }
  }
}

执行流程:

  1. 执行filter中的子查询,找出所有匹配的文档。
  2. 忽略该子查询原本可能产生的任何分数。
  3. 每一个匹配的文档赋予一个完全相同的分数,其值等于boost 参数的值。
  4. 所有不匹配的文档得分为0

总结:当你需要过滤器的性能,但又需要查询器的排序能力时,请毫不犹豫地使用constant_score查询。

4.1.4.4 function_score查询

如果说bool查询是逻辑组合的王者,那么function_score查询就是相关性评分(Relevance Scoring)控制的皇帝。它允许你完全覆盖或修改查询返回的文档的原始相关性分数 (_score),从而实现高度定制化的排名逻辑。

核心思想:自定义评分。

默认情况下,Elasticsearch使用 BM25 算法(一种 TF/IDF 的优化变体)来计算查询的相关性分数。虽然这在大多数全文搜索场景中表现良好,但它无法融入你的业务逻辑

function_score查询的核心思想是:

  1. 首先执行一个主查询(query)来获取一个初始的结果集和初始分数。
  2. 然后对匹配的文档运行一系列函数(functions),这些函数可以基于文档自身的字段值(如销量、价格、时间戳、地理位置等)来调整甚至完全替换初始分数。
  3. 最后,通过指定的方式将函数产生的新分数与原始分数组合起来,得到最终分数。

语法:

{
  "query": {
    "function_score": {
      "query": {},        // (可选)主查询,默认为 match_all
      "functions": [ ... ], // (可选)一个或多个评分函数
      "score_mode": "...", // 如何合并各个函数的输出
      "boost_mode": "...", // 如何将函数分与查询分合并
      "max_boost": ...,    // 函数分的上限
      "min_score": ...     // 分数下限,用于过滤结果
      // ... 其他参数
    }
  }
}

核心参数讲解:

  1. query主查询:定义要匹配哪些文档的基础查询。所有function_score的逻辑都基于这个查询的结果。如果省略,则默认为{ "match_all": {} },即对所有文档进行评分。

  2. functions函数数组:这是function_score的灵魂。它是一个数组,允许你定义多个评分函数。每个函数都可以选择性地拥有一个过滤器(filter),只对匹配该过滤器的文档生效。

    支持的函数类型:

    函数 作用描述 典型应用场景
    weight 为匹配的文档提供一个固定的权重倍数 给特定类别或标签的文档加权。
    field_value_factor 使用文档中某个数值字段的值来修改分数。 基于销量、点赞数、热度等字段提升排名。
    random_score 为每个文档生成一个一致的随机分数 实现结果的随机排序(A/B测试、均匀分布)。
    decay functions 根据数字、日期或地理位置字段的衰减函数(如高斯衰减)计分。 新品优先、附近优先、价格越接近某值排名越高。
    script_score 使用自定义脚本(Painless)进行最灵活的评分。 实现任何其他函数无法实现的复杂业务逻辑。
  3. score_mode分数模式:指定同一个functions数组中多个函数的输出分数如何合并。

    常用值:

    • multiply (默认):函数分相乘。
    • sum:函数分相加。
    • avg:函数分的平均值。
    • max:取函数分中的最大值。
    • min:取函数分中的最小值。
    • first:只使用第一个匹配函数的分数。
  4. boost_mode提升模式:指定如何将合并后的函数分原始查询分组合起来得到最终分数。

    常用值:

    • multiply (默认):最终分数 = 查询分 * 函数分。
    • sum:最终分数 = 查询分 + 函数分。
    • replace:最终分数 = 函数分。完全忽略原始查询分。
    • avg, max, min:与 score_mode 类似。
  5. max_boost最大提升值:限制函数分所能达到的最大值。这是一个重要的安全阀,可以防止某个函数(如 field_value_factor)因字段值过大而产生压倒性的分数,从而完全主导排序结果。

  6. min_score最小分数:排除最终分数低于此值的文档。这是一个后过滤步骤,非常有用。

4.1.4.5 boosting查询

boosting查询的核心目的非常独特:它不像bool查询的must_not子句那样完全排除匹配某些条件的文档,而是降低这些文档的相关性分数(_score),让它们排在结果列表的后面,但不会完全消失。

一个形象的比喻:

  • must_not:像一把斧头,直接把不想要的文档砍掉、扔掉。
  • boosting:像一把筛子,把不那么重要的文档筛到下面去,但你仍然能在结果集的末尾找到它们。

语法:

{
  "query": {
    "boosting": {
      "positive": { ... },       // (必需)主查询,匹配你想要的文档
      "negative": { ... },       // (必需)负面查询,匹配你希望降权的文档
      "negative_boost": 0.5      // (必需)降权系数 (0.0 到 1.0)
    }
  }
}

执行流程:

  1. 首先执行positive查询,找到所有匹配的文档并计算其原始分数 (original_score)。
  2. 然后,在这些positive匹配的文档中,检查哪些文档同时也匹配negative查询。
  3. 对于同时匹配positivenegative的文档,对其最终分数进行惩罚:final_score = original_score * negative_boost
  4. 对于只匹配positive而不匹配negative的文档,其分数保持不变:final_score = original_score

关键参数negative_boost

  • 取值范围:0.01.0
  • 作用:这是一个乘数,用于降低匹配负面查询的文档的分数。
    • negative_boost = 1.0:相当于没效果。
    • negative_boost = 0.5:负面文档的分数减半。
    • negative_boost = 0.1:负面文档的分数变为原来的十分之一,会使其排名非常靠后。
    • negative_boost = 0.0注意! 这不会把分数变为 0,Elasticsearch会将其视为 1.0。要完全排除文档,必须使用 bool查询的must_not

4.1.5 地理查询(Geo Queries)

在Elasticsearch里,地理查询(Geo Queries) 主要针对存储在geo_pointgeo_shape字段中的数据,用来处理经纬度位置地理边界等空间相关搜索。它常见于:

  • LBS(基于位置的服务):附近的人/店铺。
  • 地图服务:多边形区域内的兴趣点检索。
  • 物流/出行:路径规划、覆盖范围计算。

4.1.5.1 geo_distance查询

geo_distance查询是按距离搜索,用于查找所有落在以某个点为圆心、指定长度为半径的圆形区域内的地理点。

核心思想:圆形区域过滤。

想象一下地图应用中的“附近”功能:你把自己的位置给它,然后设置一个距离(比如 5 公里),它就能找出你周围的所有餐厅、商店等。这正是geo_distance查询所做的事情。

要使用geo_distance查询,必须先确保相关字段的映射类型是geo_pointgeo_point类型可以存储单个经度/纬度坐标对。

例如我现在创建索引并定义映射如下:

PUT /my_locations
{
  "mappings": {
    "properties": {
      "name": { "type": "text" },
      "location": { "type": "geo_point" } // 必须定义为 geo_point 类型
    }
  }
}

geo_point类型支持多种格式,最常见的是数组和字符串格式。现在我向刚创建的索引中加入文档数据:

// 数组格式 [lon, lat]
POST /my_locations/_doc/1
{
  "name": "重庆光电园地铁站",
  "location": [106.50,29.62]
}

// 字符串格式 "lat,lon"
POST /my_locations/_doc/2
{
  "name": "重庆互联网产业园",
  "location": "29.63,106.49" 
}

// 对象格式
POST /my_locations/_doc/3
{
  "name": "重庆文理学院北门",
  "location": {
    "lon": 105.94,
    "lat": 29.35
  }
}

现在我文档也有了,我们来执行geo_distance查询,基本语法是围绕distance(距离)和中心点 (pin.location 中的 pin) 构建的。

语法:

GET /索引名称/_search
{
  "query": {
    "geo_distance": {
      "distance": "5km", // 搜索半径(距离)
      "FIELD": { // 要查询的 geo_point 字段名
        "lat": 40.73, // 中心点的维度
        "lon": -74.1 // 中心点的经度
      }
    }
  }
}

关键参数讲解:

  1. distance:这个参数是必须的,它用于定义搜索区域的半径。其值为一个数字加上单位。例如:"200m", "5km", "3mi"(3英里), "2 nautical miles"(2海里)。

    常见的单位:m米、km千米、mi英里、yd码、ft英尺、nmi海里。

  2. FIELD:这个参数是必须的,它用于指定要在哪个geo_point类型的字段上进行距离计算。

  3. distance_type:这个参数是可选的,它用于指定计算距离的数学方法。这对精度和性能有细微影响。

    可选值:

    • arc (默认):最精确。将地球视为一个椭球体(WGS84 坐标系),使用Haversine 公式进行球面计算。精度最高,但计算开销稍大。
    • plane最快。将地球视为一个平坦的平面,使用简单的勾股定理计算。仅在坐标点相距很近且远离赤道时比较准确(例如一个城市内的计算)。对于长距离计算,误差会非常大。

    建议:除非你极度追求性能并且确信你的数据范围很小,否则始终使用默认的 arc

例如我有如下查询:

GET /my_locations/_search
{
  "query": {
    "geo_distance": {
      "distance": "1km",
      "location": {
        "lat": 29.34,
        "lon": 105.93
      }
    }
  }
}

这个查询的意思是:“查找 location 字段的值距离经纬度 [105.93, 29.34,] 在1公里以内的所有文档”。

在1km范围内查找数据

在2km范围内查找数据

可见在2km范围内能够找到数据,并且仔细观察我查找数据时给的中心点经纬度与查找出来数据的经纬度,差距很小,同时我们对应到地图上来看看:

地图对比

性能考量与最佳实践

  1. 用于过滤上下文geo_distance查询通常用于bool查询的filter子句中。因为它是一个严格的“在圈内/在圈外”的是非判断,不计算分数,并且其结果可以被缓存,这对性能至关重要。
  2. 优化查询范围:尽量使用合理的、尽可能小的distance值。更大的半径意味着要计算更多的候选点,性能开销更大。
  3. 结合地理网格:在底层,Elasticsearch使用地理网格(Geohash)来快速排除明显不在范围内的文档。设置合理的distance 可以帮助优化这个过程。

4.1.5.2 geo_bounding_box查询

geo_bounding_box查询是按矩形范围筛选。想象一下你在地图上用鼠标拖出一个矩形框来选择其中的地点。geo_bounding_box 查询就是为这种场景而设计的。

它的核心机制是:通过指定矩形的左上角(top_left)右下角(bottom_right) 两个点,快速判断文档中的地理点是否在这个矩形区域内。

和所有地理查询一样,要使用 geo_bounding_box,必须先确保相关字段的映射类型是 geo_point

这里还是使用之前my_locations的例子。

正确情况下,一般都是给到左上角(top_left)与右下角(bottom_right)的两个点进行查询,语法格式如下:

GET /索引名称/_search
{
  "query": {
    "geo_bounding_box": {
      "FIELD": {
        "top_left": {
          "lat": 40.73, // 左上角维度
          "lon": -74.1 // 左上角经度
        },
        "bottom_right": {
          "lat": 40.717, // 右下角维度
          "lon": -73.99 // 右下角维度
        }
      }
    }
  }
}

左上角(top_left)与右下角(bottom_right)示例:

GET /my_locations/_search
{
  "query": {
    "geo_bounding_box": {
      "location": {
        "top_left": {
          "lat": "29.63",
          "lon": "106.48"
        },
        "bottom_right": {
          "lat": "29.62",
          "lon": "106.49"
        }
      }
    }
  }
}

geo_bounding_box查询结果

当然,你也可以提供右上角(top_right)与左下角(bottom_left),语法:

GET /my_locations/_search
{
  "query": {
    "geo_bounding_box": {
      "location": {
        "top_right": {
          "lat": "29.63", //右上角维度
          "lon": "106.49" //右上角经度
        },
        "bottom_left": {
          "lat": "29.62", //左下角维度
          "lon": "106.48" //左下角经度
        }
      }
    }
  }
}

geo_bounding_box查询结果

这里点位的语法可以是多种格式,支持对象、数组、字符串和地理哈希格式。

4.1.5.3 geo_polygon查询

geo_polygon查询允许你超越圆形和矩形的限制,在地图上绘制任意形状的多边形,并查找位于该多边形区域内的所有地理点。

geo_bounding_box查询只能处理矩形,geo_distance查询只能处理圆形。而geo_polygon查询则没有这些限制。你可以通过指定一系列的顶点(points)来定义任意复杂度的多边形,从而实现高度定制化的地理空间过滤。

它的核心机制是:使用射线法(Ray Casting Algorithm) 来判断一个点是否位于多边形内部。算法会从目标点向右发射一条水平射线,计算其与多边形边界的交点数量。如果交点数量为奇数,则在内部;如果为偶数,则在外部。

要使用geo_polygon查询,同样需要确保相关字段的映射类型是geo_point

语法:

GET /my_locations/_search
{
  "query": {
    "geo_polygon": {
      "location": {
        "points": [
          {
            "lat": "29.6319",
            "lon": "106.4873"
          },
          {
            "lat": "29.6316",
            "lon": "106.4917"
          },
          // 多个点...
          // 注意:通常需要将第一个点重复一次以“闭合”多边形,但Elasticsearch会自动处理
        ]
      }
    }
  }
}

例如为有这样一个示例:

GET /my_locations/_search
{
  "query": {
    "geo_polygon": {
      "location": {
        "points": [
          {
            "lat": "29.6319",
            "lon": "106.4873"
          },
          {
            "lat": "29.6316",
            "lon": "106.4917"
          },
          {
            "lat": "29.6289",
            "lon": "106.4891"
          }
        ]
      }
    }
  }
}

在这个示例中,我只指定了3个点,所以其实这是一个三角形,对应如图:

三个点位大致位置

geo_polygon查询结果

可见能够查询出结果,不过这里有个提示,说是geo_polygon查询已经过时了,应该使用geo_shape代替。

下一小节我们就看看geo_shape查询。

现在看看geo_polygon查询的关键参数:

points:该参数为必要参数,它是一个数组,按顺序定义多边形的各个顶点。每个顶点可以是对象、数组或字符串(与 geo_bounding_box 相同):

  • 对象格式: { "lat": 40.73, "lon": -74.1 }
  • 数组格式: [-74.1, 40.73] // [经度, 纬度]
  • 字符串格式: "40.73, -74.1" // "纬度, 经度"

顶点的顺序至关重要。它们必须按顺时针(Clockwise)或逆时针(Counterclockwise) 顺序连续定义多边形的边界。错误的顺序会导致定义一个“扭曲”的多边形,产生不可预料的查询结果。

同时你不需要将第一个点复制到列表末尾来显式闭合多边形,Elasticsearch 会自动连接第一个和最后一个点。

[!warning]

geo_polygon查询是 Elasticsearch 地理查询中性能开销最大的一个,必须谨慎使用。

4.1.5.4 geo_shape查询

geo_shape查询算是ES中地图查询功能最全面的一个,它超越了简单的点与范围的关系,进入了真正的地理信息系统(GIS)领域。

与之前讲过的geo_point查询(处理单个点)不同,geo_shape查询用于处理复杂的几何图形(如线、多边形、多点集合)以及它们之间的空间关系(如一个形状是否包含另一个形状、是否相交等)。

它的核心能力是:判断两个几何图形(一个存储在文档中,另一个作为查询条件)是否满足指定的空间关系。

要使用geo_shape,必须先正确定义映射。它使用一种名为geo_shape的专属字段类型。

例如定义映射如下:

PUT /parks_and_landmarks
{
  "mappings": {
    "properties": {
      "name": { "type": "text" },
      "boundary": { "type": "geo_shape" } // 用于存储公园边界等复杂形状
    }
  }
}

geo_shape类型使用GeoJSON格式来定义和存储几何图形,现在我们来定义文档:

PUT /parks_and_landmarks/_doc/1
{
  "name": "恋湖公园",
  "boundary": {
    "type": "polygon", // 图形类型:多边形
    "coordinates": [
      [ // 坐标是一个三维数组,表示环(Rings)
        [106.4849, 29.6246],
        [106.4878, 29.6244],
        [106.4918, 29.6253],
        [106.4925, 29.6238],
        [106.4862, 29.6228],
        [106.4850, 29.6234],
        [106.4849, 29.6246]    // <-- 闭合(重复起点)
      ]
    ]
  }
}

[!important]

  1. 这里的环其实就是一个多边形,环不一定是圆形。
  2. 环必须闭合:第一个点和最后一个点的坐标必须相同。
  3. 方向:外环必须是逆时针方向。内环(洞)必须是顺时针方向。

对于文档的coordinates字段,为啥是一个三维数组呢?因为,有可能你描述的多边形里面可能有带洞的需求,如果一个多边形带洞,coordinates就会有两组,如下:

"coordinates": [
  [
    [116.380, 39.900],
    [116.420, 39.900],
    [116.420, 39.940],
    [116.380, 39.940],
    [116.380, 39.900]
  ],
  [
    [116.390, 39.910],
    [116.410, 39.910],
    [116.410, 39.930],
    [116.390, 39.930],
    [116.390, 39.910]
  ]
]

第一组:外边界(公园范围)。

第二组:内边界(湖,占用的区域,不算公园)。

好了,现在索引以及文档都有了,我们现在来正式执行geo_shape查询。


查询时,你需要指定两件事:

  1. 一个图形(Shape):作为查询条件的图形。
  2. 一种空间关系(Relation):希望文档中的图形与查询图形满足何种关系。

语法如下:

GET /parks_and_landmarks/_search
{
  "query": {
    "geo_shape": {
      "FIELD": { // 要查询的 geo_shape 字段名
        "shape": { //定义查询所用的图形(格式与索引时相同)
          "type": "polygon",
          "coordinates": [
            [
              -45,
              45
            ],
            [
              45,
              -45
            ]
          ]
        },
        "relation": "within" // 定义期望的空间关系
      }
    }
  }
}

geo_shape查询的核心参数:

relation(空间关系):这是geo_shape查询的灵魂,它定义了图形之间如何交互。

关系 (relation) 含义 图示说明
intersects (默认) 相交。文档中的图形与查询图形有至少一个公共点。这是最常用的关系。 文档图形与查询图形有重叠部分。
within 在内。文档中的图形完全位于查询图形内部。文档图形不能越过查询图形的边界。 文档图形被查询图形完全包裹。
contains 包含。文档中的图形完全包含了查询图形。 查询图形被文档图形完全包裹。
disjoint 相离。文档中的图形与查询图形没有任何公共点 两个图形完全分离。

具体示例:

GET /parks_and_landmarks/_search
{
  "query": {
    "geo_shape": {
      "boundary": { // 要查询的 geo_shape 字段名
        "shape": { //定义查询所用的图形(格式与索引时相同)
          "type": "polygon",
          "coordinates": [
            [
              [106.4880,29.6243],
              [106.4892,29.6243],
              [106.4894,29.6238],
              [106.4880,29.6235]
            ]
          ]
        },
        "relation": "contains" // 定义期望的空间关系
      }
    }
  }
}

geo_shape查询结果

4.1.6 特殊查询(Specialized Queries)

由于篇幅有限,这里暂时不做讲解,后续有需要会补充上。

4.1.7 连接查询(Joining Queries)

由于篇幅有限,这里暂时不做讲解,后续有需要会补充上。

4.2 设置搜索结果

搜索的结果可以按照用户指定的方式去处理或展示。

查询的DSL是一个大的JSON对象,其搜索结果的总类包含下列属性:

  • query:查询条件
  • from和size:分页条件
  • sort:排序条件
  • highlight:高亮条件
  • aggs:定义聚合

4.2.1 排序

Elasticsearch中存在的两种主要排序逻辑:

  1. 相关性排序 (Relevance Sorting) - 默认行为:基于_score(相关性分数)。Elasticsearch使用 BM25 算法(一种 TF/IDF 的改进算法)为匹配查询的每个文档计算一个分数。其中分数越高,代表文档与搜索条件的匹配度越高。当你不指定任何排序条件时,结果默认_score降序排列。

  2. 字段排序 (Field Sorting):基于一个或多个文档字段的值进行排序(如日期、价格、名称等)。当你指定了字段排序后,_score计算将被禁用(除非你显式要求计算),因为计算分数是耗时的,且既然你已经决定按字段排序,相关性就变得不重要了。

    [!warning]

    text类型的字段进行排序通常不会得到预期结果,需要使用其.keyword子字段。下文会详细解释。

[!tip]

  1. 普通字段是根据字典序排序。
  2. 地理坐标是根据举例远近排序。

排序在请求体中的sort参数中定义,它是一个数组,允许你指定多个排序条件,并按优先级顺序执行。

语法:

GET /索引名称/_search
{
  "query": {}, // 查询
  "sort": [ // 排序
    {
      "FIELD": {
        "order": "desc"
      },
      // 其他字段排序
    }
  ]
}

排序条件是一个数组,也就是可以写多个排序条件。按照声明的顺序,当第一个条件相等时,再按照第二个条件排序

排序模式详解:

  1. 按标准字段排序:这是最常见的场景,针对数字、日期、布尔值或keyword类型的字段,不过多解释

  2. 处理多值字段排序:如果一个字段有多个值(如数组tags: ["apple", "banana", "cherry"]),你需要告诉Elasticsearch使用哪个值来排序。

    通过mode参数控制:

    • min - 取数组中的最小值作为排序依据。
    • max - 取数组中的最大值作为排序依据。
    • avg - 取数组中的平均值作为排序依据。(适用于数字)
    • sum - 取数组中的总和作为排序依据。(适用于数字)
    • median - 取数组中的中位数作为排序依据。(适用于数字)

    例如:

    {
      "sort": [
        {
          "prices": { // 假设 prices 是一个数字数组 [100, 200, 50]
            "order": "asc",
            "mode": "min" // 这里会使用 50 来代表这个文档进行排序
          }
        }
      ]
    }
    
  3. text类型字段排序:这是一个初学者最常见的坑。你不能直接对text类型的字段进行排序。 因为text 类型的字段会被分析(分词),例如"Quick Brown Fox"会被处理成["quick", "brown", "fox"]并建立倒排索引。ES不知道用哪个词条来排序。

    如果你真要对它进行排序,那么你应该使用.keyword子字段。它在映射时默认创建,类型为keyword,保留了原始字符串的完整内容,用于精确匹配和排序。

    例如:

    // 错误示范:这可能会返回一个错误或无意义的结果
    {
      "sort": { "product_name": "asc" } // product_name 是 text 类型
    }
    
    // 正确示范:使用 .keyword 子字段
    {
      "sort": { "product_name.keyword": { "order": "asc" } } // 按字符串的字典序排序
    }
    
  4. 地理距离排序:对于geo_point类型的字段,可以按距离某个坐标的远近进行排序。这是实现“附近的人”或“最近的店铺”功能的核心。

    例如:

    {
      "sort": [
        {
          "_geo_distance": {
            "field": "location",         // geo_point 类型的字段
            "origin": {                  // 中心点坐标。支持多种格式:
              "lat": 39.9042,           // - 对象格式
              "lon": 116.4074
            },
            // "origin": "39.9042, 116.4074",   // - 字符串格式 "lat,lon"
            // "origin": [116.4074, 39.9042],   // - 数组格式 [lon, lat]
            "order": "asc",              // asc 由近到远,desc 由远到近
            "unit": "km",                // 距离单位:m, km, mi, ft, ...
            "distance_type": "arc"       // 计算方式:arc(球面,精确) or plane(平面,快速)
          }
        }
      ]
    }
    

    返回的结果中会包含一个sort值,表示该文档到中心点的具体距离。

  5. 脚本排序:当内置排序方式无法满足你的复杂业务逻辑时,可以使用Painless脚本进行自定义排序。但是脚本排序计算开销极大,会严重影响搜索性能,只应作为最后的手段。

  6. 文档得分排序:即使你添加了字段排序,你仍然可以强制计算并按_score排序。

    例如:

    {
      "query": { "match": { "title": "apple" } },
      "sort": [
        { "price": { "order": "desc" } }, // 主要按价格降序排
        { "_score": { "order": "desc" } } // 价格相同的,再按相关性分排
      ]
    }
    

针对之前商品相关的索引,我有如下请求排序示例:

GET /products/_search
{
  "sort": [
    {
      "product.price": {
        "order": "asc"
      }
    }
  ]
}

基本数值排序示例

例如我还有如下坐标排序:

GET /my_locations/_search
{
  "sort": [
    {
      "_geo_distance": {
        "location": {
          "lat": 40,
          "lon": -70
        },
        "order": "asc", // 排序方式
        "unit": "km" // 排序的距离单位
      }
    }
  ]
}

在这个示例中,ES会取每条文档的location字段里的经纬度,与你给定的查询点{lat:40, lon:-70}计算距离,然后按距离排序。

坐标排序示例

4.2.2 分页

ElasticSearch默认情况下只返回前10条的数据。而如果要查询更多数据就需要修改分页参数了。

分页的本质是从全局有序的结果集中,截取一个连续的子集返回给用户。在传统数据库中,这通常通过LIMIT offset, count实现。在Elasticsearch中,实现方式更丰富,但也更复杂。

Elasticsearch提供了三种主要的分页方式,每种方式都有其特定的适用场景和代价:

  1. from + size:最直观,但深度分页性能极差
  2. search_after:ES 官方推荐的深度分页解决方案
  3. Scroll API:用于离线导出大量数据,非实时

4.2.2.1 from+size分页 (浅分页)

这是最符合直觉、使用最简单的分页方式,类似于 SQL 中的LIMIT from, size

语法:

GET /索引名称/_search
{
  "query": { ... },
  "from": 20, // 从第几条结果开始(从0开始计数)
  "size": 10  // 返回多少条结果
}

例如,"from": 20, "size": 10表示获取第21到30条结果(即第3页,每页10条)。

对于from+size,它的深度分页是很危险的(性能极差)。要理解为什么深度分页是危险的,你需要了解from+size的工作流程:

  1. 查询阶段:用户请求发送到协调节点 (Coordinating Node),之后协调节点将请求转发给索引的所有相关分片(主分片或副本分片)。其中每个分片在本地执行查询,生成一个按相关性排序的前 (from + size) 条结果的优先级队列,并将其ID和排序值返回给协调节点。例如,from=1000, size=10,每个分片需要本地生成并排序前1010条结果。
  2. 取回阶段:协调节点收到所有分片返回的(from + size)个结果后,对所有结果进行全局排序,选出排名前(from + size)的结果,然后它只从from开始,取size条结果,最后,协调节点根据这size条结果的ID,回到各个分片去_get完整的文档内容(_source)。

[!tip]

出于保护机制,index.max_result_window设置(默认值为10000)限制了from + size的最大值。这意味着你无法使用 from + size翻到 10000 条结果之后的内容。

适用场景

  1. 小规模翻页。
  2. 随机访问或短跳分页。

不适用场景

  1. 深度分页(from > 10000):ES会扫描大量文档再丢弃前面条目,性能差,内存占用大。
  2. 大量数据导出 / 全量扫描:一次性需要几十万或上百万条文档。
  3. 实时更新的索引:深度分页时,前面的结果可能因为写入变动而导致不稳定。

4.2.2.2 search_after分页(推荐)

这是Elasticsearch官方推荐的深度分页方案。它解决了from + size的性能问题,其核心思想是:避免全局排序和计算,而是使用上一页的结果来定位下一页的起始点

基本语法与大体使用示例:

GET /索引名称/_search
{
  "query": { ... },
  "sort": [ // 必须提供一个唯一的排序组合!
    {"date": "desc"}, // 第一个排序字段
    {"_id": "asc"}    // 通常加上 _id 作为第二排序条件以确保顺序绝对唯一
  ],
  "size": 10,
  "search_after": [ "2023-01-01T00:00:00.000Z", "abc123" ] // 上一页最后一条记录的排序值
}

工作原理与优势:

  1. 提供“游标”search_after参数接收的是上一页最后一条记录的所有排序字段的值。这就像一个书签,告诉 ES:“从这本书签之后开始找下一页”。
  2. 高效查询:每个分片只需要在本地找到比这个“书签”更大的值,然后返回前size个结果。它不需要from + size 那样计算和维护一个巨大的全局队列。
  3. 要求稳定的排序search_after要求排序条件必须是唯一的。如果排序字段值不唯一(例如,很多文档的date相同),分页就会乱序。因此,最佳实践是总是在排序数组的最后加上{"_id": "asc"}{"_id": "desc"},因为_id是唯一的。

在实际使用过程中,首次查询时不提供search_after,只指定sortsize

GET /索引名称/_search
{
  "query": { ... },
  "sort": [ // 必须提供一个唯一的排序组合!
    {"date": "desc"}, // 第一个排序字段
    {"_id": "asc"}    // 通常加上 _id 作为第二排序条件以确保顺序绝对唯一
  ],
  "size": 10
}

在首次查询之后,后续的查询从第一次返回的hits中,取最后一个结果的sort字段的值,作为下一次请求的search_after 参数:

GET /索引名称/_search
{
  "query": { ... },
  "sort": [ // 必须提供一个唯一的排序组合!
    {"date": "desc"}, // 第一个排序字段
    {"_id": "asc"}    // 通常加上 _id 作为第二排序条件以确保顺序绝对唯一
  ],
  "size": 10,
  "search_after": [ "2023-01-01T00:00:00.000Z", "abc123" ] // 上一页最后一条记录的排序值
}

之后一直重复上述步骤,直到返回的hits.hits数组为空。

这里在多提一下,可能有人对这里会有疑问:"search_after": [ "2023-01-01T00:00:00.000Z", "abc123" ]search_after的参数不是上一次查询结果的sort值吗,为啥这里指定了两个呢?

这里出现两个值,是因为 ES 要求search_after数组长度与你排序字段的数量一致。一般在使用search_after分页的时候,sort一般都是指定的两个,第二个往往都是_id文档ID,这里"search_after": [ "2023-01-01T00:00:00.000Z", "abc123" ]中,其中2023-01-01T00:00:00.000Z是上一次查询结果的date值,abc123是上一次查询结果的文档ID(_id)。

search_after查询优点:

  • 高性能:不受页面深度影响,查询成本是恒定的。
  • 无限制:可以遍历所有数据,没有max_result_window的限制。

search_after查询缺点:

  • 无法随机跳页:只能一页一页地顺序向下翻页,无法直接跳到第 N 页(例如,无法直接跳到第 100 页,必须先获取前 99 页)。
  • 需要客户端维护状态:客户端需要记住上一页的最后一条记录的排序值。

适用场景

  1. 无限滚动。
  2. 深度分页。
  3. 需要导出大量数据,但场景不够离线。

[!tip]

在实践中,往往就是循环的再查,先上一页做后一条记录的sort值,之后循环的查,一直查到你想要的那一页。

4.2.2.3 scroll_api分页

Scroll API用于离线、大规模地遍历所有匹配的文档,而不是为实时用户请求提供分页。它的设计初衷是为索引创建一个快照,然后像操作一个游标一样不断拉取数据,直到遍历完成。

基本语法及使用:

// 1. 初始化一个 Scroll 上下文,设置一个存活时间 (e.g., 1m)
GET /索引名称/_search?scroll=1m
{
  "query": { ... },
  "size": 100, // 每次滚动返回的数量
  "sort": ["_doc"] // 通常使用最高效的 "_doc" 排序,不按相关性
}

// 2. 上述请求的响应会返回一个 `_scroll_id`
{
  "_scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAA...",
  "hits": { ... }
}

// 3. 使用返回的 `_scroll_id` 获取下一批结果
GET /_search/scroll
{
  "scroll": "1m", // 每次请求可以刷新上下文的存活时间
  "scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAA..."
}

初始化Scroll上下文并设置1m存活时间

之后使用返回的_scroll_id获取下一批结果:

使用_scroll_id获取下一批结果

_scroll_api分页查询特点与注意事项

  • 非实时:Scroll会在初始化时为查询创建一个快照,之后对索引的任何写入(新增、更新、删除)都不会影响遍历结果。

  • 资源开销:Scroll上下文需要在Elasticsearch端维护,占用资源。每个Scroll请求都会刷新其存活时间。

  • 必须清理:使用完毕后,必须显式地删除Scroll上下文以释放资源。

    DELETE /_search/scroll
    {
      "scroll_id": "DnF1ZXJ5VGhlbkZldGNoBQAAAAAA..."
    }
    
  • _scroll_api分页查询方式已被search_after+Point In Time (PIT)部分取代:在新版本中,search_after与PIT结合可以更好地实现实时性要求更高的大量数据遍历。

适用场景:

  • 索引数据导出(全量或基于查询条件)。
  • 离线数据处理和分析。
  • 数据迁移和备份。
  • 绝不用于用户实时请求的分页。

4.2.2.4 分页查询总结

特性 from + size search_after Scroll API
核心原理 全局排序后截取 使用上一页书签定位 创建快照游标遍历
性能 深度分页时极差 深度分页时极佳 佳,但非实时
跳页能力 支持随机跳页 仅支持顺序下一页 顺序下一页
实时性 实时 实时 非实时(快照)
资源消耗 高(协调节点) 中(服务端维护上下文)
结果集上限 max_result_window 限制 无限制 无限制
适用场景 顶部结果、浅分页 无限滚动、深度分页 离线数据导出

黄金法则:

  1. Top N 或前几页:使用from +size
  2. 深度分页或无限滚动:使用search_after
  3. 全量数据导出:使用Scroll APIsearch_after+PIT

4.2.3 高亮

高亮,顾名思义就是你搜索的结果会被高亮显示出来。

高亮是对关键字高亮,因此搜索条件必须带有关键字,而不能是范围这样的查询。默认情况下,高亮的字段,必须与搜索指定的字段一致,否则无法高亮。如果要对非搜索字段高亮,则需要添加一个属性:required_field_match=false

高亮的工作流程可以概括为:

  1. 定位:首先执行正常的查询,找到匹配的文档。
  2. 分析:对于每个匹配的字段,重新分析其文本,并定位查询词条出现的位置。
  3. 提取与包装:从原始文本中提取出包含匹配词条的片段(称为“碎片”),并用预定义的标签(如 <em>)包裹这些词条。
  4. 返回:将处理后的高亮碎片作为结果的一部分返回给用户。

语法:

GET /索引名称/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT" // 查询条件,
    }
  },
  "highlight": {
    "fields": { 
      "FIELD": {} // 指定要高亮的字段,高亮的字段不需要包含在查询的字段中,否则不会有高亮结果
    }
  }
}

高亮示例结果

可以看到,匹配的词条"002"被包裹在了<em>(强调)标签中。

高亮参数详解:

  1. 高亮标签(pre_tagspost_tags):默认使用<em>标签,但你可以自定义任何标签,通常是 HTML 标签以便前端渲染。

    GET /products/_search
    {
      "query": {
        // 查询条件
      },
      "highlight": {
        "fields": {
          "product.description": {
            "pre_tags": "<strong>", //高亮开始标签
            "post_tags": "</strong>" //高亮结束标签
          }
        }
      }
    
    }
    
  2. 碎片控制(控制返回文本的片段):对于长文本字段,我们通常不希望返回整个内容,而是返回包含匹配词的一小段上下文。

    • fragment_size:指定每个高亮碎片(片段)的最大字符长度(包括标签)。默认是100
    • number_of_fragments:指定最多返回多少个碎片。默认是5。如果设置为0,则不会产生碎片,但如果字段被匹配,整个字段的内容会被返回(不推荐用于大字段)。
    • no_match_size:如果字段没有匹配项,但你仍然想返回一些文本,可以设置这个参数。例如,"no_match_size": 150会返回字段的前150个字符。
  3. 碎片边界与排序:

    • boundary_scanner:指定如何划分碎片的边界,避免在单词中间切断。常用chars(字符边界,默认)或sentence(句子边界)、word(单词边界)。
    • boundary_chars:当boundary_scannerchars时,定义哪些字符被视为边界(如.!,? \n\t)。
    • order:多个碎片的排序方式。默认是none(按相关性排序?实际行为更复杂)。可以设置为score,按每个碎片内部包含的匹配词条的分数总和排序,这通常能确保最相关的碎片排在最前面。

4.2.4 数据聚合

聚合提供了对数据进行分组、提取统计信息的能力。它就像是 SQL 中的GROUP BYCOUNT()SUM()AVG()等操作的超集,但其功能、灵活性和可扩展性远超传统数据库。

  • 搜索 (Search):目标是“找到匹配的文档”。回答的问题是:“哪些文档符合我的条件?” 核心是query
  • 聚合 (Aggregation):目标是“分析匹配文档集的数据”。回答的问题是:“这些文档的整体情况是怎样的?有什么规律?” 核心是aggs(也可用全称aggregations)。

聚合在查询的基础上进行。它会基于查询返回的文档集合(称为“上下文”)进行数据分析。你可以先执行一个查询来过滤数据,然后对其结果进行聚合。

聚合的种类如下:

  1. 指标聚合:从一组文档中提取计算出一个或多个指标值(如平均值、最大值、总和等)。
  2. 桶聚合:将匹配的文档集合划分成一个一个的“桶”(组)。每个桶都与一个标准相关联,该标准决定文档是否属于该桶。
  3. 管道聚合对其他聚合的结果进行再次聚合。

[!caution]

参加聚合的字段必须是keyword、日期、数值、布尔类型。

4.2.4.1 指标聚合(Metric Aggregations)

指标聚合不是对文档进行分组(那是桶聚合的事),而是对文档集中的特定字段的值进行数学计算

语法:

GET /索引名称/_search
{
 "aggs": {
   "NAME": {
     "AGG_TYPE": {}
   }
 } 
}

指标聚合主要有两类:

  1. 单值聚合:这类聚合只输出一个单一的数值。
  2. 多值聚合:这类聚合会输出一个包含多个数值的对象。

具体的单值聚合有如下几种:

  • 平均值聚合(avg):计算所有文档中某个字段的算术平均值。

    GET /products/_search
    {
      "aggs": {
        "avg_price": { // 自定义字段存放聚合结果
          "avg": { // 聚合类型
            "field": "product.price"  // 需要聚合的字段
          }
        }
      }
    }
    

    平均值聚合示例

  • 求和聚合(sum):计算所有文档中某个字段值的总和。

    GET /products/_search
    {
      "aggs": {
        "total_price": { // 自定义字段存放聚合结果
          "sum": { // 聚合类型
            "field": "product.price"  // 需要聚合的字段
          }
        }
      }
    }
    

    求和聚合示例

  • 最小值聚合(min):查找所有文档中某个字段的最小值。

    GET /products/_search
    {
      "aggs": {
        "min_price": { // 自定义字段存放聚合结果
          "min": { // 聚合类型
            "field": "product.price"  // 需要聚合的字段
          }
        }
      }
    }
    

    最小值聚合示例

  • 最大值聚合(max):查找所有文档中某个字段的最大值。

    GET /products/_search
    {
      "aggs": {
        "max_price": { // 自定义字段存放聚合结果
          "max": { // 聚合类型
            "field": "product.price"  // 需要聚合的字段
          }
        }
      }
    }
    

    最大值聚合示例

  • 值计数聚合(value_count):计算所有文档中某个字段有值(非空)的数量。它不关心值是什么,只关心字段是否存在。

    GET /products/_search
    {
      "aggs": {
        "price_count": { // 自定义字段存放聚合结果
          "value_count": { // 聚合类型
            "field": "product.price"  // 需要聚合的字段
          }
        }
      }
    }
    

    值计数聚合


具体的多值聚合有如下几种:

  • 基本统计聚合(stats):一次性返回一个字段的5个核心统计指标:count(计数), min(最小值), max(最大值), avg(平均值), sum(总和)。

    GET /products/_search
    {
      "aggs": {
        "price_stats": { // 自定义字段存放聚合结果
          "stats": { // 聚合类型
            "field": "product.price"  // 需要聚合的字段
          }
        }
      }
    }
    

    基本统计聚合示例

  • 拓展统计聚合(extended_stats):在stats的基础上,增加了平方和、方差、标准差、标准差范围,用于更深入的统计分析。

    GET /products/_search
    {
      "aggs": {
        "price_extended_stats": { // 自定义字段存放聚合结果
          "extended_stats": { // 聚合类型
            "field": "product.price"  // 需要聚合的字段
          }
        }
      }
    }
    

    拓展统计聚合示例

  • 百分位数聚合(percentiles):计算字段值分布的百分位数。这对于分析性能数据(如延迟、响应时间)至关重要,因为平均值(avg)往往会掩盖异常值。默认百分位:[1, 5, 25, 50, 75, 95, 99]。当然你可以通过percents参数指定任何想要的百分位。

    GET /products/_search
    {
      "aggs": {
        "price_percentiles": { // 自定义字段存放聚合结果
          "percentiles": { // 聚合类型
            "field": "product.price",  // 需要聚合的字段
            "percents": [50, 95, 99] // 只计算中位数、95分位、99分位
          }
        }
      }
    }
    

    百分位数聚合示例

  • 百分位数等级聚合(percentile_ranks)percentiles的逆运算。它计算一个或多个具体数值处于哪个百分位。

    例如我想知道价格为500和600在文档中所处的百分位:

    GET /products/_search
    {
      "aggs": {
        "price_percentile_ranks": { // 自定义字段存放聚合结果
          "percentile_ranks": { // 聚合类型
            "field": "product.price",  // 需要聚合的字段
            "values":[500,600] // 查询这两个值所处的百分位
          }
        }
      }
    }
    

    百分位数等级聚合示例

  • 基数/唯一值计数聚合(cardinality):计算字段唯一值的近似数量。类似于 SQL 中的COUNT(DISTINCT field)

    基于 HyperLogLog++ 算法:这是一个概率算法,用于在可接受的误差范围内高效计算巨大数据集的基数,牺牲少量精度以换取极高的性能和极低的内存开销

    精度控制:通过precision_threshold参数调整。值越大,精度越高,内存消耗也越大。

    GET /products/_search
    {
      "aggs": {
        "price_cardinality": { // 自定义字段存放聚合结果
          "cardinality": { // 聚合类型
            "field": "product.price"  // 需要聚合的字段
          }
        }
      }
    }
    

    基数/唯一值计数聚合示例


4.2.4.2 桶聚合(Bucket Aggregations)

如果说指标聚合是计算“值”,那么桶聚合就是创建“组”。它是进行数据分组、分段、分类的核心,是构建所有高级分析(如 OLAP 立方体)的基石。

桶聚合将匹配的文档集合划分成不同的组,每个组称为一个“桶”(Bucket)。每个桶都与一个标准相关联,该标准决定一个文档是否属于这个桶。

桶聚合之后的结果为一个桶列表。每个桶通常包含:

  • key: 标识该桶的唯一值(例如,品牌名"Apple",价格范围"100-200")。
  • doc_count: 属于该桶的文档数量
  • (可选)子聚合的结果:可以在每个桶内嵌套其他聚合(指标聚合或其他桶聚合),从而进行更细粒度的分析。

可见桶聚合完全对应于 SQL 中的GROUP BY语句,但其功能更加强大和灵活。

在Elasticsearch提供了多种构建桶的方式,以适应不同的分组需求:

  1. 词条聚合(terms):这个是最常用的桶聚合方式,它按字段的精确值进行分组。每个唯一值都会生成一个桶。它适用于keyword类型的字段,其聚合的结果按照该桶的文档数量(doc_count) 降序排列。

    例如我要按照商品名称来分组:

    GET /products/_search
    {
      "aggs": {
        "price_by_name": { // 自定义字段存放聚合结果
          "terms": { // 聚合类型
            "field": "product.name", // 分组字段
            "size": 2, // 返回前2个最多的桶(默认10个)
            "order": {
              "_count" :"asc" // 排序方式,按照doc_count升序
            }
          }
        }
      }
    }
    

    桶聚合之词条聚合示例

    terms聚合的重要参数与陷阱:

    • size: 控制返回多少term 。如果你有100个唯一品牌,size: 10只返回文档数最多的前 10 个。增大size会显著增加内存和 CPU 开销
    • shard_size: 为了在分片上计算出顶级term,每个分片需要返回shard_size个 term 到协调节点。默认 shard_size = size * 1.5 + 10。对于高基数字段,增加shard_size可以提高精度,但代价更高。
    • show_term_doc_count_error: 布尔值,设置为true会在每个桶中显示 doc_count_error_upper_bound,表示该 term 的文档数可能的最大误差。
    • missing: 为那些缺少该字段的文档指定一个默认的桶键。
    • 精度问题terms聚合在分布式环境下是近似的。协调节点从每个分片获取top term,然后合并产生全局top term。对于低频term,sum_other_doc_count可能会很大。如果需要极端精确的计数(如慢速的COUNT(DISTINCT)),可以考虑使用cardinality聚合。
  2. 范围聚合(range):按用户自定义的数值范围创建桶。

    例如为现在按照商品价格区间分组:

    GET /products/_search
    {
      "aggs": {
        "price_by_name": { // 自定义字段存放聚合结果
          "range": {
            "field": "product.price",
            "ranges": [
              { "to": 100 }, // 价格 < 100
              { "from": 100, "to": 300 }, // 100 <= 价格 < 300
              { "from": 300, "to": 600 }, // 300 <= 价格 < 600
              { "from": 600, "to": 900 } // 600 <= 价格 < 900
            ]
            //"keyed": true // 如果设置为true,返回的对象是key->bucket的映射,而不是数组
          }
        }
      }
    }
    

    范围聚合示例

    从上诉示例中,我注释掉了keyed配置项,现在我打开在看看结果:

    范围聚合示例

  3. 日期范围聚合(date_range):与range聚合类似,但专门用于date类型的字段,这里不多讲。

  4. 直方图聚合(histogram):基于数值字段,按固定的间隔(interval)自动创建桶。

    GET /products/_search
    {
      "aggs": {
        "prices": { // 自定义字段存放聚合结果
          "histogram": {
            "field": "product.price",
            "interval": 100 // 固定间隔
          }
        }
      }
    }
    

    直方图聚合示例

  5. 日期直方图聚合(date_histogram):这是时间序列分析中最重要、最常用的聚合。它按固定的时间间隔(如每小时、每天、每月)自动创建桶。

    例如按天统计网站访问量:

    GET /access_logs/_search
    {
      "size": 0,
      "query": { // 可以先过滤数据
        "range": { "@timestamp": { "gte": "now-7d/d" } }
      },
      "aggs": {
        "visits_per_day": {
          "date_histogram": {
            "field": "@timestamp",
            "calendar_interval": "day", // 日历间隔:day, hour, week, month, quarter, year
            // 或使用 fixed_interval: "1h" (固定间隔,适用于所有单位)
            "format": "yyyy-MM-dd",     // 格式化桶的key
            "min_doc_count": 0,         // 没有访问的日期也返回0
            "extended_bounds": {        // 保证返回完整的7天,即使首尾那天没数据
              "min": "now-7d/d",
              "max": "now/d"
            }
          }
        }
      }
    }
    
  6. 过滤器聚合(filter/filters):按用户定义的过滤器来创建桶。filter创建单个桶,filters可以创建多个桶。

    例如同时统计不同状态的产品数量:

    GET /products/_search
    {
      "size": 0,
      "aggs": {
        "product_status": {
          "filters": {
            "filters": {
              "active": { "term": { "status": "active" } },
              "inactive": { "term": { "status": "inactive" } },
              "on_sale": { "range": { "price": { "lte": 50 } } }
            }
          }
        }
      }
    }
    

    响应结果:

    {
      "aggregations": {
        "product_status": {
          "buckets": {
            "active": { "doc_count": 100 },
            "inactive": { "doc_count": 20 },
            "on_sale": { "doc_count": 75 }
          }
        }
      }
    }
    

上述桶聚合只是一些基本的使用,桶聚合的精华在于嵌套。你可以在一个桶内继续做其他聚合,从而实现多维分析(下钻分析)。

例如:

GET /products/_search
{
  "aggs": {
    "productName": { // 第一层桶,按照商品名称分
      "terms": {
        "field": "product.name"
      },
      "aggs": { // 子聚合
        "productPrice": { // 第二层桶,按照商品价格分
          "terms": {
            "field": "product.price"
          }
        }
      }
    }
  }
}

桶聚合嵌套示例

4.2.4.3 管道聚合(Pipeline Aggregations)

管道聚合是对其他聚合的输出结果进行再次聚合。它们将先前聚合的结果作为输入,进行“链式”处理,而不是直接作用于文档数据。

为什么称为管道呢?

想象数据流经一个处理管道。桶聚合指标聚合是第一道工序,它们从原始文档中提取和计算数据,形成初步结果(如一系列按日期分好的桶,每个桶里有销售额总和)。管道聚合则是后续工序,对这些初步结果进行再加工(如计算每个桶相对于前一个桶的增长率)。

管道聚合根据其操作对象和输出结果的范围,分为两大类:

  1. 父级管道聚合 (Parent Pipeline Aggregations):这类聚合嵌入在现有桶的内部,为每个桶计算并输出一个值。它们为每个父桶都添加新的信息。

    • 特点:输出结果与父桶数量相同。
    • 语法位置:作为子聚合嵌套在另一个聚合(通常是桶聚合)内部。
    • 示例:derivative (导数), cumulative_sum (累积和), moving_function (移动函数)。
  2. 兄弟管道聚合 (Sibling Pipeline Aggregations):这类聚合与现有桶聚合平级,它读取另一个聚合的全部结果,然后计算并输出一个全新的、独立的聚合结果。

    • 特点:输出结果通常是一个单一值或一个全新的结构,与父桶的结构不同。
    • 语法位置:与父聚合在同一层级。
    • 示例:avg_bucket (桶平均值), max_bucket (桶最大值), stats_bucket (桶统计信息), bucket_script (桶脚本)。

如何区分? 看聚合定义的位置。如果在aggs内部,是父级;如果在aggs外部与之并列,是兄弟级。

这里篇幅有限,不做过多讲解,后续有需要会补充。

5. RestAPI

ES官方提供了各种不同语言的客户端,用来操作ES。这些客户端的本质就是组装DSL语句,通过http请求发送给ES。

官方文档地址:ES各种客户端操作

因为我是Java语言,所以这里我选择Java:

ES客户端选择

[!tip]

只是官方提供的API,如果你是Java程序员,其实你可以考虑使用Spring官方提供的集成。

参考文档:SpringBoot3整合ElasticSearch

4.1 SpringBoot中使用ElasticSearch官方ClientAPI

集成官方ClientAPI很简单,引入依赖:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.14.2</version>
</dependency>
<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-client</artifactId>
    <version>9.1.2</version>
</dependency>

配置文件:

spring:
  elasticsearch:
    uris: http://192.168.3.69:9200 #ES的连接地址,要带上协议与端口
    username: elastic # 如果你的ES设置了账号密码,那么这里需要指定
    password: aBTM22ECz6xsBRd2uFY7 # 如果你的ES设置了账号密码,那么这里需要指定

之后需要创建ES客户端在配置类中:

@Configuration
// 因为ElasticsearchProperties类没有自动注入,所以我们需要使用@EnableConfigurationProperties来使用这个类
@EnableConfigurationProperties(ElasticsearchProperties.class)
public class ElasticSearchConfig {

    /**
     * ElasticsearchProperties类就是yaml配置文件中的elasticsearch的配置
     */
    @Resource
    private ElasticsearchProperties elasticsearchProperties;

    @Bean
    @Primary
    public ElasticsearchClient elasticSearchClient() {
        return ElasticsearchClient.of(client -> client
                .host(elasticsearchProperties.getUris().getFirst())
                .usernameAndPassword(elasticsearchProperties.getUsername(), elasticsearchProperties.getPassword())
        );
    }
}

4.1.1 Mapping映射API

4.1.1.1 创建Mapping

从之前的小节中我们知道,创建ES映射主要有两个时机:

  1. 创建索引时同时创建映射(常用)。
  2. 在创建索引之前创建好映射。

我们先来看看再创建索引时同时创建映射的API:

// 这里的indices() 返回的是索引操作相关的客户端,封装了所有索引操作
elasticsearchClient.indices().create(c -> c
        .index("books") // 索引名称
        .settings(s -> s
                .numberOfShards("3") // 分片数
                .numberOfReplicas("2") // 副本数
        )
        .mappings(...) //设置映射
);

这里的mappings就是再设置映射了,我们来看看里面有啥参数:

mappingsAPI参数

可见这里支持两个类型的参数,一个是TypeMapping类型,另一个是函数式接口的方式,提供一个Builder返回一个TypeMapping类型的ObjectBuilder;实际上就是提供的一个TypeMapping

我们来看看TypeMapping这个类:

@JsonpDeserializable
public class TypeMapping implements JsonpSerializable {
	@Nullable
	private final AllField allField;

	@Nullable
	private final Boolean dateDetection;

	@Nullable
	private final DynamicMapping dynamic;

	private final List<String> dynamicDateFormats;

	private final List<NamedValue<DynamicTemplate>> dynamicTemplates;

	@Nullable
	private final FieldNamesField fieldNames;

	@Nullable
	private final IndexField indexField;

	private final Map<String, JsonData> meta;

	@Nullable
	private final Boolean numericDetection;

	private final Map<String, Property> properties;

	@Nullable
	private final RoutingField routing;

	@Nullable
	private final SizeField size;

	@Nullable
	private final SourceField source;

	private final Map<String, RuntimeField> runtime;

	@Nullable
	private final Boolean enabled;

	@Nullable
	private final Subobjects subobjects;

	@Nullable
	private final DataStreamTimestamp dataStreamTimestamp;
    
    // 其他省略
}

TypeMapping中,各个属性的含义如下:

  1. 核心字段属性
    • allField:配置_all字段(已在新版ES中弃用),曾用于将所有字段值连接成一个大字符串,便于全文搜索。
    • dateDetection:控制是否自动检测日期格式的字符串并映射为date类型。默认值为true,当设为false时,"2023-01-01"会被当作普通字符串而非日期
    • numericDetection:控制是否自动检测数字格式的字符串并映射为数字类型,默认值为false,当设为true时,"123"可能被映射为long类型。
  2. 动态映射相关
    • dynamic:控制如何处理新字段,当为true的时候自动添加新字段,当为false时忽略新字段但仍可搜索,当为strict时遇到新字段时抛出异常。
    • dynamicDateFormats:定义动态日期检测时使用的日期格式列表,包括ISO 8601等常用格式。
    • dynamicTemplates:定义动态字段映射的模板规则,根据字段名称或类型模式自动应用特定映射。
  3. 元数据字段
    • fieldNames:控制_field_names字段,用于exists查询优化,存储文档中包含值的字段名称。
    • indexField:控制_index字段的行为,在多索引查询中标识文档来源索引。
    • routing:控制_routing字段配置,自定义文档路由到特定分片的逻辑。
    • size:控制_size字段,存储原始JSON的字节大小,注意,需要mapper-size插件支持。
    • source:控制_source字段的存储和压缩,包括enabled、includes、excludes等。
  4. 数据存储
    • properties:定义文档中各字段的具体映射规则,字段名到Property对象的映射。
    • runtime:定义运行时字段,查询时计算,不占用存储空间。
    • meta:存储索引的元数据信息,应用级别的自定义元数据。
  5. 特殊配置
    • enabled:控制整个映射是否启用,当设为false时,文档存储但不索引。
    • subobjects:控制是否支持嵌套对象的子对象,可设为false来扁平化对象结构(版本8.3+)。
    • dataStreamTimestamp:数据流的时间戳字段配置,用于数据流(Data Stream)场景中的时间字段设置。

这里我们其他的配置先不关心,主要看看properties这个属性,这个properties属性的类型为Property,而在Property类中有一个枚举对象Kind,这个Kind其实就是定义了你字段的数据类型:

Kind字段类型枚举类

并且Property类中还提供了便捷的设置字段类型的方式:

Property快捷定义字段数据类型

比如我的如下代码:

elasticsearchClient.indices().create(c -> c
                .index("books") // 索引名称
                .settings(s -> s
                        .numberOfShards("3") // 分片数
                        .numberOfReplicas("2") // 副本数
                )
                //设置映射
                .mappings(m->m
                        .properties("title",p->p
                                .text(t->t
                                        .analyzer("ik_max_word")
                                )
                        )
                )
        );

这里主要看映射设置,我给字段”title“的类型为text,并且设置了分词器为”ik_max_word“。

properties参数

这里我使用的是函数式接口的方式来设置properties,我将"title"字段的数据类型设置为了text,此时返回的类型是TextProperty

返回具体的xxxProperty类型数据

之后我又继续使用TextProperty类型种的属性,来设置,这里就不继续下钻讲解了。

这里我给出完整的例子:

@Test
void createIndexAndMapping() throws IOException {
    // 这里的indices() 返回的是索引操作相关的客户端,封装了所有索引操作
    elasticsearchClient.indices().create(c -> c
            .index("books") // 索引名称
            .settings(s -> s
                    .numberOfShards("3") // 分片数
                    .numberOfReplicas("2") // 副本数
            )
            //设置映射
            .mappings(m -> m
                    .properties("title", p -> p
                            .text(t -> t
                                    .analyzer("ik_max_word")
                            )
                    )
                    .properties("author", p -> p
                            .keyword(k -> k)
                    )
                    .properties("publish_date", p -> p
                            .date(d -> d
                                    .format("yyyy-MM-dd")
                            )
                    )
                    .properties("description", p -> p
                            .text(t -> t
                                    .analyzer("ik_max_word")
                            )
                    )
            )
    );
}

创建结果


对于第二种创建映射的方式:在创建索引之前创建好映射。这种不过多讲解,其实就是实现创建好一个TypeMapping对象,然后在创建索引的时候把这个TypeMapping对象设置进去就行了,本质上其实也是属于第一种。

4.1.1.2 更新Mapping

这里的更新不是指更新已经创建好Mapping的字段,而是往索引种新增Mapping字段。具体原因在之前的小节中已经讲过,这里不做过多赘述。

如果索引已存在,你可以使用PutMapping这个API来添加或更新映射:

@Test
void updateMapping() throws IOException {
    // 使用indices().putMapping()更新索引映射
    elasticsearchClient.indices().putMapping(p -> p
            .index("books")
            .properties("category", prop -> prop
                    .keyword(k->k) // 添加一个新的 keyword 类型字段
            )
    );
}

更新Mapping结果

4.1.1.3 查询映射信息

查询映射信息时,你可以查询多个索引的映射信息,也可以查询单个索引的映射、甚至查某个字段的映射信息:

查询指定索引的映射信息

查询多个索引的映射信息

查询单个索引种的某个字段的映射信息

示例代码:

@Test
void getMappingInfo() throws IOException {
    log.info("====================================获取多个索引的映射信息====================================");
    GetMappingResponse responseMulti = elasticsearchClient.indices().getMapping(m -> m
            .index("books","products") // 指定索引名称,可以是单个或多个索引
    );

    // 对于多个索引的映射信息,你可以使用GetMappingResponse.mappings()方法获取所有索引的映射
    responseMulti.mappings().forEach((indexName,indexMapping)->{
        log.info("Index: {}", indexName);
        log.info("Mappings: {}", indexMapping);
        indexMapping.mappings().properties().forEach((fieldName, property) -> {
            log.info("字段: {}, 类型: {}", fieldName, property);
        });
    });

    log.info("====================================获取单个索引的映射信息====================================");

    // 对于单个索引的映射信息,你可以使用GetMappingResponse.get()方法获取指定索引映射
    GetMappingResponse responseSingle = elasticsearchClient.indices().getMapping(m -> m
            .index("books")
    );
    IndexMappingRecord booksMapping = responseSingle.get("books");
    Assertions.assertNotNull(booksMapping);

    log.info("Index: books");
    log.info("Mappings: {}", booksMapping);

    booksMapping.mappings().properties().forEach((fieldName, property) -> {;
        log.info("字段: {}, 类型: {}", fieldName, property);
    });

    log.info("====================================获取单个索引的字段级别的映射信息====================================");

    GetFieldMappingResponse fieldMapping = elasticsearchClient.indices().getFieldMapping(fm -> fm
            .index("books")
            .fields("author", "title")
    );
    fieldMapping.fieldMappings().forEach((indexName,fieldMappings)->{
        log.info("Index: {}", indexName);
        log.info("TypeFieldMappings: {}", fieldMappings);
        fieldMappings.mappings().forEach((fieldName, mappingMetaData) -> {
            log.info("字段: {}, 映射元数据: {}", fieldName, mappingMetaData);
            if (mappingMetaData.mapping() != null) {
                mappingMetaData.mapping().forEach((fName, prop) -> {
                    log.info("字段: {}, 属性: {}", fName, prop);
                });
            }
        });
    });
}

执行结果

4.1.2 Index索引API

4.1.2.1 创建索引(Index)

创建索引很简单,核心API:client.indices().create

有如下几种方式创建索引:

  1. 简单创建索引

    // 创建索引(最简单方式)
    CreateIndexResponse simpleIndexResponse = esClient.indices().create(c -> c
                    .index("索引名称")
            );
    
  2. 包含设置的索引创建

    esClient.indices().create(c -> c
                    .index("settings_index")
                    .settings(s -> s
                            .numberOfShards("3")      // 设置主分片数
                            .numberOfReplicas("1")    // 设置副本数
                            .analysis(a -> a        // 配置分析器(若有需要)
                                    .analyzer("my_analyzer", ana -> ana // 配置自定义分析器
                                            .custom(cus -> cus
                                                    .tokenizer("standard") // 使用标准分词器
                                                    .filter("lowercase", "stop") // 使用小写和停用词过滤器
                                            )
                                    )
                            )
                    )
            );
    
  3. 包含映射的索引创建:这里不过多讲解,请看映射创建部分。

这里比较复杂的就是在创建索引的时候可以进行索引设置,这里讲解一些常用的索引设置:

  1. numberOfShards:指定索引中主分片的数量,一旦索引创建成功,主分片的数量就无法被更改,所以我们要在创建索引时就想好。

    [!tip]

    分片本质上是数据的水平分割。当你的索引数据量很大时,ES会将数据分成多个较小的片段,每个片段就是一个分片。

    为什么需要分片:

    • 突破单机存储限制:如果你有1TB的数据,单台服务器可能装不下,通过分片可以将数据分散到多台机器上
    • 提高查询性能:多个分片可以并行处理查询请求,提升整体性能
    • 水平扩展:可以通过增加节点来分布更多分片

    举个例子: 假设你有一个用户索引,包含1000万用户数据。如果设置5个主分片,ES会将这1000万条记录大致均匀地分成5份,每个分片存储约200万条记录。

  2. numberOfReplicas:它定义了每个主分片拥有多少个副本(复制分片)。与主分片不同,副本的数量可以在索引创建之后动态调整副本是主分片的完整拷贝,主要用于:

    • 高可用性:如果存储主分片的节点宕机,副本可以立即接管服务。
    • 提升查询性能:副本也可以处理搜索请求,分担主分片的压力。
    • 数据安全:防止数据丢失。
  3. refreshInterval:设置新写入的数据,需要等待多久才能被搜索到。默认情况下,ES每1 秒自动进行一次Refresh。这就是为什么ES被称为 “近实时(Near Real-Time, NRT)” 搜索引擎的原因——数据在写入后约 1 秒即可被搜索到。

    你可以在创建索引时指定refreshInterval,也可以在索引创建之后动态地更新它。

    [!tip]

    Refresh操作讲解:

    • 当你索引一个新文档时,它首先被写入到内存缓冲区(In-memory Buffer)
    • 之后Refresh操作会将在内存缓冲区中的文档(以及已在操作但未提交的段)转换成一个新的、不可变的 Lucene 段(Segment),并将其打开,使其可供搜索
    • 非常重要的一点是,Refresh并不会将这些新段fsync到磁盘。数据此时仍然主要存在于内存中(尽管操作系统可能会开始将其写入磁盘缓存)。持久化到磁盘是由Flush操作完成的。

    简单来说,Refresh 是让新写入的数据变得对搜索可见的过程,也常被称为“可搜索化”。

    最佳实践与注意事项

    • 批量导入时禁用刷新:在进行大规模数据导入(如日志灌入、历史数据迁移)时,先将refresh_interval设置为-1 以禁用自动刷新。导入完成后,再将其恢复为合理值(如 1s)。这可以极大缩短数据导入的总时间。
    • 使用 Force Merge:在大批量导入之后,你可能会有很多小段。在恢复自动刷新后,可以调用 _force merge API 将段合并为更大的段,以优化查询性能。
    • 理解与持久化的区别:再次强调,Refresh不等于数据持久化。要保证数据持久化到磁盘,需要等待Flush。默认情况下,Flush由Elasticsearch自动管理(根据时间、事务日志大小等)。
    • 动态调整refresh_interval是一个可以动态更新的设置,你可以根据一天中的不同时间(例如,在白天业务高峰期设置更长的间隔,在夜间查询低峰期设置较短的间隔)来调整它。
  4. maxResultWindow限制一次搜索请求所能返回的【最深记录位置】。它定义了from + size这个值的最大值。默认是 10,000

  5. analysis:它的目的是将一段原始文本(如一个句子或一个词),转换成一系列最基础的、最适合搜索的词条(Terms),这个过程叫做分词,而这些分出来的词条会被存入倒排索引中。

    analysis主要由以下几部分组成:

    概念 职责 类比
    Analyzer (分析器) 总指挥官,负责执行整个分析过程。一个分析器包含三部分:字符过滤器、分词器、词条过滤器。 一条食品加工流水线
    Tokenizer (分词器) 核心工人,唯一负责将文本切分成一个个独立的词条(Token)。一个分析器有且只有一个分词器。 流水线上的切割机
    Token Filter (词条过滤器) 加工工人,接收分词器产生的词条,并对其进行再加工(如转小写、删除词、增加同义词等)。一个分析器可以有零个或多个词条过滤器。 流水线上的调味、去壳、包装等工序
    Character Filter (字符过滤器) 预处理员,在文本被分词器处理之前,先对原始文本进行预处理(如去掉HTML标签、将&替换为and)。一个分析器可以有零个或多个字符过滤器。 流水线开始前的蔬菜清洗、去蒂环节

示例代码:

@Test
void createIndex() throws IOException {
    log.info("================简单创建索引================");
    CreateIndexResponse simpleIndexResponse = esClient.indices().create(c -> c
            .index("simple_index")
    );

    log.info("简单创建索引响应: {}", simpleIndexResponse);

    log.info("================包含设置的索引创建================");

    CreateIndexResponse createSettingIndexResponse = esClient.indices().create(c -> c
            .index("settings_index")
            .settings(s -> s
                    .numberOfShards("3")      // 设置主分片数
                    .numberOfReplicas("1")    // 设置副本数
                    .analysis(a -> a        // 配置分析器(若有需要)
                            .analyzer("my_analyzer", ana -> ana // 配置自定义分析器
                                    .custom(cus -> cus
                                            .tokenizer("standard") // 使用标准分词器
                                            .filter("lowercase", "stop") // 使用小写和停用词过滤器
                                    )
                            )
                    )
            )
    );

    log.info("包含设置的索引创建响应: {}", createSettingIndexResponse);

}

索引创建结果

索引创建结果

4.1.2.2 检查索引是否存在

在对索引进行操作前,通常需要检查索引是否存在。

@Test
void indexExists() throws IOException {
    boolean exists = esClient.indices()
            .exists(e -> e.index("simple_index"))
            .value();
    log.info("索引是否存在: {}", exists);
}

执行结果

4.1.2.3 查询索引信息

查询索引有很多种方式:

  1. 查询单个索引信息:

    GetIndexResponse getIndexResponse = esClient.indices().get(g -> g
                    .index("simple_index")
            );
    
  2. 查询多个索引信息:

    GetIndexResponse getIndicesResponse = esClient.indices().get(g -> g
                    .index("simple_index", "settings_index")
            );
    
  3. 查询所有索引信息:

    // 使用cat API获取所有索引信息
    IndicesResponse indicesResponse = esClient.cat().indices();
    

示例代码:

@Test
void getIndexInfo() throws IOException {
    log.info("================获取单个索引信息================");
    GetIndexResponse getIndexResponse = esClient.indices().get(g -> g
            .index("simple_index")
    );
    log.info("单个索引信息: {}", getIndexResponse);

    IndexState simpleIndex = getIndexResponse.get("simple_index");
    log.info("单个索引详细信息: {}", simpleIndex);

    log.info("================获取多个索引信息================");
    GetIndexResponse getIndicesResponse = esClient.indices().get(g -> g
            .index("simple_index", "settings_index")
    );

    log.info("多个索引信息: {}", getIndicesResponse);

    getIndicesResponse.indices().forEach((indexName, indexState) -> {
        log.info("索引名称: {}, 详细信息: {}", indexName, indexState);
    });

    log.info("================获取所有索引信息================");
    // 使用cat API获取所有索引信息
    IndicesResponse indicesResponse = esClient.cat().indices();
    indicesResponse.indices().forEach(indexRecord -> {
        log.info("索引信息:{}", indexRecord);
    });
}

查询索引信息结果

4.1.2.4 更新索引

索引创建后,可以更新某些设置。根据client.indices().putXxx这个API更新:

索引更新API

比如我这里更新索引的副本数:

@Test
void updateIndex() throws IOException {
    String indexName = "simple_index";

    // 判断索引是否存在
    boolean exists = esClient.indices()
            .exists(e -> e.index(indexName))
            .value();

    if (!exists) {
        log.info("索引:{}不存在,无法更新",indexName);
        return;
    }

    // 更新索引设置
    PutIndicesSettingsResponse putIndicesSettingsResponse = esClient.indices().putSettings(ps -> ps
            .index(indexName)
            .settings(s -> s
                    .numberOfReplicas("2") // 更新副本数为2
            )
    );
    log.info("更新索引设置响应: {}", putIndicesSettingsResponse);
}

更新索引执行结果

4.1.2.5 删除索引

删除索引之前也要先判断索引是否存在,防止因为索引不存在而抛出异常。

使用client.indices().delete()API删除索引:

@Test
void deleteIndex() throws IOException {
    log.info("================删除单个索引================");
    String singleIndexName = "simple_index";
    // 删除索引
    if (isExists(singleIndexName)) {
        esClient.indices().delete(d -> d.index(singleIndexName));
        log.info("索引:{} 删除成功",singleIndexName);
    } else {
        log.info("索引:{} 不存在,无法删除",singleIndexName);
    }

    log.info("================删除多个索引================");
    String[] multipleIndexNames = {"settings_index", "non_existent_index"};
    for (String indexName : multipleIndexNames) {
        if (isExists(indexName)) {
            esClient.indices().delete(d -> d.index(indexName));
            log.info("索引:{} 删除成功",indexName);
        } else {
            log.info("索引:{} 不存在,无法删除",indexName);
        }
    }
}

private boolean isExists(String indexName) throws IOException {
    // 判断索引是否存在
    return esClient.indices()
            .exists(e -> e.index(indexName))
            .value();
}

删除索引结果

[!note]

你可能在低版本中看见能用通配符删除索引,不过从ES8.X开始,使用通配符删除索引就被禁止了,防止开发者误删。

如果你在8.X及以上版本使用通配符删除索引,你可能会看到如下错误:[illegal_argument_exception] Wildcard expressions or all indices are not allowed

4.1.2.6 打开/关闭索引

Elasticsearch允许临时关闭索引以减少资源消耗,之后可以重新打开。

当你关闭一个索引时,Elasticsearch会:

  • 卸载它的分片数据(shard不再常驻内存)。
  • 停止对该索引的写入和查询。
  • 只在集群状态里保留“索引的元信息”(settings、mappings、aliases 等仍然存在)。

应用场景:

  • 历史数据:某些日志、归档数据,短时间内不会再查,可以先 close,等需要时再 open。
  • 调整 mapping(部分情况需要 close 索引才能修改某些配置,例如 analyzer)。
  • 减少集群资源占用:当集群里有非常多索引,但只活跃访问其中一部分时,可以把冷门的 close 掉。

在使用API操作的过程中,你应该先判断索引是否存在,否则会抛出异常。

使用client.indices().close/open()API打开/关闭索引:

@Test
void openOrCloseIndex() throws IOException {
    String indexName = "books";

    // 判断索引是否存在
    boolean exists = esClient.indices()
            .exists(e -> e.index(indexName))
            .value();

    if (!exists) {
        log.info("索引:{}不存在,无法操作",indexName);
        return;
    }

    // 关闭索引
    CloseIndexResponse closeIndexResponse = esClient.indices().close(c -> c.index(indexName));
    log.info("关闭索引响应: {}", closeIndexResponse);

    // 打开索引
    OpenResponse openResponse = esClient.indices().open(o -> o.index(indexName));
    log.info("打开索引响应: {}", openResponse);
}

这里我们先测试一下关闭索引:

关闭索引执行结果

关闭索引执行结果

现在我们再测测打开索引:

打开索引测试结果

打开索引测试结果

4.1.2.7 索引别名操作

在Elasticsearch里,索引别名(alias)是一个虚拟名称,可以指向一个或多个真实索引。它的作用很大,特别是在需要做索引管理和透明切换时。

索引别名的作用

  1. 逻辑层抽象:别名就像数据库里的视图/同义词,对外提供一个统一的名字。应用程序可以永远用别名来读写,而不直接依赖具体的物理索引名。这样一来,底层索引可以随时替换,而不用改应用代码。

    例如:日志每天建一个新索引(logs-2025-09-13logs-2025-09-14),但应用永远只查"logs_read"这个别名。

  2. 零停机切换:当你需要重新建索引(例如 mapping 变更、分片数调整时必须重建),可以创建新索引(my_index_v2),之后把数据迁移进去,修改别名从my_index→指向my_index_v2,最后删除旧索引。

    对应用来说,始终访问的都是别名,感知不到索引切换。

  3. 多聚合索引:一个别名可以指向多个索引。查询时通过别名,就能一次性查所有指向的索引。logs_alias→指向logs-2025-09-*,这样应用只要查询logs_alias,就能查到所有当月日志。

  4. 读写分离:别名可以设置is_write_index属性。

    例如logs_write指定一个写入索引(最新的),而logs_read指向多个历史索引。这样写入和读取逻辑就能分开,不需要应用自己区分。

  5. 安全与权限:在某些场景下,权限是基于索引名/别名控制的。你可以只给用户授权访问某个别名,而不暴露底层所有索引。


现在我们来添加一个索引别名,可以通过修改索引的API来实现,例如:

@Test
void addIndexAlias() throws IOException {
    String indexName = "books";
    String aliasName = "books_alias";

    // 判断索引是否存在
    boolean exists = esClient.indices()
            .exists(e -> e.index(indexName))
            .value();

    if (!exists) {
        log.info("索引:{}不存在,无法添加别名",indexName);
        return;
    }

    // 添加别名
    PutAliasResponse putAliasResponse = esClient.indices().putAlias(p -> p
            .index(indexName)
            .name(aliasName) // 设置索引别名
    );
    log.info("添加别名响应: {}", putAliasResponse);
}

添加索引别名结果

添加索引别名结果


现在我们通过client.indices().getAliasAPI获取刚刚添加的索引别名:

@Test
void getIndexAlias() throws IOException {
    // 获取别名信息
    GetAliasResponse getAliasResponse = esClient.indices().getAlias(g -> g
            .name("books_alias") // 根据别名获取索引信息
    );
    log.info("获取别名响应: {}", getAliasResponse);

    getAliasResponse.aliases().forEach((indexName, indexAlias) -> {
        log.info("索引名称: {}, 索引别名: {}", indexName, indexAlias);
        indexAlias.aliases().forEach((aliasName,aliasDefinition)->{
            log.info("别名名称: {}, 别名定义: {}", aliasName, aliasDefinition);
        });
    });
}

获取别名信息示例结果

[!caution]

如果获取的索引别名不存在,这个API是会抛异常的。


现在我们通过client.indices().deleteAliasAPI删除索引别名:

@Test
void deleteIndexAlias() throws IOException {
    String indexName = "books";
    String aliasName = "books_alias";

    // 判断索引是否存在
    boolean exists = esClient.indices()
            .exists(e -> e.index(indexName))
            .value();

    if (!exists) {
        log.info("索引:{}不存在,无法删除别名",indexName);
        return;
    }

    // 删除别名
    DeleteAliasResponse deleteAliasResponse = esClient.indices().deleteAlias(d -> d
            .index(indexName)
            .name(aliasName) // 设置要删除的别名
    );
    log.info("删除别名响应: {}", deleteAliasResponse);
}

删除索引别名示例结果

删除索引别名示例结果

4.1.2.8 索引模版操作

索引模版就是你提前将索引的配置(settings)、映射(mappings)、别名(aliases)等规则定义好,当新索引创建时,就会自动应用这些规则。

索引模版的主要作用

  1. 统一规范新建索引:如果你不设模板,新建索引时Elasticsearch会用默认配置(可能导致分片数、字段类型不合适)。有了模版,你可以预先定义好分片副本数、映射、分词器、别名等规则,这样,新索引一旦被创建,就能自动继承这些设置,保持一致性。
  2. 按规则自动匹配:模板里可以写索引匹配模式(如 logs-*metrics-*)。当你创建一个符合模式的新索引(比如logs-2025-09-13),它就会自动套用对应的模板。这对于日志、监控等每天/每月自动建索引的场景特别重要。
  3. 配合ILM(索引生命周期管理):模板里可以直接指定index.lifecycle.namerollover_alias。这样,当新索引按rollover 策略创建时,它就会继承正确的生命周期策略(冷热分层、删除策略)。
  4. 灵活覆盖:Elasticsearch里可以有多个模板,并且有优先级(priority字段)。当多个模板匹配同一个索引时,优先级高的会覆盖优先级低的设置。这样可以实现全局默认规则 + 特定业务的专用规则。

例如我现在创建模版:

PUT /_index_template/logs_template
{
  // 模板匹配规则:所有以 "logs-" 开头的索引都会使用这个模板
  // 例如:logs-2024-01, logs-app, logs-error 等都会匹配
  "index_patterns": ["logs-*"],
  
  // 优先级:当多个模板匹配同一个索引时,优先级高的模板会覆盖优先级低的
  // 数字越大优先级越高,这里是10
  "priority": 10,
  
  // 模板的具体配置内容
  "template": {
    
    // ========== 索引设置 ==========
    "settings": {
      "number_of_shards": 3,     // 每个匹配的索引创建3个主分片
      "number_of_replicas": 1    // 每个主分片创建1个副本分片
    },
    
    // ========== 字段映射定义 ==========
    "mappings": {
      "properties": {
        // timestamp字段:存储时间戳,ES会自动解析各种时间格式
        "timestamp": { "type": "date" },
        
        // message字段:全文搜索字段,会被分词和分析
        // 适合存储日志内容,支持模糊搜索和关键词搜索
        "message": { "type": "text" },
        
        // level字段:精确匹配字段,不会被分词
        // 适合存储日志级别如:ERROR, WARN, INFO, DEBUG
        "level": { "type": "keyword" }
      }
    },
    
    // ========== 索引别名 ==========
    "aliases": {
      // 创建一个名为 "logs_current" 的别名
      "logs_current": { 
        "is_write_index": true  // 标记这个别名为写入索引
        // 这意味着当你向 logs_current 写数据时,会写入到最新匹配的索引中
      }
    }
  }
}

这样以后只要你创建logs-2025-09-13这样的索引,它会自动套用上面的配置。

索引模版创建结果

[!caution]

  1. 模板不会影响已经存在的索引,只对未来新建的索引生效。
  2. 如果要修改已有索引,需要手动PUT /index/_mapping或 reindex。
  3. ES 7.x 之后推荐用Composable Index Template(组合索引模板,简称 CIT),可以组合基础模板 + 业务模板。

索引模版查询


现在我们使用client.indices().putTemplate()API来创建索引:

@Test
void createIndexTemplate() throws IOException {

    // 创建索引模板
    PutIndexTemplateResponse putIndexTemplateResponse = esClient.indices().putIndexTemplate(p -> p
            .name("my_log_template") // 设置模板名称
            .indexPatterns("my_log_*") // 设置索引模式, 匹配 my_log_ 开头的索引
            .template(t -> t
                    .settings(s -> s
                            .numberOfShards("3")      // 设置主分片数
                            .numberOfReplicas("1")    // 设置副本数
                    )
                    .mappings(m -> m
                            .properties("timestamp", mp -> mp
                                    .date(d -> d
                                            .format("yyyy-MM-dd HH:mm:ss")
                                    )
                            )
                            .properties("message", mp -> mp
                                    .text(tp -> tp
                                            .analyzer("ik_max_word")
                                    )
                            )
                    )
            )
    );
    log.info("创建索引模板响应: {}", putIndexTemplateResponse);
}

索引模版创建结果

索引模版创建结果


上述索引模版创建好了,现在我们使用client.indices().getTemplate()API来获取创建的索引模版:

@Test
void getIndexTemplate() throws IOException {
    // 获取索引模板
    GetIndexTemplateResponse getIndexTemplateResponse = esClient.indices().getIndexTemplate(g -> g
            .name("my_log_template") // 根据模板名称获取
    );
    log.info("获取索引模板响应: {}", getIndexTemplateResponse);

    getIndexTemplateResponse.indexTemplates().forEach((indexTemplate) -> {
        log.info("模板名称: {}, 模板详细信息: {}", indexTemplate.name(), indexTemplate);
    });
}

获取索引模版执行结果


最后再来试试删除索引模版,使用client.indices().deleteTemplateAPI完成索引模版的删除:

@Test
void deleteIndexTemplate() throws IOException {
    // 删除索引模板
    DeleteIndexTemplateResponse deleteIndexTemplateResponse = esClient.indices().deleteIndexTemplate(d -> d
            .name("my_log_template") // 设置要删除的模板名称
    );
    log.info("删除索引模板响应: {}", deleteIndexTemplateResponse);
}

删除模版示例执行结果

4.1.3 Document文档API

[!caution]

所有文档操作都要保证对应的索引是否存在,否则会抛出异常。

4.1.3.1 创建文档(Document)

主要通过如下API完成文档的创建:

esClient.index(i -> i
                .index("索引名称")
               	.id("文档ID") // 如果不指定,那么ES会为你自动生成文档ID
                .document("文档") // 这里给到的是一个Object的文档对象
        );

示例代码:

@Test
void createDocument() throws Exception {
    log.info("================使用Map创建文档================");
    Map<String, Object> bookMap = new HashMap<>();
    bookMap.put("title", "Elasticsearch 基础");
    bookMap.put("author", "念心卓");
    bookMap.put("type", "技术");
    bookMap.put("description", "Elasticsearch 是一个基于 Lucene 的开源搜索引擎,提供了分布式、多租户能力的全文搜索引擎,具有 RESTful 风格的接口。");
    bookMap.put("publish_date", "2025-09-14");


    esClient.index(i -> i
            .index("books")
            .id("1")
            .document(bookMap)
    );

    log.info("================使用实体类创建文档================");
    Book book = new Book();
    book.setTitle("Elasticsearch 进阶");
    book.setAuthor("念心卓");
    book.setCategory("技术");
    book.setDescription("深入理解 Elasticsearch 的工作原理和高级功能,提升搜索性能和用户体验。");
    book.setPublishDate(new Date());
    esClient.index(i -> i
            .index("books")
            .id("2")
            .document(book)
    );

    log.info("================使用JSON创建文档================");
    String bookJson = """
            {
              "title": "Elasticsearch 高级",
              "author": "念心卓",
              "category": "技术",
              "description": "掌握 Elasticsearch 的集群管理、监控和优化技巧,打造高效稳定的搜索系统。",
              "publish_date": "2025-09-14"
            }
            """;

    esClient.index(i -> i
            .index("books")
            .id("3")
            .withJson(new StringReader(bookJson))
    );

}

文档创建结果

4.1.3.2 检查文档是否存在/根据文档ID查询文档

对于根据文档ID查询文档,对应的DSL语法如下:

GET /索引名称/_doc/文档ID

搜索结果

所以,对应到java中,使用如下API即可完成查询:

GetResponse<Book> bookGetResponse = esClient.get(g -> g
                        .index("索引名称")
                        .id("文档ID"),
                Book.class // 需要转为的实体类
        );

具体示例:

@Test
void documentSearch() throws IOException {
    log.info("================判断文档是否存在================");
    boolean exists = esClient.exists(e -> e
            .index("books")
            .id("1")
    ).value();
    log.info("文档是否存在: {}", exists);


    log.info("================查询指定文档================");
    GetResponse<Book> bookGetResponse = esClient.get(g -> g
                    .index("books")
                    .id("2"),
            Book.class
    );
    log.info("查询指定文档响应: {}", bookGetResponse);

    if (bookGetResponse.found()) {
        Book book = bookGetResponse.source();
        log.info("查询到的文档内容: {}", book);
    } else {
        log.info("未找到指定ID的文档");
    }
}

文档判断与查询执行结果

4.1.3.3 更新文档

在之前学习修改文档DLS语法的时候,我们了解到,更新文档有两种方式:

  1. 全量修改
  2. 增量修改

主要使用client.update()API完成更新:

@Test
void updateDocument() throws IOException {
    log.info("================更新文档(部分字段更新)================");
    Map<String, Object> updateFields = new HashMap<>();
    updateFields.put("description", "Elasticsearch 是一个强大的分布式搜索引擎,广泛应用于日志分析、全文搜索等场景。");

    UpdateResponse<Book> bookUpdateResponse = esClient.update(u -> u.index("books")
                    .id("1")
                    .doc(updateFields),
            Book.class
    );

    log.info("更新文档响应: {}", bookUpdateResponse);

    if (bookUpdateResponse.result().name().equals("Updated")) {
        log.info("文档更新成功");
    } else {
        log.info("文档更新失败或无变化");
    }

    log.info("================更新文档(全量更新)================");
    Book updatedBook = new Book();
    updatedBook.setTitle("Elasticsearch 基础与实战");
    updatedBook.setAuthor("念心卓");
    updatedBook.setCategory("技术");
    updatedBook.setDescription("全面介绍 Elasticsearch 的基础知识和实战应用,帮助读者快速上手并应用于实际项目中。");
    updatedBook.setPublishDate(new Date());
    UpdateResponse<Book> fullUpdateResponse = esClient.update(u -> u.index("books")
                    .id("2")
                    .doc(updatedBook),
            Book.class
    );

    log.info("全量更新文档响应: {}", fullUpdateResponse);

}

上述代码我更新了文档ID为1和2的文档,我们先来看看更新之前的文档:

文档更新前数据

文档更新响应

文档更新后结果

4.1.3.4 删除文档

使用client.delete()API完成文档的删除:

@Test
void deleteDocument() throws IOException {
    log.info("================删除指定文档================");
    DeleteResponse deleteResponse = esClient.delete(d -> d
            .index("books")
            .id("3")
    );
    log.info("删除文档响应: {}", deleteResponse);
}

文档删除结果

文档删除结果

4.1.3.5 批量文档

批量操作可以显著提高处理大量文档时的效率。

在ES的批量操作中,主要是使用bulk操作,例如我有如下DSL语句:

POST /_bulk
// --------------- 第 1 个操作:index ---------------
{ 
  "index": {                // 操作类型:index(写入/覆盖文档)
    "_index": "books_bulk", // 目标索引名(不存在时可能会自动创建)
    "_id": "1"              // 文档 ID = 1
  }
}
// --------------- 执行index的同时,将文档也放到对应的index中去 ---------------
{
  "title": "Elasticsearch 高级", 
  "author": "念心卓"             
}

// --------------- 第 2 个操作:delete ---------------
{
  "delete": {               // 操作类型:delete(删除文档)
    "_index": "books_bulk", // 目标索引名
    "_id": "2"              // 要删除的文档 ID = 2
  }
}

// --------------- 第 3 个操作:update ---------------
{
  "update": {               // 操作类型:update(更新/部分更新)
    "_index": "books_bulk", // 目标索引名
    "_id": "3"              // 要更新的文档 ID = 3
  }
}
{
  "doc": {                  // 更新的字段(部分更新)
    "category": "技术"        // 添加/修改字段:category=技术
  },
  "doc_as_upsert": true     // 如果文档不存在,就用 doc 内容新建一个文档
}

对于bulk有4种操作类型:

  1. index:它的作用的是写入或覆盖文档

    如果文档不存在,那么就会直接新增,如果文档已存在,那么就会直接替换整个_source

    例如上述的:

    { 
      "index": {                // 操作类型:index(写入/覆盖文档)
        "_index": "books_bulk", // 目标索引名(不存在时可能会自动创建)
        "_id": "1"              // 文档 ID = 1
      }
    }
    // --------------- 执行index的同时,将文档也放到对应的index中去 ---------------
    {
      "title": "Elasticsearch 高级", 
      "author": "念心卓"             
    }
    

    它的含义是,在索引books_bulk种,写入或覆盖文档ID为1的文档,文档的内容为{ "title": "Elasticsearch 高级", "author": "念心卓" }

  2. createcreateindex功能和bulk中的index中的功能类似,不过它只在文档不存在时创建文档。如果创建的文档已经存在,那么他会返回409 conflict错误。

    例如:

    { "create": { "_index": "books", "_id": "2" } }
    { "title": "Spring Boot 实战", "author": "念心卓" }
    
  3. delete:删除指定 ID 的文档。如果删除的文档不存在,会返回not_found不会报错,但标记失败。文档文档存在,则删除成功(result=deleted)。

  4. update:它的作用是部分更新文档,默认只修改_source中的部分字段(不会覆盖整个文档)。可选参数doc_as_upsert: true,表示文档不存在时,新建一个文档。

    支持两种方式的修改:

    • 提供doc更新指定文档字段

      { "update": { "_index": "books", "_id": "4" } }
      { "doc": { "category": "技术" }, "doc_as_upsert": true }
      
    • 提供script,用脚本来更新文档。


总结表:

操作类型 需要几行 行为 覆盖/限制
index 2 行 写入或覆盖文档 已存在则覆盖
create 2 行 仅在不存在时创建 已存在时报错
delete 1 行 删除文档 不存在则 not_found
update 2 行 部分更新或 upsert 支持 doc / script

例如对于之前的bulk的DSL操作:

POST /_bulk
{
  "index": { 
    "_index": "books_bulk",
    "_id": "1"
  }
}
{ 
  "title": "Elasticsearch 高级",
  "author": "念心卓"
}
{
  "delete": {
    "_index": "books_bulk",
    "_id": "2"
  }
}
{
  "update": {
    "_index": "books_bulk",
    "_id": "3"
  }
}
{
  "doc": {
    "category": "技术"
  },
  "doc_as_upsert": true
}

bulk示例操作结果

从上图执行bulk的返回结果可知,bulk 返回包含:

  • 顶层errors(布尔) — 只要有任一子操作失败,这个值为true
  • items数组 — 顺序对应请求中每一项操作,单个item会带_index_idstatus、以及可能的error对象(若失败)。因此要逐条检查items来定位失败项。注意:bulk不是事务性(non-transactional),部分成功是常态,必要时客户端要负责重试/补偿。

有了这部分基础,那么使用Java来批量操作文档就显得很简单了。你可以使用client.bulk()API来批量操作文档。

4.1.3.5.1 批量创建文档

示例代码:

@Test
void bulkCreateDocuments() throws IOException {
    log.info("================批量创建文档================");
    List<Book> bookList = Arrays.asList(
            new Book("Elasticsearch 入门", "念心卓", "技术", "介绍 Elasticsearch 的基本概念和入门知识。", new Date()),
            new Book("Elasticsearch 实战", "念心卓", "技术", "通过实际案例讲解 Elasticsearch 的应用。", new Date()),
            new Book("Elasticsearch 高级", "念心卓", "技术", "深入探讨 Elasticsearch 的高级功能和优化技巧。", new Date())
    );

    // 准备批量操作列表
    List<BulkOperation> bulkOperationList = new ArrayList<>();

    // 遍历书籍列表,创建索引操作并添加到批量操作列表中
    for (int i = 0; i < bookList.size(); i++) {
        Book book = bookList.get(i);
        final int documentId = i;
        bulkOperationList.add(BulkOperation.of(b -> b
                .index(idx -> idx // bulk的index操作(创建/覆盖文档操作)
                        .index("books")
                        .id(String.valueOf(documentId + 4)) // 从ID 4开始,避免与已有文档冲突
                        .document(book)
                )
        ));
    }

    // 执行批量操作
    BulkResponse bulkResponse = esClient.bulk(b -> b
            .index("books")
            .operations(bulkOperationList)
    );

    log.info("批量创建文档响应: {}", bulkResponse);
    log.info("批量操作总数: {}", bulkResponse.items().size());
    log.info("批量操作耗时: {} ms", bulkResponse.took());
    log.info("批量操作是否有错误: {}", bulkResponse.errors());

    // 检查是否有失败的操作
    if (bulkResponse.errors()) {
        log.error("批量操作中存在错误");
        bulkResponse.items().forEach(item -> {
            if (item.error() != null) {
                log.error("操作失败的文档ID: {}, 错误信息: {}", item.id(), item.error().reason());
            }
        });
    } else {
        log.info("所有批量操作均成功");
    }

}

批量创建文档结果

批量创建文档结果

可见确实操作成功。

4.1.3.5.2 批量更新文档

示例代码:

@Test
void bulkUpdateDocuments() throws IOException {
    log.info("================批量更新文档================");
    List<Book> bookList = Arrays.asList(
            new Book().setDescription("描述更新哈哈"),
            new Book().setDescription("描述更新嘻嘻"),
            new Book().setDescription("描述更新嘿嘿")
    );

    // 准备批量操作列表
    List<BulkOperation> bulkOperationList = new ArrayList<>();

    // 遍历更新列表,创建更新操作并添加到批量操作列表中

    for (int i = 0; i < bookList.size(); i++) {
        Book book = bookList.get(i);
        final int documentId = i;
        bulkOperationList.add(BulkOperation.of(b -> b
                .update(u -> u // bulk的update操作
                        .index("books")
                        .id(String.valueOf(documentId + 4)) // 假设要更新ID为4,5,6的文档
                        .action(a->a.doc(book))
                )
        ));

    }


    // 执行批量操作
    BulkResponse bulkResponse = esClient.bulk(b -> b
            .index("books")
            .operations(bulkOperationList)
    );

    log.info("批量更新文档响应: {}", bulkResponse);
    log.info("批量操作总数: {}", bulkResponse.items().size());
    log.info("批量操作耗时: {} ms", bulkResponse.took());
    log.info("批量操作是否有错误: {}", bulkResponse.errors());

    // 检查是否有失败的操作
    if (bulkResponse.errors()) {
        log.error("批量操作中存在错误");
        bulkResponse.items().forEach(item -> {
            if (item.error() != null) {
                log.error("操作失败的文档ID: {}, 错误信息: {}", item.id(), item.error().reason());
            }
        });
    } else {
        log.info("所有批量操作均成功");
    }
}

批量更新文档结果

批量更新文档结果

可见确实批量更新成功。

4.1.3.5.3 批量删除文档

示例代码:

@Test
void bulkDeleteDocuments() throws IOException {
    log.info("================批量删除文档================");
    // 假设要删除ID为4,5,6的文档
    List<String> documentIdsToDelete = Arrays.asList("4", "5", "6");

    // 准备批量操作列表
    List<BulkOperation> bulkOperationList = new ArrayList<>();

    // 遍历ID列表,创建删除操作并添加到批量操作列表中
    for (String documentId : documentIdsToDelete) {
        bulkOperationList.add(BulkOperation.of(b -> b
                .delete(d -> d // bulk的delete操作
                        .index("books")
                        .id(documentId)
                )
        ));
    }

    // 执行批量操作
    BulkResponse bulkResponse = esClient.bulk(b -> b
            .index("books")
            .operations(bulkOperationList)
    );

    log.info("批量删除文档响应: {}", bulkResponse);
    log.info("批量操作总数: {}", bulkResponse.items().size());
    log.info("批量操作耗时: {} ms", bulkResponse.took());
    log.info("批量操作是否有错误: {}", bulkResponse.errors());

    // 检查是否有失败的操作
    if (bulkResponse.errors()) {
        log.error("批量操作中存在错误");
        bulkResponse.items().forEach(item -> {
            if (item.error() != null) {
                log.error("操作失败的文档ID: {}, 错误信息: {}", item.id(), item.error().reason());
            }
        });
    } else {
        log.info("所有批量操作均成功");
    }
}

批量删除文档操作结果

批量删除文档操作结果

4.1.3.6 高级文档操作

4.1.3.6.1 多文档获取

多文档获取主要是使用_mget操作完成。

基本语法:

GET /_mget
{
  "docs": [
    {
      "_index": "index_name",
      "_id": "1"
    },
    {
      "_index": "index_name",
      "_id": "2"
    }
  ]
}

例如:

GET /_mget
{
  "docs": [
    {
      "_index": "books",
      "_id": "1"
    },
    {
      "_index": "books",
      "_id": "2"
    }
  ]
}

多文档获取结果

多文档获取有很多种方式:

  1. 同一索引下的多文档获取

    GET /索引名称/_mget
    {
      "docs": [
        {
          "_id": "1"
        },
        {
          "_id": "2"
        },
        {
          "_id": "3"
        }
      ]
    }
    

    简化写法:

    GET /索引名称/_mget
    {
      "ids": ["1", "2", "3"]
    }
    

    同一索引下的多文档获取结果

  2. 返回指定字段的多文档获取

    GET /_mget
    {
      "docs": [
        {
          "_index": "books", // 索引名称
          "_id": "1", // 文档ID
          "_source": ["title", "author"] // 指定字段
        },
        {
          "_index": "books",
          "_id": "2",
          "_source": false
        }
      ]
    }
    

    返回指定字段的多文档获取

  3. 跨索引多文档获取

    GET /_mget
    {
      "docs": [
        {
          "_index": "books", // 索引名称
          "_id": "1" // 文档ID
        },
        {
          "_index": "users", // 索引名称
          "_id": "100" // 文档ID
        }
      ]
    }
    

    跨索引多文档获取结果


现在我们来看看Java对应的API操作,你只需要使用client.mget()API即可完成多文档获取操作:

@Test
void documentMget() throws IOException {
    log.info("================多文档查询================");
    List<String> documentIdsToGet = Arrays.asList("1", "2", "3");

    // 执行多文档查询
    MgetResponse<Book> mgetResponse = esClient.mget(m -> m
                    .index("books")
                    .ids(documentIdsToGet),
            Book.class
    );

    log.info("多文档查询响应: {}", mgetResponse);
    log.info("查询到的文档总数: {}", mgetResponse.docs().size());

    // 遍历查询结果
    mgetResponse.docs().forEach(doc -> {
        GetResult<Book> result = doc.result();
        log.info("文档结果:{}", result);

        if (result.found()) {
            log.info("文档ID: {}, 文档内容: {}", result.id(), result.source());
        } else {
            log.info("文档ID: {} 未找到", result.id());
        }
    });
}

多文档获取执行结果

4.1.3.6.2 更新时查询

更新时查询主要是根据查询条件来批量更新文档

基本使用:

POST /books/_update_by_query
{
    "query": { // 查询条件
        "match": {
        "title": "Elasticsearch 基础"
      }
    },
    "script": { // 更新脚本
      "source": "ctx._source.category = ELK中间件", // 这里是更新查询结果文档中的_source.category字段
      "lang": "painless"
    }
}

执行流程:

  1. 查询阶段:Elasticsearch在books中搜索所有title字段等于"Elasticsearch 基础"的文档。
  2. 更新阶段:对每个匹配的文档执行脚本,将其category字段更新为"ELK中间件"。
  3. 重新索引:更新后的文档会被重新索引。

更新时查询DSL语句执行结果

查看更新结果

这里解释一下上述的脚本语句:

script:定义如何更新匹配的文档。

source:脚本内容。

  • ctx:脚本执行上下文对象。
  • ctx._source:当前文档的原始JSON数据。
  • ctx._source.category = 'ELK中间价':将文档的category字段设置为"ELK中间价"。

lang: "painless":指定脚本语言为Painless(Elasticsearch的默认脚本语言)。


在Java中,可以使用client.updateByQuery()API完成更新时查询操作:

@Test
void documentUpdateByQuery() throws IOException {
    log.info("================通过查询更新文档================");
    UpdateByQueryResponse updateByQueryResponse = esClient.updateByQuery(u -> u
            .index("books")
            .query(q -> q  // 查询条件,匹配作者为 "念心卓" 的文档
                    .match(m -> m
                            .field("author")
                            .query("念心卓")
                    )
            ) 
            .script(s -> s
                    .source(ss -> ss.scriptString("ctx._source.category = params.new_category")) // 使用脚本更新 category 字段
                    .lang("painless")
                    .params("new_category", JsonData.of("ELK分类")) // 传递参数
            )
    );
    log.info("通过查询更新文档响应: {}", updateByQueryResponse);
}

更新时查询执行结果

查询更新的结果

4.1.3.6.3 删除时查询

删除时查询主要是根据查询的条件来删除文档。使用方法和上一小节的更新时查询差不多。

基本使用:

POST /books/_delete_by_query
{
  "query": {
    "match": {
      "title": "基础"
    }
  }
}

上述DSL语句表示删除title中包含基础的文档。

删除时查询DSL执行结果

删除时查询结果验证


对于Java代码来说,只需要使用client.deleteByQuery()API操作:

@Test
void documentDeleteByQuery() throws IOException {
    log.info("================通过查询删除文档================");
    DeleteByQueryResponse deleteByQueryResponse = esClient.deleteByQuery(d -> d
            .index("books")
            .query(q -> q  // 查询条件,匹配作者为 "念心卓" 的文档
                    .match(m -> m
                            .field("author")
                            .query("念心卓")
                    )
            )
    );
    log.info("通过查询删除文档响应: {}", deleteByQueryResponse);
}

删除时查询执行结果

删除时查询结果验证

4.1.4 Search文档API

从之前的第4节“ES搜索引擎”可知,搜索是ES的核心,同时,搜索的方式有很多种,但是篇幅有限,这里只列举其中的一些,只要你了解了DSL搜索语言,那么在Java代码中,也是一样的。

在Client API中,主要使用client.search()API来完成文档的搜索。

4.1.4.1 全文查询之Match

例如我有如下DSL语句:

Match查询结果

对应的Java代码:

@Test
void matchSearch() throws IOException {
    SearchResponse<ProductForES> searchResponse = esClient.search(s -> s
                    .index("products")
                    .query(q -> q
                            .match(m -> m
                                    .field("product.description")
                                    .query("好用 商品")

                            )

                    ),
            ProductForES.class
    );

    log.info("搜索响应: {}", searchResponse);

    searchResponse.hits().hits().forEach(hit -> {
        ProductForES productForES = hit.source();
        log.info("文档ID: {}, 文档内容: {}", hit.id(), productForES);
    });
}

match查询结果

4.1.4.2 词级查询之Term

例如我有如下DSL语句:

term查询结果

对应的Java代码:

@Test
void termSearch() throws IOException {
    SearchResponse<ProductForES> searchResponse = esClient.search(s -> s
                    .index("products")
                    .query(q -> q
                            .term(t -> t
                                    .field("product.name")
                                    .value("商品001")
                            )
                    ),
            ProductForES.class
    );
    log.info("搜索响应: {}", searchResponse);

    searchResponse.hits().hits().forEach(hit -> {
        ProductForES productForES = hit.source();
        log.info("文档ID: {}, 文档内容: {}", hit.id(), productForES);
    });
}

term查询结果

4.1.4.3 复合查询之bool

例如我有如下DSL语句:

bool中的must查询结果

对应的Java代码:

@Test
void boolSearch() throws IOException {
    SearchResponse<ProductForES> searchResponse = esClient.search(s -> s
            .index("products")
            .query(q -> q
                    .bool(b -> b
                            .must(m -> m
                                    .match(ma -> ma
                                            .field("product.description")
                                            .query("升级款")
                                    )
                            )
                            .must(m -> m.range(r -> r.number(nr -> nr.field("product.price").lte((double) 1000))))
                    )
            ), ProductForES.class
    );

    log.info("搜索响应: {}", searchResponse);

    searchResponse.hits().hits().forEach(hit -> {
        ProductForES productForES = hit.source();
        log.info("文档ID: {}, 文档内容: {}", hit.id(), productForES);
    });
}

bool查询结果

4.1.4.4 地理查询之geo_distance

例如我有如下DSL语句:

在2km范围内查找数据

对应的Java代码:

@Test
void geoDistanceSearch() throws IOException {
    SearchResponse<Object> searchResponse = esClient.search(s -> s
            .index("my_locations")
            .query(q -> q
                    .geoDistance(gd -> gd
                            .field("location")
                            .distance("2km")
                            .location(l -> l.latlon(ll -> ll
                                            .lat(29.34)
                                            .lon(105.93)
                                    )
                            )
                    )
            ), Object.class
    );
    log.info("搜索响应: {}", searchResponse);
}

geo_distance查询结果

4.1.4.5 排序

排序很简单,没啥可说的,直接看代码即可:

@Test
void searchSort() throws IOException {
    SearchResponse<ProductForES> searchResponse = esClient.search(
            s -> s
                    .index("products")
                    .query(q -> q
                            .matchAll(ma -> ma)
                    )
                    // 根据价格降序排序
                    .sort(so -> so
                            .field(f -> f
                                    .field("product.price")
                                    .order(SortOrder.Desc)
                            )
                    )
            , ProductForES.class
    );
    log.info("搜索响应: {}", searchResponse);

    searchResponse.hits().hits().forEach(hit -> {
        ProductForES productForES = hit.source();
        log.info("文档ID: {}, 文档内容: {}", hit.id(), productForES);
    });
}

排序结果

4.1.4.5 分页

这里分页主要示范from+sizesearch_after两种分页。

先来看看from+size分页示例代码:

@Test
void fromSizePageSearch() throws IOException {
    SearchResponse<Object> searchResponse = esClient.search(s -> s
                    .index("books")
                    .from(0)  // 起始位置
                    .size(2), // 每页大小,
            Object.class
    );
    log.info("搜索响应: {}", searchResponse);

    searchResponse.hits().hits().forEach(hit -> {
        Object source = hit.source();
        log.info("文档ID: {}, 文档内容: {}", hit.id(), source);
    });
}

from+size分页结果

再来看看search_after分页:

@Test
void searchAfterSearch() throws IOException {
    // 当前页码
    int currentPage = 1;
    // 每页大小
    int pageSize = 2;

    // 用于存储当前响应,以便获取下一页的排序值
    SearchResponse<Object> currentResponse = null;

    // 第一次查询无需使用 search_after参数
    SearchResponse<Object> firstSearchResponse = esClient.search(s -> s
                    .index("books")
                    .size(pageSize)
                    // 必须指定一个排序字段,如果有多个排序字段,需要保证排序的组合是唯一的
                    .sort(so -> so.score(sc -> sc.order(SortOrder.Desc)))
                    .sort(so -> so.doc(sc->sc.order(SortOrder.Asc)))
            , Object.class
    );

    log.info("第 {} 页搜索响应: {}", currentPage, firstSearchResponse);
    log.info("=========================================================");

    // 处理第一页的结果
    firstSearchResponse.hits().hits().forEach(hit -> {
        Object source = hit.source();
        log.info("文档ID: {}, 文档内容: {}", hit.id(), source);
    });

    // 如果第一页就没有结果,直接返回
    if (firstSearchResponse.hits().hits().isEmpty()) {
        log.info("没有找到任何文档");
        return;
    }

    currentResponse = firstSearchResponse;
    currentPage++;

    // 进行后续查询
    while (true) {
        // 获取当前页的最后一个文档的排序值
        List<FieldValue> sortValues = currentResponse.hits()
                .hits()
                .get(currentResponse.hits().hits().size() - 1)
                .sort();

        log.info("使用的 search_after 值: {}", sortValues);

        // 使用 search_after 参数进行分页查询
        SearchResponse<Object> pagedSearchResponse = esClient.search(s -> s
                        .index("books")
                        .size(pageSize)
                        // 使用当前页最后一个文档的排序值
                        .searchAfter(sortValues)
                        // 保证与首次查询相同的排序
                        .sort(so -> so.score(sc -> sc.order(SortOrder.Desc)))
                        .sort(so -> so.doc(sc->sc.order(SortOrder.Asc)))
                , Object.class
        );

        log.info("第 {} 页搜索响应: {}", currentPage, pagedSearchResponse);
        log.info("=========================================================");

        // 如果没有更多结果,退出循环
        if (pagedSearchResponse.hits().hits().isEmpty()) {
            log.info("没有更多数据,分页结束");
            break;
        }

        // 处理当前页的结果
        pagedSearchResponse.hits().hits().forEach(hit -> {
            Object source = hit.source();
            log.info("文档ID: {}, 文档内容: {}", hit.id(), source);
        });

        // 更新当前响应,用于下一次循环
        currentResponse = pagedSearchResponse;
        currentPage++;

        // 防止无限循环,设置最大页数限制(可选)
        if (currentPage > 10) {
            log.info("达到最大页数限制,停止查询");
            break;
        }
    }
}

search_after分页结果

4.1.4.6 高亮

例如我有如下DSL语句:

高亮示例结果

对应的Java代码如下:

@Test
void highlightSearch() throws IOException {
    SearchResponse<ProductForES> searchResponse = esClient.search(s -> s
                    .index("products")
                    .query(q -> q
                            .match(m -> m
                                    .field("product.description")
                                    .query("002")

                            )

                    )
                    .highlight(h -> h   // 高亮配置
                            .fields(List.of(
                                    NamedValue.of("product.description",
                                            HighlightField.of(hf -> hf
                                                    // 可以添加更多高亮配置
                                                    // .preTags("<em>")
                                                    // .postTags("</em>")
                                                    // .fragmentSize(100)
                                                    // .numberOfFragments(3)
                                            )
                                    )
                            ))
                    )
            ,
            ProductForES.class
    );

    log.info("搜索响应: {}", searchResponse);

    searchResponse.hits().hits().forEach(hit -> {
        ProductForES productForES = hit.source();
        log.info("文档ID: {}, 文档内容: {}", hit.id(), productForES);
        if (hit.highlight() != null && hit.highlight().containsKey("product.description")) {
            List<String> highlights = hit.highlight().get("product.description");
            log.info("高亮内容: {}", highlights);
        }
    });
}

高亮结果

4.1.4.7 数据聚合之指标聚合

例如我有如下DSL语句:

平均值聚合示例

对应的Java代码如下:

@Test
void aggregationSearch() throws IOException {
    SearchResponse<ProductForES> searchResponse = esClient.search(s -> s
                    .index("products")
                    .size(0) // 不需要返回文档,只需要聚合结果
                    .aggregations("avg_price", a -> a
                            .avg(avg -> avg
                                    .field("product.price")
                            )
                    )
            ,
            ProductForES.class
    );

    log.info("搜索响应: {}", searchResponse);

    // 获取聚合结果
    if (searchResponse.aggregations() != null && searchResponse.aggregations().containsKey("avg_price")) {
        AvgAggregate avgPriceAgg = searchResponse.aggregations().get("avg_price").avg();
        if (avgPriceAgg != null && avgPriceAgg.value() != null) {
            log.info("平均价格: {}", avgPriceAgg.value());
        } else {
            log.info("未找到平均价格聚合结果");
        }
    } else {
        log.info("未找到名为 'avg_price' 的聚合");
    }
}

聚合搜索结果

4.1.4.8 数据聚合之桶聚合

例如我有如下DSL语句:

范围聚合示例

对应的Java代码:

@Test
void bucketAggregationSearch() throws IOException {
    SearchResponse<ProductForES> searchResponse = esClient.search(s -> s
                    .index("products")
                    .size(0) // 不需要返回文档,只需要聚合结果
                    .aggregations("price_by_name", a -> a
                            .range(r -> r
                                    .field("product.price")
                                    .ranges(
                                            AggregationRange.of(ar -> ar.to(100.0)),
                                            AggregationRange.of(ar -> ar.from(100.0).to(300.0)),
                                            AggregationRange.of(ar -> ar.from(300.0).to(600.0)),
                                            AggregationRange.of(ar -> ar.from(600.0).to(900.0))
                                    )
                            )
                    )
            ,
            ProductForES.class
    );

    log.info("搜索响应: {}", searchResponse);

    // 获取桶聚合结果
    if (searchResponse.aggregations() != null && searchResponse.aggregations().containsKey("price_by_name")) {
        RangeAggregate priceByNameAgg = searchResponse.aggregations().get("price_by_name").range();
        if (priceByNameAgg != null && priceByNameAgg.buckets() != null) {
            for (RangeBucket bucket : priceByNameAgg.buckets().array()) {
                log.info("范围: {}, 文档数量: {}", bucket.key(), bucket.docCount());
            }
        } else {
            log.info("未找到价格范围聚合结果");
        }
    } else {
        log.info("未找到名为 'price_by_name' 的聚合");
    }
}

桶聚合结果

4.2 SpringBoot中使用Spring Data Elasticsearch

这一小节不会讲解的很详细,核心已经在4.1小节中讲解了,这里只是简单讲解一下API使用。

在上一小节我们使用ElasticSearch官方提供的Java Client API来操作ES,这一小节我们来看看使用Spring Data Elasticsearch来更方便的操作ES,它其实有点像我们ORM框架中的MyBatis一样,将ES官方提供的API又给封装了一层,方便我们开发者快速开发。

首先添加依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

这个依赖里面包含了Spring Data ElasticSearch依赖:

spring-boot-starter-data-elasticsearch依赖详情

而在spring-data-elasticsearch依赖中,有包含有ElasticSearch官方提供的Java Client API依赖:

spring-data-elasticsearch依赖包含elasticsearch-java依赖

在我的代码中,我的SpringBoot版本为3.5.4,所以我的spring-boot-starter-data-elasticsearch依赖版本也为3.5.4,在spring-boot-starter-data-elasticsearch中,spring-data-elasticsearch的版本为5.5.2,在spring-data-elasticsearch中,elasticsearch-javaelasticsearch-rest-client的版本均为8.18.1。

[!caution]

由于目前spring-boot-starter-data-elasticsearch支持的elasticsearch版本为8.18.1,为了避免后续API不兼容,最好elasticsearch的版本也是8.18.1,我这里自己已经改为了8.18.1了。

4.2.1 核心注解

4.2.1.1 @Document注解

@Document用于将一个Java实体类映射到Elasticsearch中的一个索引(Index),它定义了文档如何与Elasticsearch索引进行交互的元数据(metadata),例如索引名称、版本控制策略等。

对应的源码如下:

@Persistent
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface Document {
  	
  	// 指定该实体类对应的 Elasticsearch 索引的名称
  	// 当被注解@Document标注的实体类在保存时,会被持久化到该值指定的索引当中去
    String indexName();

  	// 决定应用程序启动时,Spring Data Elasticsearch 是否应该尝试自动创建索引。
  	// 在生产环境中,通常设置为 false,因为索引的设置和映射可能比较复杂(如分析器、分词器设置),需要精确控制。
  	// 在开发或测试环境中,可以设置为 true 以方便快速迭代。
    boolean createIndex() default true;

  	// 决定是否在每次应用程序启动时都将映射(mapping)更新到 Elasticsearch
  	// 当为true的时候,每次启动都会将实体的映射规则发送到 Elasticsearch,并尝试更新索引的映射
  	// 当为false的时候,只有在索引是新创建的时候(即 createIndex 为 true 且索引不存在时),才会写入映射。
    boolean alwaysWriteMapping() default false;

  	// 定义文档版本控制的类型。版本控制用于实现乐观锁(Optimistic Locking),确保在并发更新时数据的一致性。
  	// 它需要与实体类中的一个用 @Version 注解标注的字段(通常是 Long 类型)配合使用。
    VersionType versionType() default Document.VersionType.EXTERNAL;

  	// 控制是否在发送到 Elasticsearch 的文档源(_source)中写入一个类型提示字段。
  	// 这是为了处理 Elasticsearch 从多类型索引(_type)到单类型索引的演进历史。
    WriteTypeHint writeTypeHint() default WriteTypeHint.DEFAULT;

  	// 为这个索引覆盖默认的动态映射(Dynamic Mapping)策略。它对应于 Elasticsearch 映射中的 dynamic 属性。
  	// TRUE: 允许动态映射。遇到未知字段时,Elasticsearch 会自动为其创建映射。
		// FALSE: 禁用动态映射。未知字段不会被索引或搜索,但会出现在 _source 中。
		// STRICT: 遇到未知字段时,直接拒绝文档并抛出异常。
		// INHERIT: 不覆盖索引的默认设置,继承索引级别的动态映射策略。
    Dynamic dynamic() default Dynamic.INHERIT;

  	// 决定是否将文档的 ID(通常由 @Id 注解标注的字段)也存储在文档的 _source JSON 中。
    boolean storeIdInSource() default true;
		
  	//  与 storeIdInSource 类似,决定是否将版本号(由 @Version 标注的字段)存储在 _source 中。
    boolean storeVersionInSource() default true;

  	// 允许在创建索引时,为该索引定义一个或多个别名(Alias)。
    Alias[] aliases() default {};

    public static enum VersionType {
      	// 默认的 Elasticsearch 行为。每次更新文档后,Elasticsearch 都会增加其内部版本号。
      	// 当你更新文档时,提供的版本号必须与当前存储在 ES 中的版本号完全一致,更新才能成功。成功后,版本号 +1。
        INTERNAL("internal"),
      
      	// 版本号由应用程序外部(例如你的数据库)管理,Elasticsearch 只负责验证。
      	// 更新文档时,提供的版本号必须大于 ES 中当前存储的版本号。
      	// 更新成功后,ES 会将版本号设置为你提供的这个新值,而不是在原有基础上增加。
      
      	// 典型的场景:你的数据主要存储在另一个数据库(如 MySQL)中,ES 作为搜索镜像。
      	// 你希望 ES 的文档版本与主数据库中的记录版本(或更新时间戳)保持同步。
        EXTERNAL("external"),
      
      	// 与 EXTERNAL 类似,但条件更宽松。注意:此类型在 Elasticsearch 7.x 及以后版本中已弃用,不建议使用。
      	// 更新文档时,提供的版本号必须大于或等于 ES 中当前存储的版本号。如果相等,文档也会被覆盖。这有导致数据丢失的风险。
        EXTERNAL_GTE("external_gte"),
      
      	// 已弃用,不应再使用。
        FORCE("force");

        private final String esName;

        private VersionType(String esName) {
            this.esName = esName;
        }

        public String getEsName() {
            return this.esName;
        }
    }
}

@Document注解最佳实践

  • indexName必须设置,根据业务命名。
  • createIndex生产环境建议设为false,通过更可控的方式(如脚本)管理索引生命周期。
  • versionType: 如果需要乐观锁,强烈推荐使用EXTERNAL(与外部数据库版本同步)或INTERNAL(纯ES内部使用)。理解两者区别至关重要。
  • dynamic: 根据你对数据结构的严格要求程度来设置。生产环境建议设为STRICTFALSE,以防止映射污染。
  • writeTypeHint: 如果不需要,设为FALSE 以保持_source数据的干净。
  • aliases: 在进行索引管理(如基于日期的索引滚动)时非常有用,推荐使用

4.2.1.2 @Field注解

@Field这个注解是定义Elasticsearch映射(Mapping)的核心,它允许你精细地控制一个Java实体类中的字段如何被索引和存储到 Elasticsearch中。

@Field注解通常标注在实体类的字段(或getter方法)上,它的主要作用是覆盖Spring Data Elasticsearch的自动类型推断,并提供丰富的参数来定义字段在Elasticsearch中的完整行为,包括数据类型、是否索引、分析器、是否存储等。

对应的源码如下:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Documented
@Inherited
public @interface Field {
  	// 指定字段在 Elasticsearch 中映射的名称。
  	// 当 Java 实体中的字段名与 ES 中希望的字段名不一致时使用。如果为空,则默认使用 Java 字段名。
    @AliasFor("name")
    String value() default "";

  	// 参考name
    @AliasFor("value")
    String name() default "";

  	// 定义字段的数据类型。这是最重要的属性之一
  	// Auto: (默认)让 Spring Data ES 自动推断类型(如 String -> text, Long -> long)。
  	// 不推荐在生产环境使用Auto,因为推断可能不准确。

		// Text: 用于全文搜索的文本类型,会被分析器(analyzer)处理。
		// Keyword: 用于精确值匹配、聚合、排序的字符串类型,不会被分析。
		// Integer, Long, Float, Double, Boolean, Date: 对应各种基本数据类型。
		// Object, Nested: 用于嵌套对象和数组。
		// Ip, Geo_Point, Geo_Shape等:用于特殊数据类型。
    FieldType type() default FieldType.Auto;

  	// 定义该字段是否可以被索引。如果为 false,则无法被搜索、聚合或排序。
    boolean index() default true;
		
  	// 为 FieldType.Date 类型的字段指定可接受的日期格式数组。
    DateFormat[] format() default {DateFormat.date_optional_time, DateFormat.epoch_millis};

  	// 当 format 设置为 DateFormat.custom 时,用于指定自定义的日期格式模式字符串。
    String[] pattern() default {};

  	// 决定是否将字段值独立于 _source 单独存储。
  	// Elasticsearch 默认将整个文档 JSON 存储为 _source 字段。
  	// store: true 允许你单独提取该字段而不用解析整个 _source,但会占用额外空间。通常不需要设置,除非有特殊性能要求。
    boolean store() default false;

  	// 是否允许 text 字段启用 fielddata,从而可以用于聚合和排序。非常消耗内存,通常不建议开启。优先考虑使用 keyword 多字段。
    boolean fielddata() default false;

  	// 指定搜索时使用的分析器。有时索引和搜索需要使用不同的分析策略。
    String searchAnalyzer() default "";

  	// 指定索引时使用的分析器。分析器通常包括分词器(Tokenizer)和过滤器(Filters),用于处理文本。
    String analyzer() default "";

  	// 用于 keyword 字段的规范化处理(如转为小写、去除空格),通常用于优化精确匹配。
    String normalizer() default "";

  	// 在同一个文档中,忽略当前字段对其他字段的引用所产生的影响。这是一个非常高级且不常用的功能。
  	// 它并不是用来忽略当前字段本身的。它用于处理这样一种场景:当你的文档中包含另一个对象的字段
  	//(例如通过 @Field(type = FieldType.Object)),而这个被嵌入的对象可能有一个与顶层文档中间名的字段。
  	// ignoreFields 允许你指定在处理当前字段时,应该忽略掉顶层文档中的哪些字段,以避免命名冲突或意外的数据覆盖。
  	// 绝大多数应用程序不需要使用这个属性。
    String[] ignoreFields() default {};

  	// 仅用于 @Field(type = FieldType.Nested) 的嵌套文档(Nested Documents)。
  	// 如果设置为 true,会将嵌套文档中的字段平铺到父文档中。
    boolean includeInParent() default false;

  	// 将该字段的值复制到另一个“超级字段”中。用于实现跨字段搜索。
    String[] copyTo() default {};
		
  	// 对于 keyword 类字段,超过指定长度的字符串将不会被索引或存储。用于防止巨大的关键字污染索引。
  	// -1 代表不限制
    int ignoreAbove() default -1;

  	// 是否尝试将不规则的输入数据强制转换为期望的数据类型。
    boolean coerce() default true;

  	// 是否为该字段启用列式存储(Doc Values)。对排序、聚合、脚本访问至关重要。
  	// 对于明确不需要排序和聚合的字段(如单纯的搜索用 text 字段),可以设为 false 以节省磁盘空间。
    boolean docValues() default true;
		
  	// 是否忽略格式错误的值。如果为 true,当遇到无法为当前字段类型所解析的数据时,该字段会被静默忽略(不被索引),
  	// 但文档中的其他字段仍然会被正常处理。
    boolean ignoreMalformed() default false;

  	// 控制索引时哪些内容会被存储到倒排索引中(如 docs, freqs, positions, offsets)。影响高亮等功能。
    IndexOptions indexOptions() default IndexOptions.none;

  	// 仅用于 text 字段。如果启用,分析器不仅会索引单个词条(terms),还会额外索引相邻词对组成的短语(phrases)
	  //(例如:"quick brown fox" 会额外索引 "quick brown" 和 "brown fox")。
    boolean indexPhrases() default false;

  	// 仅用于 text 字段。如果启用,会为单词的前缀创建额外的子字段并索引。
  	// 例如,单词 "elasticsearch" 会索引 e, el, ela, ... 等前缀(长度可配置)。
    IndexPrefixes[] indexPrefixes() default {};

  	// 是否存储归一化因子(用于查询评分评分)。如果字段不用于评分(如仅用于过滤),可以设为 false 来节省空间。
    boolean norms() default true;

  	// 当字段值为 null 时,用指定的值替换并存入索引。这可以确保 null 值也能被搜索到。
    String nullValue() default "";
	
  	// 用于数组类型的 text 字段。它设置一个虚拟的“间隔”值,用于分隔同一个字段中不同数组元素之间的词条位置。
    int positionIncrementGap() default -1;

    String similarity() default "default";
		
  	// 仅用于 text 字段。控制是否存储词条向量(Term Vectors)信息。
  	// 词条向量包含了一个词条在文档中的出现位置、顺序、偏移量等信息。
    TermVector termVector() default TermVector.none;

  	// 仅用于scaled_float类型的字段。scaled_float是一种将浮点数通过一个缩放因子(scaling factor)转换为整数来存储的数据类型,旨在节省空间并减少浮点数精度带来的舍入误差。
    double scalingFactor() default 1.0;

  	// 与 indexPhrases 类似但更强大,用于配置 shingle token filter 的最大尺寸。
  	// Shingle 可以生成 N-gram 短语(不仅仅是二元短语)。
    int maxShingleSize() default -1;

  	// 已弃用。它的功能已被 nullValue 属性完全取代。应该使用 nullValue 来显式定义 null 值的替换值。
    boolean storeNullValue() default false;

  	// 仅用于 rank_features 类型的字段。该字段类型用于存储影响评分的特征。
  	// 此属性定义该特征的值是否与评分正相关(值越大,评分越高)。如果为 false,则表示负相关(值越大,评分越低)。
    boolean positiveScoreImpact() default true;

  	// 如果设置为 false,则 ES 会完全跳过该字段的解析和索引。仅存储于 _source 中。适用于那些不需要搜索的大型 JSON 对象。
    boolean enabled() default true;

  	// 是否在索引刷新时急切地加载全局序数(Global Ordinals)。
    boolean eagerGlobalOrdinals() default false;

  	// 与 nullValue 属性配合使用,指定你提供的 nullValue 替换值的字面量类型。
    NullValueType nullValueType() default NullValueType.String;

  	// 定义向量(dense_vector)的维度数
    int dims() default -1;

    String elementType() default "";

  	// 定义向量相似度计算算法(如 l2_norm, cosine 等)。
    KnnSimilarity knnSimilarity() default KnnSimilarity.DEFAULT;

  	// 对于向量字段,控制是否以及如何为其创建 KNN 索引。
    KnnIndexOptions[] knnIndexOptions() default {};

  	// 对于 Object 或 Nested 类型的字段,可以单独设置其动态映射策略,覆盖父级的设置。
    Dynamic dynamic() default Dynamic.INHERIT;

  	// 如果为 true,则该字段不会包含在 _source 中。
  	// 用于存储一些不希望返回给客户端但需要用于搜索的中间数据(例如,一个经过复杂处理后的搜索键)。
    boolean excludeFromSource() default false;

    boolean storeEmptyValue() default true;

    String mappedTypeName() default "";
}

@Field注解最佳设置与实践:

  1. 明确指定type: 不要依赖Auto,明确指定类型(如Text, Keyword, Date)以避免意外映射,保证生产环境的一致性。
  2. 善用TextKeyword的多字段: 这是处理字符串最常见和强大的模式。
  3. 按需禁用索引和Doc Values: 对于确定仅用于展示、从不用于查询的字段,设置index = false。对于确定不用于排序和聚合的字段,设置docValues = false。这是一种有效的空间优化手段。
  4. 谨慎使用fielddata: 在text字段上启用fielddata是最后的手段,通常有更好的替代方案(如使用keyword多字段)。
  5. 理解store_source的关系: 99%的场景都不需要设置store = true,直接使用_source即可。
  6. 为日期字段指定格式: 使用format属性明确日期格式,避免解析错误。

4.2.2 ElasticsearchRepository接口

ElasticsearchRepository是Spring Data Elasticsearch模块提供的一个核心接口。它继承自Spring Data Commons项目中的 CrudRepository接口,并在此基础上增加了针对Elasticsearch的特有功能(如搜索、高亮等)。

它的核心思想是 “约定大于配置”。开发者只需要定义一个接口并继承ElasticsearchRepository,Spring Data就会在运行时自动生成该接口的实现(通常是一个代理对象)。我们无需编写任何具体的实现代码,就可以获得大量开箱即用的数据访问方法。

要理解ElasticsearchRepository,最好先看它的继承关系:

ElasticsearchRepository类关系图

在上图中:

  • CrudRepository:提供了最基本的save, findById, existsById, findAll, count, delete, deleteAll等方法。
  • PagingAndSortingRepository:在 CRUD 基础上,增加了findAll(Pageable pageable)findAll(Sort sort)方法,用于分页和排序查询。
  • ElasticsearchRepository:这是Elasticsearch的专属接口,它除了继承上述所有方法外,还添加了诸如searchsearchSimilar等方法,并设置了默认的@Refresh策略等。

4.2.2.1 ElasticsearchRepository接口使用步骤

  1. 定义实体类(Document)

    首先定义需要映射到Elasticsearch索引的实体类。

    @Data
    @Document(indexName = "people_index") // 指定索引名称
    public class PeopleForEs {
    
        // @Id标记在字段上,指定该字段为文档的_id。
        @Id
        private String id;
    
        // @Field标记在字段上,指定字段的类型和其他属性。
      	// 当映射为Keyword类型时,表示该字段是不可分词的,不可指定analyzer。
        @Field(type = FieldType.Keyword)
        private String name;
    
        // index = false 表示该字段不被索引,不能用于搜索
        @Field(type = FieldType.Integer,index = true)
        private Integer age;
    
        @Field(type = FieldType.Text,analyzer = "ik_max_word",searchAnalyzer = "ik_smart")
        private String address;
    }
    
  2. 定义Repository接口

    创建一个接口,继承ElasticsearchRepository,并指定泛型类型为<实体类, 主键类型>

    public interface PeopleRepository extends ElasticsearchRepository<PeopleForEs,String> {
    
        // 除了继承来的大量方法,你还可以在这里声明自定义查询方法
        List<PeopleForEs> findByName(String name);
    }
    

    继承了ElasticsearchRepository接口里的很多已经封装好了的方法:

    继承的ElasticsearchRepository方法

  3. 在服务类中注入使用

    现在,你可以在你的Service或Controller中直接注入PeopleRepository并使用它。

    @Service
    public class PeopleServiceImpl implements PeopleService {
        @Resource
        private PeopleRepository peopleRepository;
    
    
        @Override
        public PeopleForEs save(PeopleForEs peopleForEs) {
            return peopleRepository.save(peopleForEs);
        }
    }
    

上面讲解了基本ElasticsearchRepository基本使用步骤,现在我们来看看示例代码:

@Resource
private PeopleService peopleService;

@Test
void testElasticsearchRepository() {
    PeopleForEs people = new PeopleForEs();
    people.setId("1");
    people.setName("张三");
    people.setAge(30);
    people.setAddress("北京市朝阳区");
    PeopleForEs save = peopleService.save(people);

    log.info("保存的文档: {}", save);
}

执行结果

执行结果

映射定义结果

可见不仅索引,映射是按照@Document、@Field注解来的,而且文档也成功创建。

4.2.2.2 自定义查询方法

这是Spring Data Repository最强大的特性之一。你只需要在接口中按照特定的规则声明方法,Spring Data会自动为你实现该方法

要想Spring Data自动帮你实现,你必须要按照它的规则来才可以:

  1. 方法命名查询

    根据方法名自动解析成查询。规则是:findBy|readBy|queryBy|getBy...+属性名+查询条件

    智能提示

    例如如下代码:

    public interface PeopleRepository extends ElasticsearchRepository<PeopleForEs,String> {
    
        // 除了继承来的大量方法,你还可以在这里声明自定义查询方法
    
        // 根据名字查询,精确匹配 (针对keyword类型的字段效果最好)
        List<PeopleForEs> findByName(String name);
    
        List<PeopleForEs> findByAge(int age);
    
        // AND 连接条件
        List<PeopleForEs> findByNameAndAge(String name, int age);
    
        // OR 连接条件
        List<PeopleForEs> findByNameOrAddress(String name, String address);
    
        // 模糊查询 (针对text类型字段)
        List<PeopleForEs> findByAddressContaining(String addressPart);
    
        // 范围查询
        List<PeopleForEs> findByAgeBetween(int startAge, int endAge);
    
        // 排序(根据姓名查询然后按年龄排序)
        List<PeopleForEs> findByNameOrderByAgeDesc(String name);
    
        // 忽略大小写
        List<PeopleForEs> findByNameIgnoreCase(String name);
    }
    
  2. 使用@Query注解

    对于更复杂的查询,你可以使用@Query注解,直接在方法上编写Elasticsearch的JSON查询 DSL。

    使用@Query时,你需要对Elasticsearch的查询DSL有较好的了解。返回类型也可以是SearchHits<T>,它包含了搜索命中的详细信息(如得分、高亮等),而不仅仅是实体列表。

    例如如下代码:

    public interface PeopleRepository extends ElasticsearchRepository<PeopleForEs,String> {
        // 使用@Query注解自定义查询
        // 使用原生DSL查询,?0代表第一个参数
        @Query("{\"match\": {\"address\": \"?0\"}}")
        List<PeopleForEs> searchByAddress(String address);
    
        @Query("{\"range\":{\"age\":{\"gte\":?0,\"lte\":?1}}}")
        SearchHits<PeopleForEs> searchByAgeRange(int minAge, int maxAge);
    }
    

测试类:

@Resource
private PeopleRepository peopleRepository;

@Test
void testElasticsearchRepositoryCustomMethod() {
    List<PeopleForEs> peopleList = peopleRepository.findByName("张三");
    peopleList.forEach(p -> log.info("根据名称查询到的文档: {}", p));

    List<PeopleForEs> peopleLikeList = peopleRepository.findByAddressContaining("朝阳");
    peopleLikeList.forEach(p -> log.info("根据地址模糊查询到的文档: {}", p));

    SearchHits<PeopleForEs> searchHits = peopleRepository.searchByAgeRange(20, 30);
    searchHits.forEach(hit -> log.info("根据年龄范围查询到的文档: {}", hit.getContent()));
}

测试结果

可见确实是有效的。

同时自定义查询方法还支持灵活的返回类型:

  • T: 单个实体。
  • Optional<T>: 可能为空的单个实体。
  • List<T>, Iterable<T>, Stream<T>: 多个实体。
  • Page<T>: 分页结果,包含总页数、总条数等信息。
  • SearchHits<T>: 包含得分、高亮、聚合等搜索元数据的结果。
  • long, boolean 等:用于返回计数或判断是否存在。

总结

ElasticsearchRepository是Spring Data Elasticsearch的基石,它通过以下方式极大地提升了开发效率:

  • 零实现:自动生成标准 CRUD 操作的实现。
  • 方法名解析:通过约定好的方法名自动生成查询。
  • 分页排序:内置了对分页和排序的强大支持。
  • 灵活注解:支持@Query注解满足复杂查询需求。
  • 丰富返回:支持多种返回类型以适应不同场景。

对于大多数标准的数据库交互操作,使用ElasticsearchRepository是完全足够的。只有在需要进行高度定制化的查询或操作时,才需要转而使用更底层的ElasticsearchRestTemplate

4.2.3 ElasticsearchTemplate

[!tip]

网上有很多ElasticsearchRestTemplate,不过从Spring Data Elasticsearch 4.0版本开始(伴随 Spring Boot 3.0),原先的 ElasticsearchRestTemplate类被重命名为了ElasticsearchTemplate

这个新的ElasticsearchTemplate在底层是基于Elasticsearch的Java API Client(官方推荐的新客户端),而不是旧版的 RestHighLevelClient

ElasticsearchTemplate为开发者提供了一个强大且灵活的方式,通过Spring管理的Bean来直接调用Elasticsearch的原生 API。

ElasticsearchTemplate与ElasticsearchRepository都是提供封装好的方法给开发者快捷使用,但是他们两者又有一些区别,ElasticsearchTemplate相较于ElasticsearchRepository提供的方法是更加低层的,所以ElasticsearchTemplate相较于ElasticsearchRepository能够提供更加灵活的方法:

特性 ElasticsearchTemplate/ElasticsearchOperations ElasticsearchRepository
抽象级别 较低层级,更接近Elasticsearch原生 API,提供精细控制 较高层级,Repository抽象,提供开箱即用的常见数据访问方法
类型安全 在编译时检查类型,减少运行时错误 强类型,与特定域类绑定
灵活性 非常高,可以执行任何复杂的DSL查询、聚合、脚本等 相对较低,主要用于标准CRUD和派生查询
使用方式 通过注入ElasticsearchTemplate这个Bean,手动构建查询对象 (如 NativeSearchQuery) 定义接口并继承ElasticsearchRepository,Spring自动实现接口
主要用途 处理复杂查询聚合自定义操作索引管理(需注意,索引管理通常不建议通过Java代码频繁进行) 简单的CRUD操作基于方法名的派生查询分页和排序等常见场景
底层关系 Repository的默认实现通常依赖于Template 来执行底层操作 作为更高级的抽象,其底层调用Template 的方法

所以,从上表就看出他们各自的使用场景如下:

  • 使用ElasticsearchRepository的场景:当你需要进行标准的CRUD操作(Create, Read, Update, Delete)、简单的条件查询(如通过方法名派生查询)、分页查询排序时,使用ElasticsearchRepository非常方便,能极大提高开发效率。
  • 使用ElasticsearchTemplate的场景:当你需要执行复杂的查询(如布尔查询、范围查询、模糊查询等)、聚合分析(求和、平均值、分组等)、高亮显示自定义脚本、或者直接操作索引(创建、删除映射,但请注意:不建议频繁用Java代码操作索引)等ElasticsearchRepository无法满足的高级功能时,就应使用ElasticsearchTemplate

简单来说,ElasticsearchRepository用于快速开发常见操作,而ElasticsearchTemplate则用于处理更复杂和特定的需求。 在许多项目中,两者经常结合使用。


现在了解了ElasticsearchTemplate的基本概念,再来看看ElasticsearchTemplate涉及的API操作。

4.2.3.1 保存文档相关API

使用elasticsearchTemplate.save()API即可完成文档的保存。

ElasticsearchTemplate文档保存API

// 保存单个实体对象到默认索引(@Document注解中指定的索引)
public <T> T save(T entity);

// 保存单个实体对象到指定索引(需要将数据保存到与@Document注解不同的索引中时,使用这个方法)
public <T> T save(T entity, IndexCoordinates index);

// 批量保存多个实体对象到默认索引(@Document注解中指定的索引)
public <T> Iterable<T> save(Iterable<T> entities);

// 批量保存多个实体对象到指定索引(需要将数据保存到与@Document注解不同的索引中时,使用这个方法)
public <T> Iterable<T> save(Iterable<T> entities, IndexCoordinates index);

// 使用可变参数保存多个实体对象到默认索引
public final <T> Iterable<T> save(T... entities);

例如我有如下代码:

@Test
void savDocumentBySDE(){
    // 保存单个
    PeopleForEs people = new PeopleForEs();
    people.setId("3");
    people.setName("王五");
    people.setAge(40);
    people.setAddress("上海市浦东新区");
    PeopleForEs save = elasticsearchTemplate.save(people);
    log.info("保存的文档: {}", save);

    // 保存多个
    PeopleForEs people2 = new PeopleForEs();
    people2.setId("4");
    people2.setName("赵六");
    people2.setAge(28);
    people2.setAddress("广州市天河区");

    PeopleForEs people3 = new PeopleForEs();
    people3.setId("5");
    people3.setName("钱七");
    people3.setAge(35);
    people3.setAddress("深圳市南山区");
    List<PeopleForEs> peopleList = Arrays.asList(people3, people2);

    Iterable<PeopleForEs> saveAll = elasticsearchTemplate.save(peopleList);

    saveAll.forEach(p -> log.info("保存的文档: {}", p));
}

文档保存执行结果

4.2.3.2 获取文档相关API

使用elasticsearchTemplate.get()/exists()API即可完成文档的保存。

ElasticsearchTemplate文档获取API

ElasticsearchTemplate文档判断是否存在API

/**
 * 根据文档ID从默认索引中获取单个实体对象
 * @param <T> 目标实体类型
 * @param id 文档的唯一标识符(主键)
 * @param clazz 目标实体的Class对象,用于类型转换和确定默认索引
 * @return 查询到的实体对象,如果不存在则返回null
 * 
 * 工作原理:
 * - 使用clazz上的@Document注解确定要查询的索引
 * - 直接通过文档ID进行精确查询(最快的查询方式)
 * - 将Elasticsearch返回的JSON自动映射为Java对象
 * 
 * 使用场景:
 * - 根据已知ID快速获取单个文档
 * - 用户详情页面数据加载
 * - 缓存未命中后的数据库查询
 * 
 * 示例:
 * User user = get("user123", User.class);
 * if (user != null) {
 *     System.out.println("找到用户: " + user.getName());
 * }
 */
<T> T get(String id, Class<T> clazz);


/**
 * 根据文档ID从指定索引中获取单个实体对象
 * @param <T> 目标实体类型
 * @param id 文档的唯一标识符
 * @param clazz 目标实体的Class对象
 * @param index 指定的索引坐标,覆盖@Document注解中的默认索引
 * @return 查询到的实体对象,如果不存在则返回null
 * 
 * 工作原理:
 * - 忽略clazz上的@Document注解,使用参数指定的索引
 * - 适用于多索引场景或索引名动态变化的情况
 * 
 * 使用场景:
 * - 跨索引查询(如历史数据归档)
 * - 多租户应用中查询不同租户的数据
 * - 按时间分片的索引查询
 * - A/B测试中查询不同版本的数据
 * 
 */
<T> T get(String id, Class<T> clazz, IndexCoordinates index);

/**
 * 根据查询条件批量获取多个文档(多文档获取)
 * @param <T> 目标实体类型
 * @param query 查询条件,通常包含多个文档ID的查询
 * @param clazz 目标实体的Class对象,用于确定默认索引和类型映射
 * @return MultiGetItem列表,每个item包含查询结果和元数据
 * 
 * MultiGetItem结构:
 * - getItem(): 获取实际的实体对象(可能为null)
 * - hasItem(): 判断是否成功获取到数据
 * - getFailure(): 获取失败信息(如果查询失败)
 * - getId(): 获取文档ID
 * - getIndex(): 获取索引名
 * 
 * 工作原理:
 * - 使用Elasticsearch的Multi Get API一次性获取多个文档
 * - 比多次调用单个get方法效率更高
 * - 支持部分成功(某些ID存在,某些不存在)
 * 
 * 使用场景:
 * - 批量获取用户信息(如好友列表详情)
 * - 购物车中多个商品的详细信息
 * - 批量数据验证和检查
 * - 报表生成中的批量数据获取
 * 
 * 示例:
 * // 构建包含多个ID的查询
 * Query query = NativeQuery.builder()
 *     .withIds("user1", "user2", "user3")
 *     .build();
 * 
 * List<MultiGetItem<User>> results = multiGet(query, User.class);
 * 
 * for (MultiGetItem<User> item : results) {
 *     if (item.hasItem()) {
 *         User user = item.getItem();
 *         System.out.println("用户: " + user.getName());
 *     } else {
 *         System.out.println("未找到ID为 " + item.getId() + " 的用户");
 *     }
 * }
 */
<T> List<MultiGetItem<T>> multiGet(Query query, Class<T> clazz);

/**
 * 根据查询条件从指定索引批量获取多个文档
 * @param <T> 目标实体类型
 * @param query 查询条件,包含要获取的文档ID列表
 * @param clazz 目标实体的Class对象
 * @param index 指定的索引坐标,覆盖默认索引配置
 * @return MultiGetItem列表,包含查询结果和元数据
 * 
 * 工作原理:
 * - 结合了multiGet的批量查询能力和指定索引的灵活性
 * - 所有查询都会在指定的索引中执行
 * 
 * 使用场景:
 * - 跨索引的批量数据获取
 * - 历史数据的批量检索
 * - 多租户环境下的批量查询
 * - 数据迁移过程中的批量验证
 * 
 */
<T> List<MultiGetItem<T>> multiGet(Query query, Class<T> clazz, IndexCoordinates index);

/**
 * 检查指定ID的文档在默认索引中是否存在
 * @param id 要检查的文档ID
 * @param clazz 实体类的Class对象,用于确定默认索引位置
 * @return true表示文档存在,false表示文档不存在
 * 
 * 工作原理:
 * - 使用Elasticsearch的EXISTS API进行轻量级检查
 * - 只返回文档是否存在的布尔值,不传输文档内容
 * - 比get()方法更高效,因为不需要传输完整文档数据
 * - 基于clazz上的@Document注解确定查询的索引
 * 
 * 性能优势:
 * - 网络传输量小(只返回true/false)
 * - Elasticsearch服务端处理开销小
 * - 适合大量存在性检查的场景
 * 
 * 使用场景:
 * - 数据去重:插入前检查是否已存在
 * - 权限验证:检查用户是否有权访问某个资源
 * - 数据完整性检查:验证关联数据是否存在
 * - 缓存策略:决定是否需要从数据库加载数据
 * - 批量处理中的预检查
 * 
 * 示例:
 * // 检查用户是否存在,避免重复创建
 * if (!exists("user123", User.class)) {
 *     User newUser = new User("user123", "张三");
 *     save(newUser);
 * } else {
 *     System.out.println("用户已存在,跳过创建");
 * }
 * 
 * // 批量处理中的预检查
 * List<String> userIds = Arrays.asList("user1", "user2", "user3");
 * for (String userId : userIds) {
 *     if (exists(userId, User.class)) {
 *         // 用户存在,执行更新逻辑
 *         updateUser(userId);
 *     } else {
 *         // 用户不存在,记录日志或创建用户
 *         log.warn("用户 {} 不存在", userId);
 *     }
 * }
 */
boolean exists(String id, Class<?> clazz);

/**
 * 检查指定ID的文档在指定索引中是否存在
 * @param id 要检查的文档ID
 * @param index 指定的索引坐标,覆盖任何默认索引配置
 * @return true表示文档存在,false表示文档不存在
 * 
 * 工作原理:
 * - 与上面方法类似,但使用参数指定的索引而非默认索引
 * - 适用于需要跨索引检查或动态索引的场景
 * 
 * 使用场景:
 * - 多租户应用:检查不同租户索引中的数据
 * - 历史数据检查:在归档索引中查找历史记录
 * - 数据迁移:验证数据是否已迁移到目标索引
 * - A/B测试:检查测试数据是否存在
 * - 时间分片索引:检查特定时期的数据
 * 
 * 示例:
 * // 检查历史归档中是否存在某个订单
 * IndexCoordinates archiveIndex = IndexCoordinates.of("orders_2023");
 * if (exists("order123", archiveIndex)) {
 *     System.out.println("在2023年归档中找到订单");
 * }
 */
boolean exists(String id, IndexCoordinates index);

示例代码:

@Test
void getDocumentBySDE(){
    PeopleForEs people = elasticsearchTemplate.get("1", PeopleForEs.class);
    log.info("查询到的文档: {}", people);

    boolean exists = elasticsearchTemplate.exists("2", PeopleForEs.class);
    log.info("文档是否存在: {}", exists);
}

文档获取执行结果

4.2.3.3 查询文档相关API

当你使用ElasticsearchTemplate查询文档的时候,一般分为3步走:

  1. 构建查询条件:QueryBuilder
  2. 执行查询方法():elasticsearchTemplate.search()
  3. 处理查询结果。

先来看看构建查询条件。查询条件核心主要是通过Query接口实现的:

Query接口实现类

在实际使用中,我们往往使用它的实现类来构建查询条件,下面是各个实现类的作用与区别:

实现类 全限定名 主要作用 适用场景 特点
BaseQuery org.springframework.data.elasticsearch.core.query 抽象基类 作为其他Query实现的基础 提供通用的分页、排序、字段选择等基础功能
CriteriaQuery org.springframework.data.elasticsearch.core.query 基于条件构建器的查询 动态构建复杂查询条件 类似JPA的Criteria API,支持链式调用构建查询条件
NativeQuery org.springframework.data.elasticsearch.client.elc 原生Elasticsearch查询 需要使用完整ES查询DSL的场景 直接使用Elasticsearch的原生JSON查询语法
SearchTemplateQuery org.springframework.data.elasticsearch.core.query 基于搜索模板的查询 预定义查询模板,参数化执行 支持Elasticsearch的搜索模板功能,提高查询重用性
StringQuery org.springframework.data.elasticsearch.core.query 基于字符串的查询 简单的字符串查询场景 直接使用JSON字符串构建查询,简单直接

示例代码:

@Test
void searchDocumentBySDE(){
    // 构建查询条件
    NativeQuery nativeQuery = NativeQuery.builder()
            .withQuery(q -> q
                    .match(mq -> mq
                            .field("address")
                            .query("朝阳")
                    )
            )
            .build();

    // 执行搜索
    SearchHits<PeopleForEs> searchHits = elasticsearchTemplate.search(nativeQuery, PeopleForEs.class);

    // 处理搜索结果
    searchHits.forEach(hit -> log.info("搜索到的文档: {}", hit.getContent()));
}

查询文档执行结果

4.2.3.4 删除文档相关API

使用elasticsearchTemplate.delete()API即可完成文档的删除。

ElasticsearchTemplate文档删除API

/**
 * 根据文档ID从默认索引中删除单个文档
 * @param id 要删除的文档ID
 * @param entityType 实体类型的Class对象,用于确定默认索引位置
 * @return 删除操作的结果ID,通常是被删除文档的ID
 * 
 * 工作原理:
 * - 使用entityType上的@Document注解确定目标索引
 * - 执行Elasticsearch的DELETE BY ID操作
 * - 如果文档不存在,不会报错,但返回值可能为空
 * 
 * 返回值说明:
 * - 成功删除:返回被删除文档的ID
 * - 文档不存在:可能返回null或空字符串(取决于ES版本)
 * - 删除失败:抛出异常
 * 
 * 使用场景:
 * - 用户注销删除账户信息
 * - 软删除之外的物理删除
 * - 清理测试数据
 * - 定时清理过期数据
 */
String delete(String id, Class<?> entityType);

// 根据文档ID从指定索引中删除单个文档
String delete(String id, IndexCoordinates index);

/**
 * 根据实体对象删除对应的文档(从默认索引)
 * @param entity 要删除的实体对象,必须包含@Id标注的主键字段
 * @return 删除操作的结果ID
 * 
 * 工作原理:
 * - 从entity对象中提取@Id标注的字段值作为文档ID
 * - 使用entity类上的@Document注解确定目标索引
 * - 实际上是对delete(String id, Class<?> entityType)的封装
 * 
 * 前提条件:
 * - entity对象必须有@Id标注的字段
 * - @Id字段必须有值(不能为null)
 * - entity类必须有@Document注解(或遵循默认命名规则)
 * 
 * 使用场景:
 * - 已经有完整实体对象,需要删除对应文档
 * - ORM风格的删除操作
 * - 在业务逻辑中直接删除实体
 * - 缓存失效后的数据清理
 */
String delete(Object entity);

// 根据实体对象从指定索引中删除对应的文档
String delete(Object entity, IndexCoordinates index);

/**
 * 根据查询条件批量删除文档(从默认索引)
 * @param query 删除查询条件,定义要删除哪些文档
 * @param clazz 实体类型,用于确定默认索引和映射
 * @return ByQueryResponse 包含删除操作的详细结果信息
 * 
 * ByQueryResponse 主要信息:
 * - getDeleted(): 实际删除的文档数量
 * - getTook(): 操作耗时(毫秒)
 * - getTimedOut(): 是否超时
 * - getFailures(): 失败的操作列表
 * - getTotal(): 总共匹配的文档数
 * 
 * 工作原理:
 * - 使用Elasticsearch的Delete By Query API
 * - 先根据查询条件找到匹配的文档,再批量删除
 * - 适合大批量删除操作,比逐个删除效率更高
 * 
 * 使用场景:
 * - 按条件批量删除过期数据
 * - 清理符合特定条件的测试数据
 * - 数据归档前的批量清理
 * - 按时间范围删除历史数据
 * - 按状态删除无效数据
 */
ByQueryResponse delete(DeleteQuery query, Class<?> clazz);

// 根据查询条件从指定索引中批量删除文档
ByQueryResponse delete(DeleteQuery query, Class<?> clazz, IndexCoordinates index);

示例代码:

@Test
void deleteDocumentBySDE(){
    String delete = elasticsearchTemplate.delete("1", PeopleForEs.class);
    log.info("删除的文档ID: {}", delete);
}

删除执行结果

4.2.3.5 更新文档相关API

更新文档这里,你可以使用之前的elasticsearchTemplate.save()方法来更新,也可以使用elasticsearchTemplate.update()方法来更新。只是save()是完全替换文档,而update()是更新文档部分字段而已。

ElasticsearchTemplate文档更新API

/**
 * 使用实体对象更新文档(到默认索引)
 * @param <T> 实体类型
 * @param entity 包含更新数据的实体对象,必须有@Id字段
 * @return UpdateResponse 更新操作的结果信息
 * 
 * UpdateResponse 主要信息:
 * - getResult(): 更新结果(UPDATED, NOOP, NOT_FOUND等)
 * - getId(): 被更新文档的ID
 * - getIndex(): 更新操作的目标索引
 * - getVersion(): 更新后的文档版本号
 * - getShards(): 分片操作信息
 * 
 * 工作原理:
 * - 从entity对象提取@Id字段作为文档ID
 * - 使用entity类的@Document注解确定目标索引
 * - 执行部分文档更新(partial update)
 * - 如果文档不存在,可能会创建新文档(取决于配置)
 * 
 */
<T> UpdateResponse update(T entity);

// 使用实体对象更新指定索引中的文档
<T> UpdateResponse update(T entity, IndexCoordinates index);

/**
 * 使用UpdateQuery进行精确的文档更新
 * @param updateQuery 更新查询对象,定义具体的更新操作
 * @param index 指定的索引坐标
 * @return UpdateResponse 更新操作结果
 * 
 * UpdateQuery 主要组成:
 * - setId(): 指定要更新的文档ID
 * - setDoc(): 设置部分更新的字段内容
 * - setScript(): 使用脚本进行复杂更新
 * - setDocAsUpsert(): 设置当文档不存在时是否创建
 * - setRetryOnConflict(): 设置版本冲突时的重试次数
 * 
 * 工作原理:
 * - 提供比实体更新更精确的控制
 * - 支持脚本更新、条件更新等高级功能
 * - 可以只更新指定字段,而不影响其他字段
 * 
 * 使用场景:
 * - 只更新特定字段(如计数器、时间戳)
 * - 使用脚本进行复杂的字段计算
 * - 条件更新(基于当前字段值)
 * - 需要精确控制更新行为的场景
 * 
 * 示例:
 * // 只更新特定字段
 * Map<String, Object> updateFields = new HashMap<>();
 * updateFields.put("lastLoginTime", LocalDateTime.now());
 * updateFields.put("loginCount", 15);
 * 
 * UpdateQuery updateQuery = UpdateQuery.builder("user123")
 *     .withDoc(Document.from(updateFields))
 *     .withDocAsUpsert(true)  // 如果不存在则创建
 *     .withRetryOnConflict(3) // 冲突时重试3次
 *     .build();
 * 
 * IndexCoordinates index = IndexCoordinates.of("users");
 * UpdateResponse response = update(updateQuery, index);
 * 
 * // 使用脚本更新(增加访问计数)
 * UpdateQuery scriptUpdateQuery = UpdateQuery.builder("product123")
 *     .withScript("ctx._source.viewCount += params.increment")
 *     .withScriptParams(Map.of("increment", 1))
 *     .build();
 * 
 * UpdateResponse scriptResult = update(scriptUpdateQuery, 
 *                                     IndexCoordinates.of("products"));
 * 
 * // 条件更新(只在库存大于0时减少库存)
 * UpdateQuery conditionalUpdate = UpdateQuery.builder("product456")
 *     .withScript("""
 *         if (ctx._source.stock > 0) {
 *             ctx._source.stock -= params.quantity;
 *             ctx._source.lastSoldTime = params.currentTime;
 *         } else {
 *             ctx.op = 'noop';  // 不执行更新
 *         }
 *         """)
 *     .withScriptParams(Map.of(
 *         "quantity", 2,
 *         "currentTime", LocalDateTime.now().toString()
 *     ))
 *     .build();
 */
UpdateResponse update(UpdateQuery updateQuery, IndexCoordinates index);

/**
 * 根据查询条件批量更新多个文档
 * @param updateQuery 更新查询,包含查询条件和更新操作
 * @param index 指定的索引坐标
 * @return ByQueryResponse 批量更新操作的结果信息
 * 
 * ByQueryResponse 主要信息:
 * - getUpdated(): 实际更新的文档数量
 * - getTotal(): 匹配查询条件的总文档数
 * - getTook(): 操作耗时(毫秒)
 * - getFailures(): 失败的操作详情
 * - getVersionConflicts(): 版本冲突的数量
 * 
 * 工作原理:
 * - 使用Elasticsearch的Update By Query API
 * - 先根据查询条件找到匹配的文档
 * - 对每个匹配的文档执行相同的更新操作
 * - 适合大批量的统一更新操作
 * 
 * 使用场景:
 * - 批量状态更新(如批量激活用户)
 * - 数据规范化(如统一字段格式)
 * - 批量字段计算(如重新计算评分)
 * - 数据迁移中的批量字段更新
 * 
 * 示例:
 * // 批量激活所有待激活的用户
 * UpdateQuery batchActivateQuery = UpdateQuery.builder()
 *     .withQuery(QueryBuilders.termQuery("status", "PENDING"))
 *     .withScript("ctx._source.status = 'ACTIVE'; " +
 *                "ctx._source.activatedTime = params.now")
 *     .withScriptParams(Map.of("now", LocalDateTime.now().toString()))
 *     .build();
 * 
 * IndexCoordinates userIndex = IndexCoordinates.of("users");
 * ByQueryResponse result = updateByQuery(batchActivateQuery, userIndex);
 * log.info("批量激活用户: {} 个,耗时: {}ms", 
 *          result.getUpdated(), result.getTook());
 * 
 * // 批量更新商品价格(打9折)
 * UpdateQuery priceUpdateQuery = UpdateQuery.builder()
 *     .withQuery(QueryBuilders.rangeQuery("price").gte(100))
 *     .withScript("ctx._source.price = Math.round(ctx._source.price * 0.9 * 100) / 100; " +
 *                "ctx._source.discountApplied = true")
 *     .build();
 * 
 * ByQueryResponse priceResult = updateByQuery(priceUpdateQuery, 
 *                                           IndexCoordinates.of("products"));
 * 
 * // 批量数据清理(移除过期字段)
 * UpdateQuery cleanupQuery = UpdateQuery.builder()
 *     .withQuery(QueryBuilders.existsQuery("deprecatedField"))
 *     .withScript("ctx._source.remove('deprecatedField')")
 *     .build();
 * 
 * ByQueryResponse cleanupResult = updateByQuery(cleanupQuery, userIndex);
 */
ByQueryResponse updateByQuery(UpdateQuery updateQuery, IndexCoordinates index);


/**
 * 批量更新操作(使用默认索引)
 * @param queries 更新查询列表
 * @param clazz 实体类型,用于确定默认索引
 * 
 * 工作原理:
 * - 使用clazz上的@Document注解确定目标索引
 * - 执行批量更新操作
 * 
 * 使用场景:
 * - 在实体默认索引中进行批量更新
 * - 不需要跨索引的批量操作
 * 
 * 示例:
 * List<UpdateQuery> queries = new ArrayList<>();
 * 
 * // 批量更新商品库存
 * Map<String, Integer> stockUpdates = getStockUpdates(); // 从业务系统获取
 * for (Map.Entry<String, Integer> entry : stockUpdates.entrySet()) {
 *     UpdateQuery query = UpdateQuery.builder(entry.getKey())
 *         .withDoc(Document.from(Map.of("stock", entry.getValue())))
 *         .build();
 *     queries.add(query);
 * }
 * 
 * bulkUpdate(queries, Product.class);  // 使用Product的默认索引
 */
void bulkUpdate(List<UpdateQuery> queries, Class<?> clazz);

/**
 * 批量更新操作(完整配置)
 * @param queries 更新查询列表,每个查询对应一个更新操作
 * @param bulkOptions 批量操作选项,控制批量更新的行为
 * @param index 指定的索引坐标
 * 
 * BulkOptions 主要配置:
 * - setTimeout(): 设置操作超时时间
 * - setRefreshPolicy(): 设置刷新策略(立即刷新、等待刷新等)
 * - setPipeline(): 设置处理管道
 * - setRouting(): 设置路由
 * - setWaitForActiveShards(): 等待活跃分片数量
 * 
 * 工作原理:
 * - 使用Elasticsearch的Bulk API执行批量更新
 * - 提供最精确的批量操作控制
 * - 可以优化性能和一致性行为
 * 
 * 使用场景:
 * - 需要精确控制批量更新行为
 * - 高性能要求的批量更新
 * - 需要特殊配置(如立即刷新、特定路由)的场景
 * - 生产环境的关键批量更新操作
 * 
 * 示例:
 * List<UpdateQuery> queries = new ArrayList<>();
 * 
 * // 准备批量更新数据
 * List<User> users = getUsersToUpdate();
 * for (User user : users) {
 *     Map<String, Object> updateData = new HashMap<>();
 *     updateData.put("lastModified", LocalDateTime.now());
 *     updateData.put("version", user.getVersion() + 1);
 *     
 *     UpdateQuery query = UpdateQuery.builder(user.getId())
 *         .withDoc(Document.from(updateData))
 *         .withRetryOnConflict(3)  // 版本冲突时重试
 *         .build();
 *     queries.add(query);
 * }
 * 
 * // 配置批量操作选项
 * BulkOptions options = BulkOptions.builder()
 *     .withTimeout(Duration.ofSeconds(30))           // 30秒超时
 *     .withRefreshPolicy(WriteRequest.RefreshPolicy.WAIT_UNTIL) // 等待刷新
 *     .withWaitForActiveShards(ActiveShardCount.ONE) // 等待至少1个活跃分片
 *     .build();
 * 
 * IndexCoordinates index = IndexCoordinates.of("users");
 * bulkUpdate(queries, options, index);
 * 
 * log.info("批量更新 {} 个用户完成", queries.size());
 * 
 * // 高性能批量更新(不等待刷新)
 * BulkOptions highPerformanceOptions = BulkOptions.builder()
 *     .withRefreshPolicy(WriteRequest.RefreshPolicy.NONE)  // 不立即刷新
 *     .withTimeout(Duration.ofMinutes(5))                  // 5分钟超时
 *     .build();
 * 
 * bulkUpdate(largeUpdateQueries, highPerformanceOptions, index);
 */
default void bulkUpdate(List<UpdateQuery> queries, IndexCoordinates index) {
    this.bulkUpdate(queries, BulkOptions.defaultOptions(), index);
}

void bulkUpdate(List<UpdateQuery> queries, Class<?> clazz);

void bulkUpdate(List<UpdateQuery> queries, BulkOptions bulkOptions, IndexCoordinates index);

4.2.3.6 索引管理API

你可以通过elasticsearchTemplate.indexOps()来管理索引。

ElasticsearchTemplate索引管理API

这里两个就不多讲了,从前几小节你也知道这是啥意思了,这里主要来看看IndexOperations提供的API方法:

// =====================创建索引。如果索引已存在,则会抛出异常=====================
boolean create();
boolean create(Map<String, Object> settings);
boolean create(Map<String, Object> settings, Document mapping);
boolean createWithMapping();

// =====================删除索引。如果索引不存在,则会抛出异常=====================
boolean delete();

// =====================检查索引是否存在=====================
boolean exists()

// 刷新索引,使之可搜索最新添加的文档。在索引文档后,需要调用 refresh() 方法才能保证文档可以被搜索到
void refresh()

// 创建索引映射
Document createMapping();
Document createMapping(Class<?> clazz);

// =====================为索引设置映射=====================
default boolean putMapping() {
    return this.putMapping(this.createMapping());
}
boolean putMapping(Document mapping);
default boolean putMapping(Class<?> clazz) {
    return this.putMapping(this.createMapping(clazz));
}

// =====================获取索引映射=====================
Map<String, Object> getMapping(); 

// =====================设置索引=====================
Settings createSettings();
Settings createSettings(Class<?> clazz);

// =====================获取索引的配置信息=====================
Settings getSettings();
Settings getSettings(boolean includeDefaults);

// =====================获取索引的别名信息=====================
Map<String, Set<AliasData>> getAliases(String... aliasNames);
Map<String, Set<AliasData>> getAliasesForIndex(String... indexNames);

// =====================添加别名。=====================
boolean alias(AliasActions aliasActions);

对于Spring Data ElasticSearch了解到这里就可以了。后续不会多久了。

6. 总结

本文对于ElasticSearch进行了粗略的讲解,主要讲解了ES的基本概念、分词器使用、各种DSL查询语法与使用、ES在Java代码的基本使用。还有一些知识我打算单独开文章来写,毕竟现在这篇文章字数已经很多了。

后续新篇章:

  • ES与数据库同步
  • ES集群
  • 其他...