ES快速入门

文章目录


一、Elasticsearch 基本介绍

1.什么是 Elasticsearch?

Elasticsearch 是一个基于 Apache Lucene 构建的分布式、RESTful 风格的搜索与分析引擎。它能够近乎实时地存储、检索和分析海量数据,通常作为应用程序的核心搜索组件或大数据分析平台的基础。

简单来说,Elasticsearch 解决了两个核心问题:

  • 全文搜索:在大量文本中快速找到最相关的内容(例如电商搜索、日志搜索)。
  • 结构化聚合分析:对数据进行统计、分组、求平均等运算(例如"过去一小时的错误日志数量")。

其底层引擎 Lucene 提供了高性能的倒排索引和评分机制,而 Elasticsearch 在此基础上增加了分布式能力、简单的 HTTP API 以及易于使用的 JSON 查询语言(Query DSL),使开发者无需深入了解 Lucene 即可实现复杂的搜索需求。

2.Elasticsearch 的核心应用场景

  • 网站与应用搜索:电商网站的商品搜索、社区帖子的全文检索、文档管理系统的内容查找。
  • 日志与运维数据分析:集中式日志管理(ELK 技术栈),实时监控服务器、应用、网络设备的日志,快速定位故障。
  • 安全分析与 SIEM:分析安全日志、网络流量,检测异常行为与威胁。
  • 指标监控与 APM:存储和聚合系统指标(CPU、内存、QPS),构建实时监控仪表盘。
  • 地理空间数据检索:支持 Geo-point 和 Geo-shape 类型,可用于"附近的门店"搜索或地图可视化。
  • 企业级搜索与推荐:为内部知识库、CRM 系统提供统一搜索入口,结合用户行为做个性化推荐。

3.与关系型数据库(MySQL)的类比

MySQL(关系型数据库) Elasticsearch(7.x 以后) 说明
Database(数据库) Index(索引) 索引是文档的容器,类似于数据库是表的容器
Table(表) Type(已废弃) 早期版本 Type 对应表,7.x 后不再支持,建议每个索引只存储一种类型的文档
Row(行) Document(文档) 一个文档是一条 JSON 数据,等同于表中的一行记录
Column(列) Field(字段) JSON 中的一个键值对,可定义多种数据类型(text, keyword, integer 等)
Schema(表结构) Mapping(映射) 定义字段的类型、是否索引、分词器等,相当于表结构定义
SELECT ... WHERE ... Query DSL(查询表达式) 使用 JSON 格式描述查询条件
GROUP BY / AVG / SUM Aggregations(聚合) 实现数据分组、指标统计

