Dunwu Blog

大道至简,知易行难

Filebeat

简介

Beats 是安装在服务器上的数据中转代理。

Beats 可以将数据直接传输到 Elasticsearch 或传输到 Logstash 。

img

Beats 有多种类型,可以根据实际应用需要选择合适的类型。

常用的类型有:

  • Packetbeat:网络数据包分析器,提供有关您的应用程序服务器之间交换的事务的信息。
  • Filebeat:从您的服务器发送日志文件。
  • Metricbeat:是一个服务器监视代理程序,它定期从服务器上运行的操作系统和服务收集指标。
  • Winlogbeat:提供 Windows 事件日志。

参考

更多 Beats 类型可以参考:community-beats

说明

由于本人工作中只应用了 FileBeat,所以后面内容仅介绍 FileBeat 。

FileBeat 的作用

相比 Logstash,FileBeat 更加轻量化。

在任何环境下,应用程序都有停机的可能性。 Filebeat 读取并转发日志行,如果中断,则会记住所有事件恢复联机状态时所在位置。

Filebeat 带有内部模块(auditd,Apache,Nginx,System 和 MySQL),可通过一个指定命令来简化通用日志格式的收集,解析和可视化。

FileBeat 不会让你的管道超负荷。FileBeat 如果是向 Logstash 传输数据,当 Logstash 忙于处理数据,会通知 FileBeat 放慢读取速度。一旦拥塞得到解决,FileBeat 将恢复到原来的速度并继续传播。

img

安装

Unix / Linux 系统建议使用下面方式安装,因为比较通用。

1
2
wget https://artifacts.elastic.co/downloads/beats/filebeat/filebeat-6.1.1-linux-x86_64.tar.gz
tar -zxf filebeat-6.1.1-linux-x86_64.tar.gz

更多内容可以参考:filebeat-installation

配置

配置文件

首先,必须要知道的是:filebeat.yml 是 filebeat 的配置文件。其路径会因为你安装方式而有所不同。

Beat 所有系列产品的配置文件都基于 YAML 格式,FileBeat 当然也不例外。

更多 filebeat 配置内容可以参考:配置 filebeat

更多 filebeat.yml 文件格式内容可以参考:filebeat.yml 文件格式

filebeat.yml 部分配置示例:

