文章目录
- [一、Elasticsearch 基本介绍](#一、Elasticsearch 基本介绍)
-
- [1.什么是 Elasticsearch?](#1.什么是 Elasticsearch?)
- [2.Elasticsearch 的核心应用场景](#2.Elasticsearch 的核心应用场景)
- 3.与关系型数据库(MySQL)的类比
- 4.核心术语
- [5.elastic stack(ELK)](#5.elastic stack(ELK))
- [二、基础操作 CRUD](#二、基础操作 CRUD)
- [三、基础指令 DSL](#三、基础指令 DSL)
-
- [1. Query DSL](#1. Query DSL)
- 2.简单查询入门
-
- [(1)match_all 查询所有文档](#(1)match_all 查询所有文档)
- [(2)match 全文检索](#(2)match 全文检索)
- [(3)term 精确匹配](#(3)term 精确匹配)
-
- [term vs match](#term vs match)
- [(4)terms 多值精确匹配](#(4)terms 多值精确匹配)
- [(5)range 范围查询](#(5)range 范围查询)
- [(6)exists 字段存在查询](#(6)exists 字段存在查询)
- 3.组合查询
-
- [(1)bool 查询](#(1)bool 查询)
- [(2) bool 查询的嵌套](#(2) bool 查询的嵌套)
- 4.排序与分页(from/size)
- 5.聚合操作
-
- [(1)指标聚合(Metric Aggregations)](#(1)指标聚合(Metric Aggregations))
- [(2)桶聚合(Bucket Aggregations)](#(2)桶聚合(Bucket Aggregations))
- (3)嵌套聚合
- (4)带查询条件的聚合
- (5)聚合结果过滤
- [四、Java 中的Spring Data Elasticsearch 方案](#四、Java 中的Spring Data Elasticsearch 方案)
-
- 1.项目依赖与配置
- 2.实体类注解
- 4.自动创建索引及映射
- [5.基础 CRUD 操作](#5.基础 CRUD 操作)
-
- [(1)定义 Repository](#(1)定义 Repository)
- (2)基础CRUD
- 6.高阶查询
-
- (1)方法命名派生查询
- [(2)@Query 注解自定义查询](#(2)@Query 注解自定义查询)
- [(3)NativeQuery 构建查询](#(3)NativeQuery 构建查询)
- [7.聚合操作的 Java 实现](#7.聚合操作的 Java 实现)
-
- [(1)指标聚合(Metric Aggregations)](#(1)指标聚合(Metric Aggregations))
- [(2)桶聚合(Bucket Aggregations)](#(2)桶聚合(Bucket Aggregations))
- (3)嵌套聚合
- (4)带查询条件的聚合
- [五、Elasticsearch 原理深潜](#五、Elasticsearch 原理深潜)
-
- 1.倒排索引
- 2.分词机制
- [3.相关性评分(TF-IDF 与 BM25)](#3.相关性评分(TF-IDF 与 BM25))
-
- (1)TF-IDF(旧版算法)
- [(2)BM25(Elasticsearch 5.x 起默认算法)](#(2)BM25(Elasticsearch 5.x 起默认算法))
- [六、数据同步:MySQL 与 Elasticsearch 的实时同步方案](#六、数据同步:MySQL 与 Elasticsearch 的实时同步方案)
-
- 1.常见同步方案对比
- 2.同步双写
- 3.异步双写
- [4.Logstash 定时拉取](#4.Logstash 定时拉取)
- [5.Canal 监听 Binlog 进行数据订阅](#5.Canal 监听 Binlog 进行数据订阅)
- 6.ETL工具
- 7.同步过程中的幂等性处理
-
- [(1)ES 端的幂等机制](#(1)ES 端的幂等机制)
- (2)消费端的幂等
- [八、ES 集群:从单机到分布式](#八、ES 集群:从单机到分布式)
- 九、深度分页问题
-
- [1.解决方案一:Scroll API](#1.解决方案一:Scroll API)
- [2.解决方案二:Search After](#2.解决方案二:Search After)
- 十、性能调优方案
- [十一、ELK 日志方案:Elasticsearch + Logstash + Kibana](#十一、ELK 日志方案:Elasticsearch + Logstash + Kibana)
一、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)分页
使用 from 和 size 参数实现分页:
- 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。桶聚合的关键字是 aggs 或 aggregations,两者等价。
- 示例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,查询的流程如下:
- 协调节点将请求广播到 3 个分片。
- 每个分片需要在内部排序后,取前 10010 条(from + size)数据,返回给协调节点。
- 协调节点收到 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 条数据,协调节点合并后返回。
十、性能调优方案
- 索引性能调优
- 批量写入:使用 Bulk API,每批 1000~5000 条或 5~15 MB。
- 调整刷新间隔:refresh_interval 默认 1s,批量导入时可改为 30s 或 -1。
- 副本置零:全量索引期间 number_of_replicas = 0,完成后恢复。
- Translog 异步:durability: async 可提升写入速度(宕机可能丢少量数据)。
- 查询性能调优
- 多用 Filter 上下文:term、range 等放入 filter 可缓存,提升性能。
- 避免深度分页:用 search_after 代替 from/size。
- 精简字段:只返回需要的 _source,不需要全文检索的字段设置 "index": false。
- 优先使用 keyword:精确匹配用 keyword 而非 text。
- 合理使用路由:将相关数据路由到同一分片,减少跨分片查询。
- 硬件与操作系统层面
- 堆内存:机器内存的 50%,且不超过 32 GB。
- 禁用 Swap:swapoff -a,避免内存交换。
- 文件描述符:至少设为 65535。
- 磁盘:使用 SSD,多磁盘可配置 path.data 用逗号分隔。
- 监控与慢日志分析
- 慢查询日志:设置阈值记录慢查询,定位瓶颈。
- 关键指标:监控 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),可自动轮转和清理历史数据,控制存储成本。