4.核心术语

  • 索引(Index):索引是具有相同结构的文档集合。例如,可以创建一个 products 索引来存储所有商品文档,一个 orders 索引来存储订单文档。索引名必须是小写字母,且不能包含 \/*?"<>| (空格)、,# 等特殊字符。
  • 文档(Document):文档是 Elasticsearch 中可被索引的基本数据单元,以 JSON 格式表示。每个文档属于一个索引,并且有一个唯一的 _id(可自动生成或手动指定)。一个典型的商品文档示例如下,文档是不可变的,替换或更新文档实际上是重新索引一个新的版本,旧版本标记为删除
json 复制代码
{
  "_index": "products",
  "_id": "1001",
  "_source": {
    "name": "iPhone 15 Pro",
    "price": 6999,
    "brand": "Apple",
    "inStock": true,
    "tags": ["智能手机", "5G"]
  }
}
  • 字段(Field):字段是文档中的一个键值对,支持多种数据类型
类型分组 示例类型 说明
基本类型 text, keyword, integer, long, float, double, boolean, date text 会被分词用于全文检索;keyword 用于精确值匹配(如标签、状态)
对象与关系 object, nested, flattened 处理 JSON 对象或数组对象,nested 解决对象数组中独立匹配的问题
空间类型 geo_point, geo_shape 存储经纬度或复杂地理形状
特殊类型 ip, binary, completion(自动补全), dense_vector(向量搜索) 用于特定业务场景
  • 映射(Mapping):映射类似于关系型数据库中的表结构定义,它指定了索引中每个字段的数据类型、索引方式、分词器等属性
    • 动态映射(Dynamic Mapping):当索引一个新文档时,如果字段尚未在映射中定义,ES 会自动根据 JSON 数据的类型推测并添加字段。这种机制适合快速原型开发,但在生产环境中建议关闭动态映射或严格约束。
    • 显式映射(Explicit Mapping):通过 API 手动创建或更新映射,对字段类型进行精细控制。

5.elastic stack(ELK)

Elastic Stack 是一套完整的生态组件,主要用于数据的采集、处理、存储、分析和可视化。包括beats、Logstash、kibana、elasticsearch。

  • Kibana是可视化界面
  • elasticsearch是数据库 + 搜索引擎
  • Logstash是服务端数据处理管道,支持从多种数据源采集数据,进行过滤、转换、解析,再发送到 ES
  • Beats是轻量级数据采集代理,部署在客户端服务器上,采集日志、指标、网络包等,并发送到 Logstash 或 ES

二、基础操作 CRUD

1.索引操作

(1)创建索引

使用 PUT /{index_name} 请求创建一个新索引。创建时可以指定映射(Mapping)和设置(Settings),也可以只创建空索引。

bash 复制代码
PUT /索引名称
{
  "settings": { ... },   # 可选:分片数、副本数、刷新间隔等
  "mappings": { ... }    # 可选:字段类型定义
}

使用命令 PUT /products,此时 ES 会使用默认配置创建名为 products 的索引(5个主分片、1个副本分片,适用于 7.x 版本前的默认值,7.x 后默认 1 个主分片)。

下面的语句表示创建时指定分片数和映射,此时指定3个分片,1个副本。映射设置title字段的类型为text,author 字段的类型为 keyword(精确匹配),price 字段为 float 浮点型,publish_date 字段为 date 日期类型。

bash 复制代码
PUT /books
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1
  },
  "mappings": {
    "properties": {
      "title": { "type": "text" },
      "author": { "type": "keyword" },
      "price": { "type": "float" },
      "publish_date": { "type": "date" }
    }
  }
}

(2)查看索引

  • GET/{index_name}查看单个索引语句
  • GET /{index_name1},{index_name2}查看多个索引
  • GET /_cat/indices?v 查看所有索引,v 表示显示表头(verbose)
  • GET /_cat/indices?h=index,status,pri,rep,docs.count 查看所有索引的简洁列表,h 表示指定要显示的列头(header)
bash 复制代码
# 查看单个索引
GET /products
#查看多个索引(逗号分隔)
GET /products,orders
# 查看所有索引 v表示xx
GET /_cat/indices?v
# 查看所有索引(简洁列表)h什么意思
GET /_cat/indices?h=index,status,pri,rep,docs.count

(3)删除索引

删除索引会同时删除其所有文档和映射,无法恢复,属于危险操作,谨慎执行。

  • DELETE/{index_name}删除单个索引
  • DELETE /{index_name1},{index_name2} 删除多个索引
  • DELETE /_all 或 DELETE /* 删除全部索引
bash 复制代码
# 删除单个索引
DELETE /products
# 删除多个索引
DELETE /products,orders
# 删除全部索引(可使用通配符,切勿在生产环境执行)
DELETE /_all
# 或
DELETE /*

生产环境建议禁用 _all 通配符删除功能,在 elasticsearch.yml 中配置

yaml 复制代码
action.destructive_requires_name: true

(4)判断索引是否存在

  • HTTP 状态码 200 表示存在,404 表示不存在
    HEAD /{index_name}

(5)修改索引设置

索引一旦创建,主分片数量不可修改(数据分布已确定),但可以调整副本数量、刷新间隔等,如下例所示,修改时需要在索引名后跟上 /_settings 路径。

bash 复制代码
PUT /products/_settings
{
  "index": {
    "number_of_replicas": 2,
    "refresh_interval": "30s"
  }
}

2.文档操作

文档(Document)是 ES 中的基本数据单元,以 JSON 格式存储。以下是文档的增删改查操作。

(1)添加文档

  • 指定文档 ID(PUT),此时设定 " _id ": " 1001 "
bash 复制代码
PUT /products/_doc/1001
{
  "name": "iPhone 15 Pro",
  "price": 6999,
  "brand": "Apple",
  "inStock": true,
  "tags": ["智能手机", "5G"]
}
  • 自动生成文档 ID(POST),此时ES 会自动生成一个唯一 ID(如 _id": "dXpE54MB_tkRqJpP8qYt")。
bash 复制代码
POST /products/_doc
{
  "name": "小米 14 Ultra",
  "price": 5999,
  "brand": "Xiaomi",
  "inStock": true,
  "tags": ["智能手机", "5G", "徕卡"]
}

(2)查询文档

  • 根据 ID 查询(GET),语句和查询结果如下
bash 复制代码
GET /products/_doc/1001
json 复制代码
{
  "_index": "products",
  "_id": "1001",
  "_version": 1,
  "_seq_no": 0,
  "_primary_term": 1,
  "found": true,
  "_source": {
    "name": "iPhone 15 Pro",
    "price": 6999,
    "brand": "Apple",
    "inStock": true,
    "tags": ["智能手机", "5G"]
  }
}
  • 查询只获取 _source 内容
bash 复制代码
GET /products/_source/1001
  • 查询选择性返回字段
bash 复制代码
GET /products/_doc/1001?_source=name,price
  • 检查文档是否存在
bash 复制代码
HEAD /products/_doc/1001

(3)更新文档

ES 中文档是不可变的,更新操作实际上是先删除旧文档,再索引新文档,同时 _version 会递增。

  • 全量替换(PUT),更新必须包含所有字段,未提供的字段会丢失
bash 复制代码
PUT /products/_doc/1001
{
  "name": "iPhone 15 Pro Max",
  "price": 8999,
  "brand": "Apple",
  "inStock": true,
  "tags": ["智能手机", "5G", "大屏"]
}
  • 局部更新(POST + _update),这种方式只会更新指定字段,其他字段保持不变
bash 复制代码
POST /products/_update/1001
{
  "doc": {
    "price": 8499
  }
}
  • 使用脚本更新,下面的例子中指将 price 减少 500
bash 复制代码
POST /products/_update/1001
{
  "script": {
    "source": "ctx._source.price += params.increment",
    "params": {
      "increment": -500
    }
  }
}

(4)删除文档

删除文档并不会立即从磁盘物理删除,而是标记为"已删除",在后续的段合并(Segment Merge)时才会真正清理。如下例所示,

bash 复制代码
DELETE /products/_doc/1001

3.批量操作(Bulk API)

Bulk API 允许在单次请求中执行多个索引、更新、删除操作,可以极大提升写入吞吐量。Bulk 的数据格式为 每行一个 JSON 结构体 + 换行符(NDJSON 格式),最后必须有一个换行符。

Bulk 请求格式如下

text 复制代码
{ action: { metadata } }\n
{ request body }\n
{ action: { metadata } }\n
{ request body }\n
...
action 说明 是否需要请求体
index 创建或全量替换文档 需要(文档内容)
create 创建文档,如果 ID 已存在则失败 需要(文档内容)
update 局部更新文档 需要(doc 部分)
delete 删除文档 不需要
  • 批量索引(添加)文档
bash 复制代码
POST /_bulk
{"index": {"_index": "products", "_id": "1001"}}
{"name": "iPhone 15 Pro", "price": 6999, "brand": "Apple", "inStock": true}
{"index": {"_index": "products", "_id": "1002"}}
{"name": "小米 14 Ultra", "price": 5999, "brand": "Xiaomi", "inStock": true}
{"index": {"_index": "products", "_id": "1003"}}
{"name": "华为 Mate 60 Pro", "price": 6999, "brand": "Huawei", "inStock": false}
  • 混合操作(增 + 改 + 删)
bash 复制代码
POST /_bulk
{"index": {"_index": "products", "_id": "1004"}}
{"name": "OPPO Find X7", "price": 4999, "brand": "OPPO", "inStock": true}
{"update": {"_index": "products", "_id": "1001"}}
{"doc": {"price": 6799}}
{"delete": {"_index": "products", "_id": "1003"}}
  • 如果所有操作都针对同一个索引,可以在 URL 中指定默认索引
bash 复制代码
POST /products/_bulk
{"index": {"_id": "1005"}}
{"name": "vivo X100 Pro", "price": 5499, "brand": "vivo", "inStock": true}
{"index": {"_id": "1006"}}
{"name": "荣耀 Magic6", "price": 5699, "brand": "Honor", "inStock": true}
{"update": {"_id": "1001"}}
{"doc": {"tags": ["智能手机", "5G", "热门"]}}

三、基础指令 DSL

1. Query DSL

DSL(Domain Specific Language,领域特定语言)是 Elasticsearch 提供的基于 JSON 的查询语言,用于构建复杂的搜索和聚合请求。它将查询的语义以结构化 JSON 的形式表达,比简单的查询字符串参数更强大、更灵活。

(1)查询的两种上下文

Elasticsearch 中的查询分为两种执行上下文,

上下文类型 说明 是否计算相关性评分 典型场景
Query 上下文 回答"这个文档与查询条件匹配程度如何?" ,返回 _score 相关性分数 全文搜索、模糊匹配、语义搜索
Filter 上下文 回答"这个文档是否匹配条件?"(二元判断) ,不计算分数 精确过滤(状态、时间范围、地理位置)

由于不计算分数,Filter 查询的结果可以被缓存,性能远高于 Query 查询。对于仅需精确匹配的场景,应优先使用 Filter。

bash 复制代码
# Query 上下文:计算相关性分数
GET /products/_search
{
  "query": {
    "match": { "name": "手机" }
  }
}

# Filter 上下文:不计算分数,可缓存
GET /products/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "brand": "Apple" } },
        { "range": { "price": { "lte": 8000 } } }
      ]
    }
  }
}

(2)基本结构

一个典型的查询请求结构如下:

bash 复制代码
GET /{index_name}/_search
{
  "query": {           # 查询主体
    "query_type": {    # 查询类型(match、term、bool 等)
      "field_name": "value"
    }
  },
  "from": 0,           # 分页起始位置(可选)
  "size": 10,          # 返回文档数量(可选)
  "sort": [ ... ],     # 排序规则(可选)
  "_source": { ... },  # 返回字段控制(可选)
  "aggs": { ... }      # 聚合分析(可选)
}

2.简单查询入门

(1)match_all 查询所有文档

  • match_all 是最简单的查询,返回索引中的所有文档
bash 复制代码
GET /products/_search
{
  "query": {
    "match_all": {}
  }
}

(2)match 全文检索

  • match 是最常用的全文检索查询。它会对搜索词进行分词,然后基于倒排索引进行匹配,并计算相关性分数
bash 复制代码
# 示例:在商品名称中搜索"智能手机"
GET /products/_search
{
  "query": {
    "match": {
      "name": "智能手机"
    }
  }
}

对于下面的例子,match 会先将"智能手机"分词为"智能"和"手机",然后在倒排索引中查找包含"智能"或"手机"的文档,然后计算每个文档的相关性分数(BM25 算法),最后按分数降序返回结果。

bash 复制代码
# or(默认):包含任意一个词即可
GET /products/_search
{
  "query": {
    "match": {
      "name": {
        "query": "智能手机",
        "operator": "or"
      }
    }
  }
}

# and:必须包含所有词
GET /products/_search
{
  "query": {
    "match": {
      "name": {
        "query": "智能手机",
        "operator": "and"
      }
    }
  }
}

(3)term 精确匹配

  • term 查询用于精确值匹配,不会对搜索词进行分词。通常用于 keyword 类型字段、数字、布尔值等。
bash 复制代码
# 精确匹配品牌为 Apple
GET /products/_search
{
  "query": {
    "term": {
      "brand": "Apple"
    }
  }
}
term vs match
维度 term match
是否分词 不分词 分词
适用字段 keyword、数字、日期、布尔值 text 全文检索
典型场景 状态过滤、ID 查询、精确值匹配 搜索引擎、模糊匹配
是否算分 是(但 filter 上下文中可不算)

text 字段使用 term 查询可能查不到结果,因为 text 字段在索引时会进行分词,而 term 使用原始值匹配,两者不一致。

(4)terms 多值精确匹配

  • terms 是 term 的批量版本,匹配字段值包含在给定列表中的任意一个
bash 复制代码
# 匹配品牌为 Apple 或 Huawei
GET /products/_search
{
  "query": {
    "terms": {
      "brand": ["Apple", "Huawei"]
    }
  }
}

(5)range 范围查询

  • range 用于数字、日期类型的范围查询
    • gte 表示大于等于(≥)
    • gt 表示大于(>)
    • lte 表示小于等于(≤)
    • lt 表示小于(<)
bash 复制代码
# 查询价格在 5000 到 8000 之间的商品
GET /products/_search
{
  "query": {
    "range": {
      "price": {
        "gte": 5000,
        "lte": 8000
      }
    }
  }
}

(6)exists 字段存在查询

  • 查询某个字段存在的文档(字段值为 null 或空数组被认为不存在),查询返回所有满足条件的文档内容。
bash 复制代码
# 查询有 description 字段的商品
GET /products/_search
{
  "query": {
    "exists": {
      "field": "description"
    }
  }
}

3.组合查询

(1)bool 查询

bool 查询是 ES 中最强大的组合查询,可以组合任意多个子查询条件。它包含四个子句:

子句 作用 是否算分 可缓存 逻辑关系
must 必须满足的条件 ✅ 是 ❌ 否 AND
should 非必须,满足则加分 ✅ 是(没有 must 时变为必须) ❌ 否 OR(加分项)
filter 必须满足的条件(过滤) ❌ 否 ✅ 是 AND
must_not 必须不满足的条件 ❌ 否 ✅ 是 NOT
bash 复制代码
GET /products/_search
{
  "query": {
    "bool": {
      "must": [ ... ],     # 必须匹配,贡献分数(Query 上下文)
      "should": [ ... ],   # 可选匹配,提升分数(Query 上下文)
      "filter": [ ... ],   # 必须匹配,不贡献分数(Filter 上下文,可缓存)
      "must_not": [ ... ]  # 必须不匹配,不贡献分数(Filter 上下文)
    }
  }
}

代码简单示例如下

示例1:简单组合(must + filter)

bash 复制代码
# 搜索商品名称中包含"手机",且品牌为 Apple,价格 ≤ 8000
GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "手机" } }
      ],
      "filter": [
        { "term": { "brand": "Apple" } },
        { "range": { "price": { "lte": 8000 } } }
      ]
    }
  }
}

示例2:must + should 组合

bash 复制代码
# 搜索名称中包含"手机",如果品牌是 Apple 或 Huawei 则得分更高
GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "手机" } }
      ],
      "should": [
        { "term": { "brand": "Apple" } },
        { "term": { "brand": "Huawei" } }
      ],
      "minimum_should_match": 1   # 至少满足 1 个 should 条件
    }
  }
}

示例3:完整的四个子句

bash 复制代码
# 搜索手机,品牌必须是 Apple 或 Xiaomi,排除已下架的商品,同时提升高销量商品的分数
GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "手机" } }
      ],
      "should": [
        { "term": { "sales_level": "high" } }
      ],
      "filter": [
        { "terms": { "brand": ["Apple", "Xiaomi"] } },
        { "term": { "status": "active" } }
      ],
      "must_not": [
        { "term": { "isDeleted": true } }
      ]
    }
  }
}

(2) bool 查询的嵌套

bool 查询可以无限嵌套,用于构建极为复杂的查询逻辑。

bash 复制代码
GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "should": [
              { "match": { "name": "手机" } },
              { "match": { "description": "手机" } }
            ],
            "minimum_should_match": 1
          }
        }
      ],
      "filter": [
        {
          "bool": {
            "should": [
              { "term": { "category": "数码" } },
              { "term": { "category": "通信" } }
            ]
          }
        }
      ]
    }
  }
}

4.排序与分页(from/size)

(1)排序

使用 sort 参数对搜索结果进行排序,可以按字段值、_score 或脚本排序。

  • 按字段值排序
bash 复制代码
# 按价格升序排序
GET /products/_search
{
  "query": { "match_all": {} },
  "sort": [
    { "price": { "order": "asc" } }
  ]
}
  • 按多字段值排序
bash 复制代码
# 先按价格升序,价格相同时按销量降序
GET /products/_search
{
  "query": { "match_all": {} },
  "sort": [
    { "price": { "order": "asc" } },
    { "sales": { "order": "desc" } }
  ]
}
  • 按相关性分数排序(默认行为)
bash 复制代码
# 显式指定按 _score 排序
GET /products/_search
{
  "query": { "match": { "name": "手机" } },
  "sort": [
    { "_score": { "order": "desc" } }
  ]
}

(2)分页

使用 fromsize 参数实现分页:

  • from:跳过的文档数量(起始位置)
  • size:返回的文档数量
bash 复制代码
# 第 1 页(每页 10 条)
GET /products/_search
{
  "query": { "match_all": {} },
  "from": 0,
  "size": 10
}

# 第 3 页(每页 10 条,跳过前 20 条)
GET /products/_search
{
  "query": { "match_all": {} },
  "from": 20,
  "size": 10
}
bash 复制代码
# 按价格升序后,取第 2 页(每页 5 条)
GET /products/_search
{
  "query": { "match_all": {} },
  "sort": [{ "price": "asc" }],
  "from": 5,
  "size": 5
}

(3)分页限制与深度分页问题

ES 默认限制 from + size ≤ 10000。超过此限制会报错。如需修改限制,深度分页性能极差,生产环境中应使用 Search After 或 Scroll API 代替深度分页

bash 复制代码
# 修改限制
PUT /products/_settings
{
  "index": {
    "max_result_window": 20000
  }
}

5.聚合操作

聚合(Aggregation)用于对数据进行统计、分组、求和、平均值等分析操作,类似于 SQL 中的 GROUP BY 和聚合函数。其基本结构如下所,

bash 复制代码
GET /{index_name}/_search
{
  "aggs": {
    "聚合名称": {
      "聚合类型": {
        "参数名": "参数值"
      }
    }
  }
}

(1)指标聚合(Metric Aggregations)

常用指标聚合:

聚合类型 说明 示例
avg 平均值 "avg": { "field": "price" }
sum 总和 "sum": { "field": "sales" }
min 最小值 "min": { "field": "price" }
max 最大值 "max": { "field": "price" }
value_count 非空值数量 "value_count": { "field": "brand" }
stats 一次性返回 count、avg、min、max、sum "stats": { "field": "price" }
bash 复制代码
# 统计商品平均价格
GET /products/_search
{
  "size": 0,     # 不返回文档,只返回聚合结果
  "aggs": {
    "avg_price": {
      "avg": {
        "field": "price"
      }
    }
  }
}
bash 复制代码
# 多个指标聚合
GET /products/_search
{
  "size": 0,
  "aggs": {
    "total_products": { "value_count": { "field": "price" } },
    "avg_price": { "avg": { "field": "price" } },
    "max_price": { "max": { "field": "price" } },
    "min_price": { "min": { "field": "price" } },
    "sum_price": { "sum": { "field": "price" } }
  }
}

(2)桶聚合(Bucket Aggregations)

桶聚合是指按照某个规则将文档分组到不同的桶(Bucket)中,每个桶代表一个分组。类似于 SQL 中的 GROUP BY。桶聚合的关键字是 aggsaggregations,两者等价。

  • 示例1:terms 聚合,下面代码表示对 products 索引中的所有文档,按照 brand 字段进行分组统计,返回每种品牌下的文档数量(前10个)。size 参数控制返回的桶数量,size: 0 表示不返回文档只返回聚合结果。
bash 复制代码
# 按品牌分组统计商品数量(terms 聚合)
GET /products/_search
{
  "size": 0,
  "aggs": {
    "group_by_brand": {
      "terms": {
        "field": "brand",
        "size": 10
      }
    }
  }
}

响应示例如下,"key": "Apple" 表示品牌字段精确匹配为 Apple 的文档共有 25 个

json 复制代码
{
  "aggregations": {
    "group_by_brand": {
      "buckets": [
        { "key": "Apple", "doc_count": 25 },
        { "key": "Xiaomi", "doc_count": 18 },
        { "key": "Huawei", "doc_count": 15 }
      ]
    }
  }
}
  • 示例2:range 聚合,下面代码表示按照 price 字段的值,将商品划分到三个价格区间:低端(≤3000)、中端(3000-6000)、高端(≥6000)。to 表示小于该值(不包含),from 表示大于等于该值。key 为每个区间指定一个可读的名称。
bash 复制代码
GET /products/_search
{
  "size": 0,
  "aggs": {
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 3000, "key": "低端" },
          { "from": 3000, "to": 6000, "key": "中端" },
          { "from": 6000, "key": "高端" }
        ]
      }
    }
  }
}
  • 示例3:date_histogram 聚合,下面代码表示对 orders 索引中的订单,按照 create_time 字段按月分组,统计每个月的订单数量。calendar_interval 指定时间间隔(year、quarter、month、week、day、hour 等)。
bash 复制代码
GET /orders/_search
{
  "size": 0,
  "aggs": {
    "orders_per_month": {
      "date_histogram": {
        "field": "create_time",
        "calendar_interval": "month"
      }
    }
  }
}

(3)嵌套聚合

示例:先按品牌分组,再统计每个品牌下的平均价格

bash 复制代码
GET /products/_search
{
  "size": 0,
  "aggs": {
    "group_by_brand": {
      "terms": {
        "field": "brand",
        "size": 10
      },
      "aggs": {
        "avg_price_per_brand": {
          "avg": {
            "field": "price"
          }
        }
      }
    }
  }
}

(4)带查询条件的聚合

示例:先过滤数据,再对过滤后的数据进行聚合

bash 复制代码
# 统计价格在 5000-8000 之间的商品按品牌分组
GET /products/_search
{
  "size": 0,
  "query": {
    "range": {
      "price": {
        "gte": 5000,
        "lte": 8000
      }
    }
  },
  "aggs": {
    "brands_in_price_range": {
      "terms": {
        "field": "brand",
        "size": 10
      }
    }
  }
}

(5)聚合结果过滤

示例:筛选出文档数量大于 10 的品牌

bash 复制代码
GET /products/_search
{
  "size": 0,
  "aggs": {
    "group_by_brand": {
      "terms": {
        "field": "brand",
        "size": 10,
        "min_doc_count": 10
      }
    }
  }
}

四、Java 中的Spring Data Elasticsearch 方案

Spring Data Elasticsearch 是 Spring Data 项目的子模块,它提供了与 Elasticsearch 搜索引擎的集成,简化了在 Spring 应用中操作 Elasticsearch 的开发工作。其核心设计目标是提供熟悉且一致的基于 Spring 的编程模型,同时保留 Elasticsearch 特有的存储功能。

其核心功能如下,

  • Repository 支持 :提供 ElasticsearchRepository 接口,实现基本的 CRUD 操作和分页排序
  • 对象映射 :通过注解(@Document@Field@Id 等)将 Java 实体类映射到 Elasticsearch 索引文档[citation:4]
  • 模板支持 :提供 ElasticsearchRestTemplate(同步)和 ReactiveElasticsearchTemplate(响应式)两种操作模板[citation:1]
  • 方法命名解析:通过方法名自动生成查询语句,无需手写 DSL
  • 原生查询支持 :通过 @Query 注解或 NativeQuery 使用原生 Elasticsearch DSL

Spring Data Elasticsearch 与 Elasticsearch 服务器之间存在版本对应关系,必须确保版本兼容
从 Elasticsearch 8.x 开始,官方不再支持 TransportClient(9300 端口),必须使用 HTTP 方式(9200 端口)进行连接。Spring Data Elasticsearch 从 4.0 版本起已弃用 ElasticsearchTemplate,推荐使用 ElasticsearchRestTemplate。

1.项目依赖与配置

在 pom.xml 中添加 Spring Data Elasticsearch 启动器:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

application.yml文件配置如下

yaml 复制代码
spring:
  data:
    elasticsearch:
      # 集群名称(默认为 elasticsearch)
      cluster-name: elasticsearch
      # 客户端类型(推荐使用 rest)
      client:
        reactive:
          endpoints: localhost:9200
      # 连接配置
      repositories:
        enabled: true

2.实体类注解

实体类用于映射 Elasticsearch 中的文档,通过注解控制索引名称、字段类型、分词器等属性。

  • @Document:类级别注解,声明该实体对应 Elasticsearch 中的索引。其包含属性如下,
    • indexName:索引名称(必填),例如"products"
    • createIndex:是否自动创建索引,默认 true
    • shards:主分片数量,默认 1
    • replicas:副本数量,默认 1
  • @Id:字段级别注解,标记文档的唯一标识
  • @Field:字段级别注解,定义字段的映射属性。其包含属性如下,
    • type:字段类型,例如FieldType.Text
    • name:在 ES 中的字段名,默认使用 Java 属性名,例如"user_name"
    • analyzer:索引时使用的分词器,例如"ik_max_word"
    • searchAnalyzer:搜索时使用的分词器,例如"ik_smart"
    • format:日期格式,例如"yyyy-MM-dd HH:mm:ss"
    • store:是否额外存储原文,默认 false
    • index:是否创建索引,默认 true

代码示例,如下例所示,标识类Product对应的索引为 products,3分片,1副本;id标记文档的唯一标识;其中Elasticsearch 中 Date 类型默认存储为时间戳(毫秒)。如需要指定存储格式,需在 @Field 中配置 format 属性

java 复制代码
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.DateFormat;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;

import java.time.LocalDateTime;
import java.util.List;

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

    @Id
    private String id;

    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String name;

    @Field(type = FieldType.Keyword)
    private String brand;

    @Field(type = FieldType.Double)
    private Double price;

    @Field(type = FieldType.Boolean)
    private Boolean inStock;

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

    @Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second)
    private LocalDateTime createdAt;

    @Field(type = FieldType.Nested)
    private List<Spec> specs;

    // 构造器、getter/setter
    @Data
    public static class Spec {
        private String key;
        private String value;
    }
}

4.自动创建索引及映射

Spring Data Elasticsearch 支持在应用启动时自动创建索引并根据实体类注解生成映射。这一机制由 @Document 注解的 createIndex 属性控制。

(1)自动创建机制

当满足以下条件时,索引会自动创建:

  • @Document(createIndex = true)(默认值)
  • Repository 接口已定义
  • 应用启动时索引不存在于 Elasticsearch 中

此时,当启动时扫描 @Document 注解的实体类,检查对应索引是否存在,如不存在,根据实体类注解生成索引映射,将映射配置写入 Elasticsearch。

(2)手动创建索引

在生产环境中,通常建议手动管理索引,避免自动创建带来的潜在问题。

可以通过配置关闭自动创建

yaml 复制代码
spring:
  data:
    elasticsearch:
      repositories:
        enabled: true
      # 关闭自动创建索引
      auto-create-index: false

或在实体类中关闭

java 复制代码
@Document(indexName = "products", createIndex = false)
public class Product {
    // ...
}

通过 ElasticsearchRestTemplate 手动创建

java 复制代码
@Service
public class IndexService {

    @Autowired
    private ElasticsearchRestTemplate restTemplate;

    public void createIndex() {
        // 创建索引
        IndexOperations indexOps = restTemplate.indexOps(Product.class);
        
        // 判断是否存在,如不存在则创建
        if (!indexOps.exists()) {
            // 根据实体类注解创建映射
            indexOps.create();
            indexOps.putMapping();
        }
    }
}

可以通过 @Setting 注解配置索引级别的设置,例如下面的例子设置settingPath = "elasticsearch-settings.json",表示从 src/main/resources/ 目录下加载名为 elasticsearch-settings.json 的配置文件,将其中的配置作为该索引的设置。

java 复制代码
import org.springframework.data.elasticsearch.annotations.Setting;

@Document(indexName = "products")
@Setting(settingPath = "elasticsearch-settings.json")
public class Product {
    // ...
}

或直接内联配置

java 复制代码
@Document(indexName = "products")
@Setting(shards = 3, replicas = 2)
public class Product {
    // ...
}

5.基础 CRUD 操作

Spring Data Elasticsearch 提供了 ElasticsearchRepository 接口,继承自 CrudRepository,封装了常用的 CRUD 操作。

(1)定义 Repository

java 复制代码
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface ProductRepository extends ElasticsearchRepository<Product, String> {
    // 继承后自动拥有基础 CRUD 方法
}

(2)基础CRUD

java 复制代码
@Service
public class ProductService {

    @Autowired
    private ProductRepository productRepository;

    // 新增/更新文档
    public Product save(Product product) {
        return productRepository.save(product);
    }

    // 批量新增/更新
    public Iterable<Product> saveAll(Iterable<Product> products) {
        return productRepository.saveAll(products);
    }

    // 根据 ID 查询
    public Optional<Product> findById(String id) {
        return productRepository.findById(id);
    }

    // 查询所有
    public Iterable<Product> findAll() {
        return productRepository.findAll();
    }

    // 判断是否存在
    public boolean existsById(String id) {
        return productRepository.existsById(id);
    }

    // 统计总数
    public long count() {
        return productRepository.count();
    }

    // 根据 ID 删除
    public void deleteById(String id) {
        productRepository.deleteById(id);
    }

    // 删除实体
    public void delete(Product product) {
        productRepository.delete(product);
    }

    // 删除所有
    public void deleteAll() {
        productRepository.deleteAll();
    }
}

对于更底层的操作,可以使用 ElasticsearchRestTemplate

java 复制代码
@Service
public class ProductTemplateService {

    @Autowired
    private ElasticsearchRestTemplate restTemplate;

    // 保存文档
    public Product save(Product product) {
        return restTemplate.save(product);
    }

    // 根据 ID 查询
    public Product findById(String id) {
        return restTemplate.get(id, Product.class);
    }

    // 删除文档
    public String deleteById(String id) {
        return restTemplate.delete(id, Product.class);
    }

    // 更新文档(局部更新)
    public Product update(String id, Map<String, Object> fields) {
        UpdateQuery updateQuery = UpdateQuery.builder(id)
            .withDocument(Document.from(fields))
            .build();
        return restTemplate.update(updateQuery, IndexCoordinates.of("products"));
    }
}

6.高阶查询

(1)方法命名派生查询

通过在 Repository 接口中定义特定命名的方法,Spring Data 会自动解析方法名并生成对应的 Elasticsearch 查询。常用方法名关键词如下,

  • And:且关系
  • Or:或关系
  • Between:范围查询(包含边界)
  • LessThan:小于
  • GreaterThanEqual:大于等于
  • Like / Containing:模糊匹配
  • StartingWith:前缀匹配
  • EndingWith:后缀匹配
  • In:包含于列表
  • NotIn:不包含于列表
  • True / False:布尔值
  • OrderBy:排序
java 复制代码
@Repository
public interface ProductRepository extends ElasticsearchRepository<Product, String> {

    // 根据品牌精确查询
    List<Product> findByBrand(String brand);

    // 根据名称模糊匹配(全文搜索)
    List<Product> findByNameContaining(String keyword);

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

    // 组合条件:品牌且价格在范围内
    List<Product> findByBrandAndPriceBetween(String brand, Double min, Double max);

    // 品牌包含在指定列表中
    List<Product> findByBrandIn(List<String> brands);

    // 按价格降序排序
    List<Product> findAllByOrderByPriceDesc();

    // 分页查询
    Page<Product> findByPriceBetween(Double min, Double max, Pageable pageable);
}

(2)@Query 注解自定义查询

当方法命名无法满足复杂查询需求时,可以使用 @Query 注解直接编写原生 Elasticsearch DSL。

java 复制代码
@Repository
public interface ProductRepository extends ElasticsearchRepository<Product, String> {

    // 原生 DSL 查询
    @Query("{\"match\": {\"name\": \"?0\"}}")
    List<Product> findByNameWithCustomQuery(String keyword);

    // 参数占位符:?0 表示第一个参数
    @Query("{\"bool\": {\"must\": [{\"match\": {\"name\": \"?0\"}}], \"filter\": [{\"term\": {\"brand\": \"?1\"}}]}}")
    List<Product> findByNameAndBrand(String name, String brand);

    // 命名参数
    @Query("{\"range\": {\"price\": {\"gte\": :min, \"lte\": :max}}}")
    List<Product> findByPriceRange(@Param("min") Double min, @Param("max") Double max);

    // 复合查询
    @Query("{\n" +
           "  \"bool\": {\n" +
           "    \"must\": { \"match\": { \"name\": \"?0\" } },\n" +
           "    \"filter\": { \"term\": { \"inStock\": true } },\n" +
           "    \"should\": { \"term\": { \"brand\": \"?1\" } }\n" +
           "  }\n" +
           "}")
    List<Product> complexSearch(String keyword, String preferredBrand);
}

(3)NativeQuery 构建查询

对于更复杂的动态查询,推荐使用 NativeQuery 和 Query 构建器。

java 复制代码
@Service
public class ProductSearchService {

    @Autowired
    private ElasticsearchRestTemplate restTemplate;

    // 动态构建布尔查询
    public List<Product> searchProducts(String keyword, Double minPrice, Double maxPrice, String brand) {
        // 构建 BoolQuery
        BoolQuery.Builder boolQueryBuilder = new BoolQuery.Builder();

        // 关键词匹配
        if (keyword != null && !keyword.isEmpty()) {
            boolQueryBuilder.must(QueryBuilders.match("name", keyword));
        }

        // 价格范围过滤
        if (minPrice != null || maxPrice != null) {
            RangeQuery.Builder rangeBuilder = new RangeQuery.Builder();
            rangeBuilder.field("price");
            if (minPrice != null) rangeBuilder.gte(JsonData.of(minPrice));
            if (maxPrice != null) rangeBuilder.lte(JsonData.of(maxPrice));
            boolQueryBuilder.filter(rangeBuilder.build());
        }

        // 品牌过滤
        if (brand != null && !brand.isEmpty()) {
            boolQueryBuilder.filter(QueryBuilders.term("brand", brand));
        }

        // 组装查询
        Query query = NativeQuery.builder()
            .withQuery(boolQueryBuilder.build())
            .withPageable(PageRequest.of(0, 10))
            .build();

        SearchHits<Product> searchHits = restTemplate.search(query, Product.class);
        return searchHits.stream()
            .map(SearchHit::getContent)
            .collect(Collectors.toList());
    }
}

7.聚合操作的 Java 实现

Elasticsearch 的聚合功能可以通过 Spring Data Elasticsearch 来实现,主要使用 ElasticsearchRestTemplate 的 aggregate 方法。

(1)指标聚合(Metric Aggregations)

java 复制代码
@Service
public class ProductAggregationService {

    @Autowired
    private ElasticsearchRestTemplate restTemplate;

    // 统计价格的平均值、最大值、最小值、总和
    public Map<String, Object> priceStats() {
        // 构建聚合查询
        Query query = NativeQuery.builder()
            .withAggregations(
                AggregationBuilders.avg("avg_price").field("price"),
                AggregationBuilders.max("max_price").field("price"),
                AggregationBuilders.min("min_price").field("price"),
                AggregationBuilders.sum("sum_price").field("price")
            )
            .build();

        // 执行查询
        SearchHits<Product> searchHits = restTemplate.search(query, Product.class);
        
        // 解析结果
        Map<String, Object> result = new HashMap<>();
        result.put("avg_price", ((Avg) searchHits.getAggregations().get("avg_price")).getValue());
        result.put("max_price", ((Max) searchHits.getAggregations().get("max_price")).getValue());
        result.put("min_price", ((Min) searchHits.getAggregations().get("min_price")).getValue());
        result.put("sum_price", ((Sum) searchHits.getAggregations().get("sum_price")).getValue());

        return result;
    }
}

(2)桶聚合(Bucket Aggregations)

java 复制代码
// 按品牌分组统计数量
public List<Map<String, Object>> groupByBrand() {
    // 构建 terms 聚合
    Query query = NativeQuery.builder()
        .withAggregations(
            AggregationBuilders.terms("group_by_brand")
                .field("brand")
                .size(10)
        )
        .build();

    SearchHits<Product> searchHits = restTemplate.search(query, Product.class);
    
    // 解析桶聚合结果
    Terms terms = searchHits.getAggregations().get("group_by_brand");
    List<Map<String, Object>> buckets = new ArrayList<>();
    
    for (Terms.Bucket bucket : terms.getBuckets()) {
        Map<String, Object> bucketMap = new HashMap<>();
        bucketMap.put("brand", bucket.getKeyAsString());
        bucketMap.put("count", bucket.getDocCount());
        buckets.add(bucketMap);
    }
    
    return buckets;
}

// 价格范围聚合
public List<Map<String, Object>> priceRangeAggregation() {
    Query query = NativeQuery.builder()
        .withAggregations(
            AggregationBuilders.range("price_ranges")
                .field("price")
                .addUnboundedTo("低端", 3000)
                .addRange("中端", 3000, 6000)
                .addUnboundedFrom("高端", 6000)
        )
        .build();

    SearchHits<Product> searchHits = restTemplate.search(query, Product.class);
    
    Range range = searchHits.getAggregations().get("price_ranges");
    List<Map<String, Object>> buckets = new ArrayList<>();
    
    for (Range.Bucket bucket : range.getBuckets()) {
        Map<String, Object> bucketMap = new HashMap<>();
        bucketMap.put("range", bucket.getKeyAsString());
        bucketMap.put("count", bucket.getDocCount());
        buckets.add(bucketMap);
    }
    
    return buckets;
}

(3)嵌套聚合

java 复制代码
// 先按品牌分组,再统计每个品牌的平均价格
public List<Map<String, Object>> nestedAggregation() {
    // 构建嵌套聚合
    Query query = NativeQuery.builder()
        .withAggregations(
            AggregationBuilders.terms("group_by_brand")
                .field("brand")
                .size(10)
                .subAggregation(
                    AggregationBuilders.avg("avg_price_per_brand").field("price")
                )
        )
        .build();

    SearchHits<Product> searchHits = restTemplate.search(query, Product.class);
    
    Terms terms = searchHits.getAggregations().get("group_by_brand");
    List<Map<String, Object>> results = new ArrayList<>();
    
    for (Terms.Bucket bucket : terms.getBuckets()) {
        Map<String, Object> brandStat = new HashMap<>();
        brandStat.put("brand", bucket.getKeyAsString());
        brandStat.put("count", bucket.getDocCount());
        
        // 获取子聚合结果
        Avg avgPrice = bucket.getAggregations().get("avg_price_per_brand");
        brandStat.put("avg_price", avgPrice.getValue());
        
        results.add(brandStat);
    }
    
    return results;
}

(4)带查询条件的聚合

java 复制代码
// 先过滤价格大于 5000 的商品,再按品牌分组
public List<Map<String, Object>> filterAndAggregate() {
    Query query = NativeQuery.builder()
        .withQuery(QueryBuilders.range("price").gte(5000))
        .withAggregations(
            AggregationBuilders.terms("brands_in_high_price")
                .field("brand")
                .size(10)
        )
        .build();

    SearchHits<Product> searchHits = restTemplate.search(query, Product.class);
    
    Terms terms = searchHits.getAggregations().get("brands_in_high_price");
    List<Map<String, Object>> results = new ArrayList<>();
    
    for (Terms.Bucket bucket : terms.getBuckets()) {
        Map<String, Object> bucketMap = new HashMap<>();
        bucketMap.put("brand", bucket.getKeyAsString());
        bucketMap.put("count", bucket.getDocCount());
        results.add(bucketMap);
    }
    
    return results;
}

五、Elasticsearch 原理深潜

1.倒排索引

传统关系型数据库使用正排索引:文档 → 词语。例如,要查找包含"智能手机"的文档,需要逐行扫描所有文档的标题字段,效率极低。

倒排索引则相反,它的结构是:词语 → 文档列表。先将文档内容分词,然后记录每个词语出现在哪些文档中,形成"词典 + 倒排列表"的结构。

假设有 3 个文档:

文档 ID 标题内容
1 智能手机推荐
2 智能家居设备
3 手机配件推荐

正排索引(MySQL 的方式):此时查询"手机"时,需要遍历每一行,检查标题是否包含"手机"。

文档 ID 标题
1 智能手机推荐
2 智能家居设备
3 手机配件推荐

倒排索引(Elasticsearch 的方式):查询"手机"时,直接查词典找到"手机"对应的文档列表 [1, 3],瞬间返回结果。

词语 文档 ID 列表
智能 [1, 2]
手机 [1, 3]
推荐 [1, 3]
家居 [2]
设备 [2]
配件 [3]

组成结构

一个完整的倒排索引由两部分组成:

  • 词典(Dictionary / Term Index)

    • 存储所有不重复的词语(Term)
    • 通常以 B-Tree 或 FST(有限状态转换器)结构存储,支持快速的词语查找
    • 词典本身也存储在内存中,以加速查询
  • 倒排列表(Posting List)

    • 每个词语对应一个倒排列表,记录该词语出现在哪些文档中
    • 每条记录(Posting)通常包含:
      • 文档 ID:文档的唯一标识
      • 词频(TF):该词语在当前文档中出现的次数
      • 位置信息(Position):词语在文档中的位置,用于短语查询
      • 偏移量(Offset):词语在原始文本中的开始和结束位置,用于高亮显示

一个具体的例子如下

bash 复制代码
词语 "elasticsearch" →
[
{ docId: 1001, tf: 3, positions: [5, 12, 28], offsets: [(10,22), (30,42), ...] },
{ docId: 1005, tf: 1, positions: [7], offsets: [(15,27)] },
...
]

Elasticsearch 中的倒排索引一旦写入磁盘就不会修改,这样操作系统可以将索引文件缓存到内存,提升访问速度,但新增文档需要创建新的倒排索引段(Segment)

2.分词机制

分词(Analysis)是将文本转换成词项(Term)的过程,是全文检索的基础。Elasticsearch 的分词发生在两个阶段:索引时和搜索时。

(1)分词器的组成

一个标准的分析器(Analyzer)由三个组件构成

text 复制代码
原始文本 → [Character Filter] → [Tokenizer] → [Token Filter] → 词项列表
  • Character Filter:预处理文本,如去除 HTML 标签、替换特殊字符
  • Tokenizer:将文本切分成词项(Token),决定分词边界
  • Token Filter:对切分后的词项进行后处理,如转小写、停用词过滤

常见的内置分词器如下所示,

分词器 说明 示例输入:"Hello World!" 输出词项
standard 标准分词器,按词边界切分,移除标点,转为小写(默认) ["hello", "world"]
simple 按非字母字符切分,转为小写 ["hello", "world"]
whitespace 按空白字符切分,不转小写 ["Hello", "World!"]
keyword 不分词,整个文本作为一个词项 ["Hello World!"]
stop 类似 simple,但会过滤停用词(如 the、and) ["hello", "world"]
pattern 通过正则表达式切分 自定义

可以使用 _analyze API 测试分词效果

bash 复制代码
# 测试标准分词器
GET /_analyze
{
  "analyzer": "standard",
  "text": "Elasticsearch 是一个分布式搜索引擎"
}

响应示例:

json 复制代码
{
  "tokens": [
    { "token": "elasticsearch", "position": 0 },
    { "token": "是", "position": 1 },
    { "token": "一", "position": 2 },
    { "token": "个", "position": 3 },
    { "token": "分布式", "position": 4 },
    { "token": "搜索引擎", "position": 5 }
  ]
}

(2)中文分词方案

IK 分词器是最常用的中文分词,IK 分词器提供两种模式:

  • ik_max_word:最细粒度切分,会穷举所有可能的分词组合
  • ik_smart:粗粒度切分,保留更长的词项
bash 复制代码
# 测试 ik_max_word
GET /_analyze
{
  "analyzer": "ik_max_word",
  "text": "中华人民共和国国歌"
}
# 输出:["中华人民共和国", "中华人民", "中华", "华人", "人民共和国", "人民", "共和国", "共和", "国", "国歌"]

# 测试 ik_smart
GET /_analyze
{
  "analyzer": "ik_smart",
  "text": "中华人民共和国国歌"
}
# 输出:["中华人民共和国", "国歌"]

其他常用的中文分词器有

  • jieba:结巴分词,Python 社区常用,适用于通用中文
  • hanlp:汉语言处理包,支持自定义词典,适用于专业领域
  • pinyin:拼音分词器,支持拼音搜索,适用于拼音检索

(3)自定义分词器

在索引映射中自定义分词器

bash 复制代码
PUT /products
{
  "settings": {
    "analysis": {
      "char_filter": {
        "my_char_filter": {
          "type": "mapping",
          "mappings": ["& => and", "| => or"]
        }
      },
      "tokenizer": {
        "my_tokenizer": {
          "type": "pattern",
          "pattern": "\\s+"
        }
      },
      "filter": {
        "my_stop_filter": {
          "type": "stop",
          "stopwords": ["的", "了", "是"]
        }
      },
      "analyzer": {
        "my_analyzer": {
          "type": "custom",
          "char_filter": ["html_strip", "my_char_filter"],
          "tokenizer": "standard",
          "filter": ["lowercase", "my_stop_filter"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "content": {
        "type": "text",
        "analyzer": "my_analyzer"
      }
    }
  }
}

(4)索引时 vs 搜索时的分词

索引时分词:当文档被写入 Elasticsearch 时,对文档中的 text 字段内容进行分词,将分词结果存入倒排索引。写入ES时对文档中text的分词

搜索时分词:当用户发起查询时,对查询字符串进行分词,然后用分词结果去倒排索引中匹配。查询时对查询内容进行的分词

可以为同一个字段配置不同的索引分词器和搜索分词器

bash 复制代码
PUT /products
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "ik_max_word",    # 索引时:细粒度分词
        "search_analyzer": "ik_smart" # 搜索时:粗粒度分词
      }
    }
  }
}
  • 索引时:细粒度分词(ik_max_word),确保不遗漏任何可能的搜索词

  • 搜索时:粗粒度分词(ik_smart),保留更长的词项以提升精确度

3.相关性评分(TF-IDF 与 BM25)

相关性评分(Relevance Score)衡量文档与查询条件的匹配程度,分数越高表示越相关。Elasticsearch 使用 _score 字段表示相关性分数,默认按分数降序返回结果。

(1)TF-IDF(旧版算法)

TF-IDF 是信息检索领域的经典算法,由两个核心概念组成

  • 词频(TF):某个词语在文档中出现的频率。出现次数越多,文档越相关。TF(t, d) = 该词语在文档中出现的次数 / 文档的总词数。
  • 逆文档频率(IDF):某个词语在所有文档中出现的普遍程度。词越稀有,权重越高。IDF(t) = log(总文档数 / 包含该词语的文档数)

TF-IDF 计算公式:TF-IDF = TF × IDF

(2)BM25(Elasticsearch 5.x 起默认算法)

a.词频无饱和问题

TF线性增长,但是相关性并不线性增长,反而有可能是垃圾内容

TF-IDF 中,词频(TF)是线性增长的。一个词出现 10 次的文档,分数是出现 1 次的 10 倍;出现 100 次就是 100 倍。但相关性的增加不是线性的。出现一次证明"相关",出现五次证明"很相关",但出现一百次并不代表"超级相关"------反而可能是垃圾内容。

例如文档A中"手机"出现了1 次 ,此时TF值为1,用户感受为相关;文档B中出现了5次,TF值为5,用户感觉更相关;文档B中出现了20次,TF值为20,但此时可能是在堆砌关键词(SEO 作弊),实际并不比文档B好 4 倍;


BM25 的方案 :BM25 使用了一个饱和函数,让词频的影响随着次数增加而逐渐平缓。其公式为:TF_impact = TF / (TF + k1),其中 k1 默认 = 1.2

b.文档长度归一化不合理问题

长文档内容更相关,但因为文档长度过长导致词频变低

长文档天然包含更多词语,更容易出现高频词。TF-IDF 简单除以文档总词数,会过度惩罚长文档。现在有下面两个文档

  • 文档A(短文档):200 字,包含 2 次 "手机" → TF = 2/200 = 0.01
  • 文档B(长文档):2000 字,包含 10 次 "手机" → TF = 10/2000 = 0.005

此时TF-IDF 认为短文档的相关性更高(0.01 > 0.005)但真实情况可能是,长文档虽然词频密度低,但绝对值更高(10 次 vs 2 次),说明它讨论手机的篇幅更多,可能更相关。


BM25 的方案 :BM25 引入了可调参数 b(默认 0.75),控制文档长度的惩罚程度。公式为length_norm = 1 - b + b × (文档长度 / 平均文档长度)

  • 当文档长度 = 平均长度:length_norm = 1(无惩罚)
  • 当文档长度 > 平均长度:length_norm > 1,分母变大,分数降低
  • 当文档长度 < 平均长度:length_norm < 1,分母变小,分数升高
c.BM25整体方案

BM25(Best Matching 25)在 TF-IDF 的基础上增加了词频饱和机制(通过 k1)+ 可调长度归一化(通过 b),更符合真实相关性判断

text 复制代码
                    IDF × TF × (k1 + 1)
score = Σ ─────────────────────────────────────────────
          TF + k1 × (1 - b + b × 文档长度 / 平均长度)
                └──────────────┬───────────────┘
                         长度归一化因子
                └──────────┬──────────┘
                      词频饱和部分

实际使用建议如下

bash 复制代码
# 默认值(适用于大多数场景)
"similarity": {
  "default": {
    "type": "BM25",
    "k1": 1.2,
    "b": 0.75
  }
}

# 短文本场景(如标题、标签)
# 降低 b 值,减少对短文档的偏向
"similarity": {
  "default": {
    "type": "BM25",
    "k1": 1.2,
    "b": 0.3
  }
}

# 长文本场景(如文章、评论)
# 提高 b 值,对过长文档适当惩罚
"similarity": {
  "default": {
    "type": "BM25",
    "k1": 1.2,
    "b": 0.9
  }
}

# 高词频很重要的场景(如代码、标签云)
# 提高 k1 值,让高频词贡献更大
"similarity": {
  "default": {
    "type": "BM25",
    "k1": 2.0,
    "b": 0.75
  }
}

六、数据同步:MySQL 与 Elasticsearch 的实时同步方案

在实际生产环境中,ES 通常与关系型数据库(如 MySQL)搭配使用:MySQL 作为主数据库,负责事务性存储和数据一致性;ES 作为搜索引擎,负责全文检索、聚合分析等。因此,如何将 MySQL 中的数据高效、准确地同步到 ES 中,是一个必须解决的核心问题。

1.常见同步方案对比

根据同步的实时性、对业务代码的侵入程度、架构复杂度,主流方案可分为以下几类:

方案 原理 实时性 代码侵入 复杂度 适用场景
同步双写 业务代码中同时写入 MySQL 和 ES 毫秒级 写入量小、逻辑简单的场景
异步双写(MQ) 写入 MySQL 后发送 MQ 消息,消费者写 ES 秒级 写入量中等,需要解耦的场景
Logstash 定时拉取 定时执行 SQL,基于时间戳增量拉取 分钟级 对实时性要求不高的分析场景
Canal 监听 Binlog 伪装 MySQL 从库,解析 binlog 实时同步 秒级(接近实时) 生产环境,写入量大、要求高实时性
Flink 流处理 将 CDC 数据接入 Flink,经 ETL 后写入 ES 秒级 复杂数据清洗、多流关联的场景

2.同步双写

同步双写是最简单直接的方式,在业务代码中,写入 MySQL 的同时也写入 ES。

java 复制代码
@Transactional
public void saveProduct(Product product) {
    // 1. 写入 MySQL
    productMapper.insert(product);
    // 2. 同步写入 ES
    productRepository.save(product);
}

这种方式实现简单,实时性高;但每个需要同步的地方都要写 ES 代码、写入耗时增加,同时会存在一致性问题,MySQL 成功但 ES 失败,事务难以回滚。

3.异步双写

通过消息队列(如 Kafka、RocketMQ)将写入事件异步传递给消费者,消费者负责将数据写入 ES。

生产者代码如下

java 复制代码
@Transactional
public void saveProduct(Product product) {
    productMapper.insert(product);
    // 发送 MQ 消息(异步,不影响主事务)
    mqTemplate.send("product-topic", product.getId());
}

消费者代码如下

java 复制代码
@RocketMQMessageListener(topic = "product-topic")
public class ProductSyncConsumer implements RocketMQListener<String> {
    @Override
    public void onMessage(String id) {
        Product product = productMapper.selectById(id);
        productRepository.save(product);
    }
}

这种方式可以解耦业务代码与 ES ,削峰填谷,应对突发流量;但需要额外维护 MQ 组件,同时消息可能重复,消费者需幂等处理

4.Logstash 定时拉取

主动拉取的模式,依赖定时任务间隔

Logstash 的 jdbc 插件可以定期执行 SQL 查询,将结果输出到 ES。适用于全量同步和定时增量同步。

  • 全量同步指将 MySQL 中某个表(或整个库)的所有数据一次性全部导入 ES,适用于首次建立 ES 索引。
  • 增量同步:只同步自上次同步以来发生变化的数据(新增、修改、删除)。它只更新 ES 中的部分文档,而不是全部。适用于时间戳字段定时拉取 update_time > 上次同步时间的记录,或者Binlog 监听实时捕获每个变更事件。

全量同步的配置如下:

ruby 复制代码
input {
  jdbc {
    jdbc_connection_string => "jdbc:mysql://localhost:3306/mydb"
    jdbc_user => "root"
    jdbc_password => "password"
    statement => "SELECT * FROM products"
    schedule => "* * * * *"   # 每分钟执行(可改为一次性)
  }
}
output {
  elasticsearch {
    hosts => ["localhost:9200"]
    index => "products"
    document_id => "%{id}"
  }
}

增量同步(基于更新时间戳)的配置如下:

ruby 复制代码
input {
  jdbc {
    statement => "SELECT * FROM products WHERE update_time > :sql_last_value"
    schedule => "*/5 * * * *"
    tracking_column => "update_time"
    last_run_metadata_path => "/path/to/last_run"
  }
}