1
2
3
4
5
6
7
8
filebeat:
prospectors:
- type: log
paths:
- /var/log/*.log
multiline:
pattern: '^['
match: after

参考

更多 filebeat 配置内容可以参考:配置 filebeat

更多 filebeat.yml 文件格式内容可以参考:filebeat.yml 文件格式

重要配置项

下面我将列举 Filebeat 的较为重要的配置项。

如果想了解更多配置信息,可以参考:

更多 filebeat 配置内容可以参考:配置 filebeat

更多 filebeat.yml 文件格式内容可以参考:filebeat.yml 文件格式

filebeat.prospectors

(文件监视器)用于指定需要关注的文件。

示例

1
2
3
4
5
filebeat.prospectors:
- type: log
enabled: true
paths:
- /var/log/*.log

output.elasticsearch

如果你希望使用 filebeat 直接向 elasticsearch 输出数据,需要配置 output.elasticsearch 。

示例

1
2
output.elasticsearch:
hosts: ['192.168.1.42:9200']

output.logstash

如果你希望使用 filebeat 向 logstash 输出数据,然后由 logstash 再向 elasticsearch 输出数据,需要配置 output.logstash。

注意

相比于向 elasticsearch 输出数据,个人更推荐向 logstash 输出数据。

因为 logstash 和 filebeat 一起工作时,如果 logstash 忙于处理数据,会通知 FileBeat 放慢读取速度。一旦拥塞得到解决,FileBeat 将恢复到原来的速度并继续传播。这样,可以减少管道超负荷的情况。

示例

1
2
output.logstash:
hosts: ['127.0.0.1:5044']

此外,还需要在 logstash 的配置文件(如 logstash.conf)中指定 beats input 插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
input {
beats {
port => 5044 # 此端口需要与 filebeat.yml 中的端口相同
}
}

# The filter part of this file is commented out to indicate that it is
# optional.
# filter {
#
# }

output {
elasticsearch {
hosts => "localhost:9200"
manage_template => false
index => "%{[@metadata][beat]}-%{[@metadata][version]}-%{+YYYY.MM.dd}"
document_type => "%{[@metadata][type]}"
}
}

setup.kibana

如果打算使用 Filebeat 提供的 Kibana 仪表板,需要配置 setup.kibana 。

示例

1
2
setup.kibana:
host: 'localhost:5601'

setup.template.settings

在 Elasticsearch 中,索引模板用于定义设置和映射,以确定如何分析字段。

在 Filebeat 中,setup.template.settings 用于配置索引模板。

Filebeat 推荐的索引模板文件由 Filebeat 软件包安装。如果您接受 filebeat.yml 配置文件中的默认配置,Filebeat 在成功连接到 Elasticsearch 后自动加载模板。

您可以通过在 Filebeat 配置文件中配置模板加载选项来禁用自动模板加载,或加载自己的模板。您还可以设置选项来更改索引和索引模板的名称。

参考

更多内容可以参考:filebeat-template

说明

如无必要,使用 Filebeat 配置文件中的默认索引模板即可。

setup.dashboards

Filebeat 附带了示例 Kibana 仪表板。在使用仪表板之前,您需要创建索引模式 filebeat- *,并将仪表板加载到 Kibana 中。为此,您可以运行 setup 命令或在 filebeat.yml 配置文件中配置仪表板加载。

为了在 Kibana 中加载 Filebeat 的仪表盘,需要在 filebeat.yml 配置中启动开关:

1
setup.dashboards.enabled: true

参考

更多内容可以参考:configuration-dashboards

命令

filebeat 提供了一系列命令来完成各种功能。

执行命令方式:

1
./filebeat COMMAND

参考

更多内容可以参考:command-line-options

说明

个人认为命令行没有必要一一掌握,因为绝大部分功能都可以通过配置来完成。且通过命令行指定功能这种方式要求每次输入同样参数,不利于固化启动方式。

最重要的当然是启动命令 run 了。

示例 指定配置文件启动

1
2
./filebeat run -e -c filebeat.yml -d "publish"
./filebeat -e -c filebeat.yml -d "publish" # run 可以省略

模块

FilebeatMetricbeat 内部集成了一系列模块,用以简化常见日志格式(例如 NGINX、Apache 或诸如 Redis 或 Docker 等系统指标)的收集、解析和可视化过程。

  • 配置 elasticsearch 和 kibana
1
2
3
4
5
6
7
8
output.elasticsearch:
hosts: ["myEShost:9200"]
username: "elastic"
password: "elastic"
setup.kibana:
host: "mykibanahost:5601"
username: "elastic"
password: "elastic

username 和 password 是可选的,如果不需要认证则不填。

  • 初始化环境

执行下面命令,filebeat 会加载推荐索引模板。

1
./filebeat setup -e
  • 指定模块

执行下面命令,指定希望加载的模块。

1
./filebeat -e --modules system,nginx,mysql

更多内容可以参考:

原理

Filebeat 有两个主要组件:

harvester:负责读取一个文件的内容。它会逐行读取文件内容,并将内容发送到输出目的地。

prospector:负责管理 harvester 并找到所有需要读取的文件源。比如类型是日志,prospector 就会遍历制定路径下的所有匹配要求的文件。

1
2
3
4
5
filebeat.prospectors:
- type: log
paths:
- /var/log/*.log
- /var/path2/*.log

Filebeat 保持每个文件的状态,并经常刷新注册表文件中的磁盘状态。状态用于记住 harvester 正在读取的最后偏移量,并确保发送所有日志行。

Filebeat 将每个事件的传递状态存储在注册表文件中。所以它能保证事件至少传递一次到配置的输出,没有数据丢失。

参考资料

Kibana

通过 Kibana,您可以对自己的 Elasticsearch 进行可视化,还可以在 Elastic Stack 中进行导航,这样您便可以进行各种操作了,从跟踪查询负载,到理解请求如何流经您的整个应用,都能轻松完成。

安装

环境要求

版本:Elastic Stack 7.4

安装步骤

安装步骤如下:

  1. kibana 官方下载地址 下载所需版本包并解压到本地。
  2. 修改 config/kibana.yml 配置文件,设置 elasticsearch.url 指向 Elasticsearch 实例。
  3. 运行 bin/kibana (Windows 上运行 bin\kibana.bat
  4. 在浏览器上访问 http://localhost:5601

使用

检索

单击侧面导航栏中的 检索(Discover) ,可以显示 Kibana 的数据查询功能功能。

img

在搜索栏中,您可以输入 Elasticsearch 查询条件来搜索您的数据。您可以在 Discover 页面中浏览结果并在 Visualize 页面中创建已保存搜索条件的可视化。

当前索引模式显示在查询栏下方。索引模式确定提交查询时搜索哪些索引。要搜索一组不同的索引,请从下拉菜单中选择不同的模式。要添加索引模式(index pattern),请转至 Management/Kibana/Index Patterns 并单击 Add New

您可以使用字段名称和您感兴趣的值构建搜索。对于数字字段,可以使用比较运算符,如大于(>),小于(<)或等于(=)。您可以将元素与逻辑运算符 ANDORNOT 链接,全部使用大写。

默认情况下,每个匹配文档都显示所有字段。要选择要显示的文档字段,请将鼠标悬停在“可用字段”列表上,然后单击要包含的每个字段旁边的添加按钮。例如,如果只添加 account_number,则显示将更改为包含五个帐号的简单列表:

img

kibana 的搜索栏遵循 query-string-syntax 文档中所说明的查询语义。

这里说明一些最基本的查询语义。

查询字符串会被解析为一系列的术语和运算符。一个术语可以是一个单词(如:quick、brown)或用双引号包围的短语(如”quick brown”)。

查询操作允许您自定义搜索 - 下面介绍了可用的选项。

2.1.1. 字段名称

正如查询字符串查询中所述,将在搜索条件中搜索 default_field,但可以在查询语法中指定其他字段:

例如:

  • 查询 status 字段中包含 active 关键字
1
status:active
  • title 字段包含 quickbrown 关键字。如果您省略 OR 运算符,则将使用默认运算符
1
2
title:(quick OR brown)
title:(quick brown)
  • author 字段查找精确的短语 “john smith”,即精确查找。
1
author:"John Smith"
  • 任意字段 book.titlebook.contentbook.date 都包含 quickbrown(注意我们需要如何使用 \* 表示通配符)
1
book.\*:(quick brown)
  • title 字段包含任意非 null 值
1
_exists_:title

2.1.2. 通配符

ELK 提供了 ? 和 * 两个通配符。

  • ? 表示任意单个字符;
  • * 表示任意零个或多个字符。
1
qu?ck bro*

注意:通配符查询会使用大量的内存并且执行性能较为糟糕,所以请慎用。 > 提示:纯通配符 * 被写入 exsits 查询,从而提高了查询效率。因此,通配符 field:* 将匹配包含空值的文档,如:{“field”:“”},但是如果字段丢失或显示将值置为 null 则不匹配,如:“field”:null} > 提示:在一个单词的开头(例如:*ing)使用通配符这种方式的查询量特别大,因为索引中的所有术语都需要检查,以防万一匹配。通过将 allow_leading_wildcard 设置为 false,可以禁用。

2.1.3. 正则表达式

可以通过 / 将正则表达式包裹在查询字符串中进行查询

例:

1
name:/joh?n(ath[oa]n)/

支持的正则表达式语义可以参考:Regular expression syntax

2.1.4. 模糊查询

我们可以使用 ~ 运算符来进行模糊查询。

例:

假设我们实际想查询

1
quick brown forks

但是,由于拼写错误,我们的查询关键字变成如下情况,依然可以查到想要的结果。

1
quikc\~ brwn\~ foks\~

这种模糊查询使用 Damerau-Levenshtein 距离来查找所有匹配最多两个更改的项。所谓的更改是指单个字符的插入,删除或替换,或者两个相邻字符的换位。

默认编辑距离为 2,但编辑距离为 1 应足以捕捉所有人类拼写错误的 80%。它可以被指定为:

1
quikc\~1

2.1.5. 近似检索

尽管短语查询(例如,john smith)期望所有的词条都是完全相同的顺序,但是近似查询允许指定的单词进一步分开或以不同的顺序排列。与模糊查询可以为单词中的字符指定最大编辑距离一样,近似搜索也允许我们指定短语中单词的最大编辑距离:

1
"fox quick"\~5

字段中的文本越接近查询字符串中指定的原始顺序,该文档就越被认为是相关的。当与上面的示例查询相比时,短语 "quick fox" 将被认为比 "quick brown fox" 更近似查询条件。

2.1.6. 范围

可以为日期,数字或字符串字段指定范围。闭区间范围用方括号 [min TO max] 和开区间范围用花括号 {min TO max} 来指定。

我们不妨来看一些示例。

  • 2012 年的所有日子
1
date:[2012-01-01 TO 2012-12-31]
  • 数字 1 到 5
1
count:[1 TO 5]
  • alphaomega 之间的标签,不包括 alphaomega
1
tags:{alpha TO omega}
  • 10 以上的数字
1
count:[10 TO *]
  • 2012 年以前的所有日期
1
date:{* TO 2012-01-01}

此外,开区间和闭区间也可以组合使用

  • 数组 1 到 5,但不包括 5
1
count:[1 TO 5}

一边无界的范围也可以使用以下语法:

1
2
3
4
age:>10
age:>=10
age:<10
age:<=10

当然,你也可以使用 AND 运算符来得到连个查询结果的交集

1
2
age:(>=10 AND <20)
age:(+>=10 +<20)

2.1.7. Boosting

使用操作符 ^ 使一个术语比另一个术语更相关。例如,如果我们想查找所有有关狐狸的文档,但我们对狐狸特别感兴趣:

1
quick^2 fox

默认提升值是 1,但可以是任何正浮点数。 0 到 1 之间的提升减少了相关性。

增强也可以应用于短语或组:

1
"john smith"^2   (foo bar)^4

2.1.8. 布尔操作

默认情况下,只要一个词匹配,所有词都是可选的。搜索 foo bar baz 将查找包含 foobarbaz 中的一个或多个的任何文档。我们已经讨论了上面的default_operator,它允许你强制要求所有的项,但也有布尔运算符可以在查询字符串本身中使用,以提供更多的控制。

首选的操作符是 +(此术语必须存在)和 - (此术语不得存在)。所有其他条款是可选的。例如,这个查询:

1
quick brown +fox -news

这条查询意味着:

  • fox 必须存在
  • news 必须不存在
  • quick 和 brown 是可有可无的

熟悉的运算符 ANDORNOT(也写成 &&||!)也被支持。然而,这些操作符有一定的优先级:NOT 优先于 ANDAND 优先于 OR。虽然 +- 仅影响运算符右侧的术语,但 ANDOR 会影响左侧和右侧的术语。

2.1.9. 分组

多个术语或子句可以用圆括号组合在一起,形成子查询

1
(quick OR brown) AND fox

可以使用组来定位特定的字段,或者增强子查询的结果:

1
status:(active OR pending) title:(full text search)^2

2.1.10. 保留字

如果你需要使用任何在你的查询本身中作为操作符的字符(而不是作为操作符),那么你应该用一个反斜杠来转义它们。例如,要搜索(1 + 1)= 2,您需要将查询写为 \(1\+1\)\=2

保留字符是:+ - = && || > < ! ( ) { } [ ] ^ " ~ * ? : \ /

无法正确地转义这些特殊字符可能会导致语法错误,从而阻止您的查询运行。

2.1.11. 空查询

如果查询字符串为空或仅包含空格,则查询将生成一个空的结果集。

可视化

要想使用可视化的方式展示您的数据,请单击侧面导航栏中的 可视化(Visualize)

Visualize 工具使您能够以多种方式(如饼图、柱状图、曲线图、分布图等)查看数据。要开始使用,请点击蓝色的 Create a visualization+ 按钮。

https://www.elastic.co/guide/en/kibana/6.1/images/tutorial-visualize-landing.png

有许多可视化类型可供选择。

https://www.elastic.co/guide/en/kibana/6.1/images/tutorial-visualize-wizard-step-1.png

下面,我们来看创建几个图标示例:

2.2.1. Pie

您可以从保存的搜索中构建可视化文件,也可以输入新的搜索条件。要输入新的搜索条件,首先需要选择一个索引模式来指定要搜索的索引。

默认搜索匹配所有文档。最初,一个“切片”包含整个饼图:

https://www.elastic.co/guide/en/kibana/6.1/images/tutorial-visualize-pie-1.png

要指定在图表中展示哪些数据,请使用 Elasticsearch 存储桶聚合。分组汇总只是将与您的搜索条件相匹配的文档分类到不同的分类中,也称为分组。

为每个范围定义一个存储桶:

  1. 单击 Split Slices
  2. Aggregation 列表中选择 Terms。_注意:这里的 Terms 是 Elk 采集数据时定义好的字段或标签_。
  3. Field 列表中选择 level.keyword
  4. 点击 images/apply-changes-button.png 按钮来更新图表。

image.png

完成后,如果想要保存这个图表,可以点击页面最上方一栏中的 Save 按钮。

2.2.2. Vertical Bar

我们在展示一下如何创建柱状图。

  1. 点击蓝色的 Create a visualization+ 按钮。选择 Vertical Bar
  2. 选择索引模式。由于您尚未定义任何 bucket ,因此您会看到一个大栏,显示与默认通配符查询匹配的文档总数。
  3. 指定 Y 轴所代表的字段
  4. 指定 X 轴所代表的字段
  5. 点击 images/apply-changes-button.png 按钮来更新图表。

image.png

完成后,如果想要保存这个图表,可以点击页面最上方一栏中的 Save 按钮。

报表

报表(Dashboard) 可以整合和共享 Visualize 集合。

  1. 点击侧面导航栏中的 Dashboard。
  2. 点击添加显示保存的可视化列表。
  3. 点击之前保存的 Visualize,然后点击列表底部的小向上箭头关闭可视化列表。
  4. 将鼠标悬停在可视化对象上会显示允许您编辑,移动,删除和调整可视化对象大小的容器控件。

FAQ

Kibana No Default Index Pattern Warning

问题:安装 ELK 后,访问 kibana 页面时,提示以下错误信息:

1
2
3
Warning No default index pattern. You must select or create one to continue.
...
Unable to fetch mapping. Do you have indices matching the pattern?

这就说明 logstash 没有把日志写入到 elasticsearch。

解决方法:

检查 logstash 与 elasticsearch 之间的通讯是否有问题,一般问题就出在这。

参考资料

Logstash

简介

Logstash 是开源的服务器端数据处理管道,能够同时从多个来源采集数据,转换数据,然后将数据发送到您最喜欢的“存储库”中。

功能

Logstash 是 Elasticsearch 的最佳数据管道。

Logstash 是插件式管理模式,在输入、过滤、输出以及编码过程中都可以使用插件进行定制。Logstash 社区有超过 200 种可用插件。

工作原理

Logstash 有两个必要元素:inputoutput ,一个可选元素:filter

这三个元素,分别代表 Logstash 事件处理的三个阶段:输入 > 过滤器 > 输出。

img

  • input 负责从数据源采集数据。
  • filter 将数据修改为你指定的格式或内容。
  • output 将数据传输到目的地。

在实际应用场景中,通常输入、输出、过滤器不止一个。Logstash 的这三个元素都使用插件式管理方式,用户可以根据应用需要,灵活的选用各阶段需要的插件,并组合使用。

后面将对插件展开讲解,暂且不表。

安装

安装步骤

安装步骤如下:

(1)在 logstash 官方下载地址 下载所需版本包并解压到本地。

(2)添加一个 logstash.conf 文件,指定要使用的插件以及每个插件的设置。举个简单的例子:

1
2
3
4
5
input { stdin { } }
output {
elasticsearch { hosts => ["localhost:9200"] }
stdout { codec => rubydebug }
}

(3)运行 bin/logstash -f logstash.conf (Windows 上运行bin/logstash.bat -f logstash.conf

配置

设置文件

  • **logstash.yml**:logstash 的默认启动配置文件
  • **jvm.options**:logstash 的 JVM 配置文件。
  • startup.options (Linux):包含系统安装脚本在 /usr/share/logstash/bin 中使用的选项为您的系统构建适当的启动脚本。安装 Logstash 软件包时,系统安装脚本将在安装过程结束时执行,并使用 startup.options 中指定的设置来设置用户,组,服务名称和服务描述等选项。

logstash.yml 设置项

节选部分设置项,更多项请参考:https://www.elastic.co/guide/en/logstash/current/logstash-settings-file.html

参数 描述 默认值
node.name 节点名 机器的主机名
path.data Logstash 及其插件用于任何持久性需求的目录。 LOGSTASH_HOME/data
pipeline.workers 同时执行管道的过滤器和输出阶段的工作任务数量。如果发现事件正在备份,或 CPU 未饱和,请考虑增加此数字以更好地利用机器处理能力。 Number of the host’s CPU cores
pipeline.batch.size 尝试执行过滤器和输出之前,单个工作线程从输入收集的最大事件数量。较大的批量处理大小一般来说效率更高,但是以增加的内存开销为代价。您可能必须通过设置 LS_HEAP_SIZE 变量来有效使用该选项来增加 JVM 堆大小。 125
pipeline.batch.delay 创建管道事件批处理时,在将一个尺寸过小的批次发送给管道工作任务之前,等待每个事件需要多长时间(毫秒)。 5
pipeline.unsafe_shutdown 如果设置为 true,则即使在内存中仍存在 inflight 事件时,也会强制 Logstash 在关闭期间退出。默认情况下,Logstash 将拒绝退出,直到所有接收到的事件都被推送到输出。启用此选项可能会导致关机期间数据丢失。 false
path.config 主管道的 Logstash 配置路径。如果您指定一个目录或通配符,配置文件将按字母顺序从目录中读取。 Platform-specific. See [dir-layout].
config.string 包含用于主管道的管道配置的字符串。使用与配置文件相同的语法。 None
config.test_and_exit 设置为 true 时,检查配置是否有效,然后退出。请注意,使用此设置不会检查 grok 模式的正确性。 Logstash 可以从目录中读取多个配置文件。如果将此设置与 log.level:debug 结合使用,则 Logstash 将记录组合的配置文件,并注掉其源文件的配置块。 false
config.reload.automatic 设置为 true 时,定期检查配置是否已更改,并在配置更改时重新加载配置。这也可以通过 SIGHUP 信号手动触发。 false
config.reload.interval Logstash 检查配置文件更改的时间间隔。 3s
config.debug 设置为 true 时,将完全编译的配置显示为调试日志消息。您还必须设置log.level:debug。警告:日志消息将包括任何传递给插件配置作为明文的“密码”选项,并可能导致明文密码出现在您的日志! false
config.support_escapes 当设置为 true 时,带引号的字符串将处理转义字符。 false
modules 配置时,模块必须处于上表所述的嵌套 YAML 结构中。 None
http.host 绑定地址 "127.0.0.1"
http.port 绑定端口 9600
log.level 日志级别。有效选项:fatal > error > warn > info > debug > trace info
log.format 日志格式。json (JSON 格式)或 plain (原对象) plain
path.logs Logstash 自身日志的存储路径 LOGSTASH_HOME/logs
path.plugins 在哪里可以找到自定义的插件。您可以多次指定此设置以包含多个路径。

启动

命令行

通过命令行启动 logstash 的方式如下:

1
bin/logstash [options]

其中 options 是您可以指定用于控制 Logstash 执行的命令行标志。

在命令行上设置的任何标志都会覆盖 Logstash 设置文件(logstash.yml)中的相应设置,但设置文件本身不会更改。

虽然可以通过指定命令行参数的方式,来控制 logstash 的运行方式,但显然这么做很麻烦。

建议通过指定配置文件的方式,来控制 logstash 运行,启动命令如下:

1
bin/logstash -f logstash.conf

若想了解更多的命令行参数细节,请参考:https://www.elastic.co/guide/en/logstash/current/running-logstash-command-line.html

配置文件

上节,我们了解到,logstash 可以执行 bin/logstash -f logstash.conf ,按照配置文件中的参数去覆盖默认设置文件(logstash.yml)中的设置。

这节,我们就来学习一下这个配置文件如何配置参数。

配置文件结构

在工作原理一节中,我们已经知道了 Logstash 主要有三个工作阶段 input 、filter、output。而 logstash 配置文件文件结构也与之相对应:

1
2
3
4
5
input {}

filter {}

output {}

每个部分都包含一个或多个插件的配置选项。如果指定了多个过滤器,则会按照它们在配置文件中的显示顺序应用它们。

插件配置

插件的配置由插件名称和插件的一个设置块组成。

下面的例子中配置了两个输入文件配置:

1
2
3
4
5
6
7
8
9
10
11
input {
file {
path => "/var/log/messages"
type => "syslog"
}

file {
path => "/var/log/apache/access.log"
type => "apache"
}
}

您可以配置的设置因插件类型而异。你可以参考: Input Plugins, Output Plugins, Filter Plugins, 和 Codec Plugins

值类型

一个插件可以要求设置的值是一个特定的类型,比如布尔值,列表或哈希值。以下值类型受支持。

  • Array
1
users => [ {id => 1, name => bob}, {id => 2, name => jane} ]
  • Lists
1
2
;(path) => ['/var/log/messages', '/var/log/*.log']
;(uris) => ['http://elastic.co', 'http://example.net']
  • Boolean
1
;(ssl_enable) => true
  • Bytes
1
2
3
4
my_bytes => "1113"   # 1113 bytes
my_bytes => "10MiB" # 10485760 bytes
my_bytes => "100kib" # 102400 bytes
my_bytes => "180 mb" # 180000000 bytes
  • Codec
1
;(codec) => 'json'
  • Hash
1
2
3
4
5
match => {
"field1" => "value1"
"field2" => "value2"
...
}
  • Number
1
;(port) => 33
  • Password
1
;(my_password) => 'password'
  • URI
1
;(my_uri) => 'http://foo:bar@example.net'
  • Path
1
;(my_path) => '/tmp/logstash'
  • String

  • 转义字符

插件

input

Logstash 支持各种输入选择 ,可以在同一时间从众多常用来源捕捉事件。能够以连续的流式传输方式,轻松地从您的日志、指标、Web 应用、数据存储以及各种 AWS 服务采集数据。

常用 input 插件

  • file:从文件系统上的文件读取,就像 UNIX 命令 tail -0F 一样
  • syslog:在众所周知的端口 514 上侦听系统日志消息,并根据 RFC3164 格式进行解析
  • redis:从 redis 服务器读取,使用 redis 通道和 redis 列表。 Redis 经常用作集中式 Logstash 安装中的“代理”,它将来自远程 Logstash“托运人”的 Logstash 事件排队。
  • beats:处理由 Filebeat 发送的事件。

更多详情请见:Input Plugins

filter

过滤器是 Logstash 管道中的中间处理设备。如果符合特定条件,您可以将条件过滤器组合在一起,对事件执行操作。

常用 filter 插件

  • grok:解析和结构任意文本。 Grok 目前是 Logstash 中将非结构化日志数据解析为结构化和可查询的最佳方法。
  • mutate:对事件字段执行一般转换。您可以重命名,删除,替换和修改事件中的字段。
  • drop:完全放弃一个事件,例如调试事件。
  • clone:制作一个事件的副本,可能会添加或删除字段。
  • geoip:添加有关 IP 地址的地理位置的信息(也可以在 Kibana 中显示惊人的图表!)

更多详情请见:Filter Plugins

output

输出是 Logstash 管道的最后阶段。一个事件可以通过多个输出,但是一旦所有输出处理完成,事件就完成了执行。

常用 output 插件

  • elasticsearch:将事件数据发送给 Elasticsearch(推荐模式)。
  • file:将事件数据写入文件或磁盘。
  • graphite:将事件数据发送给 graphite(一个流行的开源工具,存储和绘制指标。 http://graphite.readthedocs.io/en/latest/)。
  • statsd:将事件数据发送到 statsd (这是一种侦听统计数据的服务,如计数器和定时器,通过 UDP 发送并将聚合发送到一个或多个可插入的后端服务)。

更多详情请见:Output Plugins

codec

用于格式化对应的内容。

常用 codec 插件

  • json:以 JSON 格式对数据进行编码或解码。
  • multiline:将多行文本事件(如 java 异常和堆栈跟踪消息)合并为单个事件。

更多插件请见:Codec Plugins

实战

前面的内容都是对 Logstash 的介绍和原理说明。接下来,我们来实战一些常见的应用场景。

传输控制台数据

stdin input 插件从标准输入读取事件。这是最简单的 input 插件,一般用于测试场景。

应用

(1)创建 logstash-input-stdin.conf

1
2
3
4
5
input { stdin { } }
output {
elasticsearch { hosts => ["localhost:9200"] }
stdout { codec => rubydebug }
}

更多配置项可以参考:https://www.elastic.co/guide/en/logstash/current/plugins-inputs-stdin.html

(2)执行 logstash,使用 -f 来指定你的配置文件:

1
bin/logstash -f logstash-input-stdin.conf

传输 logback 日志

elk 默认使用的 Java 日志工具是 log4j2 ,并不支持 logback 和 log4j。

想使用 logback + logstash ,可以使用 logstash-logback-encoderlogstash-logback-encoder 提供了 UDP / TCP / 异步方式来传输日志数据到 logstash。

如果你使用的是 log4j ,也不是不可以用这种方式,只要引入桥接 jar 包即可。如果你对 log4j 、logback ,或是桥接 jar 包不太了解,可以参考我的这篇博文:细说 Java 主流日志工具库

TCP 应用

logstash 配置

(1)创建 logstash-input-tcp.conf

1
2
3
4
5
6
7
8
9
10
input {
tcp {
port => 9251
codec => json_lines
mode => server
}
output {
elasticsearch { hosts => ["localhost:9200"] }
stdout { codec => rubydebug }
}

更多配置项可以参考:https://www.elastic.co/guide/en/logstash/current/plugins-inputs-tcp.html

(2)执行 logstash,使用 -f 来指定你的配置文件:bin/logstash -f logstash-input-udp.conf

java 应用配置

(1)在 Java 应用的 pom.xml 中引入 jar 包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>4.11</version>
</dependency>

<!-- logback 依赖包 -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-core</artifactId>
<version>1.2.3</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-access</artifactId>
<version>1.2.3</version>
</dependency>

(2)接着,在 logback.xml 中添加 appender

1
2
3
4
5
6
7
8
9
10
11
<appender name="ELK-TCP" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<!--
destination 是 logstash 服务的 host:port,
相当于和 logstash 建立了管道,将日志数据定向传输到 logstash
-->
<destination>192.168.28.32:9251</destination>
<encoder charset="UTF-8" class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<logger name="io.github.dunwu.spring" level="TRACE" additivity="false">
<appender-ref ref="ELK-TCP" />
</logger>

(3)接下来,就是 logback 的具体使用 ,如果对此不了解,不妨参考一下我的这篇博文:细说 Java 主流日志工具库

实例:我的 logback.xml

UDP 应用

UDP 和 TCP 的使用方式大同小异。

logstash 配置

(1)创建 logstash-input-udp.conf

1
2
3
4
5
6
7
8
9
input {
udp {
port => 9250
codec => json
}
output {
elasticsearch { hosts => ["localhost:9200"] }
stdout { codec => rubydebug }
}

更多配置项可以参考:https://www.elastic.co/guide/en/logstash/current/plugins-inputs-udp.html

(2)执行 logstash,使用 -f 来指定你的配置文件:bin/logstash -f logstash-input-udp.conf

java 应用配置

(1)在 Java 应用的 pom.xml 中引入 jar 包:

TCP 应用 一节中的引入依赖包完全相同。

(2)接着,在 logback.xml 中添加 appender

1
2
3
4
5
6
7
<appender name="ELK-UDP" class="net.logstash.logback.appender.LogstashSocketAppender">
<host>192.168.28.32</host>
<port>9250</port>
</appender>
<logger name="io.github.dunwu.spring" level="TRACE" additivity="false">
<appender-ref ref="ELK-UDP" />
</logger>

(3)接下来,就是 logback 的具体使用 ,如果对此不了解,不妨参考一下我的这篇博文:细说 Java 主流日志工具库

实例:我的 logback.xml

传输文件

在 Java Web 领域,需要用到一些重要的工具,例如 Tomcat 、Nginx 、Mysql 等。这些不属于业务应用,但是它们的日志数据对于定位问题、分析统计同样很重要。这时无法使用 logback 方式将它们的日志传输到 logstash。

如何采集这些日志文件呢?别急,你可以使用 logstash 的 file input 插件。

需要注意的是,传输文件这种方式,必须在日志所在的机器上部署 logstash 。

应用

logstash 配置

(1)创建 logstash-input-file.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
input {
file {
path => ["/var/log/nginx/access.log"]
type => "nginx-access-log"
start_position => "beginning"
}
}

output {
if [type] == "nginx-access-log" {
elasticsearch {
hosts => ["localhost:9200"]
index => "nginx-access-log"
}
}
}

(2)执行 logstash,使用 -f 来指定你的配置文件:bin/logstash -f logstash-input-file.conf

更多配置项可以参考:https://www.elastic.co/guide/en/logstash/current/plugins-inputs-file.html

小技巧

启动、终止应用

如果你的 logstash 每次都是通过指定配置文件方式启动。不妨建立一个启动脚本。

1
2
# cd xxx 进入 logstash 安装目录下的 bin 目录
logstash -f logstash.conf

如果你的 logstash 运行在 linux 系统下,不妨使用 nohup 来启动一个守护进程。这样做的好处在于,即使关闭终端,应用仍会运行。

创建 startup.sh

1
nohup ./logstash -f logstash.conf >> nohup.out 2>&1 &

终止应用没有什么好方法,你只能使用 ps -ef | grep logstash ,查出进程,将其 kill 。不过,我们可以写一个脚本来干这件事:

创建 shutdown.sh

脚本不多解释,请自行领会作用。

1
2
PID=`ps -ef | grep logstash | awk '{ print $2}' | head -n 1`
kill -9 ${PID}

参考资料

ElasticSearch API

Elasticsearch 是一个分布式、RESTful 风格的搜索和数据分析引擎,能够解决不断涌现出的各种用例。 作为 Elastic Stack 的核心,它集中存储您的数据,帮助您发现意料之中以及意料之外的情况。

Elasticsearch 基于搜索库 Lucene 开发。ElasticSearch 隐藏了 Lucene 的复杂性,提供了简单易用的 REST API / Java API 接口(另外还有其他语言的 API 接口)。

_以下简称 ES_。

REST API 最详尽的文档应该参考:ES 官方 REST API

ElasticSearch API 简介

Elasticsearch 官方提供了很多版本的 Java 客户端,包含但不限于:

如果当前是:8.X 版本,推荐 Elasticsearch Java API客户端。

如果当前是:7.X 版本且不考虑升级,推荐 High Level REST客户端。

如果当前是:5.X、6.X 版本,推荐尽早升级集群版本。

Elasticsearch Java API Client 快速入门

:::detail 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//创建一个低级的客户端
final RestClient restClient = RestClient.builder(new HttpHost("localhost", 9200)).build();
//创建 JSON 对象映射器
final RestClientTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
//创建 API 客户端
final ElasticsearchClient client = new ElasticsearchClient(transport);
//查询所有索引-------------------------------------------------------------------------------------
final GetIndexResponse response = client.indices().get(query -> query.index("_all"));
final IndexState products = response.result().get("products");
System.out.println(products.toString());
//关闭
client.shutdown();
transport.close();
restClient.close();

:::

Transport Client 快速入门

TransportClient 使用 transport 模块远程连接到 Elasticsearch 集群。它不会加入集群,而只是获取一个或多个初始传输地址,并以轮询方式与它们通信。

扩展:https://www.elastic.co/guide/en/elasticsearch/client/java-api/current/transport-client.html

:::detail 示例

启动客户端:

1
2
3
4
5
6
7
// 启动
TransportClient client = new PreBuiltTransportClient(Settings.EMPTY)
.addTransportAddress(new TransportAddress(InetAddress.getByName("host1"), 9300))
.addTransportAddress(new TransportAddress(InetAddress.getByName("host2"), 9300));

// 关闭
client.close();

配置集群名称

注意,如果使用的集群名称与 “elasticsearch” 不同,则必须设置集群名称。

1
2
3
4
Settings settings = Settings.builder()
.put("cluster.name", "myClusterName").build();
TransportClient client = new PreBuiltTransportClient(settings);
// Add transport addresses and do something with the client...

启用 sniffing

1
2
3
Settings settings = Settings.builder()
.put("client.transport.sniff", true).build();
TransportClient client = new PreBuiltTransportClient(settings);

:::

ElasticSearch Rest

ElasticSearch Rest API 语法格式

向 Elasticsearch 发出的请求的组成部分与其它普通的 HTTP 请求是一样的:

1
curl -X<VERB> '<PROTOCOL>://<HOST>:<PORT>/<PATH>?<QUERY_STRING>' -d '<BODY>'
  • VERB:HTTP 方法,支持:GET, POST, PUT, HEAD, DELETE
  • PROTOCOL:http 或者 https 协议(只有在 Elasticsearch 前面有 https 代理的时候可用)
  • HOST:Elasticsearch 集群中的任何一个节点的主机名,如果是在本地的节点,那么就叫 localhost
  • PORT:Elasticsearch HTTP 服务所在的端口,默认为 9200 PATH API 路径(例如、_count 将返回集群中文档的数量),
  • PATH:可以包含多个组件,例如 _cluster/stats 或者 _nodes/stats/jvm
  • QUERY_STRING:一些可选的查询请求参数,例如?pretty 参数将使请求返回更加美观易读的 JSON 数据
  • BODY:一个 JSON 格式的请求主体(如果请求需要的话)

ElasticSearch Rest API 分为两种:

  • URI Search:在 URL 中使用查询参数
  • Request Body Search:基于 JSON 格式的、更加完备的 DSL

URI Search 示例:

Request Body Search 示例:

索引 API

参考资料:Elasticsearch 官方之 cat 索引 API

创建索引

新建 Index,可以直接向 ES 服务器发出 PUT 请求。

语法格式:

1
2
3
4
5
6
7
8
9
PUT /my_index
{
"settings": { ... any settings ... },
"mappings": {
"type_one": { ... any mappings ... },
"type_two": { ... any mappings ... },
...
}
}

示例:

1
2
3
4
5
6
7
8
9
PUT /user
{
"settings": {
"index": {
"number_of_shards": 3,
"number_of_replicas": 2
}
}
}

服务器返回一个 JSON 对象,里面的 acknowledged 字段表示操作成功。

1
{"acknowledged":true,"shards_acknowledged":true,"index":"user"}

如果你想禁止自动创建索引,可以通过在 config/elasticsearch.yml 的每个节点下添加下面的配置:

1
action.auto_create_index: false

删除索引

然后,我们可以通过发送 DELETE 请求,删除这个 Index。

1
DELETE /user

删除多个索引

1
2
DELETE /index_one,index_two
DELETE /index_*

查看索引

可以通过 GET 请求查看索引信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 查看索引相关信息
GET kibana_sample_data_ecommerce

# 查看索引的文档总数
GET kibana_sample_data_ecommerce/_count

# 查看前 10 条文档,了解文档格式
GET kibana_sample_data_ecommerce/_search

# _cat indices API
# 查看 indices
GET /_cat/indices/kibana*?v&s=index

# 查看状态为绿的索引
GET /_cat/indices?v&health=green

# 按照文档个数排序
GET /_cat/indices?v&s=docs.count:desc

# 查看具体的字段
GET /_cat/indices/kibana*?pri&v&h=health,index,pri,rep,docs.count,mt

# 查看索引占用的内存
GET /_cat/indices?v&h=i,tm&s=tm:desc

索引别名

ES 的索引别名就是给一个索引或者多个索引起的另一个名字,典型的应用场景是针对索引使用的平滑切换。

首先,创建索引 my_index,然后将别名 my_alias 指向它,示例如下:

1
2
PUT /my_index
PUT /my_index/_alias/my_alias

也可以通过如下形式:

1
2
3
4
5
6
POST /_aliases
{
"actions": [
{ "add": { "index": "my_index", "alias": "my_alias" }}
]
}

也可以在一次请求中增加别名和移除别名混合使用:

1
2
3
4
5
6
7
POST /_aliases
{
"actions": [
{ "remove": { "index": "my_index", "alias": "my_alias" }}
{ "add": { "index": "my_index_v2", "alias": "my_alias" }}
]
}

需要注意的是,如果别名与索引是一对一的,使用别名索引文档或者查询文档是可以的,但是如果别名和索引是一对多的,使用别名会发生错误,因为 ES 不知道把文档写入哪个索引中去或者从哪个索引中读取文档。

ES 索引别名有个典型的应用场景是平滑切换,更多细节可以查看 Elasticsearch(ES)索引零停机(无需重启)无缝平滑切换的方法

打开/关闭索引

通过在 POST 中添加 _close_open 可以打开、关闭索引。

打开索引

1
2
3
4
# 打开索引
POST kibana_sample_data_ecommerce/_open
# 关闭索引
POST kibana_sample_data_ecommerce/_close

文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
############Create Document############
#create document. 自动生成 _id
POST users/_doc
{
"user" : "Mike",
"post_date" : "2019-04-15T14:12:12",
"message" : "trying out Kibana"
}

#create document. 指定 Id。如果 id 已经存在,报错
PUT users/_doc/1?op_type=create
{
"user" : "Jack",
"post_date" : "2019-05-15T14:12:12",
"message" : "trying out Elasticsearch"
}

#create document. 指定 ID 如果已经存在,就报错
PUT users/_create/1
{
"user" : "Jack",
"post_date" : "2019-05-15T14:12:12",
"message" : "trying out Elasticsearch"
}

### Get Document by ID
#Get the document by ID
GET users/_doc/1

### Index & Update
#Update 指定 ID (先删除,在写入)
GET users/_doc/1

PUT users/_doc/1
{
"user" : "Mike"

}

#GET users/_doc/1
#在原文档上增加字段
POST users/_update/1/
{
"doc":{
"post_date" : "2019-05-15T14:12:12",
"message" : "trying out Elasticsearch"
}
}

### Delete by Id
# 删除文档
DELETE users/_doc/1

### Bulk 操作
#执行两次,查看每次的结果

#执行第 1 次
POST _bulk
{ "index" : { "_index" : "test", "_id" : "1" } }
{ "field1" : "value1" }
{ "delete" : { "_index" : "test", "_id" : "2" } }
{ "create" : { "_index" : "test2", "_id" : "3" } }
{ "field1" : "value3" }
{ "update" : {"_id" : "1", "_index" : "test"} }
{ "doc" : {"field2" : "value2"} }

#执行第 2 次
POST _bulk
{ "index" : { "_index" : "test", "_id" : "1" } }
{ "field1" : "value1" }
{ "delete" : { "_index" : "test", "_id" : "2" } }
{ "create" : { "_index" : "test2", "_id" : "3" } }
{ "field1" : "value3" }
{ "update" : {"_id" : "1", "_index" : "test"} }
{ "doc" : {"field2" : "value2"} }

### mget 操作
GET /_mget
{
"docs" : [
{
"_index" : "test",
"_id" : "1"
},
{
"_index" : "test",
"_id" : "2"
}
]
}

#URI 中指定 index
GET /test/_mget
{
"docs" : [
{

"_id" : "1"
},
{

"_id" : "2"
}
]
}

GET /_mget
{
"docs" : [
{
"_index" : "test",
"_id" : "1",
"_source" : false
},
{
"_index" : "test",
"_id" : "2",
"_source" : ["field3", "field4"]
},
{
"_index" : "test",
"_id" : "3",
"_source" : {
"include": ["user"],
"exclude": ["user.location"]
}
}
]
}

### msearch 操作
POST kibana_sample_data_ecommerce/_msearch
{}
{"query" : {"match_all" : {}},"size":1}
{"index" : "kibana_sample_data_flights"}
{"query" : {"match_all" : {}},"size":2}

### 清除测试数据
#清除数据
DELETE users
DELETE test
DELETE test2

创建文档

指定 ID

语法格式:

1
PUT /_index/_type/_create/_id

示例:

1
2
3
4
5
6
PUT /user/_doc/_create/1
{
"user": "张三",
"title": "工程师",
"desc": "数据库管理"
}

注意:指定 Id,如果 id 已经存在,则报错

自动生成 ID

新增记录的时候,也可以不指定 Id,这时要改成 POST 请求。

语法格式:

1
POST /_index/_type

示例:

1
2
3
4
5
6
POST /user/_doc
{
"user": "张三",
"title": "工程师",
"desc": "超级管理员"
}

删除文档

语法格式:

1
DELETE /_index/_doc/_id

示例:

1
DELETE /user/_doc/1

更新文档

先删除,再写入

语法格式:

1
PUT /_index/_type/_id

示例:

1
2
3
4
5
6
PUT /user/_doc/1
{
"user": "李四",
"title": "工程师",
"desc": "超级管理员"
}

在原文档上增加字段

语法格式:

1
POST /_index/_update/_id

示例:

1
2
3
4
5
6
POST /user/_update/1
{
"doc":{
"age" : "30"
}
}

查询文档

指定 ID 查询

语法格式:

1
GET /_index/_type/_id

示例:

1
GET /user/_doc/1

结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"_index": "user",
"_type": "_doc",
"_id": "1",
"_version": 1,
"_seq_no": 536248,
"_primary_term": 2,
"found": true,
"_source": {
"user": "张三",
"title": "工程师",
"desc": "数据库管理"
}
}

返回的数据中,found 字段表示查询成功,_source 字段返回原始记录。

如果 id 不正确,就查不到数据,found 字段就是 false

查询所有记录

使用 GET 方法,直接请求 /index/type/_search,就会返回所有记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
$ curl 'localhost:9200/user/admin/_search?pretty'
{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 3,
"successful" : 3,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 2,
"max_score" : 1.0,
"hits" : [
{
"_index" : "user",
"_type" : "admin",
"_id" : "WWuoDG8BHwECs7SiYn93",
"_score" : 1.0,
"_source" : {
"user" : "李四",
"title" : "工程师",
"desc" : "系统管理"
}
},
{
"_index" : "user",
"_type" : "admin",
"_id" : "1",
"_score" : 1.0,
"_source" : {
"user" : "张三",
"title" : "工程师",
"desc" : "超级管理员"
}
}
]
}
}

上面代码中,返回结果的 took字段表示该操作的耗时(单位为毫秒),timed_out字段表示是否超时,hits字段表示命中的记录,里面子字段的含义如下。

  • total:返回记录数,本例是 2 条。
  • max_score:最高的匹配程度,本例是1.0
  • hits:返回的记录组成的数组。

返回的记录中,每条记录都有一个_score字段,表示匹配的程序,默认是按照这个字段降序排列。

全文搜索

ES 的查询非常特别,使用自己的 查询语法,要求 GET 请求带有数据体。

1
2
3
4
$ curl -H 'Content-Type: application/json' 'localhost:9200/user/admin/_search?pretty'  -d '
{
"query" : { "match" : { "desc" : "管理" }}
}'

上面代码使用 Match 查询,指定的匹配条件是desc字段里面包含”软件”这个词。返回结果如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{
"took" : 2,
"timed_out" : false,
"_shards" : {
"total" : 3,
"successful" : 3,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 2,
"max_score" : 0.38200712,
"hits" : [
{
"_index" : "user",
"_type" : "admin",
"_id" : "WWuoDG8BHwECs7SiYn93",
"_score" : 0.38200712,
"_source" : {
"user" : "李四",
"title" : "工程师",
"desc" : "系统管理"
}
},
{
"_index" : "user",
"_type" : "admin",
"_id" : "1",
"_score" : 0.3487891,
"_source" : {
"user" : "张三",
"title" : "工程师",
"desc" : "超级管理员"
}
}
]
}
}

Elastic 默认一次返回 10 条结果,可以通过size字段改变这个设置,还可以通过from字段,指定位移。

1
2
3
4
5
6
$ curl 'localhost:9200/user/admin/_search'  -d '
{
"query" : { "match" : { "desc" : "管理" }},
"from": 1,
"size": 1
}'

上面代码指定,从位置 1 开始(默认是从位置 0 开始),只返回一条结果。

逻辑运算

如果有多个搜索关键字, Elastic 认为它们是or关系。

1
2
3
4
$ curl 'localhost:9200/user/admin/_search'  -d '
{
"query" : { "match" : { "desc" : "软件 系统" }}
}'

上面代码搜索的是软件 or 系统

如果要执行多个关键词的and搜索,必须使用 布尔查询

1
2
3
4
5
6
7
8
9
10
11
$ curl -H 'Content-Type: application/json' 'localhost:9200/user/admin/_search?pretty'  -d '
{
"query": {
"bool": {
"must": [
{ "match": { "desc": "管理" } },
{ "match": { "desc": "超级" } }
]
}
}
}'

批量执行

支持在一次 API 调用中,对不同的索引进行操作

支持四种类型操作

  • index
  • create
  • update
  • delete

操作中单条操作失败,并不会影响其他操作。

返回结果包括了每一条操作执行的结果。

1
2
3
4
5
6
7
8
POST _bulk
{ "index" : { "_index" : "test", "_id" : "1" } }
{ "field1" : "value1" }
{ "delete" : { "_index" : "test", "_id" : "2" } }
{ "create" : { "_index" : "test2", "_id" : "3" } }
{ "field1" : "value3" }
{ "update" : {"_id" : "1", "_index" : "test"} }
{ "doc" : {"field2" : "value2"} }

说明:上面的示例如果执行多次,执行结果都不一样。

批量读取

读多个索引

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /_mget
{
"docs" : [
{
"_index" : "test",
"_id" : "1"
},
{
"_index" : "test",
"_id" : "2"
}
]
}

读一个索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
GET /test/_mget
{
"docs" : [
{

"_id" : "1"
},
{

"_id" : "2"
}
]
}

GET /_mget
{
"docs" : [
{
"_index" : "test",
"_id" : "1",
"_source" : false
},
{
"_index" : "test",
"_id" : "2",
"_source" : ["field3", "field4"]
},
{
"_index" : "test",
"_id" : "3",
"_source" : {
"include": ["user"],
"exclude": ["user.location"]
}
}
]
}

批量查询

1
2
3
4
5
POST kibana_sample_data_ecommerce/_msearch
{}
{"query" : {"match_all" : {}},"size":1}
{"index" : "kibana_sample_data_flights"}
{"query" : {"match_all" : {}},"size":2}

URI Search 查询语义

Elasticsearch URI Search 遵循 QueryString 查询语义,其形式如下:

1
2
3
4
GET /movies/_search?q=2012&df=title&sort=year:desc&from=0&size=10&timeout=1s
{
"profile": true
}
  • q 指定查询语句,使用 QueryString 语义
  • df 默认字段,不指定时
  • sort 排序:from 和 size 用于分页
  • profile 可以查看查询时如何被执行的
1
2
3
4
GET /movies/_search?q=title:2012&sort=year:desc&from=0&size=10&timeout=1s
{
"profile":"true"
}

Term 和 Phrase

Beautiful Mind 等效于 Beautiful OR Mind

“Beautiful Mind” 等效于 Beautiful AND Mind

1
2
3
4
5
6
7
8
9
10
11
# Term 查询
GET /movies/_search?q=title:Beautiful Mind
{
"profile":"true"
}

# 使用引号,Phrase 查询
GET /movies/_search?q=title:"Beautiful Mind"
{
"profile":"true"
}

分组与引号

title:(Beautiful AND Mind)

title=”Beautiful Mind”

AND、OR、NOT 或者 &&、||、!

注意:AND、OR、NOT 必须大写

1
2
3
4
5
6
7
8
9
10
# 布尔操作符
GET /movies/_search?q=title:(Beautiful AND Mind)
{
"profile":"true"
}

GET /movies/_search?q=title:(Beautiful NOT Mind)
{
"profile":"true"
}

范围查询

  • [] 表示闭区间
  • {} 表示开区间

示例:

1
2
3
4
5
6
7
8
9
10
# 范围查询 , 区间写法
GET /movies/_search?q=title:beautiful AND year:{2010 TO 2018%7D
{
"profile":"true"
}

GET /movies/_search?q=title:beautiful AND year:[* TO 2018]
{
"profile":"true"
}

算数符号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 2010 年以后的记录
GET /movies/_search?q=year:>2010
{
"profile":"true"
}

# 2010 年到 2018 年的记录
GET /movies/_search?q=year:(>2010 && <=2018)
{
"profile":"true"
}

# 2010 年到 2018 年的记录
GET /movies/_search?q=year:(+>2010 +<=2018)
{
"profile":"true"
}

通配符查询

  • ? 代表 1 个字符
  • * 代表 0 或多个字符

示例:

1
2
3
4
5
6
7
8
9
GET /movies/_search?q=title:mi?d
{
"profile":"true"
}

GET /movies/_search?q=title:b*
{
"profile":"true"
}

正则表达式

title:[bt]oy

模糊匹配与近似查询

示例:

1
2
3
4
5
6
7
8
9
10
11
# 相似度在 1 个字符以内
GET /movies/_search?q=title:beautifl~1
{
"profile":"true"
}

# 相似度在 2 个字符以内
GET /movies/_search?q=title:"Lord Rings"~2
{
"profile":"true"
}

Request Body & DSL

Elasticsearch 除了 URI Search 查询方式,还支持将查询语句通过 Http Request Body 发起查询。

1
2
3
4
5
6
7
GET /kibana_sample_data_ecommerce/_search?ignore_unavailable=true
{
"profile":"true",
"query": {
"match_all": {}
}
}

分页

1
2
3
4
5
6
7
8
9
GET /kibana_sample_data_ecommerce/_search?ignore_unavailable=true
{
"profile": "true",
"from": 0,
"size": 10,
"query": {
"match_all": {}
}
}

排序

最好在数字型或日期型字段上排序

因为对于多值类型或分析过的字段排序,系统会选一个值,无法得知该值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GET /kibana_sample_data_ecommerce/_search?ignore_unavailable=true
{
"profile": "true",
"sort": [
{
"order_date": "desc"
}
],
"from": 1,
"size": 10,
"query": {
"match_all": {}
}
}

_source 过滤

如果 _source 没有存储,那就只返回匹配的文档的元数据

_source 支持使用通配符,如:_source["name*", "desc*"]

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /kibana_sample_data_ecommerce/_search?ignore_unavailable=true
{
"profile": "true",
"_source": [
"order_date",
"category.keyword"
],
"from": 1,
"size": 10,
"query": {
"match_all": {}
}
}

脚本字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
GET /kibana_sample_data_ecommerce/_search?ignore_unavailable=true
{
"profile": "true",
"script_fields": {
"new_field": {
"script": {
"lang": "painless",
"source":"doc['order_date'].value+' hello'"
}
}
},
"from": 1,
"size": 10,
"query": {
"match_all": {}
}
}

使用查询表达式 - Match

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST movies/_search
{
"query": {
"match": {
"title": "last christmas"
}
}
}

POST movies/_search
{
"query": {
"match": {
"title": {
"query": "last christmas",
"operator": "and"
}
}
}
}

短语搜索 - Match Phrase

1
2
3
4
5
6
7
8
9
10
11
POST movies/_search
{
"query": {
"match_phrase": {
"title":{
"query": "last christmas"

}
}
}
}

集群 API

Elasticsearch 官方之 Cluster API

一些集群级别的 API 可能会在节点的子集上运行,这些节点可以用节点过滤器指定。例如,任务管理、节点统计和节点信息 API 都可以报告来自一组过滤节点而不是所有节点的结果。

节点过滤器以逗号分隔的单个过滤器列表的形式编写,每个过滤器从所选子集中添加或删除节点。每个过滤器可以是以下之一:

  • _all:将所有节点添加到子集
  • _local:将本地节点添加到子集
  • _master:将当前主节点添加到子集
  • 根据节点 ID 或节点名将匹配节点添加到子集
  • 根据 IP 地址或主机名将匹配节点添加到子集
  • 使用通配符,将节点名、地址名或主机名匹配的节点添加到子集
  • master:true, data:true, ingest:true, voting_only:true, ml:truecoordinating_only:true, 分别意味着将所有主节点、所有数据节点、所有摄取节点、所有仅投票节点、所有机器学习节点和所有协调节点添加到子集中。
  • master:false, data:false, ingest:false, voting_only:true, ml:falsecoordinating_only:false, 分别意味着将所有主节点、所有数据节点、所有摄取节点、所有仅投票节点、所有机器学习节点和所有协调节点排除在子集外。
  • 配对模式,使用 * 通配符,格式为 attrname:attrvalue,将所有具有自定义节点属性的节点添加到子集中,其名称和值与相应的模式匹配。自定义节点属性是通过 node.attr.attrname: attrvalue 形式在配置文件中设置的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 如果没有给出过滤器,默认是查询所有节点
GET /_nodes
# 查询所有节点
GET /_nodes/_all
# 查询本地节点
GET /_nodes/_local
# 查询主节点
GET /_nodes/_master
# 根据名称查询节点(支持通配符)
GET /_nodes/node_name_goes_here
GET /_nodes/node_name_goes_*
# 根据地址查询节点(支持通配符)
GET /_nodes/10.0.0.3,10.0.0.4
GET /_nodes/10.0.0.*
# 根据规则查询节点
GET /_nodes/_all,master:false
GET /_nodes/data:true,ingest:true
GET /_nodes/coordinating_only:true
GET /_nodes/master:true,voting_only:false
# 根据自定义属性查询节点(如:查询配置文件中含 node.attr.rack:2 属性的节点)
GET /_nodes/rack:2
GET /_nodes/ra*:2
GET /_nodes/ra*:2*

集群健康 API

1
2
3
4
GET /_cluster/health
GET /_cluster/health?level=shards
GET /_cluster/health/kibana_sample_data_ecommerce,kibana_sample_data_flights
GET /_cluster/health/kibana_sample_data_flights?level=shards

集群状态 API

集群状态 API 返回表示整个集群状态的元数据。

1
GET /_cluster/state

节点 API

Elasticsearch 官方之 cat Nodes API——返回有关集群节点的信息。

1
2
3
4
# 查看默认的字段
GET /_cat/nodes?v=true
# 查看指定的字段
GET /_cat/nodes?v=true&h=id,ip,port,v,m

分片 API

Elasticsearch 官方之 cat Shards API——shards 命令是哪些节点包含哪些分片的详细视图。它会告诉你它是主还是副本、文档数量、它在磁盘上占用的字节数以及它所在的节点。

1
2
3
4
5
6
# 查看默认的字段
GET /_cat/shards
# 根据名称查询分片(支持通配符)
GET /_cat/shards/my-index-*
# 查看指定的字段
GET /_cat/shards?h=index,shard,prirep,state,unassigned.reason

监控 API

Elasticsearch 中集群相关的健康、统计等相关的信息都是围绕着 cat API 进行的。

通过 GET 请求发送 cat,下面列出了所有可用的 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
GET /_cat

=^.^=
/_cat/allocation
/_cat/shards
/_cat/shards/{index}
/_cat/master
/_cat/nodes
/_cat/tasks
/_cat/indices
/_cat/indices/{index}
/_cat/segments
/_cat/segments/{index}
/_cat/count
/_cat/count/{index}
/_cat/recovery
/_cat/recovery/{index}
/_cat/health
/_cat/pending_tasks
/_cat/aliases
/_cat/aliases/{alias}
/_cat/thread_pool
/_cat/thread_pool/{thread_pools}
/_cat/plugins
/_cat/fielddata
/_cat/fielddata/{fields}
/_cat/nodeattrs
/_cat/repositories
/_cat/snapshots/{repository}
/_cat/templates

参考资料

Elasticsearch CRUD

::: info 概述

CRUD 由英文单词 Create, Read, Update, Delete 的首字母组成,即增删改查

本文通过介绍基本的 Elasticsearch CRUD 方法,向读者呈现如何访问 Elasticsearch 数据。
:::

阅读全文 »

Elasticsearch 简介

::: info 概述

Elasticsearch 是一个基于 Lucene 的搜索和数据分析工具,它提供了一个分布式服务。Elasticsearch 是遵从 Apache 开源条款的一款开源产品,是当前主流的企业级搜索引擎。

本文简单介绍了 Elasticsearch 的功能、特性、简史、概念,可以让读者在短时间内对于 Elasticsearch 有一个初步的认识。

:::

阅读全文 »

Elasticsearch 运维

Elasticsearch 是一个分布式、RESTful 风格的搜索和数据分析引擎,能够解决不断涌现出的各种用例。 作为 Elastic Stack 的核心,它集中存储您的数据,帮助您发现意料之中以及意料之外的情况。

Elasticsearch 安装

Elasticsearch 官方下载安装说明

(1)下载解压

访问 官方下载地址 ,选择需要的版本,下载解压到本地。

(2)运行

运行 bin/elasticsearch (Windows 系统上运行 bin\elasticsearch.bat )

(3)访问

执行 curl http://localhost:9200/ 测试服务是否启动

Elasticsearch 集群规划

ElasticSearch 集群需要根据业务实际情况去合理规划。

需要考虑的问题点:

  • 集群部署几个节点?
  • 有多少个索引?
  • 每个索引有多大数据量?
  • 每个索引有多少个分片?

一个参考规划:

  • 3 台机器,每台机器是 6 核 64G 的。
  • 我们 es 集群的日增量数据大概是 2000 万条,每天日增量数据大概是 500MB,每月增量数据大概是 6 亿,15G。目前系统已经运行了几个月,现在 es 集群里数据总量大概是 100G 左右。
  • 目前线上有 5 个索引(这个结合你们自己业务来,看看自己有哪些数据可以放 es 的),每个索引的数据量大概是 20G,所以这个数据量之内,我们每个索引分配的是 8 个 shard,比默认的 5 个 shard 多了 3 个 shard。

Elasticsearch 配置

ES 的默认配置文件为 config/elasticsearch.yml

基本配置说明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
cluster.name: elasticsearch
#配置es的集群名称,默认是elasticsearch,es会自动发现在同一网段下的es,如果在同一网段下有多个集群,就可以用这个属性来区分不同的集群。
node.name: 'Franz Kafka'
#节点名,默认随机指定一个name列表中名字,该列表在es的jar包中config文件夹里name.txt文件中,其中有很多作者添加的有趣名字。
node.master: true
#指定该节点是否有资格被选举成为node,默认是true,es是默认集群中的第一台机器为master,如果这台机挂了就会重新选举master。
node.data: true
#指定该节点是否存储索引数据,默认为true。
index.number_of_shards: 5
#设置默认索引分片个数,默认为5片。
index.number_of_replicas: 1
#设置默认索引副本个数,默认为1个副本。
path.conf: /path/to/conf
#设置配置文件的存储路径,默认是es根目录下的config文件夹。
path.data: /path/to/data
#设置索引数据的存储路径,默认是es根目录下的data文件夹,可以设置多个存储路径,用逗号隔开,例:
#path.data: /path/to/data1,/path/to/data2
path.work: /path/to/work
#设置临时文件的存储路径,默认是es根目录下的work文件夹。
path.logs: /path/to/logs
#设置日志文件的存储路径,默认是es根目录下的logs文件夹
path.plugins: /path/to/plugins
#设置插件的存放路径,默认是es根目录下的plugins文件夹
bootstrap.mlockall: true
#设置为true来锁住内存。因为当jvm开始swapping时es的效率会降低,所以要保证它不swap,可以把#ES_MIN_MEM和ES_MAX_MEM两个环境变量设置成同一个值,并且保证机器有足够的内存分配给es。同时也要#允许elasticsearch的进程可以锁住内存,linux下可以通过`ulimit -l unlimited`命令。
network.bind_host: 192.168.0.1
#设置绑定的ip地址,可以是ipv4或ipv6的,默认为0.0.0.0。
network.publish_host: 192.168.0.1
#设置其它节点和该节点交互的ip地址,如果不设置它会自动判断,值必须是个真实的ip地址。
network.host: 192.168.0.1
#这个参数是用来同时设置bind_host和publish_host上面两个参数。
transport.tcp.port: 9300
#设置节点间交互的tcp端口,默认是9300。
transport.tcp.compress: true
#设置是否压缩tcp传输时的数据,默认为false,不压缩。
http.port: 9200
#设置对外服务的http端口,默认为9200。
http.max_content_length: 100mb
#设置内容的最大容量,默认100mb
http.enabled: false
#是否使用http协议对外提供服务,默认为true,开启。
gateway.type: local
#gateway的类型,默认为local即为本地文件系统,可以设置为本地文件系统,分布式文件系统,hadoop的#HDFS,和amazon的s3服务器,其它文件系统的设置方法下次再详细说。
gateway.recover_after_nodes: 1
#设置集群中N个节点启动时进行数据恢复,默认为1。
gateway.recover_after_time: 5m
#设置初始化数据恢复进程的超时时间,默认是5分钟。
gateway.expected_nodes: 2
#设置这个集群中节点的数量,默认为2,一旦这N个节点启动,就会立即进行数据恢复。
cluster.routing.allocation.node_initial_primaries_recoveries: 4
#初始化数据恢复时,并发恢复线程的个数,默认为4。
cluster.routing.allocation.node_concurrent_recoveries: 2
#添加删除节点或负载均衡时并发恢复线程的个数,默认为4。
indices.recovery.max_size_per_sec: 0
#设置数据恢复时限制的带宽,如入100mb,默认为0,即无限制。
indices.recovery.concurrent_streams: 5
#设置这个参数来限制从其它分片恢复数据时最大同时打开并发流的个数,默认为5。
discovery.zen.minimum_master_nodes: 1
#设置这个参数来保证集群中的节点可以知道其它N个有master资格的节点。默认为1,对于大的集群来说,可以设置大一点的值(2-4)
discovery.zen.ping.timeout: 3s
#设置集群中自动发现其它节点时ping连接超时时间,默认为3秒,对于比较差的网络环境可以高点的值来防止自动发现时出错。
discovery.zen.ping.multicast.enabled: false
#设置是否打开多播发现节点,默认是true。
discovery.zen.ping.unicast.hosts: ['host1', 'host2:port', 'host3[portX-portY]']
#设置集群中master节点的初始列表,可以通过这些节点来自动发现新加入集群的节点。

Elasticsearch FAQ

elasticsearch 不允许以 root 权限来运行

问题:在 Linux 环境中,elasticsearch 不允许以 root 权限来运行。

如果以 root 身份运行 elasticsearch,会提示这样的错误:

1
can not run elasticsearch as root

解决方法:使用非 root 权限账号运行 elasticsearch

1
2
3
4
5
6
7
8
# 创建用户组
groupadd elk
# 创建新用户,-g elk 设置其用户组为 elk,-p elk 设置其密码为 elk
useradd elk -g elk -p elk
# 更改 /opt 文件夹及内部文件的所属用户及组为 elk:elk
chown -R elk:elk /opt # 假设你的 elasticsearch 安装在 opt 目录下
# 切换账号
su elk

vm.max_map_count 不低于 262144

问题:vm.max_map_count 表示虚拟内存大小,它是一个内核参数。elasticsearch 默认要求 vm.max_map_count 不低于 262144。

1
max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]

解决方法:

你可以执行以下命令,设置 vm.max_map_count ,但是重启后又会恢复为原值。

1
sysctl -w vm.max_map_count=262144

持久性的做法是在 /etc/sysctl.conf 文件中修改 vm.max_map_count 参数:

1
2
echo "vm.max_map_count=262144" > /etc/sysctl.conf
sysctl -p

注意

如果运行环境为 docker 容器,可能会限制执行 sysctl 来修改内核参数。

这种情况下,你只能选择直接修改宿主机上的参数了。

nofile 不低于 65536

问题: nofile 表示进程允许打开的最大文件数。elasticsearch 进程要求可以打开的最大文件数不低于 65536。

1
max file descriptors [4096] for elasticsearch process is too low, increase to at least [65536]

解决方法:

/etc/security/limits.conf 文件中修改 nofile 参数:

1
2
echo "* soft nofile 65536" > /etc/security/limits.conf
echo "* hard nofile 131072" > /etc/security/limits.conf

nproc 不低于 2048

问题: nproc 表示最大线程数。elasticsearch 要求最大线程数不低于 2048。

1
max number of threads [1024] for user [user] is too low, increase to at least [2048]

解决方法:

/etc/security/limits.conf 文件中修改 nproc 参数:

1
2
echo "* soft nproc 2048" > /etc/security/limits.conf
echo "* hard nproc 4096" > /etc/security/limits.conf

参考资料

Elasticsearch 面试

Elasticsearch 简介

扩展阅读:

【简单】什么是 ES?

Elasticsearch

Elasticsearch 是一个开源的分布式搜索和分析引擎

Elasticsearch 基于搜索库 Lucene 开发。Elasticsearch 隐藏了 Lucene 的复杂性,提供了简单易用的 REST API / Java API 接口(另外还有其他语言的 API 接口)。

Elasticsearch 是面向文档的,它将复杂数据结构序列化为 JSON 形式存储。

Elasticsearch 提供近实时(Near Realtime,缩写 NRT)的全文搜索。近实时是指:

  • 从写入数据到数据可以被搜索,存在较小的延迟(大概是 1s)。
  • 基于 Elasticsearch 执行搜索和分析可以达到秒级。

【简单】ES 有哪些应用场景?

Elasticsearch 的主要功能如下:

  • 海量数据的分布式存储及集群管理
  • 提供丰富的近实时搜索能力
  • 海量数据的近实时分析(聚合)

Elasticsearch 被广泛应用于以下场景:

  • 搜索
    • 全文检索 - Elasticsearch 通过快速搜索大型数据集,使复杂的搜索查询变得更加容易。它对于需要即时和相关搜索结果的网站、应用程序或企业特别有用。
    • 自动补全和拼写纠正 - 可以在用户输入内容时,实时提供自动补全和拼写纠正,以增加用户体验并提高搜索效率。
    • 地理空间搜索 - 使用地理空间查询搜索位置并计算空间关系。
    • 近实时分析 - Elasticsearch 能够进行实时分析,使其适用于追踪实时数据的仪表板,例如用户活动、用户画像等,分析后进行推送。
  • 可观测性
    • 日志、指标和链路追踪 - 收集、存储和分析来自应用程序、系统和服务的日志、指标和追踪。
    • 性能监控 - 监控和分析业务关键性能指标。
    • OpenTelemetry - 使用 OpenTelemetry 标准,将遥测数据采集到 Elastic Stack。

【简单】ES 有哪些里程碑版本?

Elasticsearch 里程碑版本:

  • 1.0(2014 年)
  • 5.0(2016 年)
    • Lucene 6.x
    • 默认打分机制从 TD-IDF 改为 BM25
    • 增加 Keyword 类型
  • 6.0(2017 年)
    • Lucene 7.x
    • 跨集群复制
    • 索引生命周期管理
    • SQL 的支持
  • 7.0(2019 年)
    • Lucene 8.0
    • 移除 Type
    • ECK (用于支持 K8S)
    • 集群协调
    • High Level Rest Client
    • Script Score 查询
  • 8.0(2022 年)
    • Lucene 9.0
    • 向量搜索
    • 支持 OpenTelemetry

【简单】什么是 Elasic Stack(ELK)?

Elastic Stack 通常被用来作为日志采集、检索、可视化的解决方案。

ELK

Elastic Stack 也常被称为 ELK,这是 Elastic 公司旗下三款产品 ElasticsearchLogstashKibana 的首字母组合。

  • Elasticsearch 负责存储数据,并提供对数据的检索和分析。
  • Logstash 传输和处理你的日志、事务或其他数据。
  • Kibana 将 Elasticsearch 的数据分析并渲染为可视化的报表。

Elastic Stack,在 ELK 的基础上扩展了一些新的产品。如:Beats,这是针对不同类型数据的轻量级采集器套件。

此外,基于 Elastic Stack,其技术生态还可以和一些主流的分布式中间件进行集成,以应对各种不同的场景。

Elastic Stack

Elasticsearch CRUD

扩展阅读:

【简单】如何在 ES 中 CRUD?

Elasticsearch 的基本 CRUD 方式如下:

  • 添加索引
    • PUT <index>/_create/<id> - 指定 id,如果 id 已存在,报错
    • POST <index>/_doc - 自动生成 _id
  • 删除索引 - DELETE /<index>?pretty
  • 更新索引 - POST <index>/_update/<id>
  • 查询索引 - GET <index>/_doc/<id>
  • 批量更新 - bulk API 支持 index/create/update/delete
  • 批量查询 - _mget_msearch 可以用于批量查询

扩展阅读:Quick starts

Elasticsearch Mapping

扩展阅读:

【简单】ES 支持哪些数据类型?

Elasticsearch 支持丰富的数据类型,常见的有:

扩展:数据类型

【简单】ES 如何识别字段的数据类型?

在 Elasticsearch 中,Mapping(映射),用来定义一个文档以及其所包含的字段如何被存储和索引,可以在映射中事先定义字段的数据类型、字段的权重、分词器等属性,就如同在关系型数据库中创建数据表时会设置字段的类型。简言之,Mapping 定义了索引中的文档有哪些字段及其类型、这些字段是如何存储和索引的,就好像数据库的表定义一样。

Mapping 会把 json 文档映射成 Lucene 所需要的扁平格式

一个 Mapping 属于一个索引的 Type

  • 每个文档都属于一个 Type
  • 一个 Type 有一个 Mapping 定义
  • 7.0 开始,不需要在 Mapping 定义中指定 type 信息

每个 document 都是 field 的集合,每个 field 都有自己的数据类型。映射数据时,可以创建一个 mapping,其中包含与 document 相关的 field 列表。映射定义还包括元数据 field,例如 _source ,它自定义如何处理 document 的关联元数据。

在 Elasticsearch 中,映射可分为静态映射动态映射。在关系型数据库中写入数据之前首先要建表,在建表语句中声明字段的属性,在 Elasticsearch 中,则不必如此,Elasticsearch 最重要的功能之一就是让你尽可能快地开始探索数据,文档写入 Elasticsearch 中,它会根据字段的类型自动识别,这种机制称为动态映射,而静态映射则是写入数据之前对字段的属性进行手工设置。

静态映射

Elasticsearch 官方将静态映射称为显式映射(Explicit mapping静态映射是在创建索引时手工指定索引映射。静态映射和 SQL 中在建表语句中指定字段属性类似。相比动态映射,通过静态映射可以添加更详细、更精准的配置信息。

例如:

  • 哪些字符串字段应被视为全文字段。
  • 哪些字段包含数字、日期或地理位置。
  • 日期值的格式。
  • 用于控制动态添加字段的自定义规则。

【示例】创建索引时,显示指定 mapping

1
2
3
4
5
6
7
8
9
10
PUT /my-index-000001
{
"mappings": {
"properties": {
"age": { "type": "integer" },
"email": { "type": "keyword" },
"name": { "type": "text" }
}
}
}

【示例】在已存在的索引中,指定一个 field 的属性

1
2
3
4
5
6
7
8
9
PUT /my-index-000001/_mapping
{
"properties": {
"employee-id": {
"type": "keyword",
"index": false
}
}
}

【示例】查看 mapping

1
GET /my-index-000001/_mapping

【示例】查看指定 field 的 mapping

1
GET /my-index-000001/_mapping/field/employee-id

动态映射

动态映射机制,允许用户不手动定义映射,Elasticsearch 会自动识别字段类型。在实际项目中,如果遇到的业务在导入数据之前不确定有哪些字段,也不清楚字段的类型是什么,使用动态映射非常合适。当 Elasticsearch 在文档中碰到一个以前没见过的字段时,它会利用动态映射来决定该字段的类型,并自动把该字段添加到映射中。

示例:创建一个名为 data 的索引、其 mapping 类型为 _doc,并且有一个类型为 long 的字段 count

1
2
PUT data/_doc/1
{ "count": 5 }

Elasticsearch 存储

扩展阅读:

【简单】ES 的逻辑存储是怎样设计的?

Elasticsearch 的逻辑存储被设计为层级结构,自上而下依次为:

Elasticsearch 逻辑存储

各层级结构的说明如下:

(1)Document(文档)

Elasticsearch 是面向文档的,这意味着读写数据的最小单位是文档。Elasticsearch 以 JSON 文档的形式序列化和存储数据。文档是一组字段,这些字段是包含数据的键值对。每个文档都有一个唯一的 ID。

一个简单的 Elasticsearch 文档可能如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"_index": "my-first-elasticsearch-index",
"_id": "DyFpo5EBxE8fzbb95DOa",
"_version": 1,
"_seq_no": 0,
"_primary_term": 1,
"found": true,
"_source": {
"email": "john@smith.com",
"first_name": "John",
"last_name": "Smith",
"info": {
"bio": "Eco-warrior and defender of the weak",
"age": 25,
"interests": ["dolphins", "whales"]
},
"join_date": "2024/05/01"
}
}

Elasticsearch 中的 document 是无模式的,也就是并非所有 document 都必须拥有完全相同的字段,它们不受限于同一个模式。

(2)Field(字段)

field 包含数据的键值对。默认情况下,Elasticsearch 对每个字段中的所有数据建立索引,并且每个索引字段都具有专用的优化数据结构。

document 包含数据和元数据。Metadata Field(元数据字段) 是存储有关文档信息的系统字段。在 Elasticsearch 中,元数据字段都以 _ 开头。常见的元数据字段有:

  • _index - 文档所属的索引
  • _id - 文档的 ID
  • _source - 表示文档原文的 JSON

(3)Type(类型)

在 Elasticsearch 中,type 是 document 的逻辑分类。每个 index 里可以有一个或多个 type。

不同的 type 应该有相似的结构(schema)。举例来说,id字段不能在这个组是字符串,在另一个组是数值。

注意:Elasticsearch 7.x 版已彻底移除 type。

(4)Index(索引)

在 Elasticsearch 中,可以将 index 视为 document 的集合

Elasticsearch 会为所有字段建立索引,经过处理后写入一个倒排索引(Inverted Index)。查找数据的时候,直接查找该索引。

所以,Elasticsearch 数据管理的顶层单位就叫做 Index。它是单个数据库的同义词。每个 Index 的名字必须是小写。

(5)Elasticsearch 概念和 RDBM 概念

Elasticsearch DB
索引(index) 数据库(database)
类型(type,6.0 废弃,7.0 移除) 数据表(table)
文档(docuemnt) 行(row)
字符(field) 列(column)
映射(mapping) 表结构(schema)

【简单】ES 的物理存储是怎样设计的?

Elasticsearch 的物理存储,天然使用了分布式设计。

每个 Elasticsearch 进程都从属于一个 cluster,一个 cluster 可以有一个或多个 node(即 Elasticsearch 进程)。

Elasticsearch 存储会将每个 index 分为多个 shard,而 shard 可以分布在集群中不同节点上。正是由于这个机制,使得 Elasticsearch 有了水平扩展的能力。shard 也是 Elasticsearch 将数据从一个节点迁移到拎一个节点的最小单位。

Elasticsearch 的每个 shard 对应一个 Lucene index(一个包含倒排索引的文件目录)。Lucene index 又会被分解为多个 segment。segment 是索引中的内部存储元素,由于写入效率的考虑,所以被设计为不可变更的。segment 会定期 合并 较大的 segment,以保持索引大小。简单来说,Lucene 就是一个 jar 包,里面包含了封装好的构建、管理倒排索引的算法代码。

Elasticsearch 物理存储

【中等】什么是倒排索引?

既然有倒排索引,顾名思义,有与之相对的正排索引。这里,以实现一个诗词检索器为例,来说明一下正排索引和倒排索引的区别。

正排索引是 ID 到数据的映射关系。如下所示,每首诗词用一个 ID 唯一识别。如果,我们要查找诗歌内容中是否包含某个关键字,就不得不在内容的完整文本中进行检索,效率很低。即使针对文档内容创建传统 RDBM 的索引(通常为 B+ 树结构),查找效率依然低下,并且会产生较大的额外存储空间开销。

ID 文档标题 文档内容
1 望月怀远 海上生明月,天涯共此时…
2 春江花月夜 春江潮水连海平,海上明月共潮生…
3 静夜思 床前明月光,疑是地上霜。举头望明月,低头思故乡。
4 锦瑟 沧海月明珠有泪,蓝田日暖玉生烟…

倒排索引的实现与正排索引相反。将文本分词后保存为多个词项,词项到 ID 的映射关系称为倒排索引(Inverted index)

词项 ID 词频
1, 2, 3, 4 1:1 次、2:1 次、3:2 次、4:1 次
明月 1, 2, 3 1:1 次、2:1 次、3:2 次
1, 2, 4 1:1 次、2:1 次、4:1 次

除了要保存词项与 ID 的关系外,还需要保存这个词项在对应文档出现的位置、偏移量等信息,这是因为很多检索的场景中还需要判断关键词前后的内容是否符合搜索要求。

Elasticsearch 倒排索引

有了倒排索引,搜索引擎可以很方便地响应用户的查询。比如用户输入查询 明月,搜索系统查找倒排索引,从中读出包含这个单词的文档,这些文档就是提供给用户的搜索结果。

要注意倒排索引的两个重要细节:

  • 倒排索引中的所有词项对应一个或多个文档;
  • 倒排索引中的词项根据字典顺序升序排列

【中等】什么是字典树?

Trie(字典树),也被称为前缀树,是一种树状数据结构,用于有效检索键值对。它通常用于实现字典和自动补全功能,使其成为许多搜索算法的基本组件。

Trie 遵循一个规则:如果两个字符串有共同的前缀,那么它们在 Trie 中将具有相同的祖先。

Trie 的检索能力也可以使用 Hash 替代,但是 Trie 比 Hash 更高效。此外,Trie 有 Hash 不具备的优点:Trie 支持前缀搜索和排序。Trie 的主要缺点是:存储词项需要额外的空间,对于长文本,空间可能会变得很大。

字典树

【困难】ES 如何实现倒排索引?

在 Elasticsearch 中,数据存储、检索实际上是基于 Lucene 实现。

一个 Elasticsearch shard 对应一个 Lucene index,

Elasticsearch 的每个 shard 对应一个 Lucene index(一个包含倒排索引的文件目录)。Lucene index 又会被分解为多个 segment。segment 是索引中的内部存储元素,由于写入效率的考虑,所以被设计为不可变更的。segment 会定期 合并 较大的 segment,以保持索引大小。

Elasticsearch 倒排索引

倒排索引的组成主要有 3 个部分:

  • Term Dictionary - Term Dictionary 用于保存 term(词项)。由于 ES 会对 document 中的每个 field 都进行分词,所以数据量可能会非常大。
    • Term Dictionary 存储数据时,先将所有的 term 进行排序,然后将 Term Dictionary 中有共同前缀的 term 抽取出来进行分块存储;再对共同前缀做索引,最后通过索引就可以找到公共前缀对应的块在 Term Dictionary 文件中的偏移地址。
    • 由于每个块中都有共同前缀,所以不需要再保存每个 Term 的全部内容,只需要保存其后缀即可,而且这些后缀都是排好序的。
  • Term Index - Term Index 是 Term Dictionary 的索引。由于 Term Dictionary 存储的 term 可能会非常多,为了提高查询效率,从而设计了 Term Index。
    • 为了提高检索效率以及节省空间,Term Index 只使用公共前缀做索引。
    • Lucene 中实现 Term Index 采用了 FST 算法。FST 是一种非常复杂的结构,可以把它简单理解为一个占用空间小且高效的 KV 数据结构,有点类似于 Trie(字典树)。FST 有以下的特点:
      • 通过对 Term Dictionary 数据的前缀复用,压缩了存储空间;
      • 高效的查询性能,O(len(prefix)) 的复杂度;
      • 构建后不可修改,因此 Lucene segment 也不允许修改。
  • Posting List - Posting List 保存着每个 term 的映射信息。如文档 ID、词频、位置等。Lucene 把这些数据分成 3 个文件进行存储:
    • .doc 文件,记录了文档 ID 信息和 term 的词频,还额外记录了跳表的信息,用来加速文档 ID 的查询;并且还记录了 term 在 .pos.pay 文件中的位置,有助于进行快速读取。
    • .pay 文件,记录了 payload 信息和 term 在 doc 中的偏移信息;
    • .pos 文件,记录了 term 在 doc 中的位置信息。

Elasticsearch 搜索

扩展阅读:

【简单】ES 索引别名有什么用?

Elasticsearch 中的别名可用于更轻松地管理和使用索引。别名允许同时对多个索引执行操作,或者通过隐藏底层索引结构的复杂性来简化索引管理。

扩展阅读:https://www.elastic.co/guide/en/elasticsearch/reference/current/aliases.html

【简单】ES 中有哪些全文搜索 API?

ES 支持全文搜索的 API 主要有以下几个:

  • intervals - 根据匹配词的顺序和近似度返回文档。
  • match - 匹配查询,用于执行全文搜索的标准查询,包括模糊匹配和短语或邻近查询。
  • match_bool_prefix - 对检索文本分词,并根据这些分词构造一个布尔查询。除了最后一个分词之外的每个分词都进行 term 查询。最后一个分词用于 prefix 查询;其他分词都进行 term 查询。
  • match_phrase - 短语匹配查询,短语匹配会将检索内容分词,这些词语必须全部出现在被检索内容中,并且顺序必须一致,默认情况下这些词都必须连续。
  • match_phrase_prefix - 与 match_phrase 查询类似,但对最后一个单词执行通配符搜索。
  • multi_match 支持多字段 match 查询
  • combined_fields - 匹配多个字段,就像它们已索引到一个组合字段中一样。
  • query_string - 支持紧凑的 Lucene query string(查询字符串)语法,允许指定 AND|OR|NOT 条件和单个查询字符串中的多字段搜索。仅适用于专家用户。
  • simple_query_string - 更简单、更健壮的 query_string 语法版本,适合直接向用户公开。

扩展阅读:Elasticsearch 官方文档之全文查询

【简单】ES 中有哪些词项搜索 API?

Term(词项)是表达语意的最小单位。搜索和利用统计语言模型进行自然语言处理都需要处理 Term。

全文查询在执行查询之前会分析查询字符串。与全文查询不同,词项级别查询不会分词,而是将输入作为一个整体,在倒排索引中查找准确的词项。并且使用相关度计算公式为每个包含该词项的文档进行相关度计算。一言以概之:词项查询是对词项进行精确匹配。词项查询通常用于结构化数据,如数字、日期和枚举类型。

ES 支持词项搜索的 API 主要有以下几个:

  • exists - 返回在指定字段上有值的文档。
  • fuzzy - 模糊查询,返回包含与搜索词相似的词的文档。
  • ids - 根据 ID 返回文档。此查询使用存储在 _id 字段中的文档 ID。
  • prefix - 前缀查询,用于查询某个字段中包含指定前缀的文档。
  • range - 范围查询,用于匹配在某一范围内的数值型、日期类型或者字符串型字段的文档。
  • regexp - 正则匹配查询,返回与正则表达式相匹配的词项所属的文档。
  • term - 用来查找指定字段中包含给定单词的文档。
  • terms - 与 term 相似,但可以搜索多个值。
  • terms set - 与 term 相似,但可以定义返回文档所需的匹配词数。
  • wildcard - 通配符查询,返回与通配符模式匹配的文档。

扩展阅读:Elasticsearch 官方文档之词项查询

【简单】ES 支持哪些组合查询?

复合查询就是把一些简单查询组合在一起实现更复杂的查询需求,除此之外,复合查询还可以控制另外一个查询的行为。

复合查询有以下类型:

  • bool - 布尔查询,可以组合多个过滤语句来过滤文档。
  • boosting - 提供调整相关性打分的能力,在 positive 块中指定匹配文档的语句,同时降低在 negative 块中也匹配的文档的得分。
  • constant_score - 使用 constant_score 可以将 query 转化为 filter,filter 可以忽略相关性算分的环节,并且 filter 可以有效利用缓存,从而提高查询的性能。
  • dis_max - 返回匹配了一个或者多个查询语句的文档,但只将最佳匹配的评分作为相关性算分返回。
  • function_score - 支持使用函数来修改查询返回的分数。

扩展阅读:Elasticsearch 官方文档之组合查询

【简单】ES 中的 query 和 filter 有什么区别?

在 Elasticsearch 中,可以在两个不同的上下文中执行查询:

  • query context - 有相关性计算,采用相关性算法,计算文档与查询关键词之间的相关度,并根据评分(_score)大小排序。
  • filter context - 无相关性计算,可以利用缓存,性能更好。

扩展阅读:Elasticsearch 官方文档之查询和过滤上下文

【中等】ES 支持哪些推荐查询?

ES 通过 Suggester 提供了推荐搜索能力,可以用于文本纠错,文本自动补全等场景。

根据使用场景的不同,ES 提供了以下 4 种 Suggester:

  • Term Suggester - 基于词项的纠错补全。
  • Phrase Suggester - 基于短语的纠错补全。
  • Completion Suggester - 自动补全单词,输入词语的前半部分,自动补全单词。
  • Context Suggester - 基于上下文的补全提示,可以实现上下文感知推荐。

扩展阅读:Elasticsearch 官方文档之推荐查询

【困难】ES 为什么会有深分页问题?

在 Elasticsearch 中,支持三种分页查询方式:

  • from + size - 可以使用 fromsize 参数分别指定查询的起始页和每页记录数。
  • search_after - 不支持指定页数,只能向下翻页;并且需要指定 sort,并保证值是唯一的。然后,可以反复使用上次结果中最后一个文档的 sort 值进行查询。
  • scroll - 类似于 RDBMS 中的游标,只允许向下翻页。每次下一页查询后,使用返回结果的 scroll id 来作为下一次翻页的标记。scroll 查询会在搜索初始化阶段会生成快照,后续数据的变化无法及时体现在查询结果,因此更加适合一次性批量查询或非实时数据的分页查询。

前文中,我们已经了解了 ES 两阶段搜索流程(Query 和 Fetch)。从中不难发现,这种搜索方式在分页查询时会出现以下情况:

  • 每个 shard 要扫描 from + size 条数据;
  • coordinate node 需要接收并处理 (from + size) * primary_shard_num 条数据。

如果 from 或 size 很大,需要处理的数据量也会很大,代价很高,这就是深分页产生的原因。为了避免深分页,ES 默认限制 from + size 不能超过 10000,可以通过 index.max_result_window 设置。

如何解决 Elasticsearch 深分页问题?

ES 官方提供了另外两种分页查询方式 search_after + PIT 和 scroll(注意:官方已不再推荐) 来避免深分页问题。

Elasticsearch 聚合

扩展阅读:

【简单】什么是聚合?ES 中有哪些聚合?

在数据库中,聚合是指将数据进行分组统计,得到一个汇总的结果。例如,计算总和、平均值、最大值或最小值等操作。

Elasticsearch 将聚合分为三类:

类型 说明
Metric(指标聚合) 根据字段值进行统计计算
Bucket(桶聚合) 根据字段值、范围或其他条件进行分组
Pipeline(管道聚合) 对其他聚合输出的结果进行再次聚合

【中等】ES 如何对海量数据(过亿)进行聚合计算?

Elasticsearch 支持 cardinality(近似计算非重复值) 。它提供一个字段的基数,即该字段的 distinct 或者 unique 值的数目。它是基于 HLL 算法的。HLL 会先对我们的输入作哈希运算,然后根据哈希运算的结果中的 bits 做概率估算从而得到基数。其特点是:可配置的精度,用来控制内存的使用(更精确 = 更多内存);小的数据集精度是非常高的;我们可以通过配置参数,来设置去重需要的固定内存使用量。无论数千还是数十亿的唯一值,内存使用量只与你配置的精确度相关。

Elasticsearch 分析

【简单】什么是文本分析?为什么需要文本分析?

Elasticsearch 中存储的数据可以粗略分为:

  • 词项数据 - 采用精确查询。比较两条词项数据是否相对,实际是比较二者的二进制数据,结果只有相等或不相等。
  • 文本数据 - 采用全文搜索。比较两个文本数据是否相等,没有太大意义,一般只会比较二者是否相似。相似性比较,是通过相关性评分来评估的。而计算相关性评分,需要对全文先分词处理,然后对分词后的词项进行统计才能进行相似性评估。

Elasticsearch 文本分析是将非结构化文本转换为一组词项(term)的过程

文本分析可以分为两个方面:

  • Tokenization(分词化) - 分词化将文本分解成更小的块,称为分词。在大多数情况下,这些分词是单独的 term(词项)。
  • Normalization(标准化) - 经过分词后的文本只能进行词项匹配,但是无法进行同义词匹配。为解决这个问题,可以将文本进行标准化处理。例如:将 foxes 标准化为 fox

【简单】ES 中的分析器是什么?

文本分析由 analyzer(分析器) 执行,分析器是一组控制整个过程的规则。无论是索引还是搜索,都需要使用分析器。

analyzer(分析器) 由三个组件组成:零个或多个 Character Filters(字符过滤器)、有且仅有一个 Tokenizer(分词器)、零个或多个 Token Filters(分词过滤器)。分析的执行顺序为:character filters -> tokenizer -> token filters

Elasticsearch 内置的分析器:

  • standard - 根据单词边界将文本划分为多个 term,如 Unicode 文本分割算法所定义。它删除了大多数标点符号、小写 term,并支持删除停用词。
  • simple - 遇到非字母字符时将文本划分为多个 term,并将其转为小写。
  • whitespace - 遇到任何空格时将文本划分为多个 term,不转换为小写。
  • stop - 与 simple 相似,同时支持删除停用词(如:the、a、is)。
  • keyword - 部分词,直接将输入当做输出。
  • pattern - 使用正则表达式将文本拆分为 term。它支持小写和非索引字。
  • fingerprint - 可创建用于重复检测的指纹。
  • 语言分析器 - 提供了 30 多种常见语言的分词器。

默认情况下,Elasticsearch 使用 standard analyzer(标准分析器),它开箱即用,适用于大多数使用场景。Elasticsearch 也允许定制分析器。

Character Filters(字符过滤器)

Character Filters(字符过滤器) 将原始文本作为字符流接收,并可以通过添加、删除或更改字符来转换文本。分析器可以有零个或多个 Character Filters(字符过滤器),如果配置了多个,它会按照配置的顺序执行。

Elasticsearch 内置的字符过滤器:

  • html_strip - html_strip字符过滤器用于去除 HTML 元素(如 <b>)并转义 HTML 实体(如 &amp;)。
  • mapping - mapping 字符过滤器用于将指定字符串的任何匹配项替换为指定的替换项。
  • pattern_replace - pattern_replace 字符筛选器将匹配正则表达式的任何字符替换为指定的替换。

Tokenizer(分词器)

Tokenizer(分词器) 接收字符流,将其分解为分词(通常是单个单词),并输出一个分词流。分词器还负责记录每个 term 的顺序或位置,以及该 term 所代表的原始单词的开始和结束字符偏移量。分析器有且仅有一个 Tokenizer(分词器)

Elasticsearch 内置的分词器:

  • 面向单词的分词器
    • standard - 将文本划分为单词边界上的 term,如 Unicode 文本分割算法所定义。它会删除大多数标点符号。它是大多数语言的最佳选择。
    • letter - 遇到非字母字符时将文本划分为多个 term。
    • lowercase - 到非字母字符时将文本划分为多个 term,并将其转为小写。
    • whitespace - 遇到任何空格时将文本划分为多个 term。
    • uax_url_email - 与 standard 相似,不同之处在于它将 URL 和电子邮件地址识别为单个分词。
    • classic - 基于语法的英语分词器。
    • thai - 将泰语文本分割为单词。
  • 部分单词分词器
    • n-gram - 遇到指定字符列表(例如空格或标点符号)中的任何一个时,将文本分解为单词,然后返回每个单词的 n-gram:一个连续字母的滑动窗口,例如 quick[qu, ui, ic, ck]
    • edge_n-gram - 遇到指定字符列表(例如空格或标点符号)中的任何一个时,将文本分解为单词,然后返回锚定到单词开头的每个单词的 n 元语法,例如 quick[q, qu, qui, quic, quick]
  • 结构化文本分词器
    • keyword - 接受给定的任何文本,并输出与单个 term 完全相同的文本。它可以与 lowercase 等分词过滤器结合使用,以规范化分析的 term。
    • pattern - 使用正则表达式在文本与单词分隔符匹配时将文本拆分为 term,或者将匹配的文本捕获为 term。
    • simple_pattern - 使用正则表达式将匹配的文本捕获为 term。它使用正则表达式特征的受限子集,并且通常比 pattern 更快。
    • char_group - 可以通过要拆分的字符集进行配置,这通常比运行正则表达式代价更小。
    • simple_pattern_split - 使用与 simple_pattern 分词器相同的受限正则表达式子集,但在匹配项处拆分输入,而不是将匹配项作为 term 返回。
    • path_hierarchy - 基于文件系统的路径分隔符,进行拆分,例如 /foo/bar/baz[/foo, /foo/bar, /foo/bar/baz ]

Token Filters(分词过滤器)

Token Filters(分词过滤器) 接收分词流,并可以添加、删除或更改分词。常用的分词过滤器有: lowercase(小写转换)stop(停用词处理)synonym(同义词处理) 等等。分析器可以有零个或多个 Token Filters(分词过滤器),如果配置了多个,它会按照配置的顺序执行。

Elasticsearch 内置了很多分词过滤器,这里列举几个常见的:

【中等】如果需要中文分词怎么办?

在英文中,单词有自然的空格作为分隔。

在中文中,分词有以下难点:

  • 中文不能根据一个个汉字进行分词
  • 不同于英文可以根据自然的空格进行分词;中文中一般不会有空格。
  • 同一句话,在不同的上下文中,有不同个理解。例如:这个苹果,不大好吃;这个苹果,不大,好吃!

可以使用一些插件来获得对中文更好的分析能力:

  • analysis-icu - 添加了扩展的 Unicode 支持,包括更好地分析亚洲语言、Unicode 规范化、Unicode 感知大小写折叠、排序规则支持和音译。
  • elasticsearch-analysis-ik - 支持自定义词库,支持热更新分词字典
  • elasticsearch-thulac-plugin - 清华大学自然语言处理和社会人文计算实验室的一套中文分词器。

Elasticsearch 复制

【中等】ES 如何保证高可用?

ES 通过副本机制实现高可用。ES 的数据副本模型参考了 PacificA 算法

ES 必须满足以下条件才能运行:

默认的情况下,ES 的数据写入只需要保证主副本写入了即可,ES 在写上选择的是可用性优先,而并不是像 PacificA 协议那样的强一致性。而数据读取方面,ES 可能会读取到没有 commit 的数据,所以 ES 的数据读取可能产生不一致的情况。

在数据恢复方面,系统可以借助 GlobalCheckpoint 和 LocalCheckpoint 来加速数据恢复的过程。如果集群中只有旧的副本可用,那么可以使用 allocate_stale_primary 将一个指定的旧分片分配为主分片,但会造成数据丢失,慎用!

扩展:

【中等】ES 是如何实现选主的?

发起选主流程的条件:

  • 只有 master-eligible 节点(通过 node.master: true 设置)才能发起选主流程。
  • 该 master-eligible 节点的当前状态不是 master。
  • 该 master-eligible 节点通过 ZenDiscovery 模块的 ping 操作询问其已知的集群其他节点是否连接到 master。
  • 包括本节点在内,当前已有超过 discovery.zen.minimum_master_nodes 个节点没有连接到 master。

一般,应设置 discovery.zen.minimum_master_nodesN / 2 + 1,以保证各种分布式决议能得到大多数节点认可。当集群由于故障(如:通信失联)被分割成多个子集群时,节点数未达到半数以上的子集群,不允许进行选主。以此,来避免出现脑裂问题。

选主流程:

  • Elasticsearch 的选主是 ZenDiscovery 模块负责的,主要包含 Ping(节点之间通过这个 RPC 来发现彼此)和 Unicast(单播模块,包含一个主机列表以控制哪些节点需要 ping 通)这两部分;
  • 对所有 master-eligible 节点根据 nodeId 字典排序:每次选举时,每个节点都把自己所知道的节点排一次序,然后选出 id 最小的节点,投票该节点为 master 节点。
  • 如果对某个节点的投票数达到一定的值(投票数 > N / 2 + 1),并且该节点自己也投票自己,那这个节点就当选 master;否则,重新发起选举,直到满足上述条件。

【中等】ES 如何避免脑裂问题?

ES 集群采用主从架构模式,集群中有且只能有一个 Master 存在。

现在假设这样一种场景,ES 集群部署在 2 个不同的机房。若两个机房网络断连,其中没有主节点的机房进行选主,产生了一个新的主节点。这时,就同时存在了两个主节点,它们各自负责处理接收的请求,会存在数据不一致。一旦,两个机房恢复通信,又将以哪个主节点为主,数据不一致问题怎么办,这就是脑裂问题。

那如何避免产生脑裂呢?ES 使用了 Quorum 机制来避免脑裂,在进行选主的时候,需要超过半数 Master 候选节点参与选主才行。假如有 5 个 Master 候选节点,如果要成功选举出 Master,必须有 (5 / 2) + 1 = 3 个 Master 候选节点参与选主才行。

在 6.x 及之前的版本使用 Zen Discovery 的集群协调子系统,Zen Discovery 允许用户通过使用 discovery.zen.minimum_master_nodes 设置来决定多少个符合主节点条件的节点可以选举出主节点。通常,只有 Master Eligible 节点(Master 候选节点)数大于 Quorum 的时候才能进行选主。计算公式如下:

1
Quorum = (Master 候选节点数 / 2) + 1

Elasticsearch 7.0 中,重新设计并重建了集群协调子系统:

  • 移除了 discovery.zen.minimum_master_nodes 设置,让 Elasticsearch 自己选择可以形成法定数量的节点。
  • 典型的主节点选举只需很短时间就能完成。
  • 集群的扩充和缩减变得更加安全和简单,并且大幅降低了因系统配置不当而可能造成数据丢失的风险。
  • 节点状态记录比以往清晰很多,有助于诊断它们不能加入集群的原因,或者为何不能选举出主节点。

【中等】Elasticsearch 集群中有哪些不同类型的节点?

Elasticsearch 中的节点是指集群中的单个 Elasticsearch 进程实例。节点用于存储数据并参与集群的索引和搜索功能。

节点间会相互通信以分配数据和工作负载,从而确保集群的平衡和高性能。节点可以配置不同的角色,这些角色决定了它们在集群中的职责。

可以通过在 elasticsearch.yml 中设置 node.roles 来为节点分配角色。

ES 中主要有以下节点类型:

节点类型 说明 配置
master eligible node 候选主节点。一旦成为主节点,可以管理整个集群:创建、更新、删除索引;添加或删除节点;为节点分配分片。 低配置的 CPU、内存、磁盘
data node 数据节点。负责数据的存储和读取。 高配置的 CPU、内存、磁盘
coordinating node 协调节点。负责请求的分发,结果的汇总。 高配置的 CPU、中等配置的内存、低配置的磁盘
ingest node 预处理节点。负责处理数据、数据转换。 高配置的 CPU、中等配置的内存、低配置的磁盘
warm & hot node 存储冷、热数据的数据节点。 Hot 类型的节点,都是高配配置,Warm 都是中低配即可

Elasticsearch 分片

【中等】ES 是如何实现水平扩展的?

Elasticsearch 通过分片来实现水平扩展。在 Elasticsearch 中,分片是索引的逻辑划分。索引可以有一个或多个分片,并且每个分片可以存储在集群中的不同节点上。分片用于在多个节点之间分配数据,从而提高性能和可扩展性。

Elasticsearch 中有两种类型的分片:

  • primary shard(主分片) - 用于存储原始数据。适当增加主分片数,可以提升 Elasticsearch 集群的吞吐量和整体容量。
  • replica shard(副本分片) - 用于存储数据备份。

默认情况下,每个索引都有 1 个主分片(早期版本,默认每个索引有 5 个主分片)。

【中等】ES 如何选择读写数据映射到哪个分片上?

为了避免出现数据倾斜,系统需要一种高效的方式把数据均匀分散到各个节点上存储,并且在检索的时候可以快速找到文档所在的节点与分片。这就需要确立路由算法,使得数据可以映射到指定的节点上。

常见的路由方式如下:

算法 描述
随机算法 写数据时,随机写入到一个节点中;读数据时,由于不知道查询数据存在于哪个节点,所以需要遍历所有节点。
路由表 由中心节点统一维护数据的路由表,以保证唯一性;但是,中心化产生了新的问题:单点故障、数据越大,路由表越大、单点容易称为性能瓶颈、数据迁移复杂等。
哈希取模 对 key 值进行哈希计算,然后根据节点数取模,以确定节点。

ES 的数据路由算法是根据文档 ID 和 routing key 来确定 Shard ID 的过程。默认的情况下 routing key 为文档 ID,路由算法一般情况下的计算公式如下:

1
shard_number = hash(_routing) % numer_of_primary_shards

也可以在请求中指定 routing key,下面是新增数据的时候指定 routing 的方式:

1
2
3
4
5
PUT <index>/_doc/<id>?routing=routing_key
{
"field1": "xxx",
"field2": "xxx"
}

添加数据时,如果不指定文档 ID,ES 会自动分片一个随机 ID。这种情况下,结合 Hash 算法,可以保证数据被均匀分布到各个分片中。如果指定文档 ID,或指定 routing key,Hash 计算得出的值可能会不够随机,从而导致数据倾斜。

index 一旦设置了主分片数就不能修改,如果要修改就需要 reindex(即数据迁移)。之所以如此,就是因为:一旦修改了主分片数,即等于修改了原 Hash 计算中的变量,无法再通过 Hash 计算正确路由到数据存储的分片。

【中等】如何合理设置 ES 分片?

ES 索引设置多分片有以下好处:

  • 多分片如果分布在不同的节点,查询可以在不同分片上并行执行,提升查询速度;
  • 数据写入时,会分散在不同节点存储,避免数据倾斜。

设置多少分片合适:

一般,分片数要大于节点数,这样可以保证:一旦集群中有新的数据节点加入,ES 会自动对分片数进行再均衡,使得分片尽量在集群中分布均匀。

分片数也不宜设置过多,这会带来一些问题:

  • 每一个 ES 分片对应一个 Lucene 索引,Lucene 索引存储在一个文件系统的目录中,它又可以分为多个 Segment,每个存储在一个文件中。因此,过多的分片意味着过多的文件,这会导致较大的读写性能开销。
  • 此外,分片的元数据信息由 Master 节点维护,分片过多,会增加管理负担。建议,集群的总分片数控制在 10w 以内

单数据节点分片限制:

  • 每个非冻结数据节点 1000 个分片,通过 cluster.max_shards_per_node 控制
  • 每个冻结数据节点 3000 个分片,通过 cluster.max_shards_per_node.frozen 控制

此外,分片大小也要有所限制:

  • 理论上,一个分片最多包含约 20 亿个文档(Integer.MAX_VALUE - 128。但是,经验表明,每个分片的文档数量最好保持在 2 亿以下
  • 非日志型(搜索型、线上业务型) ES 的单分片容量最好在 [10GB, 30GB] 范围内
  • 日志型 ES 的单分片容量最好在 [30GB, 50GB] 范围内

分片大小的上下限可以分别通过 max_primary_shard_sizemin_primary_shard_size 来控制。

扩展:

Elasticsearch 架构

【困难】ES 搜索数据的流程是怎样的?

在 Elasticsearch 中,搜索一般分为两个阶段,query 和 fetch 阶段。可以简单的理解,query 阶段确定要取哪些 doc,fetch 阶段取出具体的 doc。

Query 阶段会根据搜索条件遍历每个分片(主分片或者副分片中的其一)中的数据,返回符合条件的前 N 条数据的 ID 和排序值,然后在协调节点中对所有分片的数据进行排序,获取前 N 条数据的 ID。

Query 阶段的流程如下:

  1. 客户端发送请求到任意一个节点,这个 node 成为 coordinate node(协调节点)。coordinate node 创建一个大小为 from + size 的优先级队列用来存放结果。
  2. coordinate node 对 document 进行路由,将请求转发到对应的 node,此时会使用 round-robin 随机轮询算法,在 primary shard 以及其所有 replica 中随机选择一个,让读请求负载均衡。
  3. 每个分片在本地执行搜索请求,并将查询结果打分排序,然后将结果保存到 from + size 大小的有序队列中。
  4. 接着,每个分片将结果返回给 coordinate node,coordinate node 对数据进行汇总处理:合并、排序、分页,将汇总数据存到一个大小为 from + size 的全局有序队列。

需要注意的是,在协调节点转发搜索请求的时候,如果有 N 个 Shard 位于同一个节点时,并不会合并这些请求,而是发生 N 次请求!

在 Fetch 阶段,协调节点会从 Query 阶段产生的全局排序列表中确定需要取回的文档 ID 列表,然后通过路由算法计算出各个文档对应的分片,并且用 multi get 的方式到对应的分片上获取文档数据。

Fetch 阶段的流程如下:

  1. coordinate node 确定需要获取哪些文档,然后向相关节点发起 multi get 请求;
  2. 分片所在节点读取文档数据,并且进行 _source 字段过滤、处理高亮参数等,然后把处理后的文档数据返回给协调节点;
  3. coordinate node 汇总所有数据后,返回给客户端。

【困难】ES 存储数据的流程是怎样的?

::: info 扩展阅读

:::

::: tip 关键点

  • 集群角度:请求任意节点,路由转发到主分片;主分片写入本地,然后复制数据到副本分片;最后响应客户端
  • 分片角度:对内容做格式校验、分词
  • 节点角度:持久化、refresh Lucene Segment,flush(fsync) Translog

:::

ES 存储数据的流程可以从三个角度来阐述:

  • 集群的角度来看,数据写入会先路由到主分片,在主分片上写入成功后,会并发写副本分片,最后响应给客户端。

  • 分片的角度来看,数据到达分片后,需要对内容进行格式校验、分词处理然后再索引数据。

  • 节点的角度来看,ES 数据持久化的步骤可归纳为:Refresh、写 Translog、Flush、Merge。

    • 默认,ES 会每秒执行一次 Refresh 操作,把 Index Buffer 的数据写入磁盘中,但不会调用 fsync 刷盘。ES 提供近实时搜索的原因是因为数据被 Refresh 后才能被检索出来 。
    • 为了保证数据不丢失,在写完 Index Buffer 后,ES 还要写 Translog。Translog 是追加写入的,并且默认是调用 fsync 进行刷盘的。
    • Flush 操作会将 Filesystem Cache 中的数据持久化到磁盘中,默认 30 分钟或者在 Translog 写满时(默认 512 MB)触发执行。Flush 将磁盘缓存持久化到磁盘后,会清空 Translog。
    • 最后,ES 和 Lucene 会自动执行 Merge 操作,清理过多的 Segment 文件,这个时候被标记为删除的文档会正式被物理删除。

【中等】ES 相关性计算和聚合计算为什么会有计算偏差?

在 ES 中,不仅仅是普通搜索,相关性计算(评分)和聚合计算也是先在每个 shard 的本地进行计算,再由 coordinate node 进行汇总。由于分片的本地计算是独立的,只能基于数据子集来进行计算,所以难免出现数据偏差。

解决这个问题的方式也有多种:

  • 当数据量不大的情况下,设置主分片数为 1,这意味着在数据全集上进行聚合。 但这种方案不太现实。
  • 设置 shard_size 参数,将计算数据范围变大,牺牲整体性能,提高精准度。shard_size 的默认值是 size * 1.5 + 10
  • 使用 DFS Query Then Fetch, 在 URL 参数中指定:_search?search_type=dfs_query_then_fetch。这样设定之后,ES 先会把每个分片的词频和文档频率的数据汇总到协调节点进行处理,然后再进行相关性算分。这样的话会消耗更多的 CPU 和内存资源,效率低下!
  • 尽量保证数据均匀地分布在各个分片中。

【困难】ES 如何保证读写一致?

乐观锁机制 - 可以通过版本号使用乐观锁并发控制,以确保新版本不会被旧版本覆盖,由应用层来处理具体的冲突;

另外对于写操作,一致性级别支持 quorum/one/all,默认为 quorum,即只有当大多数分片可用时才允许写操作。但即使大多数可用,也可能存在因为网络等原因导致写入副本失败,这样该副本被认为故障,分片将会在一个不同的节点上重建。

对于读操作,可以设置 replication 为 sync(默认),这使得操作在主分片和副本分片都完成后才会返回;如果设置 replication 为 async 时,也可以通过设置搜索请求参数、_preference 为 primary 来查询主分片,确保文档是最新版本。

【困难】ES 查询速度为什么快?

  • 倒排索引 - Elasticsearch 查询速度快最核心的点在于使用倒排索引。
    • 在 Elasticsearch 中,为了提高查询效率,它对存储的文档进行了分词处理。分词是将连续的文本切分成一个个独立的词项的过程。对文本进行分词后,Elasticsearch 会为每个词项创建一个倒排索引。这样,当用户进行查询时,Elasticsearch 只需要在倒排索引中查找匹配的词项,从而快速地定位到相关的文档。
    • 正向索引的结构是每个文档和关键字做关联,每个文档都有与之对应的关键字,记录关键字在文档中出现的位置和次数;而倒排索引则是将文档中的词项和文档的 ID 进行关联,这样就可以通过词项快速找到包含它的文档。
  • 分片 - Elasticsearch 通过分片,支持分布式存储和搜索,可以实现搜索的并行处理和负载均衡。

参考:https://cloud.tencent.com/developer/article/1922613

【中等】ES 生产环境部署情况是怎样的?

典型问题

  • 你们的 Elasticsearch 生产环境部署情况是怎样的?
  • 你们的 Elasticsearch 生产环境集群规模有多大?
  • 你们的 Elasticsearch 生产环境中有多少索引,每个索引大概有多少个分片?

知识点

根据实际 Elasticsearch 集群情况描述,以下是一个案例:

  • 版本:6.3.2
  • 集群规模:21 个节点,8 核 16G 内存,400G 磁盘
  • 容量:6600GB/8400GB,900+ 索引、1.3 万分片、150 亿+ 文档
  • 增量:日增 4 百万文档,5 GB

Elasticsearch 优化

【中等】使用 ES 有哪些最佳实践?

  • 索引
    • 大索引应拆分,增强性能,减少风险
    • index 可以按日期拆分为 **index_yyyyMMdd**,然后用 alias 映射
  • 分片:分片太大会导致查询慢、数据迁移和恢复时间长
    • 非日志型业务分片不超过 30 GB
    • 日志型业务分片不超过 50 GB
    • 单分片文档数不超过 21 亿
    • 单节点分片数不要超过 600
  • 文档
    • 单个文档大小不能超过 100MB
  • 字段
    • 一个索引中的字段数默认最大为 1000,但是不建议超过 100
    • text 和 keyword 必须理清楚,keyword 是不会进行分词。
    • 对于 keyword 类型,默认只索引前 256 个字符。超过此长度的字符串将不会被索引(即无法被term查询、聚合)。可以通过 ignore_above 参数调整。
  • Settings 设置
    • 分片数设置后,不可修改
    • 副本数默认 1 个
  • Mapping 设置
    • text 数据类型默认是关闭 fielddate
    • 关闭 _source 会导致无法使用 reindex
    • copy_to 虽然方便,但会显著增加索引大小和写入开销。只在明确需要跨字段搜索时才使用。
  • Refresh
    • 写入时,尽量不要执行 refresh,在并发较大的情况下,ES 负载可能会被打满。
  • 索引别名
    • 尽量使用索引的别名,在类似于进行索引字段类型变更需要进行索引重建的时候会减少很多的问题。
    • 别名的下面可以挂载多个索引,若是索引拆分之后业务验证允许可以这么使用。
    • alias 下面可以挂多个索引,但是需要注意的是每次请求很容易放大,比如说 alias 挂了 50 个索引,每个索引有 5 个分片,那么从集群的维度来看一共就是 50*5=250 次 query 和 fetch,很容易导致读放大的情况。

【中等】ES JVM 设置需要注意什么?

::: info 扩展阅读

:::

::: tip 关键点

-Xms-Xmx 设置 JVM,JVM 内存不超过 32GB

:::

ES 实际上是一个 Java 进程,因此也需要考虑 JVM 设置。关于 ES JVM 的设置,有以下几点建议:

  • 从 ES6 开始,支持 64 位的 JVM
  • 将内存 XmsXmx 设置一样,需要注意过多的堆可能会使垃圾回收停顿时间过长
  • 一般,将 50% 的可用内存分配给 ES
  • ES 内存不要超过 32 GB

实际上,一般而言,绝大部分 JVM 内存最好都不要超过 32 GB,不仅仅是 ES 内存。

对于 32 位系统来说,JVM 的对象指针占用 32 位(4 byte),可以表示 2^32 个内存地址。由于,CPU 寻址的最小单位是 byte,2^32 byte 即 4GB,也就是说 JVM 最大可以支持 4GB。对于 64 位系统来说,如果直接引用,就需要使用 64 位的指针,相比 32 位 指针,多使用了一倍的内存。并且,指针在主内存和各级缓存间移动数据时,会占用更大的带宽。

Java 使用了一种叫做 Compressed oops 的技术来进行优化。该技术利用 Java 对象按照 8 字节对齐的机制,让 Java 对象指针指向一个映射地址偏移量(非真实 64 位 地址)。这种方式可以寻址最大位 32 GB 的内存空间。一旦超出 32 GB,就无法利用压缩指针技术,对象指针只能指向真实内存地址,这会造成空间的浪费。

【中等】ES 主机有哪些优化点?

  • 关闭缓存 swap;
  • 堆内存设置为:Min(节点内存/2, 32GB);
  • 设置最大文件句柄数;
  • 线程池+队列大小根据业务需要做调整;
  • 磁盘存储 raid 方式——存储有条件使用 RAID10,增加单节点性能以及避免单节点存储故障。

【中等】ES 索引数据多,如何优化?

  • 动态索引 - 如果单索引数据量过大,可以创建索引模板,并周期性创建新索引(举例来说,索引名为 blog_yyyyMMdd),实现数据的分解。
  • 冷热数据分离 - 将一定范围(如:一周、一月等)的数据作为热数据,其他数据作为冷数据。针对冷数据,可以考虑定期 force_merge + shrink 进行压缩,以节省存储空间和检索效率。
  • 分区再均衡 - Elasticsearch 集群可以动态根据节点数的变化,调整索引分片在集群上的分布。但需要注意的是,要提前合理规划好索引的分片数:分片数过少,则增加节点也无法水平扩展;分片数过多,影响 Elasticsearch 读写效率。

参考资料

Java 虚拟机之垃圾收集

程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收。垃圾回收主要是针对 Java 堆和方法区进行

对象是否回收

引用计数算法

引用计数算法(Reference Counting)的原理是:在对象中添加一个引用计数器,每当有一个地方 引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

引用计数算法简单、高效,但是存在循环引用问题——两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。

1
2
3
4
5
6
7
8
9
10
public class ReferenceCountingGC {
public Object instance = null;

public static void main(String[] args) {
ReferenceCountingGC objectA = new ReferenceCountingGC();
ReferenceCountingGC objectB = new ReferenceCountingGC();
objectA.instance = objectB;
objectB.instance = objectA;
}
}

因为循环引用的存在,所以 Java 虚拟机不适用引用计数算法

可达性分析算法

通过 GC Roots 作为起始点进行搜索,JVM 将能够到达到的对象视为存活,不可达的对象视为死亡

可达性分析算法

可作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈中引用的对象
  • 本地方法栈中引用的对象(Native 方法)
  • 方法区中,类静态属性引用的对象
  • 方法区中,常量引用的对象

引用类型

无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否可被回收都与引用有关。

Java 具有四种强度不同的引用类型。

强引用

被强引用(Strong Reference)关联的对象不会被垃圾收集器回收。

强引用:使用 new 一个新对象的方式来创建强引用。

1
Object obj = new Object();

软引用

被软引用(Soft Reference)关联的对象,只有在内存不够的情况下才会被回收。

软引用:使用 SoftReference 类来创建软引用。

1
2
3
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联

弱引用

被弱引用(Weak Reference)关联的对象一定会被垃圾收集器回收,也就是说它只能存活到下一次垃圾收集发生之前。

使用 WeakReference 类来实现弱引用。

1
2
3
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;

WeakHashMapEntry 继承自 WeakReference,主要用来实现缓存。

1
private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V>

Tomcat 中的 ConcurrentCache 就使用了 WeakHashMap 来实现缓存功能。ConcurrentCache 采取的是分代缓存,经常使用的对象放入 eden 中,而不常用的对象放入 longterm。eden 使用 ConcurrentHashMap 实现,longterm 使用 WeakHashMap,保证了不常使用的对象容易被回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public final class ConcurrentCache<K, V> {

private final int size;

private final Map<K, V> eden;

private final Map<K, V> longterm;

public ConcurrentCache(int size) {
this.size = size;
this.eden = new ConcurrentHashMap<>(size);
this.longterm = new WeakHashMap<>(size);
}

public V get(K k) {
V v = this.eden.get(k);
if (v == null) {
v = this.longterm.get(k);
if (v != null)
this.eden.put(k, v);
}
return v;
}

public void put(K k, V v) {
if (this.eden.size() >= size) {
this.longterm.putAll(this.eden);
this.eden.clear();
}
this.eden.put(k, v);
}
}

虚引用

又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。

为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

使用 PhantomReference 来实现虚引用。

1
2
3
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null;

方法区的回收

因为方法区主要存放永久代对象,而永久代对象的回收率比年轻代差很多,因此在方法区上进行回收性价比不高。

方法区的垃圾收集主要回收两部分:废弃的常量和不再使用的类型

类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

可以通过 -Xnoclassgc 参数来控制是否对类进行卸载。

在大量使用反射、动态代理、CGLib 等字节码框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。

finalize()

当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能通过在该方法中让对象重新被引用,从而实现自救。

finalize() 类似 C++ 的析构函数,用来做关闭外部资源等工作。但是 try-finally 等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此**最好不要使用 finalize()**。

垃圾收集算法

垃圾收集性能

垃圾收集器的性能指标主要有两点:

  • 停顿时间 - 停顿时间是因为 GC 而导致程序不能工作的时间长度。
  • 吞吐量 - 吞吐量关注在特定的时间周期内一个应用的工作量的最大值。对关注吞吐量的应用来说长暂停时间是可以接受的。由于高吞吐量的应用关注的基准在更长周期时间上,所以快速响应时间不在考虑之内。

标记 - 清除(Mark-Sweep)

将需要回收的对象进行标记,然后清理掉被标记的对象。

不足:

  • 标记和清除过程效率都不高;
  • 会产生大量不连续的内存碎片,内存碎片过多可能导致无法给大对象分配内存。

标记 - 整理(Mark-Compact)

让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。

标记 - 复制(Copying)

将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。

主要不足是只使用了内存的一半。

现在的商业虚拟机都采用这种收集算法来回收年轻代,但是并不是将内存划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survior 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1(可以通过参数 -XX:SurvivorRatio 来调整比例),保证了内存的利用率达到 90 %。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。

分代收集

现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。

一般将 Java 堆分为年轻代和老年代。

  • 年轻代使用:复制 算法
  • 老年代使用:标记 - 清理 或者 标记 - 整理 算法

新生代

新生代是大部分对象创建和销毁的区域,在通常的 Java 应用中,绝大部分对象生命周期都是很短暂的。其内部又分为 Eden 区域,作为对象初始分配的区域;两个 Survivor,有时候也叫 fromto 区域,被用来放置从 Minor GC 中保留下来的对象。

JVM 会随意选取一个 Survivor 区域作为 to,然后会在 GC 过程中进行区域间拷贝,也就是将 Eden 中存活下来的对象和 from 区域的对象,拷贝到这个to区域。这种设计主要是为了防止内存的碎片化,并进一步清理无用对象。

Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

老年代

放置长生命周期的对象,通常都是从 Survivor 区域拷贝过来的对象。当然,也有特殊情况,如果对象较大,JVM 会试图直接分配在 Eden 其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM 就会直接分配到老年代。

永久代

这部分就是早期 Hotspot JVM 的方法区实现方式了,储存 Java 类元数据、常量池、Intern 字符串缓存。在 JDK 8 之后就不存在永久代这块儿了。

JVM 参数

这里顺便提一下,JVM 允许对堆空间大小、各代空间大小进行设置,以调整 JVM GC。

配置 描述
-Xss 虚拟机栈大小。
-Xms 堆空间初始值。
-Xmx 堆空间最大值。
-Xmn 新生代空间大小。
-XX:NewSize 新生代空间初始值。
-XX:MaxNewSize 新生代空间最大值。
-XX:NewRatio 新生代与年老代的比例。默认为 2,意味着老年代是新生代的 2 倍。
-XX:SurvivorRatio 新生代中调整 eden 区与 survivor 区的比例,默认为 8。即 eden 区为 80% 的大小,两个 survivor 分别为 10% 的大小。
-XX:PermSize 永久代空间的初始值。
-XX:MaxPermSize 永久代空间的最大值。

垃圾收集器

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

注:G1 垃圾收集器既可以回收年轻代内存,也可以回收老年代内存。而其他垃圾收集器只能针对特定代的内存进行回收。

串行收集器

串行收集器(Serial)是最基本、发展历史最悠久的收集器。

串行收集器是 client 模式下的默认收集器配置。因为在客户端模式下,分配给虚拟机管理的内存一般来说不会很大。Serial 收集器收集几十兆甚至一两百兆的年轻代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。

串行收集器采用单线程 stop-the-world 的方式进行收集。当内存不足时,串行 GC 设置停顿标识,待所有线程都进入安全点(Safepoint)时,应用线程暂停,串行 GC 开始工作,采用单线程方式回收空间并整理内存

Serial / Serial Old 收集器运行示意图

单线程意味着复杂度更低、占用内存更少,垃圾回收效率高;但同时也意味着不能有效利用多核优势。事实上,串行收集器特别适合堆内存不高、单核甚至双核 CPU 的场合。

Serial 收集器

开启选项:-XX:+UseSerialGC

打开此开关后,使用 Serial + Serial Old 收集器组合来进行内存回收。

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:

  • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

并行收集器

开启选项:-XX:+UseParallelGC

打开此开关后,使用 Parallel Scavenge + Serial Old 收集器组合来进行内存回收。

开启选项:-XX:+UseParallelOldGC

打开此开关后,使用 Parallel Scavenge + Parallel Old 收集器组合来进行内存回收。

其他收集器都是以关注停顿时间为目标,而并行收集器是以关注吞吐量(Throughput)为目标的垃圾收集器

  • 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验;
  • 而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
1
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

并行收集器是 server 模式下的默认收集器。

并行收集器与串行收集器工作模式相似,都是 stop-the-world 方式,只是暂停时并行地进行垃圾收集。并行收集器年轻代采用复制算法,老年代采用标记-整理,在回收的同时还会对内存进行压缩。并行收集器适合对吞吐量要求远远高于延迟要求的场景,并且在满足最差延时的情况下,并行收集器将提供最佳的吞吐量。

在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 收集器 + Parallel Old 收集器。

Parallel / Parallel Old 收集器运行示意图

Parallel Scavenge 收集器

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是:

  • -XX:MaxGCPauseMillis - 控制最大垃圾收集停顿时间,收集器将尽可能保证内存回收时间不超过设定值。
  • -XX:GCTimeRatio - 直接设置吞吐量大小的(值为大于 0 且小于 100 的整数)。

缩短停顿时间是以牺牲吞吐量和年轻代空间来换取的:年轻代空间变小,垃圾回收变得频繁,导致吞吐量下降。

Parallel Scavenge 收集器还提供了一个参数 -XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后,就不需要手工指定年轻代的大小(-Xmn)、Eden 和 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 GC 自适应的调节策略(GC Ergonomics)。

Parallel Old 收集器

是 Parallel Scavenge 收集器的老年代版本,使用多线程和 “标记-整理” 算法

并发标记清除收集器

开启选项:-XX:+UseConcMarkSweepGC

打开此开关后,使用 CMS + ParNew + Serial Old 收集器组合来进行内存回收。

并发标记清除收集器是以获取最短停顿时间为目标。

开启后,年轻代使用 ParNew 收集器;老年代使用 CMS 收集器,如果 CMS 产生的碎片过多,导致无法存放浮动垃圾,JVM 会出现 Concurrent Mode Failure ,此时使用 Serial Old 收集器来替代 CMS 收集器清理碎片。

CMS 收集器

CMS 收集器是一种以获取最短停顿时间为目标的收集器。

CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。

CMS 回收机制

CMS 收集器运行步骤如下:

  1. 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
  2. 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
  3. 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  4. 并发清除:回收在标记阶段被鉴定为不可达的对象。不需要停顿。

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

CMS 收集器运行示意图

CMS 回收年轻代详细步骤

(1)堆空间被分割为三块空间

img
年轻代分割成一个 Eden 区和两个 Survivor 区。年老代一个连续的空间。就地完成对象收集。除非有 FullGC 否则不会压缩。

(2)CMS 年轻代垃圾收集如何工作

年轻代被标为浅绿色,年老代被标记为蓝色。如果你的应用已经运行了一段时间,CMS 的堆看起来应该是这个样子。对象分散在年老代区域里。

img

使用 CMS,年老代对象就地释放。它们不会被来回移动。这个空间不会被压缩除非发生 FullGC。

(3)年轻代收集

从 Eden 和 Survivor 区复制活跃对象到另一个 Survivor 区。所有达到他们的年龄阈值的对象会晋升到年老代。

img
(4)年轻代回收之后

一次年轻代垃圾收集之后,Eden 区和其中一个 Survivor 区被清空。

img
最近晋升的对象以深蓝色显示在上图中,绿色的对象是年轻代幸免的还没有晋升到老年代对象。

CMS 回收年老代详细步骤

(1)CMS 的年老代收集

发生两次 stop the world 事件:初始标记和重新标记。当年老代达到特定的占用比例时,CMS 开始执行。

img

  • 初始标记是一个短暂暂停的、可达对象被标记的阶段。
  • 并发标记寻找活跃对象在应用连续执行时。
  • 最后,在重新标记阶段,寻找在之前并发标记阶段中丢失的对象。

(2)年老代收集-并发清除

在之前阶段没有被标记的对象会被就地释放。不进行压缩操作。

img
注意:未被标记的对象等于死亡对象

(3)年老代收集-清除之后

清除阶段之后,你可以看到大量内存被释放。你还可以注意到没有进行压缩操作。

img
最后,CMS 收集器会再次进入重新设置阶段,等待下一次垃圾收集时机的到来。

CMS 特点

CMS 收集器具有以下缺点:

  • 并发收集 - 并发指的是用户线程和 GC 线程同时运行。
  • 吞吐量低 - 低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
  • 无法处理浮动垃圾 - 可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。
    • 可以使用 -XX:CMSInitiatingOccupancyFraction 来改变触发 CMS 收集器工作的内存占用百分,如果这个值设置的太大,导致预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 收集器来替代 CMS 收集器。
  • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
    • 可以使用 -XX:+UseCMSCompactAtFullCollection ,用于在 CMS 收集器要进行 Full GC 时开启内存碎片的合并整理,内存整理的过程是无法并发的,空间碎片问题没有了,但是停顿时间不得不变长了。
    • 可以使用 -XX:CMSFullGCsBeforeCompaction ,用于设置执行多少次不压缩的 Full GC 后,来一次带压缩的(默认为 0,表示每次进入 Full GC 时都要进行碎片整理)。

ParNew 收集器

开启选项:-XX:+UseParNewGC

ParNew 收集器其实是 Serial 收集器的多线程版本。

ParNew 收集器运行示意图

是 Server 模式下的虚拟机首选年轻代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。

ParNew 收集器也是使用 -XX:+UseConcMarkSweepGC 后的默认年轻代收集器。

ParNew 收集器默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。

G1 收集器

开启选项:-XX:+UseG1GC

前面提到的垃圾收集器一般策略是关注吞吐量或停顿时间。而 G1 是一种兼顾吞吐量和停顿时间的 GC 收集器。G1 是 Oracle JDK9 以后的默认 GC 收集器。G1 可以直观的设定停顿时间的目标,相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。

G1 最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至 CMS 的众多缺陷。

分代和分区

旧的垃圾收集器一般采取分代收集,Java 堆被分为年轻代、老年代和永久代。收集的范围都是整个年轻代或者整个老年代。

G1 取消了永久代,并把年轻代和老年代划分成多个大小相等的独立区域(Region),年轻代和老年代不再物理隔离。G1 可以直接对年轻代和老年代一起回收。

通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。

G1 回收机制

G1 收集器运行示意图

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  1. 初始标记
  2. 并发标记
  3. 最终标记 - 为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
  4. 筛选回收 - 首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

具备如下特点:

  • 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
  • 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

G1 回收年轻代详细步骤

(1)G1 初始堆空间

堆空间是一个被分成许多固定大小区域的内存块。

img
Java 虚拟机启动时选定区域大小。Java 虚拟机通常会指定 2000 个左右的大小相等、每个大小范围在 1 到 32M 的区域。

(2)G1 堆空间分配

实际上,这些区域被映射成 Eden、Survivor、年老代空间的逻辑表述形式。

img
图片中的颜色表明了哪个区域被关联上什么角色。活跃对象从一个区域疏散(复制、移动)到另一个区域。区域被设计为并行的方式收集,可以暂停或者不暂停所有的其它用户线程。

明显的区域可以被分配成 Eden、Survivor、Old 区域。另外,有第四种类型的区域叫做*极大区域 (Humongous regions)*。这些区域被设计成保持标准区域大小的 50%或者更大的对象。它们被保存在一个连续的区域集合里。最后,最后一个类型的区域就是堆空间里没有使用的区域。

注意:写作此文章时,收集极大对象时还没有被优化。因此,你应该避免创建这个大小的对象。

(3)G1 的年轻代

堆空间被分割成大约 2000 个区域。最小 1M,最大 32M,蓝色区域保持年老代对象,绿色区域保持年轻代对象。

img
注意:区域没有必要像旧的收集器一样是保持连续的。

(4)G1 的年轻代收集

活跃对象会被疏散(复制、移动)到一个或多个 survivor 区域。如果达到晋升总阈值,对象会晋升到年老代区域。

img
这是一个 stop the world 暂停。为下一次年轻代垃圾回收计算 Eden 和 Survivor 的大小。保留审计信息有助于计算大小。类似目标暂停时间的事情会被考虑在内。

这个方法使重调区域大小变得很容易,按需把它们调大或调小。

(5)G1 年轻代回收的尾声

活跃对象被疏散到 Survivor 或者年老代区域。

img
最近晋升的对象显示为深蓝色。Survivor 区域显示为绿色。

关于 G1 的年轻代回收做以下总结:

  • 堆空间是一块单独的内存空间被分割成多个区域。
  • 年轻代内存是由一组非连续的区域组成。这使得需要重调大小变得容易。
  • 年轻代垃圾回收是 stop the world 事件,所有应用线程都会因此操作暂停。
  • 年轻代垃圾收集使用多线程并行回收。
  • 活跃对象被复制到新的 Survivor 区或者年老代区域。

G1 回收年老代详细步骤

(1)初始标记阶段

年轻代垃圾收集肩负着活跃对象初始标记的任务。在日志文件中被标为* GC pause (young)(inital-mark)*

img
(2)并发标记阶段

如果发现空区域 (“X”标示的),在重新标记阶段它们会被马上清除掉。当然,决定活性的审计信息也在此时被计算。

img
(3)重新标记阶段

空的区域被清除和回收掉。所有区域的活性在此时计算。

img
(4)复制/清理阶段

G1 选择活性最低的区域,这些区域能够以最快的速度回收。然后这些区域会在年轻代垃圾回收过程中被回收。在日志中被指示为* [GC pause (mixed)]*。所以年轻代和年老代在同一时间被回收。

img
(5)复制/清理阶段之后

被选择的区域已经被回收和压缩到图中显示的深蓝色区和深绿色区中。

img

总结

收集器 串行/并行/并发 年轻代/老年代 收集算法 目标 适用场景
Serial 串行 年轻代 标记-复制 响应速度优先 单 CPU 环境下的 Client 模式
Serial Old 串行 老年代 标记-整理 响应速度优先 单 CPU 环境下的 Client 模式、CMS 的后备预案
ParNew 串行 + 并行 年轻代 标记-复制 响应速度优先 多 CPU 环境时在 Server 模式下与 CMS 配合
Parallel Scavenge 串行 + 并行 年轻代 标记-复制 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 串行 + 并行 老年代 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任务
CMS 并行 + 并发 老年代 标记-清除 响应速度优先 集中在互联网站或 B/S 系统服务端上的 Java 应用
G1 并行 + 并发 年轻代 + 老年代 标记-整理 + 标记-复制 响应速度优先 面向服务端应用,将来替换 CMS

内存分配与回收策略

对象的内存分配,也就是在堆上分配。主要分配在年轻代的 Eden 区上,少数情况下也可能直接分配在老年代中。

Minor GC

Eden 区空间不足时,触发 Minor GC

Minor GC 发生在年轻代上,因为年轻代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。

Minor GC 工作流程:

  1. Java 应用不断创建对象,通常都是分配在 Eden 区域,当其空间不足时(达到设定的阈值),触发 minor GC。仍然被引用的对象(绿色方块)存活下来,被复制到 JVM 选择的 Survivor 区域,而没有被引用的对象(黄色方块)则被回收。

  2. 经过一次 Minor GC,Eden 就会空闲下来,直到再次达到 Minor GC 触发条件。这时候,另外一个 Survivor 区域则会成为 To 区域,Eden 区域的存活对象和 From 区域对象,都会被复制到 To 区域,并且存活的年龄计数会被加 1。

  3. 类似第二步的过程会发生很多次,直到有对象年龄计数达到阈值,这时候就会发生所谓的晋升(Promotion)过程,如下图所示,超过阈值的对象会被晋升到老年代。这个阈值是可以通过 -XX:MaxTenuringThreshold 参数指定。

Full GC

Full GC 发生在老年代上,老年代对象和年轻代的相反,其存活时间长,因此 Full GC 很少执行,而且执行速度会比 Minor GC 慢很多。

内存分配策略

(一)对象优先在 Eden 分配

大多数情况下,对象在年轻代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

(二)大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。

(三)长期存活的对象进入老年代

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

-XX:MaxTenuringThreshold 用来定义年龄的阈值。

(四)动态对象年龄判定

虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

(五)空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于年轻代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的;如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC。

Full GC 的触发条件

对于 Minor GC,其触发条件非常简单,当 Eden 区空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

(1)调用 System.gc()

此方法的调用是建议虚拟机进行 Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加 Full GC 的频率,也即增加了间歇性停顿的次数。因此强烈建议能不使用此方法就不要使用,让虚拟机自己去管理它的内存。可通过 -XX:DisableExplicitGC 来禁止 RMI 调用 System.gc()

(2)老年代空间不足

老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等,当执行 Full GC 后空间仍然不足,则抛出 java.lang.OutOfMemoryError: Java heap space。为避免以上原因引起的 Full GC,调优时应尽量做到让对象在 Minor GC 阶段被回收、让对象在年轻代多存活一段时间以及不要创建过大的对象及数组。

(3)方法区空间不足

JVM 规范中运行时数据区域中的方法区,在 HotSpot 虚拟机中又被习惯称为永久代,永久代中存放的是类的描述信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么 JVM 会抛出 java.lang.OutOfMemoryError: PermGen space 错误。为避免永久代占满造成 Full GC 现象,可采用的方法为增大 Perm Gen 空间或转为使用 CMS GC。

(4)Minor GC 的平均晋升空间大小大于老年代可用空间

如果发现统计数据说之前 Minor GC 的平均晋升大小比目前老年代剩余的空间大,则不会触发 Minor GC 而是转为触发 Full GC。

(5)对象大小大于 To 区和老年代的可用内存

Eden 区、From 区向 To 区复制时,对象大小大于 To 区可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。

参考资料

深入理解 Java 反射和动态代理

反射简介

img

什么是反射

反射(Reflection)是 Java 程序开发语言的特征之一,它允许运行中的 Java 程序获取自身的信息,并且可以操作类或对象的内部属性。

通过反射机制,可以在运行时访问 Java 对象的属性,方法,构造方法等。

反射的应用场景

反射的主要应用场景有:

  • 开发通用框架 - 反射最重要的用途就是开发各种通用框架。很多框架(比如 Spring)都是配置化的(比如通过 XML 文件配置 JavaBean、Filter 等),为了保证框架的通用性,它们可能需要根据配置文件加载不同的对象或类,调用不同的方法,这个时候就必须用到反射——运行时动态加载需要加载的对象。
  • 动态代理 - 在切面编程(AOP)中,需要拦截特定的方法,通常,会选择动态代理方式。这时,就需要反射技术来实现了。
  • 注解 - 注解本身仅仅是起到标记作用,它需要利用反射机制,根据注解标记去调用注解解释器,执行行为。如果没有反射机制,注解并不比注释更有用。
  • 可扩展性功能 - 应用程序可以通过使用完全限定名称创建可扩展性对象实例来使用外部的用户定义类。

反射的缺点

  • 性能开销 - 由于反射涉及动态解析的类型,因此无法执行某些 Java 虚拟机优化。因此,反射操作的性能要比非反射操作的性能要差,应该在性能敏感的应用程序中频繁调用的代码段中避免。
  • 破坏封装性 - 反射调用方法时可以忽略权限检查,因此可能会破坏封装性而导致安全问题。
  • 内部曝光 - 由于反射允许代码执行在非反射代码中非法的操作,例如访问私有字段和方法,所以反射的使用可能会导致意想不到的副作用,这可能会导致代码功能失常并可能破坏可移植性。反射代码打破了抽象,因此可能会随着平台的升级而改变行为。

反射机制

类加载过程

img

类加载的完整过程如下:

  1. 在编译时,Java 编译器编译好 .java 文件之后,在磁盘中产生 .class 文件。.class 文件是二进制文件,内容是只有 JVM 能够识别的机器码。
  2. JVM 中的类加载器读取字节码文件,取出二进制数据,加载到内存中,解析.class 文件内的信息。类加载器会根据类的全限定名来获取此类的二进制字节流;然后,将字节流所代表的静态存储结构转化为方法区的运行时数据结构;接着,在内存中生成代表这个类的 java.lang.Class 对象。
  3. 加载结束后,JVM 开始进行连接阶段(包含验证、准备、初始化)。经过这一系列操作,类的变量会被初始化。

Class 对象

要想使用反射,首先需要获得待操作的类所对应的 Class 对象。Java 中,无论生成某个类的多少个对象,这些对象都会对应于同一个 Class 对象。这个 Class 对象是由 JVM 生成的,通过它能够获悉整个类的结构。所以,java.lang.Class 可以视为所有反射 API 的入口点。

反射的本质就是:在运行时,把 Java 类中的各种成分映射成一个个的 Java 对象。

举例来说,假如定义了以下代码:

1
User user = new User();

步骤说明:

  1. JVM 加载方法的时候,遇到 new User(),JVM 会根据 User 的全限定名去加载 User.class
  2. JVM 会去本地磁盘查找 User.class 文件并加载 JVM 内存中。
  3. JVM 通过调用类加载器自动创建这个类对应的 Class 对象,并且存储在 JVM 的方法区。注意:一个类有且只有一个 Class 对象

方法的反射调用

方法的反射调用,也就是 Method.invoke 方法。

Method.invoke 方法源码:

1
2
3
4
5
6
7
8
9
10
11
public final class Method extends Executable {
...
public Object invoke(Object obj, Object... args) throws ... {
... // 权限检查
MethodAccessor ma = methodAccessor;
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}
}

Method.invoke 方法实际上委派给 MethodAccessor 接口来处理。它有两个已有的具体实现:

  • NativeMethodAccessorImpl:本地方法来实现反射调用
  • DelegatingMethodAccessorImpl:委派模式来实现反射调用

每个 Method 实例的第一次反射调用都会生成一个委派实现(DelegatingMethodAccessorImpl),它所委派的具体实现便是一个本地实现(NativeMethodAccessorImpl)。本地实现非常容易理解。当进入了 Java 虚拟机内部之后,我们便拥有了 Method 实例所指向方法的具体地址。这时候,反射调用无非就是将传入的参数准备好,然后调用进入目标方法。

【示例】通过抛出异常方式 打印 Method.invoke 调用轨迹

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MethodDemo01 {

public static void target(int i) {
new Exception("#" + i).printStackTrace();
}

public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("io.github.dunwu.javacore.reflect.MethodDemo01");
Method method = clazz.getMethod("target", int.class);
method.invoke(null, 0);
}

}
// Output:
// java.lang.Exception: #0
// at io.github.dunwu.javacore.reflect.MethodDemo01.target(MethodDemo01.java:12)
// at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
// at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)

先调用 DelegatingMethodAccessorImpl;然后调用 NativeMethodAccessorImpl,最后调用实际方法。

为什么反射调用DelegatingMethodAccessorImpl 作为中间层,而不是直接交给本地实现?

其实,Java 的反射调用机制还设立了另一种动态生成字节码的实现(下称动态实现),直接使用 invoke 指令来调用目标方法。之所以采用委派实现,便是为了能够在本地实现以及动态实现中切换。动态实现和本地实现相比,其运行效率要快上 20 倍。这是因为动态实现无需经过 Java 到 C++ 再到 Java 的切换,但由于生成字节码十分耗时,仅调用一次的话,反而是本地实现要快上 3 到 4 倍。

考虑到许多反射调用仅会执行一次,Java 虚拟机设置了一个阈值 15(可以通过 -Dsun.reflect.inflationThreshold 来调整),当某个反射调用的调用次数在 15 之下时,采用本地实现;当达到 15 时,便开始动态生成字节码,并将委派实现的委派对象切换至动态实现,这个过程我们称之为 Inflation。

【示例】执行 java -verbose:class MethodDemo02 启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MethodDemo02 {

public static void target(int i) {
new Exception("#" + i).printStackTrace();
}

public static void main(String[] args) throws Exception {
Class<?> klass = Class.forName("io.github.dunwu.javacore.reflect.MethodDemo02");
Method method = klass.getMethod("target", int.class);
for (int i = 0; i < 20; i++) {
method.invoke(null, i);
}
}

}

输出内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// ...省略
java.lang.Exception: #14
at io.github.dunwu.javacore.reflect.MethodDemo02.target(MethodDemo02.java:13)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at io.github.dunwu.javacore.reflect.MethodDemo02.main(MethodDemo02.java:20)
[Loaded sun.reflect.ClassFileConstants from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded sun.reflect.AccessorGenerator from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded sun.reflect.MethodAccessorGenerator from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded sun.reflect.ByteVectorFactory from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded sun.reflect.ByteVector from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded sun.reflect.ByteVectorImpl from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded sun.reflect.ClassFileAssembler from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded sun.reflect.UTF8 from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded sun.reflect.Label from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded sun.reflect.Label$PatchInfo from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded java.util.ArrayList$Itr from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded sun.reflect.MethodAccessorGenerator$1 from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded sun.reflect.ClassDefiner from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded sun.reflect.ClassDefiner$1 from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
[Loaded sun.reflect.GeneratedMethodAccessor1 from __JVM_DefineClass__]
java.lang.Exception: #15
at io.github.dunwu.javacore.reflect.MethodDemo02.target(MethodDemo02.java:13)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at io.github.dunwu.javacore.reflect.MethodDemo02.main(MethodDemo02.java:20)
[Loaded java.util.concurrent.ConcurrentHashMap$ForwardingNode from D:\Tools\Java\jdk1.8.0_192\jre\lib\rt.jar]
java.lang.Exception: #16
at io.github.dunwu.javacore.reflect.MethodDemo02.target(MethodDemo02.java:13)
at sun.reflect.GeneratedMethodAccessor1.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at io.github.dunwu.javacore.reflect.MethodDemo02.main(MethodDemo02.java:20)
// ...省略

可以看到,从第 16 次开始后,都是使用 DelegatingMethodAccessorImpl ,不再使用本地实现 NativeMethodAccessorImpl

反射调用的开销

方法的反射调用会带来不少性能开销,原因主要有三个:

  • 变长参数方法导致的 Object 数组
  • 基本类型的自动装箱、拆箱
  • 还有最重要的方法内联

Class.forName 会调用本地方法,Class.getMethod 则会遍历该类的公有方法。如果没有匹配到,它还将遍历父类的公有方法。可想而知,这两个操作都非常费时。

注意,以 getMethod 为代表的查找方法操作,会返回查找得到结果的一份拷贝。因此,我们应当避免在热点代码中使用返回 Method 数组的 getMethods 或者 getDeclaredMethods 方法,以减少不必要的堆空间消耗。在实践中,我们往往会在应用程序中缓存 Class.forNameClass.getMethod 的结果。

下面只关注反射调用本身的性能开销。

第一,由于 Method.invoke 是一个变长参数方法,在字节码层面它的最后一个参数会是 Object 数组(感兴趣的同学私下可以用 javap 查看)。Java 编译器会在方法调用处生成一个长度为传入参数数量的 Object 数组,并将传入参数一一存储进该数组中。

第二,由于 Object 数组不能存储基本类型,Java 编译器会对传入的基本类型参数进行自动装箱。

这两个操作除了带来性能开销外,还可能占用堆内存,使得 GC 更加频繁。(如果你感兴趣的话,可以用虚拟机参数 -XX:+PrintGC 试试。)那么,如何消除这部分开销呢?

使用反射

java.lang.reflect 包

Java 中的 java.lang.reflect 包提供了反射功能。java.lang.reflect 包中的类都没有 public 构造方法。

java.lang.reflect 包的核心接口和类如下:

  • Member 接口:反映关于单个成员(字段或方法)或构造函数的标识信息。
  • Field 类:提供一个类的域的信息以及访问类的域的接口。
  • Method 类:提供一个类的方法的信息以及访问类的方法的接口。
  • Constructor 类:提供一个类的构造函数的信息以及访问类的构造函数的接口。
  • Array 类:该类提供动态地生成和访问 JAVA 数组的方法。
  • Modifier 类:提供了 static 方法和常量,对类和成员访问修饰符进行解码。
  • Proxy 类:提供动态地生成代理类和类实例的静态方法。

获取 Class 对象

获取 Class 对象的三种方法:

(1)**Class.forName 静态方法**

【示例】使用 Class.forName 静态方法获取 Class 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package io.github.dunwu.javacore.reflect;

public class ReflectClassDemo01 {
public static void main(String[] args) throws ClassNotFoundException {
Class c1 = Class.forName("io.github.dunwu.javacore.reflect.ReflectClassDemo01");
System.out.println(c1.getCanonicalName());

Class c2 = Class.forName("[D");
System.out.println(c2.getCanonicalName());

Class c3 = Class.forName("[[Ljava.lang.String;");
System.out.println(c3.getCanonicalName());
}
}
//Output:
//io.github.dunwu.javacore.reflect.ReflectClassDemo01
//double[]
//java.lang.String[][]

使用类的完全限定名来反射对象的类。常见的应用场景为:在 JDBC 开发中常用此方法加载数据库驱动。

(2)类名 + .class

【示例】直接用类名 + .class 获取 Class 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ReflectClassDemo02 {
public static void main(String[] args) {
boolean b;
// Class c = b.getClass(); // 编译错误
Class c1 = boolean.class;
System.out.println(c1.getCanonicalName());

Class c2 = java.io.PrintStream.class;
System.out.println(c2.getCanonicalName());

Class c3 = int[][][].class;
System.out.println(c3.getCanonicalName());
}
}
//Output:
//boolean
//java.io.PrintStream
//int[][][]

(3)**ObjectgetClass 方法**

Object 类中有 getClass 方法,因为所有类都继承 Object 类。从而调用 Object 类来获取 Class 对象。

【示例】ObjectgetClass 方法获取 Class 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package io.github.dunwu.javacore.reflect;

import java.util.HashSet;
import java.util.Set;

public class ReflectClassDemo03 {
enum E {A, B}

public static void main(String[] args) {
Class c = "foo".getClass();
System.out.println(c.getCanonicalName());

Class c2 = ReflectClassDemo03.E.A.getClass();
System.out.println(c2.getCanonicalName());

byte[] bytes = new byte[1024];
Class c3 = bytes.getClass();
System.out.println(c3.getCanonicalName());

Set<String> set = new HashSet<>();
Class c4 = set.getClass();
System.out.println(c4.getCanonicalName());
}
}
//Output:
//java.lang.String
//io.github.dunwu.javacore.reflect.ReflectClassDemo.E
//byte[]
//java.util.HashSet

判断是否为某个类的实例

判断是否为某个类的实例有两种方式:

  1. instanceof 关键字
  2. Class 对象的 isInstance 方法(它是一个 Native 方法)

【示例】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InstanceofDemo {
public static void main(String[] args) {
ArrayList arrayList = new ArrayList();
if (arrayList instanceof List) {
System.out.println("ArrayList is List");
}
if (List.class.isInstance(arrayList)) {
System.out.println("ArrayList is List");
}
}
}
//Output:
//ArrayList is List
//ArrayList is List

创建实例

通过反射来创建实例对象主要有两种方式:

  • Class 对象的 newInstance 方法。
  • Constructor 对象的 newInstance 方法。

【示例】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class NewInstanceDemo {
public static void main(String[] args)
throws IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
Class<?> c1 = StringBuilder.class;
StringBuilder sb = (StringBuilder) c1.newInstance();
sb.append("aaa");
System.out.println(sb.toString());

//获取String所对应的Class对象
Class<?> c2 = String.class;
//获取String类带一个String参数的构造器
Constructor constructor = c2.getConstructor(String.class);
//根据构造器创建实例
String str2 = (String) constructor.newInstance("bbb");
System.out.println(str2);
}
}
//Output:
//aaa
//bbb

创建数组实例

数组在 Java 里是比较特殊的一种类型,它可以赋值给一个对象引用。Java 中,通过 Array.newInstance 创建数组的实例

【示例】利用反射创建数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ReflectArrayDemo {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> cls = Class.forName("java.lang.String");
Object array = Array.newInstance(cls, 25);
//往数组里添加内容
Array.set(array, 0, "Scala");
Array.set(array, 1, "Java");
Array.set(array, 2, "Groovy");
Array.set(array, 3, "Scala");
Array.set(array, 4, "Clojure");
//获取某一项的内容
System.out.println(Array.get(array, 3));
}
}
//Output:
//Scala

其中的 Array 类为 java.lang.reflect.Array 类。我们Array.newInstance 的原型是:

1
2
3
4
public static Object newInstance(Class<?> componentType, int length)
throws NegativeArraySizeException {
return newArray(componentType, length);
}

Field

Class 对象提供以下方法获取对象的成员(Field):

  • getFiled - 根据名称获取公有的(public)类成员。
  • getDeclaredField - 根据名称获取已声明的类成员。但不能得到其父类的类成员。
  • getFields - 获取所有公有的(public)类成员。
  • getDeclaredFields - 获取所有已声明的类成员。

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ReflectFieldDemo {
class FieldSpy<T> {
public boolean[][] b = { {false, false}, {true, true} };
public String name = "Alice";
public List<Integer> list;
public T val;
}

public static void main(String[] args) throws NoSuchFieldException {
Field f1 = FieldSpy.class.getField("b");
System.out.format("Type: %s%n", f1.getType());

Field f2 = FieldSpy.class.getField("name");
System.out.format("Type: %s%n", f2.getType());

Field f3 = FieldSpy.class.getField("list");
System.out.format("Type: %s%n", f3.getType());

Field f4 = FieldSpy.class.getField("val");
System.out.format("Type: %s%n", f4.getType());
}
}
//Output:
//Type: class [[Z
//Type: class java.lang.String
//Type: interface java.util.List
//Type: class java.lang.Object

Method

Class 对象提供以下方法获取对象的方法(Method):

  • getMethod - 返回类或接口的特定方法。其中第一个参数为方法名称,后面的参数为方法参数对应 Class 的对象。
  • getDeclaredMethod - 返回类或接口的特定声明方法。其中第一个参数为方法名称,后面的参数为方法参数对应 Class 的对象。
  • getMethods - 返回类或接口的所有 public 方法,包括其父类的 public 方法。
  • getDeclaredMethods - 返回类或接口声明的所有方法,包括 public、protected、默认(包)访问和 private 方法,但不包括继承的方法。

获取一个 Method 对象后,可以用 invoke 方法来调用这个方法。

invoke 方法的原型为:

1
2
3
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException

【示例】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class ReflectMethodDemo {
public static void main(String[] args)
throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {

// 返回所有方法
Method[] methods1 = System.class.getDeclaredMethods();
System.out.println("System getDeclaredMethods 清单(数量 = " + methods1.length + "):");
for (Method m : methods1) {
System.out.println(m);
}

// 返回所有 public 方法
Method[] methods2 = System.class.getMethods();
System.out.println("System getMethods 清单(数量 = " + methods2.length + "):");
for (Method m : methods2) {
System.out.println(m);
}

// 利用 Method 的 invoke 方法调用 System.currentTimeMillis()
Method method = System.class.getMethod("currentTimeMillis");
System.out.println(method);
System.out.println(method.invoke(null));
}
}

Constructor

Class 对象提供以下方法获取对象的构造方法(Constructor):

  • getConstructor - 返回类的特定 public 构造方法。参数为方法参数对应 Class 的对象。
  • getDeclaredConstructor - 返回类的特定构造方法。参数为方法参数对应 Class 的对象。
  • getConstructors - 返回类的所有 public 构造方法。
  • getDeclaredConstructors - 返回类的所有构造方法。

获取一个 Constructor 对象后,可以用 newInstance 方法来创建类实例。

【示例】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ReflectMethodConstructorDemo {
public static void main(String[] args)
throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
Constructor<?>[] constructors1 = String.class.getDeclaredConstructors();
System.out.println("String getDeclaredConstructors 清单(数量 = " + constructors1.length + "):");
for (Constructor c : constructors1) {
System.out.println(c);
}

Constructor<?>[] constructors2 = String.class.getConstructors();
System.out.println("String getConstructors 清单(数量 = " + constructors2.length + "):");
for (Constructor c : constructors2) {
System.out.println(c);
}

System.out.println("====================");
Constructor constructor = String.class.getConstructor(String.class);
System.out.println(constructor);
String str = (String) constructor.newInstance("bbb");
System.out.println(str);
}
}

绕开访问限制

有时候,我们需要通过反射访问私有成员、方法。可以使用 Constructor/Field/Method.setAccessible(true) 来绕开 Java 语言的访问限制。

动态代理

动态代理是一种方便运行时动态构建代理、动态处理代理方法调用的机制,很多场景都是利用类似机制做到的,比如用来包装 RPC 调用、面向切面的编程(AOP)。

实现动态代理的方式很多,比如 JDK 自身提供的动态代理,就是主要利用了上面提到的反射机制。还有其他的实现方式,比如利用传说中更高性能的字节码操作机制,类似 ASM、cglib(基于 ASM)、Javassist 等。

img

静态代理

静态代理其实就是指设计模式中的代理模式。

代理模式为其他对象提供一种代理以控制对这个对象的访问。

img

Subject 定义了 RealSubject 和 Proxy 的公共接口,这样就在任何使用 RealSubject 的地方都可以使用 Proxy 。

1
2
3
abstract class Subject {
public abstract void Request();
}

RealSubject 定义 Proxy 所代表的真实实体。

1
2
3
4
5
6
class RealSubject extends Subject {
@Override
public void Request() {
System.out.println("真实的请求");
}
}

Proxy 保存一个引用使得代理可以访问实体,并提供一个与 Subject 的接口相同的接口,这样代理就可以用来替代实体。

1
2
3
4
5
6
7
8
9
10
11
class Proxy extends Subject {
private RealSubject real;

@Override
public void Request() {
if (null == real) {
real = new RealSubject();
}
real.Request();
}
}

说明:

静态代理模式固然在访问无法访问的资源,增强现有的接口业务功能方面有很大的优点,但是大量使用这种静态代理,会使我们系统内的类的规模增大,并且不易维护;并且由于 Proxy 和 RealSubject 的功能本质上是相同的,Proxy 只是起到了中介的作用,这种代理在系统中的存在,导致系统结构比较臃肿和松散。

JDK 动态代理

为了解决静态代理的问题,就有了创建动态代理的想法:

在运行状态中,需要代理的地方,根据 Subject 和 RealSubject,动态地创建一个 Proxy,用完之后,就会销毁,这样就可以避免了 Proxy 角色的 class 在系统中冗杂的问题了。

img

Java 动态代理基于经典代理模式,引入了一个 InvocationHandlerInvocationHandler 负责统一管理所有的方法调用。

动态代理步骤:

  1. 获取 RealSubject 上的所有接口列表;
  2. 确定要生成的代理类的类名,默认为:com.sun.proxy.$ProxyXXXX
  3. 根据需要实现的接口信息,在代码中动态创建 该 Proxy 类的字节码;
  4. 将对应的字节码转换为对应的 class 对象;
  5. 创建 InvocationHandler 实例 handler,用来处理 Proxy 所有方法调用;
  6. Proxy 的 class 对象 以创建的 handler 对象为参数,实例化一个 proxy 对象。

从上面可以看出,JDK 动态代理的实现是基于实现接口的方式,使得 Proxy 和 RealSubject 具有相同的功能。

但其实还有一种思路:通过继承。即:让 Proxy 继承 RealSubject,这样二者同样具有相同的功能,Proxy 还可以通过重写 RealSubject 中的方法,来实现多态。CGLIB 就是基于这种思路设计的。

在 Java 的动态代理机制中,有两个重要的类(接口),一个是 InvocationHandler 接口、另一个则是 Proxy 类,这一个类和一个接口是实现我们动态代理所必须用到的。

InvocationHandler 接口

InvocationHandler 接口定义:

1
2
3
4
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}

每一个动态代理类都必须要实现 InvocationHandler 这个接口,并且每个代理类的实例都关联到了一个 Handler,当我们通过代理对象调用一个方法的时候,这个方法的调用就会被转发为由 InvocationHandler 这个接口的 invoke 方法来进行调用。

我们来看看 InvocationHandler 这个接口的唯一一个方法 invoke 方法:

1
Object invoke(Object proxy, Method method, Object[] args) throws Throwable

参数说明:

  • proxy - 代理的真实对象。
  • method - 所要调用真实对象的某个方法的 Method 对象
  • args - 所要调用真实对象某个方法时接受的参数

如果不是很明白,等下通过一个实例会对这几个参数进行更深的讲解。

Proxy 类

Proxy 这个类的作用就是用来动态创建一个代理对象的类,它提供了许多的方法,但是我们用的最多的就是 newProxyInstance 这个方法:

1
public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces,  InvocationHandler h)  throws IllegalArgumentException

这个方法的作用就是得到一个动态的代理对象。

参数说明:

  • loader - 一个 ClassLoader 对象,定义了由哪个 ClassLoader 对象来对生成的代理对象进行加载。
  • interfaces - 一个 Class<?> 对象的数组,表示的是我将要给我需要代理的对象提供一组什么接口,如果我提供了一组接口给它,那么这个代理对象就宣称实现了该接口(多态),这样我就能调用这组接口中的方法了
  • h - 一个 InvocationHandler 对象,表示的是当我这个动态代理对象在调用方法的时候,会关联到哪一个 InvocationHandler 对象上

JDK 动态代理实例

上面的内容介绍完这两个接口(类)以后,我们来通过一个实例来看看我们的动态代理模式是什么样的:

首先我们定义了一个 Subject 类型的接口,为其声明了两个方法:

1
2
3
4
5
6
public interface Subject {

void hello(String str);

String bye();
}

接着,定义了一个类来实现这个接口,这个类就是我们的真实对象,RealSubject 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RealSubject implements Subject {

@Override
public void hello(String str) {
System.out.println("Hello " + str);
}

@Override
public String bye() {
System.out.println("Goodbye");
return "Over";
}
}

下一步,我们就要定义一个动态代理类了,前面说个,每一个动态代理类都必须要实现 InvocationHandler 这个接口,因此我们这个动态代理类也不例外:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class InvocationHandlerDemo implements InvocationHandler {
// 这个就是我们要代理的真实对象
private Object subject;

// 构造方法,给我们要代理的真实对象赋初值
public InvocationHandlerDemo(Object subject) {
this.subject = subject;
}

@Override
public Object invoke(Object object, Method method, Object[] args)
throws Throwable {
// 在代理真实对象前我们可以添加一些自己的操作
System.out.println("Before method");

System.out.println("Call Method: " + method);

// 当代理对象调用真实对象的方法时,其会自动的跳转到代理对象关联的handler对象的invoke方法来进行调用
Object obj = method.invoke(subject, args);

// 在代理真实对象后我们也可以添加一些自己的操作
System.out.println("After method");
System.out.println();

return obj;
}
}

最后,来看看我们的 Client 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Client {
public static void main(String[] args) {
// 我们要代理的真实对象
Subject realSubject = new RealSubject();

// 我们要代理哪个真实对象,就将该对象传进去,最后是通过该真实对象来调用其方法的
InvocationHandler handler = new InvocationHandlerDemo(realSubject);

/*
* 通过Proxy的newProxyInstance方法来创建我们的代理对象,我们来看看其三个参数
* 第一个参数 handler.getClass().getClassLoader() ,我们这里使用handler这个类的ClassLoader对象来加载我们的代理对象
* 第二个参数realSubject.getClass().getInterfaces(),我们这里为代理对象提供的接口是真实对象所实行的接口,表示我要代理的是该真实对象,这样我就能调用这组接口中的方法了
* 第三个参数handler, 我们这里将这个代理对象关联到了上方的 InvocationHandler 这个对象上
*/
Subject subject = (Subject)Proxy.newProxyInstance(handler.getClass().getClassLoader(), realSubject
.getClass().getInterfaces(), handler);