这种方式无代码侵入,配置简单,适合数据量不大、实时性要求不高的场景;但实时性较差,对删除操作不友好(需逻辑删除或标记)、频繁拉取会对 MySQL 造成压力。

5.Canal 监听 Binlog 进行数据订阅

被动接收,MySQL 主库将 binlog 主动推送给 Canal Server,Canal Client进行监听并写入ES

Canal 是阿里巴巴开源的 MySQL binlog 解析工具,伪装成 MySQL 从库,实时接收 binlog 并解析成变更事件,然后推送到 ES 或其他存储。

①需要先开启 MySQL binlog(my.cnf),

ini 复制代码
log-bin=mysql-bin
binlog-format=ROW
server-id=1

②启动 Canal Server(docker-compose.yml),

yaml 复制代码
version: '3'
services:
  canal:
    image: canal/canal-server:latest
    ports:
      - "11111:11111"
    environment:
      - canal.instance.master.address=mysql:3306
      - canal.instance.dbUsername=canal
      - canal.instance.dbPassword=canal

③Java 客户端监听,

java 复制代码
CanalConnector connector = CanalConnectors.newSingleConnector(
    new InetSocketAddress("localhost", 11111), "example", "", "");
connector.connect();
connector.subscribe("mydb.products");
while (true) {
    Message message = connector.getWithoutAck(100);
    for (CanalEntry.Entry entry : message.getEntries()) {
        // 解析 binlog,转换为 ES 操作
        RowChange change = RowChange.parseFrom(entry.getStoreValue());
        // 根据事件类型(INSERT/UPDATE/DELETE)同步到 ES
    }
    connector.ack(message.getId());
}

这种方式完全基于 binlog,业务代码无需改动,实时性极高,且binlog 天然有序可以保证有序性;但部署运维复杂,首次需要全量同步配合。

6.ETL工具

当需要对数据进行复杂的清洗、转换、多流关联后再写入 ES 时,可以使用 Flink CDC(Change Data Capture)技术。

MySQL同步到Redis、MySQL同步到hbase、MySQL同步到es、或机房同步、主从同步等,都可以考虑使用elt工具。

ETL可以理解为就像工厂流水线------原料(原始数据)进来,经过清洗、切割、组装(转换),产出成品(干净、结构化的数据)送到仓库(目标存储)。

①配置依赖(pom.xml)

xml 复制代码
<dependency>
    <groupId>com.ververica</groupId>
    <artifactId>flink-connector-mysql-cdc</artifactId>
    <version>2.4.0</version>
</dependency>
<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-connector-elasticsearch7</artifactId>
</dependency>

②代码示例

java 复制代码
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

// 1. 从 MySQL CDC 读取数据
MySqlSource<String> mySqlSource = MySqlSource.<String>builder()
    .hostname("localhost")
    .port(3306)
    .databaseList("mydb")
    .tableList("mydb.products")
    .username("root")
    .password("password")
    .deserializer(new JsonDebeziumDeserializationSchema())
    .build();