System.out.println(subject.getClass().getName());
subject.hello("World");
String result = subject.bye();
System.out.println("Result is: " + result);
}
}

我们先来看看控制台的输出:

1
2
3
4
5
6
7
8
9
10
11
12
com.sun.proxy.$Proxy0
Before method
Call Method: public abstract void io.github.dunwu.javacore.reflect.InvocationHandlerDemo$Subject.hello(java.lang.String)
Hello World
After method

Before method
Call Method: public abstract java.lang.String io.github.dunwu.javacore.reflect.InvocationHandlerDemo$Subject.bye()
Goodbye
After method

Result is: Over

我们首先来看看 com.sun.proxy.$Proxy0 这东西,我们看到,这个东西是由 System.out.println(subject.getClass().getName()); 这条语句打印出来的,那么为什么我们返回的这个代理对象的类名是这样的呢?

1
2
Subject subject = (Subject)Proxy.newProxyInstance(handler.getClass().getClassLoader(), realSubject
.getClass().getInterfaces(), handler);

可能我以为返回的这个代理对象会是 Subject 类型的对象,或者是 InvocationHandler 的对象,结果却不是,首先我们解释一下为什么我们这里可以将其转化为 Subject 类型的对象?

原因就是:在 newProxyInstance 这个方法的第二个参数上,我们给这个代理对象提供了一组什么接口,那么我这个代理对象就会实现了这组接口,这个时候我们当然可以将这个代理对象强制类型转化为这组接口中的任意一个,因为这里的接口是 Subject 类型,所以就可以将其转化为 Subject 类型了。