DataStream<String> stream = env.fromSource(mySqlSource, 
    WatermarkStrategy.noWatermarks(), "MySQL Source");

// 2. ETL 处理:过滤、清洗、关联维度表等
DataStream<JSONObject> transformed = stream.map(new RichMapFunction<String, JSONObject>() {
    @Override
    public JSONObject map(String value) throws Exception {
        JSONObject json = JSONObject.parseObject(value);
        // 例如:价格转为美元,增加字段等
        json.put("price_usd", json.getDouble("price") / 7.0);
        return json;
    }
});

// 3. 写入 Elasticsearch
ElasticsearchSink.Builder<JSONObject> esSinkBuilder = new ElasticsearchSink.Builder<>(
    Arrays.asList("http://localhost:9200"),
    (element, ctx, indexer) -> {
        IndexRequest request = new IndexRequest("products")
            .id(element.getString("id"))
            .source(element, XContentType.JSON);
        indexer.add(request);
    }
);
transformed.sinkTo(esSinkBuilder.build());

env.execute("MySQL CDC to ES");

7.同步过程中的幂等性处理

无论采用哪种同步方案,都可能出现消息重复、binlog 重复消费等问题。幂等性是保证最终一致性的关键。

(1)ES 端的幂等机制

  • 使用 _id 唯一标识 + update 操作