同时我们一定要记住,通过 Proxy.newProxyInstance 创建的代理对象是在 jvm 运行时动态生成的一个对象,它并不是我们的 InvocationHandler 类型,也不是我们定义的那组接口的类型,而是在运行是动态生成的一个对象,并且命名方式都是这样的形式,以$开头,proxy 为中,最后一个数字表示对象的标号

接着我们来看看这两句

1
2
subject.hello("World");
String result = subject.bye();

这里是通过代理对象来调用实现的那种接口中的方法,这个时候程序就会跳转到由这个代理对象关联到的 handler 中的 invoke 方法去执行,而我们的这个 handler 对象又接受了一个 RealSubject 类型的参数,表示我要代理的就是这个真实对象,所以此时就会调用 handler 中的 invoke 方法去执行。

我们看到,在真正通过代理对象来调用真实对象的方法的时候,我们可以在该方法前后添加自己的一些操作,同时我们看到我们的这个 method 对象是这样的:

1
2
public abstract void io.github.dunwu.javacore.reflect.InvocationHandlerDemo$Subject.hello(java.lang.String)
public abstract java.lang.String io.github.dunwu.javacore.reflect.InvocationHandlerDemo$Subject.bye()

正好就是我们的 Subject 接口中的两个方法,这也就证明了当我通过代理对象来调用方法的时候,起实际就是委托由其关联到的 handler 对象的 invoke 方法中来调用,并不是自己来真实调用,而是通过代理的方式来调用的。

JDK 动态代理小结

代理类与委托类实现同一接口,主要是通过代理类实现 InvocationHandler 并重写 invoke 方法来进行动态代理的,在 invoke 方法中将对方法进行处理。

JDK 动态代理特点:

  • 优点:相对于静态代理模式,不需要硬编码接口,代码复用率高。

  • 缺点:强制要求代理类实现 InvocationHandler 接口。

CGLIB 动态代理

CGLIB 提供了与 JDK 动态代理不同的方案。很多框架,例如 Spring AOP 中,就使用了 CGLIB 动态代理。

CGLIB 底层,其实是借助了 ASM 这个强大的 Java 字节码框架去进行字节码增强操作。

CGLIB 动态代理的工作步骤:

  • 生成代理类的二进制字节码文件;
  • 加载二进制字节码,生成 Class 对象( 例如使用 Class.forName() 方法 );
  • 通过反射机制获得实例构造,并创建代理类对象。

CGLIB 动态代理特点:

优点:使用字节码增强,比 JDK 动态代理方式性能高。可以在运行时对类或者是接口进行增强操作,且委托类无需实现接口。

缺点:不能对 final 类以及 final 方法进行代理。

参考:深入理解 CGLIB 动态代理机制

参考资料