bash 复制代码
POST /products/_update/1001
{
  "doc": { "price": 6999 },
  "doc_as_upsert": true   # 文档不存在则插入
}
  • 使用版本号或时间戳防止旧数据覆盖:在 MySQL 表中增加 version 字段或 update_time 字段,ES 更新时进行比较
bash 复制代码
POST /products/_update/1001
{
  "script": {
    "source": "if (ctx._source.version < params.newVersion) { ctx._source.version = params.newVersion; ctx._source.price = params.price }",
    "params": { "newVersion": 5, "price": 6999 }
  }
}

(2)消费端的幂等

  • 消费端记录处理过的消息 ID
sql 复制代码
CREATE TABLE processed_message (
    message_id VARCHAR(64) PRIMARY KEY,
    processed_at DATETIME
);
  • 使用 Redis 集合存储已处理 ID
java 复制代码
if (redis.sismember("processed_ids", messageId)) {
    return; // 已处理过
}
// 处理业务
redis.sadd("processed_ids", messageId);

八、ES 集群:从单机到分布式

单机 ES 能承载的数据量和查询并发是有限的。生产环境中,我们通常将 ES 部署为分布式集群,以水平扩展存储与计算能力,并提供高可用保障。

常见的核心概念如下:

概念 说明 类比(以 Redis Cluster 为例)
节点(Node) 一个 ES 实例(一台服务器上的进程) 一个 Redis 节点
集群(Cluster) 多个节点组成,共享数据与负载 Redis Cluster
分片(Shard) 索引拆分为多个分片,每个分片是一个 Lucene 索引 Redis 的槽位(Slot)
副本(Replica) 分片的完整拷贝,提供高可用与读扩展 Redis 的从节点(但 ES 副本可读)
主分片(Primary Shard) 数据写入的主要分片,索引创建后不可修改数量 Redis 主节点
副分片(Replica Shard) 主分片的备份,可增加数量,可读 Redis 从节点

以下示例使用 docker-compose 启动一个 3 节点的 ES 集群(无需安装部署细节,仅演示验证集群状态)。

yaml 复制代码
version: '3'
services:
  es01:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    environment:
      - node.name=es01
      - cluster.name=my-cluster
      - discovery.seed_hosts=es02,es03
      - cluster.initial_master_nodes=es01,es02,es03
      - network.host=0.0.0.0
      - xpack.security.enabled=false   # 简化演示,生产环境需开启
    ports:
      - "9200:9200"
  es02:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    environment:
      - node.name=es02
      - cluster.name=my-cluster
      - discovery.seed_hosts=es01,es03
      - cluster.initial_master_nodes=es01,es02,es03
      - network.host=0.0.0.0
      - xpack.security.enabled=false
  es03:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    environment:
      - node.name=es03
      - cluster.name=my-cluster
      - discovery.seed_hosts=es01,es02
      - cluster.initial_master_nodes=es01,es02,es03
      - network.host=0.0.0.0
      - xpack.security.enabled=false

九、深度分页问题

在 Elasticsearch 中,默认的分页方式是通过 from 和 size 参数实现。例如:from = 10000, size = 10 表示获取第 1001 页(每页 10 条)。这种分页方式在浅分页时(前几页)效率尚可,一旦遇到深度分页(页码很大或 from + size 超过 10000),性能会急剧下降,甚至导致节点内存溢出(OOM)。

例如你有 3 个分片,查询 from = 10000, size = 10,查询的流程如下:

  1. 协调节点将请求广播到 3 个分片。
  2. 每个分片需要在内部排序后,取前 10010 条(from + size)数据,返回给协调节点。
  3. 协调节点收到 3 × 10010 = 30030 条数据,在内存中重新排序,然后丢弃前 10000 条,最后返回 10 条给客户端。

ES 默认设置 index.max_result_window = 10000,即 from + size ≤ 10000。超过会报错:

1.解决方案一:Scroll API

第一次请求生成数据的快照并返回 scroll_id,后续请求使用该 scroll_id 从同一个快照中继续滚动取下一批数据。

Scroll API 用于一次性检索大量数据(如导出全量数据到文件、数据迁移),它不是为实时用户请求设计的。其原理如下所示,

  • 首次请求时,ES 生成当前时间点的快照(snapshot),并返回一个 scroll_id。
  • 后续请求携带 scroll_id,ES 从快照中分批拉取数据,不受索引后续变更的影响。
  • 滚动期间,ES 会维持搜索上下文(search context),消耗内存。

2.解决方案二:Search After

每次请求携带上一页最后一条文档的排序值(游标),ES 从该游标之后继续检索下一页(游标由客户端自己保存,ES 不维护状态)

Search After 是 ES 官方推荐用于实时深度分页的方案。它利用上一页最后一条文档的排序值作为下一页的起始点,避免每个分片传递大量无用数据。

  • 每次请求必须带 sort 字段(推荐使用多个唯一性字段,如 _id 或 timestamp + _id)。
  • 返回的每条结果中包含 sort 值数组。
  • 下一页请求时,携带上一页最后一条文档的 search_after 参数,ES 直接从该位置之后继续检索。
  • 每个分片只需要返回 size 条数据,协调节点合并后返回。

十、性能调优方案

  1. 索引性能调优
  • 批量写入:使用 Bulk API,每批 1000~5000 条或 5~15 MB。
  • 调整刷新间隔:refresh_interval 默认 1s,批量导入时可改为 30s 或 -1。
  • 副本置零:全量索引期间 number_of_replicas = 0,完成后恢复。
  • Translog 异步:durability: async 可提升写入速度(宕机可能丢少量数据)。
  1. 查询性能调优
  • 多用 Filter 上下文:term、range 等放入 filter 可缓存,提升性能。
  • 避免深度分页:用 search_after 代替 from/size。
  • 精简字段:只返回需要的 _source,不需要全文检索的字段设置 "index": false。
  • 优先使用 keyword:精确匹配用 keyword 而非 text。
  • 合理使用路由:将相关数据路由到同一分片,减少跨分片查询。
  1. 硬件与操作系统层面
  • 堆内存:机器内存的 50%,且不超过 32 GB。
  • 禁用 Swap:swapoff -a,避免内存交换。
  • 文件描述符:至少设为 65535。
  • 磁盘:使用 SSD,多磁盘可配置 path.data 用逗号分隔。
  1. 监控与慢日志分析
  • 慢查询日志:设置阈值记录慢查询,定位瓶颈。
  • 关键指标:监控 CPU、内存、磁盘、搜索/索引速率、拒绝线程数(search_rejected)、GC 情况。
  • 常用命令:_cluster/health、_nodes/stats、_cat/thread_pool、_nodes/hot_threads。

十一、ELK 日志方案:Elasticsearch + Logstash + Kibana

ELK 是 Elasticsearch、Logstash、Kibana 的组合,用于日志的采集、处理、存储和可视化,是生产环境日志管理的标准方案。

核心组件如下,

组件 作用
Logstash 日志采集与处理管道(输入→过滤→输出)
Elasticsearch 日志存储与索引,提供全文检索和聚合
Kibana 日志可视化 Web 界面(查询、仪表盘、图表)
Beats(可选) 轻量级采集代理,部署在业务服务器上

典型架构如下,

text 复制代码
日志文件 → Filebeat → Logstash → Elasticsearch → Kibana
  • Filebeat 监控日志文件变化,发送给 Logstash。
  • Logstash 用 Grok 等插件将非结构化日志解析为结构化 JSON。
  • Elasticsearch 按时间分索引存储(如 nginx-log-2025.05.25)。
  • Kibana 提供 Discover 搜索、Dashboard 仪表盘。

常用场景

  • 集中查看多服务器应用日志,快速定位错误
  • 安全分析(如异常 IP 检测)
  • 性能监控(接口耗时、错误率趋势)
  • 业务指标(PV、UV)

小结:ELK 能实现从日志生产到消费的全链路管理,配合索引生命周期管理(ILM),可自动轮转和清理历史数据,控制存储成本。

相关推荐
一个数据大开发18 小时前
AI 不止改变工作,也将重构生活:从效率工具到个人生活操作系统
大数据·人工智能·生活
黎阳之光1 天前
黎阳之光:以视频孪生重构智慧防火,打造“天空地人智”一体化森林防火新范式
大数据·运维·人工智能·物联网·安全
Daydream.V1 天前
Python Flask超全入门实战教程|从零基础到项目部署
大数据·python·flask
SmartBrain1 天前
AI全栈开发(SDD):慢病管理系统工程级设计
java·大数据·开发语言·人工智能·架构·aigc
zandy10111 天前
2026 BI平台与数据中台融合架构实践:从数据烟囱到统一智能数据层
大数据·架构·spark
珊瑚里的鱼1 天前
【项目】基于正倒排索引的Boost搜索引擎
搜索引擎
金智维科技官方1 天前
圆桌对话:从流程自动化到智能流程,AI落地的下一站在哪里?
大数据·人工智能·ai·自动化·智能体
Volunteer Technology2 天前
集群基础环境搭建(二)
大数据·flink·apache
郑小憨2 天前
zookeeper内部原理 (进阶介绍 三)
大数据·分布式·zookeeper