Elasticsearch 运维
Elasticsearch 运维
Elasticsearch 是一个分布式、RESTful 风格的搜索和数据分析引擎,能够解决不断涌现出的各种用例。 作为 Elastic Stack 的核心,它集中存储您的数据,帮助您发现意料之中以及意料之外的情况。
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 | cluster.name: elasticsearch |
Elasticsearch FAQ
elasticsearch 不允许以 root 权限来运行
问题:在 Linux 环境中,elasticsearch 不允许以 root 权限来运行。
如果以 root 身份运行 elasticsearch,会提示这样的错误:
1 | can not run elasticsearch as root |
解决方法:使用非 root 权限账号运行 elasticsearch
1 | # 创建用户组 |
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 | echo "vm.max_map_count=262144" > /etc/sysctl.conf |
注意
如果运行环境为 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 | echo "* soft nofile 65536" > /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 | echo "* soft nproc 2048" > /etc/security/limits.conf |
参考资料
Elasticsearch 面试
Elasticsearch 面试
Elasticsearch 简介
扩展阅读:
【基础】什么是 ES?
:::details 要点
Elasticsearch 是一个开源的分布式搜索和分析引擎。
Elasticsearch 基于搜索库 Lucene 开发。Elasticsearch 隐藏了 Lucene 的复杂性,提供了简单易用的 REST API / Java API 接口(另外还有其他语言的 API 接口)。
Elasticsearch 是面向文档的,它将复杂数据结构序列化为 JSON 形式存储。
Elasticsearch 提供近实时(Near Realtime,缩写 NRT)的全文搜索。近实时是指:
- 从写入数据到数据可以被搜索,存在较小的延迟(大概是 1s)。
- 基于 Elasticsearch 执行搜索和分析可以达到秒级。
:::
【基础】ES 有哪些应用场景?
:::details 要点
Elasticsearch 的主要功能如下:
- 海量数据的分布式存储及集群管理
- 提供丰富的近实时搜索能力
- 海量数据的近实时分析(聚合)
Elasticsearch 被广泛应用于以下场景:
- 搜索
- 全文检索 - Elasticsearch 通过快速搜索大型数据集,使复杂的搜索查询变得更加容易。它对于需要即时和相关搜索结果的网站、应用程序或企业特别有用。
- 自动补全和拼写纠正 - 可以在用户输入内容时,实时提供自动补全和拼写纠正,以增加用户体验并提高搜索效率。
- 地理空间搜索 - 使用地理空间查询搜索位置并计算空间关系。
- 近实时分析 - Elasticsearch 能够进行实时分析,使其适用于追踪实时数据的仪表板,例如用户活动、用户画像等,分析后进行推送。
- 可观测性
- 日志、指标和链路追踪 - 收集、存储和分析来自应用程序、系统和服务的日志、指标和追踪。
- 性能监控 - 监控和分析业务关键性能指标。
- OpenTelemetry - 使用 OpenTelemetry 标准,将遥测数据采集到 Elastic Stack。
:::
【基础】Elasticsearch 有哪些里程碑版本?
:::details 要点
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)?
:::details 要点
Elastic Stack 通常被用来作为日志采集、检索、可视化的解决方案。
Elastic Stack 也常被称为 ELK,这是 Elastic 公司旗下三款产品 Elasticsearch 、Logstash 、Kibana 的首字母组合。
- Elasticsearch 负责存储数据,并提供对数据的检索和分析。
- Logstash 传输和处理你的日志、事务或其他数据。
- Kibana 将 Elasticsearch 的数据分析并渲染为可视化的报表。
Elastic Stack,在 ELK 的基础上扩展了一些新的产品。如:Beats,这是针对不同类型数据的轻量级采集器套件。
此外,基于 Elastic Stack,其技术生态还可以和一些主流的分布式中间件进行集成,以应对各种不同的场景。
:::
Elasticsearch CRUD
扩展阅读:
【基础】如何在 ES 中 CRUD?
:::details 要点
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 支持哪些数据类型?
:::details 要点
Elasticsearch 支持丰富的数据类型,常见的有:
- 文本类型:
text
、keyword
、constant_keyword
、wildcard
- 二进制类型:
binary
- 数值类型:
long
、float
等 - 日期类型:
date
- 布尔类型:
boolean
- 对象类型:
object
、nested
扩展:数据类型
:::
【基础】ES 如何识别字段的数据类型?
:::details 要点
在 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 | PUT /my-index-000001 |
【示例】在已存在的索引中,指定一个 field 的属性
1 | PUT /my-index-000001/_mapping |
【示例】查看 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 | PUT data/_doc/1 |
:::
Elasticsearch 存储
扩展阅读:
【基础】ES 的逻辑存储是怎样设计的?
:::details 要点
Elasticsearch 的逻辑存储被设计为层级结构,自上而下依次为:
各层级结构的说明如下:
(1)Document(文档)
Elasticsearch 是面向文档的,这意味着读写数据的最小单位是文档。Elasticsearch 以 JSON 文档的形式序列化和存储数据。文档是一组字段,这些字段是包含数据的键值对。每个文档都有一个唯一的 ID。
一个简单的 Elasticsearch 文档可能如下所示:
1 | { |
Elasticsearch 中的 document 是无模式的,也就是并非所有 document 都必须拥有完全相同的字段,它们不受限于同一个模式。
(2)Field(字段)
field 包含数据的键值对。默认情况下,Elasticsearch 对每个字段中的所有数据建立索引,并且每个索引字段都具有专用的优化数据结构。
document
包含数据和元数据。Metadata Field(元数据字段) 是存储有关文档信息的系统字段。在 Elasticsearch 中,元数据字段都以 _
开头。常见的元数据字段有:
(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 的物理存储是怎样设计的?
:::details 要点
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 包,里面包含了封装好的构建、管理倒排索引的算法代码。
:::
【中级】什么是倒排索引?
:::details 要点
既然有倒排索引,顾名思义,有与之相对的正排索引。这里,以实现一个诗词检索器为例,来说明一下正排索引和倒排索引的区别。
正排索引是 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 的关系外,还需要保存这个词项在对应文档出现的位置、偏移量等信息,这是因为很多检索的场景中还需要判断关键词前后的内容是否符合搜索要求。
有了倒排索引,搜索引擎可以很方便地响应用户的查询。比如用户输入查询 明月
,搜索系统查找倒排索引,从中读出包含这个单词的文档,这些文档就是提供给用户的搜索结果。
要注意倒排索引的两个重要细节:
- 倒排索引中的所有词项对应一个或多个文档;
- 倒排索引中的词项根据字典顺序升序排列
:::
【中级】什么是字典树?
:::details 要点
Trie(字典树),也被称为前缀树,是一种树状数据结构,用于有效检索键值对。它通常用于实现字典和自动补全功能,使其成为许多搜索算法的基本组件。
Trie 遵循一个规则:如果两个字符串有共同的前缀,那么它们在 Trie 中将具有相同的祖先。
Trie 的检索能力也可以使用 Hash 替代,但是 Trie 比 Hash 更高效。此外,Trie 有 Hash 不具备的优点:Trie 支持前缀搜索和排序。Trie 的主要缺点是:存储词项需要额外的空间,对于长文本,空间可能会变得很大。
:::
【高级】ES 如何实现倒排索引?
:::details 要点
在 Elasticsearch 中,数据存储、检索实际上是基于 Lucene 实现。
一个 Elasticsearch shard 对应一个 Lucene index,
Elasticsearch 的每个 shard 对应一个 Lucene index(一个包含倒排索引的文件目录)。Lucene index 又会被分解为多个 segment。segment 是索引中的内部存储元素,由于写入效率的考虑,所以被设计为不可变更的。segment 会定期 合并 较大的 segment,以保持索引大小。
倒排索引的组成主要有 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 索引别名有什么用?
:::details 要点
Elasticsearch 中的别名可用于更轻松地管理和使用索引。别名允许同时对多个索引执行操作,或者通过隐藏底层索引结构的复杂性来简化索引管理。
扩展阅读:https://www.elastic.co/guide/en/elasticsearch/reference/current/aliases.html
:::
【基础】ES 中有哪些全文搜索 API?
:::details 要点
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
语法版本,适合直接向用户公开。
:::
【基础】ES 中有哪些词项搜索 API?
:::details 要点
Term
(词项)是表达语意的最小单位。搜索和利用统计语言模型进行自然语言处理都需要处理 Term。
全文查询在执行查询之前会分析查询字符串。与全文查询不同,词项级别查询不会分词,而是将输入作为一个整体,在倒排索引中查找准确的词项。并且使用相关度计算公式为每个包含该词项的文档进行相关度计算。一言以概之:词项查询是对词项进行精确匹配。词项查询通常用于结构化数据,如数字、日期和枚举类型。
ES 支持词项搜索的 API 主要有以下几个:
- exists - 返回在指定字段上有值的文档。
- fuzzy - 模糊查询,返回包含与搜索词相似的词的文档。
- ids - 根据 ID 返回文档。此查询使用存储在
_id
字段中的文档 ID。 - prefix - 前缀查询,用于查询某个字段中包含指定前缀的文档。
- range - 范围查询,用于匹配在某一范围内的数值型、日期类型或者字符串型字段的文档。
- regexp - 正则匹配查询,返回与正则表达式相匹配的词项所属的文档。
- term - 用来查找指定字段中包含给定单词的文档。
- terms - 与
term
相似,但可以搜索多个值。 - terms set - 与
term
相似,但可以定义返回文档所需的匹配词数。 - wildcard - 通配符查询,返回与通配符模式匹配的文档。
:::
【基础】ES 支持哪些组合查询?
:::details 要点
复合查询就是把一些简单查询组合在一起实现更复杂的查询需求,除此之外,复合查询还可以控制另外一个查询的行为。
复合查询有以下类型:
bool
- 布尔查询,可以组合多个过滤语句来过滤文档。boosting
- 提供调整相关性打分的能力,在positive
块中指定匹配文档的语句,同时降低在negative
块中也匹配的文档的得分。constant_score
- 使用constant_score
可以将query
转化为filter
,filter 可以忽略相关性算分的环节,并且 filter 可以有效利用缓存,从而提高查询的性能。dis_max
- 返回匹配了一个或者多个查询语句的文档,但只将最佳匹配的评分作为相关性算分返回。function_score
- 支持使用函数来修改查询返回的分数。
:::
【基础】ES 中的 query 和 filter 有什么区别?
:::details 要点
在 Elasticsearch 中,可以在两个不同的上下文中执行查询:
query
context - 有相关性计算,采用相关性算法,计算文档与查询关键词之间的相关度,并根据评分(_score
)大小排序。filter
context - 无相关性计算,可以利用缓存,性能更好。
:::
【中级】ES 支持哪些推荐查询?
:::details 要点
ES 通过 Suggester
提供了推荐搜索能力,可以用于文本纠错,文本自动补全等场景。
根据使用场景的不同,ES 提供了以下 4 种 Suggester:
- Term Suggester - 基于词项的纠错补全。
- Phrase Suggester - 基于短语的纠错补全。
- Completion Suggester - 自动补全单词,输入词语的前半部分,自动补全单词。
- Context Suggester - 基于上下文的补全提示,可以实现上下文感知推荐。
:::
【高级】ES 搜索数据的流程是怎样的?
:::details 要点
在 Elasticsearch 中,搜索一般分为两个阶段,query 和 fetch 阶段。可以简单的理解,query 阶段确定要取哪些 doc,fetch 阶段取出具体的 doc。
Query 阶段会根据搜索条件遍历每个分片(主分片或者副分片中的其一)中的数据,返回符合条件的前 N 条数据的 ID 和排序值,然后在协调节点中对所有分片的数据进行排序,获取前 N 条数据的 ID。
Query 阶段的流程如下:
- 客户端发送请求到任意一个节点,这个 node 成为 coordinate node(协调节点)。coordinate node 创建一个大小为 from + size 的优先级队列用来存放结果。
- coordinate node 对 document 进行路由,将请求转发到对应的 node,此时会使用 round-robin 随机轮询算法,在 primary shard 以及其所有 replica 中随机选择一个,让读请求负载均衡。
- 每个分片在本地执行搜索请求,并将查询结果打分排序,然后将结果保存到 from + size 大小的有序队列中。
- 接着,每个分片将结果返回给 coordinate node,coordinate node 对数据进行汇总处理:合并、排序、分页,将汇总数据存到一个大小为 from + size 的全局有序队列。
需要注意的是,在协调节点转发搜索请求的时候,如果有 N 个 Shard 位于同一个节点时,并不会合并这些请求,而是发生 N 次请求!
在 Fetch 阶段,协调节点会从 Query 阶段产生的全局排序列表中确定需要取回的文档 ID 列表,然后通过路由算法计算出各个文档对应的分片,并且用 multi get 的方式到对应的分片上获取文档数据。
Fetch 阶段的流程如下:
- coordinate node 确定需要获取哪些文档,然后向相关节点发起 multi get 请求;
- 分片所在节点读取文档数据,并且进行
_source
字段过滤、处理高亮参数等,然后把处理后的文档数据返回给协调节点; - coordinate node 汇总所有数据后,返回给客户端。
:::
【高级】ES 为什么会有深分页问题?
:::details 要点
在 Elasticsearch 中,支持三种分页查询方式:
- from + size - 可以使用
from
和size
参数分别指定查询的起始页和每页记录数。 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 中有哪些聚合?
:::details 要点
在数据库中,聚合是指将数据进行分组统计,得到一个汇总的结果。例如,计算总和、平均值、最大值或最小值等操作。
Elasticsearch 将聚合分为三类:
类型 | 说明 |
---|---|
Metric(指标聚合) | 根据字段值进行统计计算 |
Bucket(桶聚合) | 根据字段值、范围或其他条件进行分组 |
Pipeline(管道聚合) | 对其他聚合输出的结果进行再次聚合 |
:::
【中级】ES 如何对海量数据(过亿)进行聚合计算?
:::details 要点
Elasticsearch 支持 cardinality
(近似计算非重复值) 。它提供一个字段的基数,即该字段的 distinct 或者 unique 值的数目。它是基于 HLL 算法的。HLL 会先对我们的输入作哈希运算,然后根据哈希运算的结果中的 bits 做概率估算从而得到基数。其特点是:可配置的精度,用来控制内存的使用(更精确 = 更多内存);小的数据集精度是非常高的;我们可以通过配置参数,来设置去重需要的固定内存使用量。无论数千还是数十亿的唯一值,内存使用量只与你配置的精确度相关。
:::
Elasticsearch 分析
【基础】什么是文本分析?为什么需要文本分析?
:::details 要点
Elasticsearch 中存储的数据可以粗略分为:
- 词项数据 - 采用精确查询。比较两条词项数据是否相对,实际是比较二者的二进制数据,结果只有相等或不相等。
- 文本数据 - 采用全文搜索。比较两个文本数据是否相等,没有太大意义,一般只会比较二者是否相似。相似性比较,是通过相关性评分来评估的。而计算相关性评分,需要对全文先分词处理,然后对分词后的词项进行统计才能进行相似性评估。
Elasticsearch 文本分析是将非结构化文本转换为一组词项(term)的过程。
文本分析可以分为两个方面:
- Tokenization(分词化) - 分词化将文本分解成更小的块,称为分词。在大多数情况下,这些分词是单独的 term(词项)。
- Normalization(标准化) - 经过分词后的文本只能进行词项匹配,但是无法进行同义词匹配。为解决这个问题,可以将文本进行标准化处理。例如:将
foxes
标准化为fox
。
:::
【基础】Elasticsearch 中的分析器是什么?
:::details 要点
文本分析由 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 实体(如&
)。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 内置了很多分词过滤器,这里列举几个常见的:
classic
- 从单词末尾删除英语所有格 ('s
),并删除首字母缩略词中的点。它使用 Lucene 的 ClassicFilter。lowercase
- 将分词转为小写。stop
- 从分词中删除 stop word(停用词)。synonym
- 允许在分析过程中轻松处理 近义词。
:::
【中级】如果需要中文分词怎么办?
:::details 要点
在英文中,单词有自然的空格作为分隔。
在中文中,分词有以下难点:
- 中文不能根据一个个汉字进行分词
- 不同于英文可以根据自然的空格进行分词;中文中一般不会有空格。
- 同一句话,在不同的上下文中,有不同个理解。例如:这个苹果,不大好吃;这个苹果,不大,好吃!
可以使用一些插件来获得对中文更好的分析能力:
- analysis-icu - 添加了扩展的 Unicode 支持,包括更好地分析亚洲语言、Unicode 规范化、Unicode 感知大小写折叠、排序规则支持和音译。
- elasticsearch-analysis-ik - 支持自定义词库,支持热更新分词字典
- elasticsearch-thulac-plugin - 清华大学自然语言处理和社会人文计算实验室的一套中文分词器。
:::
Elasticsearch 复制
【中级】ES 如何保证高可用?
:::details 要点
ES 通过副本机制实现高可用。ES 的数据副本模型参考了 PacificA 算法。
ES 必须满足以下条件才能运行:
默认的情况下,ES 的数据写入只需要保证主副本写入了即可,ES 在写上选择的是可用性优先,而并不是像 PacificA 协议那样的强一致性。而数据读取方面,ES 可能会读取到没有 commit 的数据,所以 ES 的数据读取可能产生不一致的情况。
在数据恢复方面,系统可以借助 GlobalCheckpoint 和 LocalCheckpoint 来加速数据恢复的过程。如果集群中只有旧的副本可用,那么可以使用 allocate_stale_primary 将一个指定的旧分片分配为主分片,但会造成数据丢失,慎用!
扩展:
:::
【中级】ES 是如何实现选主的?
:::details 要点
发起选主流程的条件:
- 只有 master-eligible 节点(通过
node.master: true
设置)才能发起选主流程。 - 该 master-eligible 节点的当前状态不是 master。
- 该 master-eligible 节点通过 ZenDiscovery 模块的 ping 操作询问其已知的集群其他节点,没有任何节点连接到 master。
- 包括本节点在内,当前已有超过
discovery.zen.minimum_master_nodes
个节点没有连接到 master。
一般,应设置
discovery.zen.minimum_master_nodes
为N / 2 + 1
,以保证各种分布式决议能得到大多数节点认可。当集群由于故障(如:通信失联)被分割成多个子集群时,节点数未达到半数以上的子集群,不允许进行选主。以此,来避免出现脑裂问题。
选主流程:
- Elasticsearch 的选主是 ZenDiscovery 模块负责的,主要包含 Ping(节点之间通过这个 RPC 来发现彼此)和 Unicast(单播模块,包含一个主机列表以控制哪些节点需要 ping 通)这两部分;
- 对所有 master-eligible 节点根据 nodeId 字典排序:每次选举时,每个节点都把自己所知道的节点排一次序,然后选出 id 最小的节点,投票该节点为 master 节点。
- 如果对某个节点的投票数达到一定的值(
投票数 > N / 2 + 1
),并且该节点自己也投票自己,那这个节点就当选 master。否则,重新发起选举,直到满足上述条件。
:::
【中级】ES 如何避免脑裂问题?
:::details 要点
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 分片
【中级】ES 是如何实现水平扩展的?
:::details 要点
Elasticsearch 通过分片来实现水平扩展。在 Elasticsearch 中,分片是索引的逻辑划分。索引可以有一个或多个分片,并且每个分片可以存储在集群中的不同节点上。分片用于在多个节点之间分配数据,从而提高性能和可扩展性。
Elasticsearch 中有两种类型的分片:
- primary shard(主分片) - 用于存储原始数据。适当增加主分片数,可以提升 Elasticsearch 集群的吞吐量和整体容量。
- replica shard(副本分片) - 用于存储数据备份。
默认情况下,每个索引都有 1 个主分片(早期版本,默认每个索引有 5 个主分片)。
:::
【中级】ES 如何选择读写数据映射到哪个分片上?
:::details 要点
为了避免出现数据倾斜,系统需要一种高效的方式把数据均匀分散到各个节点上存储,并且在检索的时候可以快速找到文档所在的节点与分片。这就需要确立路由算法,使得数据可以映射到指定的节点上。
常见的路由方式如下:
算法 | 描述 |
---|---|
随机算法 | 写数据时,随机写入到一个节点中;读数据时,由于不知道查询数据存在于哪个节点,所以需要遍历所有节点。 |
路由表 | 由中心节点统一维护数据的路由表,以保证唯一性;但是,中心化产生了新的问题:单点故障、数据越大,路由表越大、单点容易称为性能瓶颈、数据迁移复杂等。 |
哈希取模 | 对 key 值进行哈希计算,然后根据节点数取模,以确定节点。 |
ES 的数据路由算法是根据文档 ID 和 routing key 来确定 Shard ID 的过程。默认的情况下 routing key 为文档 ID,路由算法一般情况下的计算公式如下:
1 | shard_number = hash(_routing) % numer_of_primary_shards |
也可以在请求中指定 routing key,下面是新增数据的时候指定 routing 的方式:
1 | PUT <index>/_doc/<id>?routing=routing_key |
添加数据时,如果不指定文档 ID,ES 会自动分片一个随机 ID。这种情况下,结合 Hash 算法,可以保证数据被均匀分布到各个分片中。如果指定文档 ID,或指定 routing key,Hash 计算得出的值可能会不够随机,从而导致数据倾斜。
index 一旦设置了主分片数就不能修改,如果要修改就需要 reindex(即数据迁移)。之所以如此,就是因为:一旦修改了主分片数,即等于修改了原 Hash 计算中的变量,无法再通过 Hash 计算正确路由到数据存储的分片。
:::
【中级】如何合理设置 ES 分片?
:::details 要点
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, 30GB] 范围内;
分片大小的上下限可以分别通过 max_primary_shard_size
和 min_primary_shard_size
来控制。
扩展:
:::
Elasticsearch 集群
【中级】Elasticsearch 集群中有哪些不同类型的节点?
:::details 要点
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 存储数据的流程是怎样的?
:::details 要点
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 相关性计算和聚合计算为什么会有计算偏差?
:::details 要点
在 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 如何保证读写一致?
:::details 要点
乐观锁机制 - 可以通过版本号使用乐观并发控制,以确保新版本不会被旧版本覆盖,由应用层来处理具体的冲突;
另外对于写操作,一致性级别支持 quorum/one/all,默认为 quorum,即只有当大多数分片可用时才允许写操作。但即使大多数可用,也可能存在因为网络等原因导致写入副本失败,这样该副本被认为故障,分片将会在一个不同的节点上重建。
对于读操作,可以设置 replication 为 sync(默认),这使得操作在主分片和副本分片都完成后才会返回;如果设置 replication 为 async 时,也可以通过设置搜索请求参数、_preference 为 primary 来查询主分片,确保文档是最新版本。
:::
【高级】ES 查询速度为什么快?
:::details 要点
- 倒排索引 - Elasticsearch 查询速度快最核心的点在于使用倒排索引。
- 在 Elasticsearch 中,为了提高查询效率,它对存储的文档进行了分词处理。分词是将连续的文本切分成一个个独立的词项的过程。对文本进行分词后,Elasticsearch 会为每个词项创建一个倒排索引。这样,当用户进行查询时,Elasticsearch 只需要在倒排索引中查找匹配的词项,从而快速地定位到相关的文档。
- 正向索引的结构是每个文档和关键字做关联,每个文档都有与之对应的关键字,记录关键字在文档中出现的位置和次数;而倒排索引则是将文档中的词项和文档的 ID 进行关联,这样就可以通过词项快速找到包含它的文档。
- 分片 - Elasticsearch 通过分片,支持分布式存储和搜索,可以实现搜索的并行处理和负载均衡。
:::
【中级】ES 生产环境部署情况是怎样的?
:::details 要点
典型问题
- 你们的 Elasticsearch 生产环境部署情况是怎样的?
- 你们的 Elasticsearch 生产环境集群规模有多大?
- 你们的 Elasticsearch 生产环境中有多少索引,每个索引大概有多少个分片?
知识点
根据实际 Elasticsearch 集群情况描述,以下是一个案例:
- 节点数:19
- 机器配置:6 核,10G 内存,800G 磁盘
- 索引数、分片数:1200+ 索引、1.7 万+ 分片
- 容量:总文档数 150 亿+,总容量 15TB,使用容量 10TB+
- 日增数据量:约 4 千万条数据,50 GB 增长容量
:::
Elasticsearch 优化
【中级】ES 使用有哪些基本规范?
:::details 要点
- 索引数
- 大索引需要拆分,增强性能,减少风险
- index 可以按日期拆分为 index_yyyyMMdd,然后用 alias 映射
- Mapping 设置
- text 数据类型默认是关闭 fielddate
- 关闭
_source
会导致无法使用 reindex - ES 字段数的最大限制是 1000,但是不建议超过 100
- Refersh
- 写入时,尽量不要执行 refresh,在并发较大的情况下,ES 负载可能会被打满。
- 索引别名
- 尽量使用索引的别名,在类似于进行索引字段类型变更需要进行索引重建的时候会减少很多的问题。
- 别名的下面可以挂载多个索引,若是索引拆分之后业务验证允许可以这么使用。
- alias 下面可以挂多个索引,但是需要注意的是每次请求很容易放大,比如说 alias 挂了 50 个索引,每个索引有 5 个分片,那么从集群的维度来看一共就是 50*5=250 次 query 和 fetch,很容易导致读放大的情况。
:::
【中级】ES JVM 设置需要注意什么?
:::details 要点
ES 实际上是一个 Java 进程,因此也需要考虑 JVM 设置。关于 ES JVM 的设置,有以下几点建议:
- 从 ES6 开始,支持 64 位的 JVM
- 将内存
Xms
和Xmx
设置一样,需要注意过多的堆可能会使垃圾回收停顿时间过长 - 一般,将 50% 的可用内存分配给 ES
- ES 内存不要超过 32 GB
:::
【高级】ES 内存为什么不要超过 32 GB?
:::details 要点
实际上,一般而言,绝大部分 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,就无法利用压缩指针技术,对象指针只能指向真实内存地址,这会造成空间的浪费。
扩展:
https://wiki.openjdk.org/display/HotSpot/CompressedOops
https://blog.csdn.net/liujianyangbj/article/details/108049482
:::
【中级】ES 主机有哪些优化点?
:::details 要点
- 关闭缓存 swap;
- 堆内存设置为:Min(节点内存/2, 32GB);
- 设置最大文件句柄数;
- 线程池+队列大小根据业务需要做调整;
- 磁盘存储 raid 方式——存储有条件使用 RAID10,增加单节点性能以及避免单节点存储故障。
:::
【中级】ES 索引数据多,如何优化?
:::details 要点
- 动态索引 - 如果单索引数据量过大,可以创建索引模板,并周期性创建新索引(举例来说,索引名为 blog_yyyyMMdd),实现数据的分解。
- 冷热数据分离 - 将一定范围(如:一周、一月等)的数据作为热数据,其他数据作为冷数据。针对冷数据,可以考虑定期 force_merge + shrink 进行压缩,以节省存储空间和检索效率。
- 分区再均衡 - Elasticsearch 集群可以动态根据节点数的变化,调整索引分片在集群上的分布。但需要注意的是,要提前合理规划好索引的分片数:分片数过少,则增加节点也无法水平扩展;分片数过多,影响 Elasticsearch 读写效率。
:::
参考资料
Java 虚拟机之垃圾收集
Java 虚拟机之垃圾收集
程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收。垃圾回收主要是针对 Java 堆和方法区进行。
对象是否回收
引用计数算法
引用计数算法(Reference Counting)的原理是:在对象中添加一个引用计数器,每当有一个地方 引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
引用计数算法简单、高效,但是存在循环引用问题——两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。
1 | public class ReferenceCountingGC { |
因为循环引用的存在,所以 Java 虚拟机不适用引用计数算法。
可达性分析算法
通过 GC Roots 作为起始点进行搜索,JVM 将能够到达到的对象视为存活,不可达的对象视为死亡。

可达性分析算法
可作为 GC Roots 的对象包括下面几种:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象(Native 方法)
- 方法区中,类静态属性引用的对象
- 方法区中,常量引用的对象
引用类型
无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否可被回收都与引用有关。
Java 具有四种强度不同的引用类型。
强引用
被强引用(Strong Reference)关联的对象不会被垃圾收集器回收。
强引用:使用 new
一个新对象的方式来创建强引用。
1 | Object obj = new Object(); |
软引用
被软引用(Soft Reference)关联的对象,只有在内存不够的情况下才会被回收。
软引用:使用 SoftReference
类来创建软引用。
1 | Object obj = new Object(); |
弱引用
被弱引用(Weak Reference)关联的对象一定会被垃圾收集器回收,也就是说它只能存活到下一次垃圾收集发生之前。
使用 WeakReference
类来实现弱引用。
1 | Object obj = new Object(); |
WeakHashMap
的 Entry
继承自 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 | public final class ConcurrentCache<K, V> { |
虚引用
又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。
为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
使用 PhantomReference
来实现虚引用。
1 | Object obj = new Object(); |
方法区的回收
因为方法区主要存放永久代对象,而永久代对象的回收率比年轻代差很多,因此在方法区上进行回收性价比不高。
方法区的垃圾收集主要回收两部分:废弃的常量和不再使用的类型。
类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:
- 该类所有的实例都已经被回收,也就是 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
,有时候也叫 from
、to
区域,被用来放置从 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 收集器运行步骤如下:
- 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
- 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
- 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
- 并发清除:回收在标记阶段被鉴定为不可达的对象。不需要停顿。
在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

CMS 收集器运行示意图
CMS 回收年轻代详细步骤
(1)堆空间被分割为三块空间
年轻代分割成一个 Eden 区和两个 Survivor 区。年老代一个连续的空间。就地完成对象收集。除非有 FullGC 否则不会压缩。
(2)CMS 年轻代垃圾收集如何工作
年轻代被标为浅绿色,年老代被标记为蓝色。如果你的应用已经运行了一段时间,CMS 的堆看起来应该是这个样子。对象分散在年老代区域里。
使用 CMS,年老代对象就地释放。它们不会被来回移动。这个空间不会被压缩除非发生 FullGC。
(3)年轻代收集
从 Eden 和 Survivor 区复制活跃对象到另一个 Survivor 区。所有达到他们的年龄阈值的对象会晋升到年老代。
(4)年轻代回收之后
一次年轻代垃圾收集之后,Eden 区和其中一个 Survivor 区被清空。
最近晋升的对象以深蓝色显示在上图中,绿色的对象是年轻代幸免的还没有晋升到老年代对象。
CMS 回收年老代详细步骤
(1)CMS 的年老代收集
发生两次 stop the world 事件:初始标记和重新标记。当年老代达到特定的占用比例时,CMS 开始执行。
- 初始标记是一个短暂暂停的、可达对象被标记的阶段。
- 并发标记寻找活跃对象在应用连续执行时。
- 最后,在重新标记阶段,寻找在之前并发标记阶段中丢失的对象。
(2)年老代收集-并发清除
在之前阶段没有被标记的对象会被就地释放。不进行压缩操作。
注意:未被标记的对象等于死亡对象
(3)年老代收集-清除之后
清除阶段之后,你可以看到大量内存被释放。你还可以注意到没有进行压缩操作。
最后,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 收集器的运作大致可划分为以下几个步骤:
- 初始标记
- 并发标记
- 最终标记 - 为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
- 筛选回收 - 首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
具备如下特点:
- 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
- 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
G1 回收年轻代详细步骤
(1)G1 初始堆空间
堆空间是一个被分成许多固定大小区域的内存块。
Java 虚拟机启动时选定区域大小。Java 虚拟机通常会指定 2000 个左右的大小相等、每个大小范围在 1 到 32M 的区域。
(2)G1 堆空间分配
实际上,这些区域被映射成 Eden、Survivor、年老代空间的逻辑表述形式。
图片中的颜色表明了哪个区域被关联上什么角色。活跃对象从一个区域疏散(复制、移动)到另一个区域。区域被设计为并行的方式收集,可以暂停或者不暂停所有的其它用户线程。
明显的区域可以被分配成 Eden、Survivor、Old 区域。另外,有第四种类型的区域叫做*极大区域 (Humongous regions)*。这些区域被设计成保持标准区域大小的 50%或者更大的对象。它们被保存在一个连续的区域集合里。最后,最后一个类型的区域就是堆空间里没有使用的区域。
注意:写作此文章时,收集极大对象时还没有被优化。因此,你应该避免创建这个大小的对象。
(3)G1 的年轻代
堆空间被分割成大约 2000 个区域。最小 1M,最大 32M,蓝色区域保持年老代对象,绿色区域保持年轻代对象。
注意:区域没有必要像旧的收集器一样是保持连续的。
(4)G1 的年轻代收集
活跃对象会被疏散(复制、移动)到一个或多个 survivor 区域。如果达到晋升总阈值,对象会晋升到年老代区域。
这是一个 stop the world 暂停。为下一次年轻代垃圾回收计算 Eden 和 Survivor 的大小。保留审计信息有助于计算大小。类似目标暂停时间的事情会被考虑在内。
这个方法使重调区域大小变得很容易,按需把它们调大或调小。
(5)G1 年轻代回收的尾声
活跃对象被疏散到 Survivor 或者年老代区域。
最近晋升的对象显示为深蓝色。Survivor 区域显示为绿色。
关于 G1 的年轻代回收做以下总结:
- 堆空间是一块单独的内存空间被分割成多个区域。
- 年轻代内存是由一组非连续的区域组成。这使得需要重调大小变得容易。
- 年轻代垃圾回收是 stop the world 事件,所有应用线程都会因此操作暂停。
- 年轻代垃圾收集使用多线程并行回收。
- 活跃对象被复制到新的 Survivor 区或者年老代区域。
G1 回收年老代详细步骤
(1)初始标记阶段
年轻代垃圾收集肩负着活跃对象初始标记的任务。在日志文件中被标为* GC pause (young)(inital-mark)*
(2)并发标记阶段
如果发现空区域 (“X”标示的),在重新标记阶段它们会被马上清除掉。当然,决定活性的审计信息也在此时被计算。
(3)重新标记阶段
空的区域被清除和回收掉。所有区域的活性在此时计算。
(4)复制/清理阶段
G1 选择活性最低的区域,这些区域能够以最快的速度回收。然后这些区域会在年轻代垃圾回收过程中被回收。在日志中被指示为* [GC pause (mixed)]*。所以年轻代和年老代在同一时间被回收。
(5)复制/清理阶段之后
被选择的区域已经被回收和压缩到图中显示的深蓝色区和深绿色区中。
总结
收集器 | 串行/并行/并发 | 年轻代/老年代 | 收集算法 | 目标 | 适用场景 |
---|---|---|---|---|---|
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 工作流程:
Java 应用不断创建对象,通常都是分配在
Eden
区域,当其空间不足时(达到设定的阈值),触发 minor GC。仍然被引用的对象(绿色方块)存活下来,被复制到 JVM 选择的 Survivor 区域,而没有被引用的对象(黄色方块)则被回收。经过一次 Minor GC,Eden 就会空闲下来,直到再次达到 Minor GC 触发条件。这时候,另外一个 Survivor 区域则会成为
To
区域,Eden 区域的存活对象和From
区域对象,都会被复制到To
区域,并且存活的年龄计数会被加 1。类似第二步的过程会发生很多次,直到有对象年龄计数达到阈值,这时候就会发生所谓的晋升(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 反射和动态代理
深入理解 Java 反射和动态代理
反射简介
什么是反射
反射(Reflection)是 Java 程序开发语言的特征之一,它允许运行中的 Java 程序获取自身的信息,并且可以操作类或对象的内部属性。
通过反射机制,可以在运行时访问 Java 对象的属性,方法,构造方法等。
反射的应用场景
反射的主要应用场景有:
- 开发通用框架 - 反射最重要的用途就是开发各种通用框架。很多框架(比如 Spring)都是配置化的(比如通过 XML 文件配置 JavaBean、Filter 等),为了保证框架的通用性,它们可能需要根据配置文件加载不同的对象或类,调用不同的方法,这个时候就必须用到反射——运行时动态加载需要加载的对象。
- 动态代理 - 在切面编程(AOP)中,需要拦截特定的方法,通常,会选择动态代理方式。这时,就需要反射技术来实现了。
- 注解 - 注解本身仅仅是起到标记作用,它需要利用反射机制,根据注解标记去调用注解解释器,执行行为。如果没有反射机制,注解并不比注释更有用。
- 可扩展性功能 - 应用程序可以通过使用完全限定名称创建可扩展性对象实例来使用外部的用户定义类。
反射的缺点
- 性能开销 - 由于反射涉及动态解析的类型,因此无法执行某些 Java 虚拟机优化。因此,反射操作的性能要比非反射操作的性能要差,应该在性能敏感的应用程序中频繁调用的代码段中避免。
- 破坏封装性 - 反射调用方法时可以忽略权限检查,因此可能会破坏封装性而导致安全问题。
- 内部曝光 - 由于反射允许代码执行在非反射代码中非法的操作,例如访问私有字段和方法,所以反射的使用可能会导致意想不到的副作用,这可能会导致代码功能失常并可能破坏可移植性。反射代码打破了抽象,因此可能会随着平台的升级而改变行为。
反射机制
类加载过程
类加载的完整过程如下:
- 在编译时,Java 编译器编译好
.java
文件之后,在磁盘中产生.class
文件。.class
文件是二进制文件,内容是只有 JVM 能够识别的机器码。 - JVM 中的类加载器读取字节码文件,取出二进制数据,加载到内存中,解析.class 文件内的信息。类加载器会根据类的全限定名来获取此类的二进制字节流;然后,将字节流所代表的静态存储结构转化为方法区的运行时数据结构;接着,在内存中生成代表这个类的
java.lang.Class
对象。 - 加载结束后,JVM 开始进行连接阶段(包含验证、准备、初始化)。经过这一系列操作,类的变量会被初始化。
Class 对象
要想使用反射,首先需要获得待操作的类所对应的 Class 对象。Java 中,无论生成某个类的多少个对象,这些对象都会对应于同一个 Class 对象。这个 Class 对象是由 JVM 生成的,通过它能够获悉整个类的结构。所以,java.lang.Class
可以视为所有反射 API 的入口点。
反射的本质就是:在运行时,把 Java 类中的各种成分映射成一个个的 Java 对象。
举例来说,假如定义了以下代码:
1 | User user = new User(); |
步骤说明:
- JVM 加载方法的时候,遇到
new User()
,JVM 会根据User
的全限定名去加载User.class
。 - JVM 会去本地磁盘查找
User.class
文件并加载 JVM 内存中。 - JVM 通过调用类加载器自动创建这个类对应的
Class
对象,并且存储在 JVM 的方法区。注意:一个类有且只有一个Class
对象。
方法的反射调用
方法的反射调用,也就是 Method.invoke
方法。
Method.invoke
方法源码:
1 | public final class Method extends Executable { |
Method.invoke
方法实际上委派给 MethodAccessor
接口来处理。它有两个已有的具体实现:
NativeMethodAccessorImpl
:本地方法来实现反射调用DelegatingMethodAccessorImpl
:委派模式来实现反射调用
每个 Method
实例的第一次反射调用都会生成一个委派实现(DelegatingMethodAccessorImpl
),它所委派的具体实现便是一个本地实现(NativeMethodAccessorImpl
)。本地实现非常容易理解。当进入了 Java 虚拟机内部之后,我们便拥有了 Method
实例所指向方法的具体地址。这时候,反射调用无非就是将传入的参数准备好,然后调用进入目标方法。
【示例】通过抛出异常方式 打印 Method.invoke
调用轨迹
1 | public class MethodDemo01 { |
先调用 DelegatingMethodAccessorImpl
;然后调用 NativeMethodAccessorImpl
,最后调用实际方法。
为什么反射调用DelegatingMethodAccessorImpl
作为中间层,而不是直接交给本地实现?
其实,Java 的反射调用机制还设立了另一种动态生成字节码的实现(下称动态实现),直接使用 invoke 指令来调用目标方法。之所以采用委派实现,便是为了能够在本地实现以及动态实现中切换。动态实现和本地实现相比,其运行效率要快上 20 倍。这是因为动态实现无需经过 Java 到 C++ 再到 Java 的切换,但由于生成字节码十分耗时,仅调用一次的话,反而是本地实现要快上 3 到 4 倍。
考虑到许多反射调用仅会执行一次,Java 虚拟机设置了一个阈值 15(可以通过 -Dsun.reflect.inflationThreshold
来调整),当某个反射调用的调用次数在 15 之下时,采用本地实现;当达到 15 时,便开始动态生成字节码,并将委派实现的委派对象切换至动态实现,这个过程我们称之为 Inflation。
【示例】执行 java -verbose:class MethodDemo02 启动
1 | public class MethodDemo02 { |
输出内容:
1 | // ...省略 |
可以看到,从第 16 次开始后,都是使用 DelegatingMethodAccessorImpl
,不再使用本地实现 NativeMethodAccessorImpl
。
反射调用的开销
方法的反射调用会带来不少性能开销,原因主要有三个:
- 变长参数方法导致的 Object 数组
- 基本类型的自动装箱、拆箱
- 还有最重要的方法内联
Class.forName
会调用本地方法,Class.getMethod
则会遍历该类的公有方法。如果没有匹配到,它还将遍历父类的公有方法。可想而知,这两个操作都非常费时。
注意,以
getMethod
为代表的查找方法操作,会返回查找得到结果的一份拷贝。因此,我们应当避免在热点代码中使用返回Method
数组的getMethods
或者getDeclaredMethods
方法,以减少不必要的堆空间消耗。在实践中,我们往往会在应用程序中缓存Class.forName
和Class.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 | package io.github.dunwu.javacore.reflect; |
使用类的完全限定名来反射对象的类。常见的应用场景为:在 JDBC 开发中常用此方法加载数据库驱动。
(2)类名 + .class
【示例】直接用类名 + .class
获取 Class
对象
1 | public class ReflectClassDemo02 { |
(3)**Object
的 getClass
方法**
Object
类中有 getClass
方法,因为所有类都继承 Object
类。从而调用 Object
类来获取 Class
对象。
【示例】Object
的 getClass
方法获取 Class
对象
1 | package io.github.dunwu.javacore.reflect; |
判断是否为某个类的实例
判断是否为某个类的实例有两种方式:
- 用
instanceof
关键字 - 用
Class
对象的isInstance
方法(它是一个 Native 方法)
【示例】
1 | public class InstanceofDemo { |
创建实例
通过反射来创建实例对象主要有两种方式:
- 用
Class
对象的newInstance
方法。 - 用
Constructor
对象的newInstance
方法。
【示例】
1 | public class NewInstanceDemo { |
创建数组实例
数组在 Java 里是比较特殊的一种类型,它可以赋值给一个对象引用。Java 中,通过 Array.newInstance
创建数组的实例。
【示例】利用反射创建数组
1 | public class ReflectArrayDemo { |
其中的 Array 类为 java.lang.reflect.Array
类。我们Array.newInstance
的原型是:
1 | public static Object newInstance(Class<?> componentType, int length) |
Field
Class
对象提供以下方法获取对象的成员(Field
):
getFiled
- 根据名称获取公有的(public)类成员。getDeclaredField
- 根据名称获取已声明的类成员。但不能得到其父类的类成员。getFields
- 获取所有公有的(public)类成员。getDeclaredFields
- 获取所有已声明的类成员。
示例如下:
1 | public class ReflectFieldDemo { |
Method
Class
对象提供以下方法获取对象的方法(Method
):
getMethod
- 返回类或接口的特定方法。其中第一个参数为方法名称,后面的参数为方法参数对应 Class 的对象。getDeclaredMethod
- 返回类或接口的特定声明方法。其中第一个参数为方法名称,后面的参数为方法参数对应 Class 的对象。getMethods
- 返回类或接口的所有 public 方法,包括其父类的 public 方法。getDeclaredMethods
- 返回类或接口声明的所有方法,包括 public、protected、默认(包)访问和 private 方法,但不包括继承的方法。
获取一个 Method
对象后,可以用 invoke
方法来调用这个方法。
invoke
方法的原型为:
1 | public Object invoke(Object obj, Object... args) |
【示例】
1 | public class ReflectMethodDemo { |
Constructor
Class
对象提供以下方法获取对象的构造方法(Constructor
):
getConstructor
- 返回类的特定 public 构造方法。参数为方法参数对应 Class 的对象。getDeclaredConstructor
- 返回类的特定构造方法。参数为方法参数对应 Class 的对象。getConstructors
- 返回类的所有 public 构造方法。getDeclaredConstructors
- 返回类的所有构造方法。
获取一个 Constructor
对象后,可以用 newInstance
方法来创建类实例。
【示例】
1 | public class ReflectMethodConstructorDemo { |
绕开访问限制
有时候,我们需要通过反射访问私有成员、方法。可以使用 Constructor/Field/Method.setAccessible(true)
来绕开 Java 语言的访问限制。
动态代理
动态代理是一种方便运行时动态构建代理、动态处理代理方法调用的机制,很多场景都是利用类似机制做到的,比如用来包装 RPC 调用、面向切面的编程(AOP)。
实现动态代理的方式很多,比如 JDK 自身提供的动态代理,就是主要利用了上面提到的反射机制。还有其他的实现方式,比如利用传说中更高性能的字节码操作机制,类似 ASM、cglib(基于 ASM)、Javassist 等。
静态代理
静态代理其实就是指设计模式中的代理模式。
代理模式为其他对象提供一种代理以控制对这个对象的访问。
Subject 定义了 RealSubject 和 Proxy 的公共接口,这样就在任何使用 RealSubject 的地方都可以使用 Proxy 。
1 | abstract class Subject { |
RealSubject 定义 Proxy 所代表的真实实体。
1 | class RealSubject extends Subject { |
Proxy 保存一个引用使得代理可以访问实体,并提供一个与 Subject 的接口相同的接口,这样代理就可以用来替代实体。
1 | class Proxy extends Subject { |
说明:
静态代理模式固然在访问无法访问的资源,增强现有的接口业务功能方面有很大的优点,但是大量使用这种静态代理,会使我们系统内的类的规模增大,并且不易维护;并且由于 Proxy 和 RealSubject 的功能本质上是相同的,Proxy 只是起到了中介的作用,这种代理在系统中的存在,导致系统结构比较臃肿和松散。
JDK 动态代理
为了解决静态代理的问题,就有了创建动态代理的想法:
在运行状态中,需要代理的地方,根据 Subject 和 RealSubject,动态地创建一个 Proxy,用完之后,就会销毁,这样就可以避免了 Proxy 角色的 class 在系统中冗杂的问题了。
Java 动态代理基于经典代理模式,引入了一个 InvocationHandler
,InvocationHandler
负责统一管理所有的方法调用。
动态代理步骤:
- 获取 RealSubject 上的所有接口列表;
- 确定要生成的代理类的类名,默认为:
com.sun.proxy.$ProxyXXXX
; - 根据需要实现的接口信息,在代码中动态创建 该 Proxy 类的字节码;
- 将对应的字节码转换为对应的 class 对象;
- 创建
InvocationHandler
实例 handler,用来处理Proxy
所有方法调用; - Proxy 的 class 对象 以创建的 handler 对象为参数,实例化一个 proxy 对象。
从上面可以看出,JDK 动态代理的实现是基于实现接口的方式,使得 Proxy 和 RealSubject 具有相同的功能。
但其实还有一种思路:通过继承。即:让 Proxy 继承 RealSubject,这样二者同样具有相同的功能,Proxy 还可以通过重写 RealSubject 中的方法,来实现多态。CGLIB 就是基于这种思路设计的。
在 Java 的动态代理机制中,有两个重要的类(接口),一个是 InvocationHandler
接口、另一个则是 Proxy
类,这一个类和一个接口是实现我们动态代理所必须用到的。
InvocationHandler 接口
InvocationHandler
接口定义:
1 | public interface InvocationHandler { |
每一个动态代理类都必须要实现 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 | public interface Subject { |
接着,定义了一个类来实现这个接口,这个类就是我们的真实对象,RealSubject 类:
1 | public class RealSubject implements Subject { |
下一步,我们就要定义一个动态代理类了,前面说个,每一个动态代理类都必须要实现 InvocationHandler 这个接口,因此我们这个动态代理类也不例外:
1 | public class InvocationHandlerDemo implements InvocationHandler { |
最后,来看看我们的 Client 类:
1 | public class Client { |
我们先来看看控制台的输出:
1 | com.sun.proxy.$Proxy0 |
我们首先来看看 com.sun.proxy.$Proxy0
这东西,我们看到,这个东西是由 System.out.println(subject.getClass().getName());
这条语句打印出来的,那么为什么我们返回的这个代理对象的类名是这样的呢?
1 | Subject subject = (Subject)Proxy.newProxyInstance(handler.getClass().getClassLoader(), realSubject |
可能我以为返回的这个代理对象会是 Subject 类型的对象,或者是 InvocationHandler 的对象,结果却不是,首先我们解释一下为什么我们这里可以将其转化为 Subject 类型的对象?
原因就是:在 newProxyInstance 这个方法的第二个参数上,我们给这个代理对象提供了一组什么接口,那么我这个代理对象就会实现了这组接口,这个时候我们当然可以将这个代理对象强制类型转化为这组接口中的任意一个,因为这里的接口是 Subject 类型,所以就可以将其转化为 Subject 类型了。
同时我们一定要记住,通过 Proxy.newProxyInstance
创建的代理对象是在 jvm 运行时动态生成的一个对象,它并不是我们的 InvocationHandler 类型,也不是我们定义的那组接口的类型,而是在运行是动态生成的一个对象,并且命名方式都是这样的形式,以$开头,proxy 为中,最后一个数字表示对象的标号。
接着我们来看看这两句
1 | subject.hello("World"); |
这里是通过代理对象来调用实现的那种接口中的方法,这个时候程序就会跳转到由这个代理对象关联到的 handler 中的 invoke 方法去执行,而我们的这个 handler 对象又接受了一个 RealSubject 类型的参数,表示我要代理的就是这个真实对象,所以此时就会调用 handler 中的 invoke 方法去执行。
我们看到,在真正通过代理对象来调用真实对象的方法的时候,我们可以在该方法前后添加自己的一些操作,同时我们看到我们的这个 method 对象是这样的:
1 | public abstract void io.github.dunwu.javacore.reflect.InvocationHandlerDemo$Subject.hello(java.lang.String) |
正好就是我们的 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
方法进行代理。
参考资料
Java 并发面试一
Java 并发面试一
并发术语
并发和并行
典型问题
- 什么是并发?
- 什么是并行?
- 并发和并行有什么区别?
知识点
并发和并行是最容易让新手费解的概念,那么如何理解二者呢?其最关键的差异在于:是否是同时发生:
- 并发:是指具备处理多个任务的能力,但不一定要同时。
- 并行:是指具备同时处理多个任务的能力。
下面是我见过最生动的说明,摘自 并发与并行的区别是什么?——知乎的高票答案
- 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
- 你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
- 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。
同步和异步
典型问题
- 什么是同步?
- 什么是异步?
- 同步和异步有什么区别?
知识点
- 同步:是指在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。
- 异步:则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
举例来说明:
- 同步就像是打电话:不挂电话,通话不会结束。
- 异步就像是发短信:发完短信后,就可以做其他事;当收到回复短信时,手机会通过铃声或振动来提醒。
阻塞和非阻塞
典型问题
- 什么是阻塞?
- 阻塞和非阻塞有什么区别?
知识点
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态:
- 阻塞:是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
- 非阻塞:是指在不能立刻得到结果之前,该调用不会阻塞当前线程。
举例来说明:
- 阻塞调用就像是打电话,通话不结束,不能放下。
- 非阻塞调用就像是发短信,发完短信后,就可以做其他事,短信来了,手机会提醒。
进程、线程、协程、管程
典型问题
- 什么是进程?
- 什么是线程?
- 什么是协程?
- 什么是管程?
- 进程和线程有什么区别?
知识点
- 进程(Process) - 进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动。进程是操作系统进行资源分配的基本单位。进程可视为一个正在运行的程序。
- 线程(Thread) - 线程是操作系统进行调度的基本单位。
- 管程(Monitor) - 管程是指管理共享变量以及对共享变量的操作过程,让他们支持并发。
- Java 通过 synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法来实现管程技术。
- 管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。
- 协程(Coroutine) - 协程可以理解为一种轻量级的线程。
- 从操作系统的角度来看,线程是在内核态中调度的,而协程是在用户态调度的,所以相对于线程来说,协程切换的成本更低。
- 协程虽然也有自己的栈,但是相比线程栈要小得多,典型的线程栈大小差不多有 1M,而协程栈的大小往往只有几 K 或者几十 K。所以,无论是从时间维度还是空间维度来看,协程都比线程轻量得多。
- Go、Python、Lua、Kotlin 等语言都支持协程;Java OpenSDK 中的 Loom 项目目标就是支持协程。
进程和线程的差异:
- 一个程序至少有一个进程,一个进程至少有一个线程。
- 线程比进程划分更细,所以执行开销更小,并发性更高
- 进程是一个实体,拥有独立的资源;而同一个进程中的多个线程共享进程的资源。
JVM 在单个进程中运行,JVM 中的线程共享属于该进程的堆。这就是为什么几个线程可以访问同一个对象。线程共享堆并拥有自己的堆栈空间。这是一个线程如何调用一个方法以及它的局部变量是如何保持线程安全的。但是堆不是线程安全的并且为了线程安全必须进行同步。
程序计数器为什么是私有的?
程序计数器主要有下面两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
虚拟机栈和本地方法栈为什么是私有的?
- 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
一句话简单了解堆和方法区
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
并发概念
Java 线程和操作系统的线程有啥区别?
JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,这是一种用户级线程(用户线程),也就是说 JVM 自己模拟了多线程的运行,而不依赖于操作系统。由于绿色线程和原生线程比起来在使用时有一些限制(比如绿色线程不能直接使用操作系统提供的功能如异步 I/O、只能在一个内核线程上运行无法利用多核),在 JDK 1.2 及以后,Java 线程改为基于原生线程(Native Threads)实现,也就是说 JVM 直接使用操作系统原生的内核级线程(内核线程)来实现 Java 线程,由操作系统内核进行线程的调度和管理。
我们上面提到了用户线程和内核线程,考虑到很多读者不太了解二者的区别,这里简单介绍一下:
- 用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。
- 内核线程:由操作系统内核管理和调度的线程,运行在内核空间(只有内核程序可以访问)。
顺便简单总结一下用户线程和内核线程的区别和特点:用户线程创建和切换成本低,但不可以利用多核。内核态线程,创建和切换成本高,可以利用多核。
一句话概括 Java 线程和操作系统线程的关系:现在的 Java 线程的本质其实就是操作系统的线程。
线程模型是用户线程和内核线程之间的关联方式,常见的线程模型有这三种:
- 一对一(一个用户线程对应一个内核线程)
- 多对一(多个用户线程映射到一个内核线程)
- 多对多(多个用户线程映射到多个内核线程)
在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个 Java 线程对应一个系统内核线程。Solaris 系统是一个特例(Solaris 系统本身就支持多对多的线程模型),HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答:JVM 中的线程模型是用户级的么?。
虚拟线程在 JDK 21 顺利转正,关于虚拟线程、平台线程(也就是我们上面提到的 Java 线程)和内核线程三者的关系可以阅读我写的这篇文章:Java 20 新特性概览。
并发(多线程)编程的好处是什么?
- 更有效率的利用多处理器核心
- 更快的响应时间
- 更好的编程模型
单核 CPU 支持 Java 多线程吗?
单核 CPU 是支持 Java 多线程的。操作系统通过时间片轮转的方式,将 CPU 的时间分配给不同的线程。尽管单核 CPU 一次只能执行一个任务,但通过快速在多个线程之间切换,可以让用户感觉多个任务是同时进行的。
这里顺带提一下 Java 使用的线程调度方式。
操作系统主要通过两种线程调度方式来管理多线程的执行:
- 抢占式调度(Preemptive Scheduling):操作系统决定何时暂停当前正在运行的线程,并切换到另一个线程执行。这种切换通常是由系统时钟中断(时间片轮转)或其他高优先级事件(如 I/O 操作完成)触发的。这种方式存在上下文切换开销,但公平性和 CPU 资源利用率较好,不易阻塞。
- 协同式调度(Cooperative Scheduling):线程执行完毕后,主动通知系统切换到另一个线程。这种方式可以减少上下文切换带来的性能开销,但公平性较差,容易阻塞。
Java 使用的线程调度是抢占式的。也就是说,JVM 本身不负责线程的调度,而是将线程的调度委托给操作系统。操作系统通常会基于线程优先级和时间片来调度线程的执行,高优先级的线程通常获得 CPU 时间片的机会更多。
并发和性能
典型问题
并发一定比串行更快吗?
知识点
并发不一定比串行更快!
对于多线程而言,它不仅可能会带来线程安全问题,还有可能会带来性能问题。
多线程会产生部分额外的开销:
- 线程调度
- 上下文切换 - 在实际开发中,线程数往往是大于 CPU 核心数的,比如 CPU 核心数可能是 8 核、16 核,等等,但线程数可能达到成百上千个。这种情况下,操作系统就会按照一定的调度算法,给每个线程分配时间片,让每个线程都有机会得到运行。而在进行调度时就会引起上下文切换,上下文切换会挂起当前正在执行的线程并保存当前的状态,然后寻找下一处即将恢复执行的代码,唤醒下一个线程,以此类推,反复执行。但上下文切换带来的开销是比较大的,假设我们的任务内容非常短,比如只进行简单的计算,那么就有可能发生我们上下文切换带来的性能开销比执行线程本身内容带来的开销还要大的情况。
- 缓存失效 - 由于程序有很大概率会再次访问刚才访问过的数据,所以为了加速整个程序的运行,会使用缓存,这样我们在使用相同数据时就可以很快地获取数据。可一旦进行了线程调度,切换到其他线程,CPU就会去执行不同的代码,原有的缓存就很可能失效了,需要重新缓存新的数据,这也会造成一定的开销,所以线程调度器为了避免频繁地发生上下文切换,通常会给被调度到的线程设置最小的执行时间,也就是只有执行完这段时间之后,才可能进行下一次的调度,由此减少上下文切换的次数。
- 线程协作 - 因为线程之间如果有共享数据,为了避免数据错乱,为了保证线程安全,就有可能禁止编译器和 CPU 对其进行重排序等优化,也可能出于同步的目的,反复把线程工作内存的数据 flush 到主存中,然后再从主内存 refresh 到其他线程的工作内存中,等等。这些问题在单线程中并不存在,但在多线程中为了确保数据的正确性,就不得不采取上述方法,因为线程安全的优先级要比性能优先级更高,这也间接降低了我们的性能。
并发安全
经典问题
(1)有哪些线程不安全的情况
(2)哪些场景需要额外注意线程安全问题?
知识点
(1)有哪些线程不安全的情况
- 运行结果错误;
- 发布和初始化导致线程安全问题;
- 活跃性问题。典型的有:死锁、活锁和饥饿
- 死锁是指两个以上的线程永远相互阻塞的情况。
- 活锁 - 活锁是指两个或多个线程在执行各自的逻辑时,相互之间不断地做出反应和改变状态,从而陷入无限循环,而这种循环不会导致任何线程向前推进。
- 饥饿 - 饥饿是指线程需要某些资源时始终得不到,尤其是 CPU 资源,就会导致线程一直不能运行而产生的问题。
【示例】运行结果错误
1 | public class WrongResult { |
启动两个线程,分别对变量 i 进行 10000 次 i++ 操作。理论上得到的结果应该是 20000,但实际结果却远小于理论结果。这是因为 i 变量虽然被修饰为 volatile,但由于 i++ 不是原子操作,而 volatile 无法保证原子性,这就导致两个线程在循环 ++ 操作时,无法及时感知 i 的数值变化,最终导致累加数值远小于预期值。
【示例】发布和初始化导致线程安全问题
1 | public class WrongInit { |
(2)哪些场景需要额外注意线程安全问题?
- 访问共享变量或资源 - 典型的场景有访问共享对象的属性,访问 static 静态变量,访问共享的缓存,等等。因为这些信息不仅会被一个线程访问到,还有可能被多个线程同时访问,那么就有可能在并发读写的情况下发生线程安全问题。
- 依赖时序的操作 - 如果我们操作的正确性是依赖时序的,而在多线程的情况下又不能保障执行的顺序和我们预想的一致,这个时候就会发生线程安全问题。
- 不同数据之间存在绑定关系 - 有时候,不同数据之间是成组出现的,存在着相互对应或绑定的关系,最典型的就是 IP 和端口号。有时候我们更换了 IP,往往需要同时更换端口号,如果没有把这两个操作绑定在一起,就有可能出现单独更换了 IP 或端口号的情况,而此时信息如果已经对外发布,信息获取方就有可能获取一个错误的 IP 与端口绑定情况,这时就发生了线程安全问题。
- 对方没有声明自己是线程安全的 - 在我们使用其他类时,如果对方没有声明自己是线程安全的,那么这种情况下对其他类进行多线程的并发操作,就有可能会发生线程安全问题。举个例子,比如说我们定义了 ArrayList,它本身并不是线程安全的,如果此时多个线程同时对 ArrayList 进行并发读/写,那么就有可能会产生线程安全问题,造成数据出错,而这个责任并不在 ArrayList,因为它本身并不是并发安全的。
死锁
典型问题
(1)什么是死锁?
(2)如何预防和避免线程死锁?
知识点
(1)什么是死锁?
死锁是指两个以上的线程永远相互阻塞的情况。产生死锁的四个必要条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
(2)如何预防和避免线程死锁?
如何预防死锁? 破坏死锁的产生的必要条件即可:
- 破坏请求与保持条件:一次性申请所有的资源。
- 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
如何避免死锁?
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3……Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称
<P1、P2、P3.....Pn>
序列为安全序列。
线程基础
线程生命周期
典型问题
Java 线程生命周期中有哪些状态?各状态之间如何切换?
知识点
java.lang.Thread.State
中定义了 6 种不同的线程状态,在给定的一个时刻,线程只能处于其中的一个状态。
以下是各状态的说明,以及状态间的联系:
- 开始(NEW) - 尚未调用
start
方法的线程处于此状态。此状态意味着:创建的线程尚未启动。 - 可运行(RUNNABLE) - 已经调用了
start
方法的线程处于此状态。此状态意味着,线程已经准备好了,一旦被线程调度器分配了 CPU 时间片,就可以运行线程。- 在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态,所以 Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态 。
- 阻塞(BLOCKED) - 此状态意味着:线程处于被阻塞状态。表示线程在等待
synchronized
的隐式锁(Monitor lock)。synchronized
修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,即处于阻塞状态。当占用synchronized
隐式锁的线程释放锁,并且等待的线程获得synchronized
隐式锁时,就又会从BLOCKED
转换到RUNNABLE
状态。 - 等待(WAITING) - 此状态意味着:线程无限期等待,直到被其他线程显式地唤醒。 阻塞和等待的区别在于,阻塞是被动的,它是在等待获取
synchronized
的隐式锁。而等待是主动的,通过调用Object.wait
等方法进入。- 进入:
Object.wait()
;退出:Object.notify
/Object.notifyAll
- 进入:
Thread.join()
;退出:被调用的线程执行完毕 - 进入:
LockSupport.park()
;退出:LockSupport.unpark
- 进入:
- 定时等待(TIMED_WAITING) - 等待指定时间的状态。一个线程处于定时等待状态,是由于执行了以下方法中的任意方法:
- 进入:
Thread.sleep(long)
;退出:时间结束 - 进入:
Object.wait(long)
;退出:时间结束 /Object.notify
/Object.notifyAll
- 进入:
Thread.join(long)
;退出:时间结束 / 被调用的线程执行完毕 - 进入:
LockSupport.parkNanos(long)
;退出:LockSupport.unpark
- 进入:
LockSupport.parkUntil(long)
;退出:LockSupport.unpark
- 进入:
- 终止 (TERMINATED) - 线程
run()
方法执行结束,或者因异常退出了run()
方法,则该线程结束生命周期。死亡的线程不可再次复生。
👉 扩展阅读:
线程创建
典型问题
- Java 中,如何创建线程?
- Java 中,创建线程有几种方式?
知识点
一般来说,创建线程有很多种方式,例如:
- 实现
Runnable
接口 - 实现
Callable
接口 - 继承
Thread
类 - 通过线程池创建线程
- 使用
CompletableFuture
创建线程 - …
虽然,看似有多种多样的创建线程方式。但是,从本质上来说,Java 就只有一种方式可以创建线程,那就是通过 new Thread().start()
创建。不管是哪种方式,最终还是依赖于 new Thread().start()
。
👉 扩展阅读:大家都说 Java 有三种创建线程的方式!并发编程中的惊天骗局!。
线程启动
典型问题
(1)Thread.start()
和 Thread.run()
有什么区别?
(2)可以直接调用 Thread.run()
方法么?
(3)一个线程两次调用 Thread.start()
方法会怎样
知识点
(1)Thread.start()
和 Thread.run()
的区别:
run()
方法是线程的执行体。start()
方法负责启动线程,然后 JVM 会让这个线程去执行run()
方法。
(2)可以直接调用 Thread.run()
方法,但是它的行为和普通方法一样,不会启动新线程去执行。调用 start()
方法方可启动线程并使线程进入就绪状态,直接执行 run()
方法的话不会以多线程的方式执行。
(3)Java 的线程是不允许启动两次的,第二次调用必然会抛出 IllegalThreadStateException
。
线程等待
典型问题
(1)Thread.sleep()
、Thread.yield()
、Thread.join()
、Object.wait()
方法有什么区别?
(2)为什么 Thread.sleep()
、Thread.yield()
设计为静态方法?
知识点
(1)Thread.sleep()
、Thread.yield()
、Thread.join()
方法的区别:
Thread.sleep()
Thread.sleep()
方法需要指定等待的时间,它可以让当前正在执行的线程在指定的时间内暂停执行,进入 Blocked 状态。- 该方法既可以让其他同优先级或者高优先级的线程得到执行的机会,也可以让低优先级的线程得到执行机会。
- 但是,
Thread.sleep()
方法不会释放“锁标志”,也就是说如果有synchronized
同步块,其他线程仍然不能访问共享数据。
Thread.yield()
Thread.yield()
方法可以让当前正在执行的线程暂停,但它不会阻塞该线程,它只是将该线程从 Running 状态转入 Runnable 状态。- 当某个线程调用了
Thread.yield()
方法暂停之后,只有优先级大于等于当前线程的处于就绪状态的线程才会获得执行的机会。
Thread.join()
Thread.join()
方法会使当前线程转入 Blocked 状态,等待调用Thread.join()
方法的线程结束后才能继续执行。
Object.wait()
Object.wait()
用于使当前线程等待,直到其他线程调用相同对象的Object.notify()
或Object.notifyAll()
方法唤醒它。- 调用
Object.wait()
时,线程会释放对象锁,并进入等待状态。
(2)为什么 Thread.sleep()
、Thread.yield()
设计为静态方法?
Thread.sleep()
、Thread.yield()
针对的是 Running 状态的线程,也就是说在非 Running 状态的线程上执行这两个方法没有意义。这就是为什么这两个方法被设计为静态的。它们只针对正在 Running 状态的线程工作,避免程序员错误的认为可以在其他非 Running 状态线程上调用。
👉 扩展阅读:Java 线程中 yield 与 join 方法的区别
👉 扩展阅读:sleep(),wait(),yield() 和 join() 方法的区别
线程通信
线程间通信是线程间共享资源的一种方式。Object.wait()
, Object.notify()
和 Object.notifyAll()
是用于线程之间协作和通信的方法,它们通常与synchronized
关键字一起使用来实现线程的同步。
典型问题
(1)为什么线程通信的方法 Object.wait()
、Object.notify()
和 Object.notifyAll()
被定义在 Object
类里?
(2)为什么 Object.wait()
、Object.notify()
和 Object.notifyAll()
必须在 synchronized
方法/块中被调用?
(3) Object.wait()
和 Thread.sleep
有什么区别?
知识点
(1)为什么线程通信的方法 Object.wait()
、Object.notify()
和 Object.notifyAll()
被定义在 Object
类里?
Java 的每个对象中都有一个称之为 monitor 监视器的锁,由于每个对象都可以上锁,这就要求在对象头中有一个用来保存锁信息的位置。这个锁是对象级别的,而非线程级别的,wait/notify/notifyAll 也都是锁级别的操作,它们的锁属于对象,所以把它们定义在 Object 类中是最合适,因为 Object 类是所有对象的父类。
如果把 wait/notify/notifyAll 方法定义在 Thread 类中,会带来很大的局限性,比如一个线程可能持有多把锁,以便实现相互配合的复杂逻辑,假设此时 wait 方法定义在 Thread 类中,如何实现让一个线程持有多把锁呢?又如何明确线程等待的是哪把锁呢?既然我们是让当前线程去等待某个对象的锁,自然应该通过操作对象来实现,而不是操作线程。
Object.wait()
Object.wait()
方法用于使当前线程进入等待状态,直到其他线程调用相同对象的notify()
或notifyAll()
方法唤醒它。- 在调用
wait()
方法时,线程会释放对象的锁,并进入等待状态。通常在使用wait()
方法时需要放在一个循环中,以避免虚假唤醒(spurious wakeups)。
Object.notify()
Object.notify()
方法用于唤醒正在等待该对象的锁的一个线程。- 被唤醒的线程将会尝试重新获取对象的锁,一旦获取到锁,它将继续执行。
Object.notifyAll()
Object.notifyAll()
方法用于唤醒正在等待该对象的锁的所有线程。- 所有被唤醒的线程将会竞争对象的锁,一旦获取到锁,它们将继续执行。
(2)为什么 Object.wait()
、Object.notify()
和 Object.notifyAll()
必须在 synchronized
方法/块中被调用?
当一个线程需要调用对象的 wait()
方法的时候,这个线程必须拥有该对象的锁,接着它就会释放这个对象锁并进入等待状态直到其他线程调用这个对象上的 notify()
方法。同样的,当一个线程需要调用对象的 notify()
方法时,它会释放这个对象的锁,以便其他在等待的线程就可以得到这个对象锁。
由于所有的这些方法都需要线程持有对象的锁,这样就只能通过 synchronized
来实现,所以他们只能在 synchronized
方法/块中被调用。
(3) Object.wait()
和 Thread.sleep
有什么区别?
相同点:
- 它们都可以让线程阻塞。
- 它们都可以响应 interrupt 中断:在等待的过程中如果收到中断信号,都可以进行响应,并抛出 InterruptedException 异常。
不同点:
- wait 方法必须在 synchronized 保护的代码中使用,而 sleep 方法并没有这个要求。
- 在同步代码中执行 sleep 方法时,并不会释放 monitor 锁,但执行 wait 方法时会主动释放 monitor 锁。
- sleep 方法中会要求必须定义一个时间,时间到期后会主动恢复,而对于没有参数的 wait 方法而言,意味着永久等待,直到被中断或被唤醒才能恢复,它并不会主动恢复。
- wait/notify 是 Object 类的方法,而 sleep 是 Thread 类的方法。
👉 扩展阅读:Java 并发编程:线程间协作的两种方式:wait、notify、notifyAll 和 Condition
线程终止
经典问题
(1)如何正确停止线程?
(2)可以使用 Thread.stop
,Thread.suspend
和 Thread.resume
停止线程吗?为什么?
(3)使用 volatile
标记方式停止线程正确吗?
知识点
(1)如何正确停止线程?
通常情况下,我们不会手动停止一个线程,而是允许线程运行到结束,然后让它自然停止。但是依然会有许多特殊的情况需要我们提前停止线程,比如:用户突然关闭程序,或程序运行出错重启等。
对于 Java 而言,最正确的停止线程的方式是:通过 Thread.interrupt
和 Thread.isInterrupted
配合来控制线程终止。但 Thread.interrupt
仅仅起到通知被停止线程的作用。而对于被停止的线程而言,它拥有完全的自主权,它既可以选择立即停止,也可以选择一段时间后停止,也可以选择压根不停止。
事实上,Java 希望程序间能够相互通知、相互协作地管理线程,因为如果不了解对方正在做的工作,贸然强制停止线程就可能会造成一些安全的问题,为了避免造成问题就需要给对方一定的时间来整理收尾工作。比如:线程正在写入一个文件,这时收到终止信号,它就需要根据自身业务判断,是选择立即停止,还是将整个文件写入成功后停止,而如果选择立即停止就可能造成数据不完整,不管是中断命令发起者,还是接收者都不希望数据出现问题。
一旦调用某个线程的 Thread.interrupt
之后,这个线程的中断标记位就会被设置成 true
。每个线程都有这样的标记位,当线程执行时,应该定期检查这个标记位,如果标记位被设置成 true
,就说明有程序想终止该线程。回到源码,可以看到在 while
循环体判断语句中,首先通过 Thread.currentThread().isInterrupt()
判断线程是否被中断,随后检查是否还有工作要做。&& 逻辑表示只有当两个判断条件同时满足的情况下,才会去执行下面的工作。
需要留意一个特殊场景:**Thread.sleep
后,线程依然可以感知 Thread.interrupt
**。
【示例】正确停止线程的方式——Thread.interrupt
1 | public class ThreadStopDemo { |
(2)可以使用 Thread.stop
,Thread.suspend
和 Thread.resume
停止线程吗?为什么?
Thread.stop
,Thread.suspend
和 Thread.resume
方法已经被 Java 标记为 @Deprecated
。为什么废弃呢?
Thread.stop
会直接把线程停止,这样就没有给线程足够的时间来处理想要在停止前保存数据的逻辑,任务戛然而止,会导致出现数据完整性等问题。- 而对于
Thread.suspend
和Thread.resume
而言,它们的问题在于:如果线程调用Thread.suspend
,它并不会释放锁,就开始进入休眠,但此时有可能仍持有锁,这样就容易导致死锁问题。因为这把锁在线程被Thread.resume
之前,是不会被释放的。假设线程 A 调用了Thread.suspend
方法让线程 B 挂起,线程 B 进入休眠,而线程 B 又刚好持有一把锁,此时假设线程 A 想访问线程 B 持有的锁,但由于线程 B 并没有释放锁就进入休眠了,所以对于线程 A 而言,此时拿不到锁,也会陷入阻塞,那么线程 A 和线程 B 就都无法继续向下执行。
【示例】Thread.stop
终止线程,导致线程任务戛然而止
1 | public class ThreadStopErrorDemo { |
(3)使用 volatile
标记方式停止线程正确吗?
使用 volatile
标记方式停止线程并不总是正确的。虽然 volatile
变量可以确保可见性,即当一个线程修改了 volatile
变量的值,其他线程能够立即看到最新的值,但它并不能保证原子性,也就是说并不能保证多个线程对 volatile
变量的操作是互斥的。
当我们使用 volatile
变量来控制线程的停止,通常是通过设置一个 volatile
标志位来告诉线程停止执行。例如:
1 | public class MyTask extends Thread { |
在上述例子中,canceled
是一个 volatile
变量,用来控制线程的停止。虽然这种方式在某些情况下可以工作,但它并不是一个可靠的停止线程的方式,因为在多线程环境中,其他线程修改 canceled
的值时,可能会出现竞态条件,导致线程无法正确停止。
线程优先级
典型问题
(1)Java 的线程优先级如何控制?
(2)高优先级的 Java 线程一定先执行吗?
知识点
(1)Java 中的线程优先级的范围是 [1,10]
,一般来说,高优先级的线程在运行时会具有优先权。可以通过 thread.setPriority(Thread.MAX_PRIORITY)
的方式设置,默认优先级为 5
。
(2)即使设置了线程的优先级,也无法保证高优先级的线程一定先执行。
这是因为 Java 线程优先级依赖于操作系统的支持,然而,不同的操作系统支持的线程优先级并不相同,不能很好的和 Java 中线程优先级一一对应。因此,Java 线程优先级控制并不可靠。
守护线程
典型问题
(1)什么是守护线程?
(2)如何创建守护线程?
知识点
(1)什么是守护线程?
守护线程(Daemon Thread)是在后台执行并且不会阻止 JVM 终止的线程。与守护线程(Daemon Thread)相反的,叫用户线程(User Thread),也就是非守护线程。
守护线程的优先级比较低,一般用于为系统中的其它对象和线程提供服务。典型的应用就是垃圾回收器。
(2)创建守护线程的方式:
- 使用
thread.setDaemon(true)
可以设置 thread 线程为守护线程。 - 正在运行的用户线程无法设置为守护线程,所以
thread.setDaemon(true)
必须在thread.start()
之前设置,否则会抛出llegalThreadStateException
异常; - 一个守护线程创建的子线程依然是守护线程。
- 不要认为所有的应用都可以分配给守护线程来进行服务,比如读写操作或者计算逻辑。
👉 扩展阅读:Java 中守护线程的总结
volatile
被 volatile
关键字修饰的变量有两层含义:
- 保证变量的可见性
- 防止 JVM 的指令重排序
volatile 保证线程可见性
典型问题
volatile
有什么作用?- Java 中,如何保证变量的可见性?
知识点
在 Java 并发场景中,volatile
可以保证线程可见性。保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个共享变量,另外一个线程能读到这个修改的值。
volatile
关键字其实并非是 Java 语言特有的,在 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。如果我们将一个变量使用 volatile
修饰,这就指示 编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证。
volatile 防止 JVM 的指令重排序
典型问题
volatile
有什么作用?- Java 中,如何防止 JVM 的指令重排序?
知识点
观察加入 volatile
关键字和没有加入 volatile
关键字时所生成的汇编代码发现,加入 volatile
关键字时,会多出一个 lock
前缀指令。
lock
前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供 3 个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。
在 Java 中,Unsafe
类提供了三个开箱即用的内存屏障相关的方法,屏蔽了操作系统底层的差异:
1 | public native void loadFence(); |
理论上来说,你通过这个三个方法也可以实现和 volatile
禁止重排序一样的效果,只是会麻烦一些。
下面我以一个常见的面试题为例讲解一下 volatile
关键字禁止指令重排序的效果。
面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”
双重校验锁实现对象单例(线程安全):
1 | public class Singleton { |
uniqueInstance
采用 volatile
关键字修饰也是很有必要的, uniqueInstance = new Singleton();
这段代码其实是分为三步执行:
- 为
uniqueInstance
分配内存空间 - 初始化
uniqueInstance
- 将
uniqueInstance
指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance
() 后发现 uniqueInstance
不为空,因此返回 uniqueInstance
,但此时 uniqueInstance
还未被初始化。
volatile 不保证原子性
问题点
- volatile 能保证原子性吗?
- volatile 能完全保证并发安全吗?
知识点
线程安全需要具备:可见性、原子性、顺序性。**volatile
不保证原子性,所以决定了它不能彻底地保证线程安全**。
我们通过下面的代码即可证明:
1 | public class VolatileAtomicityDemo { |
正常情况下,运行上面的代码理应输出 2500
。但你真正运行了上面的代码之后,你会发现每次输出结果都小于 2500
。
为什么会出现这种情况呢?不是说好了,volatile
可以保证变量的可见性嘛!
也就是说,如果 volatile
能保证 inc++
操作的原子性的话。每个线程中对 inc
变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。
很多人会误认为自增操作 inc++
是原子性的,实际上,inc++
其实是一个复合操作,包括三步:
- 读取 inc 的值。
- 对 inc 加 1。
- 将 inc 的值写回内存。
volatile
是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:
- 线程 1 对
inc
进行读取操作之后,还未对其进行修改。线程 2 又读取了inc
的值并对其进行修改(+1),再将inc
的值写回内存。 - 线程 2 操作完毕后,线程 1 对
inc
的值进行修改(+1),再将inc
的值写回内存。
这也就导致两个线程分别对 inc
进行了一次自增操作后,inc
实际上只增加了 1。
其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronized
、Lock
或者 AtomicInteger
都可以。
使用 synchronized
改进:
1 | public synchronized void increase() { |
使用 AtomicInteger
改进:
1 | public AtomicInteger inc = new AtomicInteger(); |
使用 ReentrantLock
改进:
1 | Lock lock = new ReentrantLock(); |
volatile 和 synchronized
典型问题
volatile
和 synchronized
有什么区别?volatile
能替代 synchronized
?
知识点
volatile
无法替代 synchronized
,因为 volatile
无法保证操作的原子性。
volatile
本质是在告诉 jvm 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized
则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。volatile
仅能修饰变量;synchronized
可以修饰方法和代码块。volatile
仅能实现变量的修改可见性,不能保证原子性;而synchronized
则可以保证变量的修改可见性和原子性volatile
不会造成线程的阻塞;synchronized
可能会造成线程的阻塞。volatile
标记的变量不会被编译器优化;synchronized
标记的变量可以被编译器优化。
synchronized
synchronized
有 3 种应用方式:
- 同步实例方法 - 对于普通同步方法,锁是当前实例对象
- 同步静态方法 - 对于静态同步方法,锁是当前类的
Class
对象 - 同步代码块 - 对于同步方法块,锁是
synchonized
括号里配置的对象
原理
synchronized
经过编译后,会在同步块的前后分别形成 monitorenter
和 monitorexit
这两个字节码指令,这两个字节码指令都需要一个引用类型的参数来指明要锁定和解锁的对象。如果 synchronized
明确制定了对象参数,那就是这个对象的引用;如果没有明确指定,那就根据 synchronized
修饰的是实例方法还是静态方法,去对对应的对象实例或 Class
对象来作为锁对象。
synchronized
同步块对同一线程来说是可重入的,不会出现锁死问题。
synchronized
同步块是互斥的,即已进入的线程执行完成前,会阻塞其他试图进入的线程。
优化
Java 1.6 以后,synchronized
做了大量的优化,其性能已经与 Lock
、ReadWriteLock
基本上持平。
synchronized
的优化是将锁粒度分为不同级别,synchronized
会根据运行状态动态的由低到高调整锁级别(偏向锁 -> 轻量级锁 -> 重量级锁),以减少阻塞。
同步方法 or 同步块?
- 同步块是更好的选择。
- 因为它不会锁住整个对象(当然你也可以让它锁住整个对象)。同步方法会锁住整个对象,哪怕这个类中有多个不相关联的同步块,这通常会导致他们停止执行并需要等待获得这个对象上的锁。
synchronized 作用
典型问题
synchronized
有什么作用?
知识点
synchronized
可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块。
synchronized
同步块对同一线程来说是可重入的,不会出现锁死问题。
synchronized
同步块是互斥的,即已进入的线程执行完成前,会阻塞其他试图进入的线程。
在 Java 早期版本中,synchronized
属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock
来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
不过,在 Java 6 之后, synchronized
引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized
锁的效率提升了很多。因此, synchronized
还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized
。
关于偏向锁多补充一点:由于偏向锁增加了 JVM 的复杂性,同时也并没有为所有应用都带来性能提升。因此,在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking
启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)。
synchronized 用法
典型问题
- synchronized 可以用在哪些场景?
- synchronized 如何使用?
知识点
synchronized
关键字的使用方式主要有下面 3 种:
- 修饰实例方法
- 修饰静态方法
- 修饰代码块
1、修饰实例方法 (锁当前对象实例)
给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。
1 | synchronized void method() { |
2、修饰静态方法 (锁当前类)
给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。
这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。
1 | synchronized static void method() { |
静态 synchronized
方法和非静态 synchronized
方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized
方法,是允许的,不会发生互斥现象,因为访问静态 synchronized
方法占用的锁是当前类的锁,而访问非静态 synchronized
方法占用的锁是当前实例对象锁。
3、修饰代码块 (锁指定对象 / 类)
对括号里指定的对象 / 类加锁:
synchronized(object)
表示进入同步代码库前要获得 给定对象的锁。synchronized(类。class)
表示进入同步代码前要获得 给定 Class 的锁
1 | synchronized(this) { |
总结:
synchronized
关键字加到static
静态方法和synchronized(class)
代码块上都是是给 Class 类上锁;synchronized
关键字加到实例方法上是给对象实例上锁;- 尽量不要使用
synchronized(String a)
因为 JVM 中,字符串常量池具有缓存功能。
构造方法可以用 synchronized 修饰么?
构造方法不能使用 synchronized 关键字修饰。不过,可以在构造方法内部使用 synchronized 代码块。
另外,构造方法本身是线程安全的,但如果在构造方法中涉及到共享资源的操作,就需要采取适当的同步措施来保证整个构造过程的线程安全。
synchronized 底层原理了解吗?
synchronized
经过编译后,会在同步块的前后分别形成 monitorenter
和 monitorexit
这两个字节码指令,这两个字节码指令都需要一个引用类型的参数来指明要锁定和解锁的对象。如果 synchronized
明确制定了对象参数,那就是这个对象的引用;如果没有明确指定,那就根据 synchronized
修饰的是实例方法还是静态方法,去对对应的对象实例或 Class
对象来作为锁对象。
synchronized 关键字底层原理属于 JVM 层面的东西。
synchronized 同步语句块的情况
1 | public class SynchronizedDemo { |
通过 JDK 自带的 javap
命令查看 SynchronizedDemo
类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java
命令生成编译后的 .class 文件,然后执行 javap -c -s -v -l SynchronizedDemo.class
。
从上面我们可以看出:**synchronized
同步语句块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。**
上面的字节码中包含一个 monitorenter
指令以及两个 monitorexit
指令,这是为了保证锁在同步代码块代码正常执行以及出现异常的这两种情况下都能被正确释放。
当执行 monitorenter
指令时,线程试图获取锁也就是获取 对象监视器 monitor
的持有权。
在 Java 虚拟机 (HotSpot) 中,Monitor 是基于 C++ 实现的,由 ObjectMonitor 实现的。每个对象中都内置了一个
ObjectMonitor
对象。另外,
wait/notify
等方法也依赖于monitor
对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify
等方法,否则会抛出java.lang.IllegalMonitorStateException
的异常的原因。
在执行 monitorenter
时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
对象锁的的拥有者线程才可以执行 monitorexit
指令来释放锁。在执行 monitorexit
指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
synchronized 修饰方法的的情况
1 | public class SynchronizedDemo2 { |
synchronized
修饰的方法并没有 monitorenter
指令和 monitorexit
指令,取得代之的确实是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。
总结
synchronized
同步语句块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。
synchronized
修饰的方法并没有 monitorenter
指令和 monitorexit
指令,取得代之的确实是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。
不过两者的本质都是对对象监视器 monitor 的获取。
相关推荐:Java 锁与线程的那些事 - 有赞技术团队 。
🧗🏻 进阶一下:学有余力的小伙伴可以抽时间详细研究一下对象监视器 monitor
。
JDK1.6 之后的 synchronized 底层做了哪些优化?锁升级原理了解吗?
在 Java 6 之后, synchronized
引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized
锁的效率提升了很多(JDK18 中,偏向锁已经被彻底废弃,前面已经提到过了)。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
synchronized
锁升级是一个比较复杂的过程,面试也很少问到,如果你想要详细了解的话,可以看看这篇文章:浅析 synchronized 锁升级的原理与实现。
synchronized 和 volatile 有什么区别?
synchronized
关键字和 volatile
关键字是两个互补的存在,而不是对立的存在!
volatile
关键字是线程同步的轻量级实现,所以volatile
性能肯定比synchronized
关键字要好 。但是volatile
关键字只能用于变量而synchronized
关键字可以修饰方法以及代码块 。volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证。volatile
关键字主要用于解决变量在多个线程之间的可见性,而synchronized
关键字解决的是多个线程之间访问资源的同步性。
CAS
什么是 CAS?
CAS 有什么作用?
CAS 的原理是什么?
CAS 的三大问题?
作用
CAS(Compare and Swap),字面意思为比较并交换。CAS 有 3 个操作数,分别是:内存值 V,旧的预期值 A,要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值 V 修改为 B,否则什么都不做。
原理
Java 主要利用 Unsafe
这个类提供的 CAS 操作。Unsafe
的 CAS 依赖的是 JV M 针对不同的操作系统实现的 Atomic::cmpxchg
指令。
三大问题
- ABA 问题:因为 CAS 需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA 问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么 A-B-A 就会变成 1A-2B-3A。
- 循环时间长开销大。自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline), 使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起 CPU 流水线被清空(CPU pipeline flush),从而提高 CPU 的执行效率。
- 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量 i = 2,j=a,合并一下 ij=2a,然后用 CAS 来操作 ij。从 Java1.5 开始 JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。
ThreadLocal
ThreadLocal
有什么作用?
ThreadLocal
的原理是什么?如何解决
ThreadLocal
内存泄漏问题?
作用
ThreadLocal
是一个存储线程本地副本的工具类。
原理
Thread
类中维护着一个 ThreadLocal.ThreadLocalMap
类型的成员 threadLocals
。这个成员就是用来存储当前线程独占的变量副本。
ThreadLocalMap
是 ThreadLocal
的内部类,它维护着一个 Entry
数组, Entry
用于保存键值对,其 key 是 ThreadLocal
对象,value 是传递进来的对象(变量副本)。 Entry
继承了 WeakReference
,所以是弱引用。
内存泄漏问题
ThreadLocalMap 的 Entry
继承了 WeakReference
,所以它的 key (ThreadLocal
对象)是弱引用,而 value (变量副本)是强引用。
- 如果
ThreadLocal
对象没有外部强引用来引用它,那么ThreadLocal
对象会在下次 GC 时被回收。 - 此时,
Entry
中的 key 已经被回收,但是 value 由于是强引用不会被垃圾收集器回收。如果创建ThreadLocal
的线程一直持续运行,那么 value 就会一直得不到回收,产生内存泄露。
那么如何避免内存泄漏呢?方法就是:使用 ThreadLocal
的 set
方法后,显示的调用 remove
方法 。
内存模型
什么是 Java 内存模型
- Java 内存模型即 Java Memory Model,简称 JMM。JMM 定义了 JVM 在计算机内存 (RAM) 中的工作方式。JMM 是隶属于 JVM 的。
- 并发编程领域两个关键问题:线程间通信和线程间同步
- 线程间通信机制
- 共享内存 - 线程间通过写-读内存中的公共状态来隐式进行通信。
- 消息传递 - java 中典型的消息传递方式就是 wait() 和 notify()。
- 线程间同步机制
- 在共享内存模型中,必须显示指定某个方法或某段代码在线程间互斥地执行。
- 在消息传递模型中,由于发送消息必须在接收消息之前,因此同步是隐式进行的。
- Java 的并发采用的是共享内存模型
- JMM 决定一个线程对共享变量的写入何时对另一个线程可见。
- 线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。
- JMM 把内存分成了两部分:线程栈区和堆区
- 线程栈
- JVM 中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈。随着代码的不断执行,调用栈会不断变化。
- 线程栈还包含了当前方法的所有本地变量信息。线程中的本地变量对其它线程是不可见的。
- 堆区
- 堆区包含了 Java 应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如 Byte、Integer、Long 等等)。不管对象是属于一个成员变量还是方法中的本地变量,它都会被存储在堆区。
- 一个本地变量如果是原始类型,那么它会被完全存储到栈区。
- 一个本地变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区。
- 对于一个对象的成员方法,这些方法中包含本地变量,仍需要存储在栈区,即使它们所属的对象在堆区。
- 对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区。
- 线程栈
👉 扩展阅读:全面理解 Java 内存模型
同步容器和并发容器
👉 扩展阅读:Java 并发容器
⭐ 同步容器
什么是同步容器?
有哪些常见同步容器?
它们是如何实现线程安全的?
同步容器真的线程安全吗?
类型
Vector
、Stack
、Hashtable
作用/原理
同步容器的同步原理就是在方法上用 synchronized
修饰。 synchronized
可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块。
synchronized
的互斥同步会产生阻塞和唤醒线程的开销。显然,这种方式比没有使用 synchronized
的容器性能要差。
线程安全
同步容器真的绝对安全吗?
其实也未必。在做复合操作(非原子操作)时,仍然需要加锁来保护。常见复合操作如下:
- 迭代:反复访问元素,直到遍历完全部元素;
- 跳转:根据指定顺序寻找当前元素的下一个(下 n 个)元素;
- 条件运算:例如若没有则添加等;
⭐⭐⭐ ConcurrentHashMap
请描述 ConcurrentHashMap 的实现原理?
ConcurrentHashMap 为什么放弃了分段锁?
基础数据结构原理和 HashMap
一样,JDK 1.7 采用 数组+单链表;JDK 1.8 采用数组+单链表+红黑树。
并发安全特性的实现:
JDK 1.7:
- 使用分段锁,设计思路是缩小锁粒度,提高并发吞吐。也就是将内部进行分段(Segment),里面则是 HashEntry 的数组,和 HashMap 类似,哈希相同的条目也是以链表形式存放。
- 写数据时,会使用可重入锁去锁住分段(segment):HashEntry 内部使用 volatile 的 value 字段来保证可见性,也利用了不可变对象的机制以改进利用 Unsafe 提供的底层能力,比如 volatile access,去直接完成部分操作,以最优化性能,毕竟 Unsafe 中的很多操作都是 JVM intrinsic 优化过的。
JDK 1.8:
- 取消分段锁,直接采用
transient volatile HashEntry<K,V>[] table
保存数据,采用 table 数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。 - 写数据时,使用是 CAS +
synchronized
。- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。
f
即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。- 如果当前位置的
hashcode == MOVED == -1
, 则需要进行扩容。 - 如果都不满足,则利用 synchronized 锁写入数据。
- 如果数量大于
TREEIFY_THRESHOLD
则要转换为红黑树。
MySQL 优化
MySQL 优化
慢查询
慢查询日志可以帮我们找到执行慢的 SQL。
可以通过以下命令查看慢查询日志是否开启:
1 | mysql> show variables like '%slow_query_log'; |
启停慢查询日志开关:
1 | # 开启慢查询日志 |
查看慢查询的时间阈值:
1 | mysql> show variables like '%long_query_time%'; |
设置慢查询的时间阈值:
1 | mysql > set global long_query_time = 3; |
MySQL 自带了一个 mysqldumpslow 工具,用于统计慢查询日志(这个工具是个 Perl 脚本,需要先安装好 Perl)。
mysqldumpslow 命令的具体参数如下:
-s
- 采用 order 排序的方式,排序方式可以有以下几种。分别是 c(访问次数)、t(查询时间)、l(锁定时间)、r(返回记录)、ac(平均查询次数)、al(平均锁定时间)、ar(平均返回记录数)和 at(平均查询时间)。其中 at 为默认排序方式。-t
- 返回前 N 条数据 。-g
- 后面可以是正则表达式,对大小写不敏感。
比如想要按照查询时间排序,查看前两条 SQL 语句,可以执行如下命令:
1 | perl mysqldumpslow.pl -s t -t 2 "C:\ProgramData\MySQL\MySQL Server 8.0\Data\slow.log" |
执行计划(EXPLAIN)
“执行计划”是对 SQL 查询语句在数据库中执行过程的描述。 如果要分析某条 SQL 的性能问题,通常需要先查看 SQL 的执行计划,排查每一步 SQL 执行是否存在问题。
很多数据库都支持执行计划,MySQL 也不例外。在 MySQL 中,用户可以通过 EXPLAIN
命令查看优化器针对指定 SQL 生成的逻辑执行计划。
【示例】MySQL 执行计划示例
1 | mysql> explain select * from user_info where id = 2 |
执行计划返回结果参数说明:
id
- SELECT 查询的标识符。每个SELECT
都会自动分配一个唯一的标识符。select_type
-SELECT
查询的类型。SIMPLE
- 表示此查询不包含UNION
查询或子查询。PRIMARY
- 表示此查询是最外层的查询。UNION
- 表示此查询是UNION
的第二或随后的查询。DEPENDENT UNION
-UNION
中的第二个或后面的查询语句, 取决于外面的查询。UNION RESULT
-UNION
的结果。SUBQUERY
- 子查询中的第一个SELECT
。DEPENDENT SUBQUERY
- 子查询中的第一个SELECT
, 取决于外面的查询. 即子查询依赖于外层查询的结果。
table
- 查询的是哪个表,如果给表起别名了,则显示别名。partitions
- 匹配的分区。type
- 表示从表中查询到行所执行的方式,查询方式是 SQL 优化中一个很重要的指标,执行效率由高到低依次为:system
/const
- 表中只有一行数据匹配。此时根据索引查询一次就能找到对应的数据。如果是 B+ 树索引,我们知道此时索引构造成了多个层级的树,当查询的索引在树的底层时,查询效率就越低。const
表示此时索引在第一层,只需访问一层便能得到数据。eq_ref
- 使用唯一索引扫描。常见于多表连接中使用主键和唯一索引作为关联条件。ref
- 非唯一索引扫描。还可见于唯一索引最左原则匹配扫描。range
- 索引范围扫描。比如<
,>
,between
等操作。index
- 索引全表扫描。此时遍历整个索引树。ALL
- 表示全表扫描。需要遍历全表来找到对应的行。
possible_keys
- 此次查询中可能选用的索引。key
- 此次查询中实际使用的索引。如果这一项为NULL
,说明没有使用索引。ref
- 哪个字段或常数与 key 一起被使用。rows
- 显示此查询一共扫描了多少行,这个是一个估计值。filtered
- 表示此查询条件所过滤的数据的百分比。extra
- 额外的信息。Using filesort
- 当查询语句中包含GROUP BY
操作,而且无法利用索引完成排序操作的时候, 这时不得不选择相应的排序算法进行,甚至可能会通过文件排序,效率是很低的,所以要避免这种问题的出现。Using temporary
- 使了用临时表保存中间结果,MySQL 在对查询结果排序时使用临时表,常见于排序ORDER BY
和分组查询GROUP BY
。效率低,要避免这种问题的出现。Using index
- 所需数据只需在索引即可全部获得,不须要再到表中取数据,也就是使用了覆盖索引,避免了回表操作,效率不错。
更多内容请参考:MySQL 性能优化神器 Explain 使用分析
optimizer trace
在 MySQL 5.6 及之后的版本中,我们可以使用 optimizer trace 功能查看优化器生成执行计划的整个过程。有了这个功能,我们不仅可以了解优化器的选择过程,更可以了解每一个执行环节的成本,然后依靠这些信息进一步优化查询。
如下代码所示,打开 optimizer_trace 后,再执行 SQL 就可以查询 information_schema.OPTIMIZER_TRACE 表查看执行计划了,最后可以关闭 optimizer_trace 功能:
1 | SET optimizer_trace="enabled=on"; |
SQL 优化
SQL 优化基本思路
使用 EXPLAIN
命令查看当前 SQL 是否使用了索引,优化后,再通过执行计划(EXPLAIN
)来查看优化效果。
SQL 优化的基本思路:
只返回必要的列 - 最好不要使用
SELECT *
语句。只返回必要的行 - 使用
WHERE
子查询语句进行过滤查询,有时候也需要使用LIMIT
语句来限制返回的数据。缓存重复查询的数据 - 应该考虑在客户端使用缓存,尽量不要使用 MySQL 服务器缓存(存在较多问题和限制)。
使用索引覆盖查询
优化分页
当需要分页操作时,通常会使用 LIMIT
加上偏移量的办法实现,同时加上合适的 ORDER BY
字句。如果有对应的索引,通常效率会不错,否则,MySQL 需要做大量的文件排序操作。
一个常见的问题是当偏移量非常大的时候,比如:LIMIT 1000000 20
这样的查询,MySQL 需要查询 1000020 条记录然后只返回 20 条记录,前面的 1000000 条都将被抛弃,这样的代价非常高。
针对分页优化,有以下两种方案
(1)方案 - 延迟关联
优化这种查询一个最简单的办法就是尽可能的使用覆盖索引扫描,而不是查询所有的列。然后根据需要做一次关联查询再返回所有的列。对于偏移量很大时,这样做的效率会提升非常大。考虑下面的查询:
1 | SELECT film_id,description FROM film ORDER BY title LIMIT 1000000,5; |
如果这张表非常大,那么这个查询最好改成下面的样子:
1 | SELECT film.film_id,film.description |
这里的延迟关联将大大提升查询效率,让 MySQL 扫描尽可能少的页面,获取需要访问的记录后在根据关联列回原表查询所需要的列。
(2)方案 - 书签方式
有时候如果可以使用书签记录上次取数据的位置,那么下次就可以直接从该书签记录的位置开始扫描,这样就可以避免使用 OFFSET
,比如下面的查询:
1 | -- 原语句 |
其他优化的办法还包括使用预先计算的汇总表,或者关联到一个冗余表,冗余表中只包含主键列和需要做排序的列。
优化 JOIN
优化子查询
尽量使用 JOIN
语句来替代子查询。因为子查询是嵌套查询,而嵌套查询会新创建一张临时表,而临时表的创建与销毁会占用一定的系统资源以及花费一定的时间,同时对于返回结果集比较大的子查询,其对查询性能的影响更大。
小表驱动大表
JOIN 查询时,应该用小表驱动大表。因为 JOIN 时,MySQL 内部会先遍历驱动表,再去遍历被驱动表。
比如 left join,左表就是驱动表,A 表小于 B 表,建立连接的次数就少,查询速度就被加快了。
1 | select name from A left join B ; |
适当冗余字段
增加冗余字段可以减少大量的连表查询,因为多张表的连表查询性能很低,所有可以适当的增加冗余字段,以减少多张表的关联查询,这是以空间换时间的优化策略
避免 JOIN 太多表
《阿里巴巴 Java 开发手册》规定不要 join 超过三张表,第一 join 太多降低查询的速度,第二 join 的 buffer 会占用更多的内存。
如果不可避免要 join 多张表,可以考虑使用数据异构的方式异构到 ES 中查询。
优化 UNION
MySQL 执行 UNION
的策略是:先创建临时表,然后将各个查询结果填充到临时表中,最后再进行查询。很多优化策略在 UNION
查询中都会失效,因为它无法利用索引。
最好将 WHERE
、LIMIT
等子句下推到 UNION
的各个子查询中,以便优化器可以充分利用这些条件进行优化。
此外,尽量使用 UNION ALL
,避免使用 UNION
。
UNION
和 UNION ALL
都是将两个结果集合并为一个,两个要联合的 SQL 语句字段个数必须一样,而且字段类型要“相容”(一致)。
UNION
需要进行去重扫描,因此消息较低;而UNION ALL
不会进行去重。UNION
会按照字段的顺序进行排序;而UNION ALL
只是简单的将两个结果合并就返回。
优化 COUNT() 查询
COUNT()
有两种作用:
- 统计某个列值的数量。统计列值时,要求列值是非
NULL
的,它不会统计NULL
。 - 统计行数。
统计列值时,要求列值是非空的,它不会统计 NULL。如果确认括号中的表达式不可能为空时,实际上就是在统计行数。最简单的就是当使用 COUNT(*)
时,并不是我们所想象的那样扩展成所有的列,实际上,它会忽略所有的列而直接统计行数。
我们最常见的误解也就在这儿,在括号内指定了一列却希望统计结果是行数,而且还常常误以为前者的性能会更好。但实际并非这样,如果要统计行数,直接使用 COUNT(*)
,意义清晰,且性能更好。
(1)简单优化
1 | SELECT count(*) FROM world.city WHERE id > 5; |
(2)使用近似值
有时候某些业务场景并不需要完全精确的统计值,可以用近似值来代替,EXPLAIN
出来的行数就是一个不错的近似值,而且执行 EXPLAIN
并不需要真正地去执行查询,所以成本非常低。通常来说,执行 COUNT()
都需要扫描大量的行才能获取到精确的数据,因此很难优化,MySQL 层面还能做得也就只有覆盖索引了。如果不还能解决问题,只有从架构层面解决了,比如添加汇总表,或者使用 Redis 这样的外部缓存系统。
优化查询方式
切分大查询
一个大查询如果一次性执行的话,可能一次锁住很多数据、占满整个事务日志、耗尽系统资源、阻塞很多小的但重要的查询。
1 | DELEFT FROM messages WHERE create < DATE_SUB(NOW(), INTERVAL 3 MONTH); |
1 | rows_affected = 0 |
分解大连接查询
将一个大连接查询(JOIN)分解成对每一个表进行一次单表查询,然后将结果在应用程序中进行关联,这样做的好处有:
- 让缓存更高效。对于连接查询,如果其中一个表发生变化,那么整个查询缓存就无法使用。而分解后的多个查询,即使其中一个表发生变化,对其它表的查询缓存依然可以使用。
- 分解成多个单表查询,这些单表查询的缓存结果更可能被其它查询使用到,从而减少冗余记录的查询。
- 减少锁竞争;
- 在应用层进行连接,可以更容易对数据库进行拆分,从而更容易做到高性能和可扩展。
- 查询本身效率也可能会有所提升。例如下面的例子中,使用 IN() 代替连接查询,可以让 MySQL 按照 ID 顺序进行查询,这可能比随机的连接要更高效。
1 | SELECT * FROM tag |
1 | SELECT * FROM tag WHERE tag='mysql'; |
索引优化
通过索引覆盖查询,可以优化排序、分组。
详情见 MySQL 索引
数据结构优化
良好的逻辑设计和物理设计是高性能的基石。
数据类型优化
数据类型优化基本原则
- 更小的通常更好 - 越小的数据类型通常会更快,占用更少的磁盘、内存,处理时需要的 CPU 周期也更少。
- 例如:整型比字符类型操作代价低,因而会使用整型来存储 IP 地址,使用
DATETIME
来存储时间,而不是使用字符串。
- 例如:整型比字符类型操作代价低,因而会使用整型来存储 IP 地址,使用
- 简单就好 - 如整型比字符型操作代价低。
- 例如:很多软件会用整型来存储 IP 地址。
- 例如:**
UNSIGNED
表示不允许负值,大致可以使正数的上限提高一倍**。
- 尽量避免 NULL - 可为 NULL 的列会使得索引、索引统计和值比较都更复杂。
类型的选择
整数类型通常是标识列最好的选择,因为它们很快并且可以使用
AUTO_INCREMENT
。ENUM
和SET
类型通常是一个糟糕的选择,应尽量避免。应该尽量避免用字符串类型作为标识列,因为它们很消耗空间,并且通常比数字类型慢。对于
MD5
、SHA
、UUID
这类随机字符串,由于比较随机,所以可能分布在很大的空间内,导致INSERT
以及一些SELECT
语句变得很慢。- 如果存储 UUID ,应该移除
-
符号;更好的做法是,用UNHEX()
函数转换 UUID 值为 16 字节的数字,并存储在一个BINARY(16)
的列中,检索时,可以通过HEX()
函数来格式化为 16 进制格式。
- 如果存储 UUID ,应该移除
表设计
应该避免的设计问题:
- 太多的列 - 设计者为了图方便,将大量冗余列加入表中,实际查询中,表中很多列是用不到的。这种宽表模式设计,会造成不小的性能代价,尤其是
ALTER TABLE
非常耗时。 - 太多的关联 - 所谓的实体 - 属性 - 值(EAV)设计模式是一个常见的糟糕设计模式。MySQL 限制了每个关联操作最多只能有 61 张表,但 EAV 模式需要许多自关联。
- 枚举 - 尽量不要用枚举,因为添加和删除字符串(枚举选项)必须使用
ALTER TABLE
。 - 尽量避免
NULL
范式和反范式
范式化目标是尽量减少冗余,而反范式化则相反。
范式化的优点:
- 比反范式更节省空间
- 更新操作比反范式快
- 更少需要
DISTINCT
或GROUP BY
语句
范式化的缺点:
- 通常需要关联查询。而关联查询代价较高,如果是分表的关联查询,代价更是高昂。
在真实世界中,很少会极端地使用范式化或反范式化。实际上,应该权衡范式和反范式的利弊,混合使用。
索引优化
索引优化应该是查询性能优化的最有效手段。
如果想详细了解索引特性请参考:MySQL 索引
何时使用索引
- 对于非常小的表,大部分情况下简单的全表扫描更高效。
- 对于中、大型表,索引非常有效。
- 对于特大型表,建立和使用索引的代价将随之增长。可以考虑使用分区技术。
- 如果表的数量特别多,可以建立一个元数据信息表,用来查询需要用到的某些特性。
索引优化策略
- 索引基本原则
- 索引不是越多越好,不要为所有列都创建索引。
- 要尽量避免冗余和重复索引。
- 要考虑删除未使用的索引。
- 尽量的扩展索引,不要新建索引。
- 频繁作为
WHERE
过滤条件的列应该考虑添加索引。
- 独立的列 - “独立的列” 是指索引列不能是表达式的一部分,也不能是函数的参数。
- 前缀索引 - 索引很长的字符列,可以索引开始的部分字符,这样可以大大节约索引空间。
- 最左匹配原则 - 将选择性高的列或基数大的列优先排在多列索引最前列。
- 使用索引来排序 - 索引最好既满足排序,又用于查找行。这样,就可以使用索引来对结果排序。
=
、IN
可以乱序 - 不需要考虑=
、IN
等的顺序- 覆盖索引
- 自增字段作主键
数据模型和业务
- 表字段比较复杂、易变动、结构难以统一的情况下,可以考虑使用 Nosql 来代替关系数据库表存储,如 ElasticSearch、MongoDB。
- 在高并发情况下的查询操作,可以使用缓存(如 Redis)代替数据库操作,提高并发性能。
- 数据量增长较快的表,需要考虑水平分表或分库,避免单表操作的性能瓶颈。
- 除此之外,我们应该通过一些优化,尽量避免比较复杂的 JOIN 查询操作,例如冗余一些字段,减少 JOIN 查询;创建一些中间表,减少 JOIN 查询。
参考资料
MySQL 事务
MySQL 事务
::: info 概述
不是所有的 MySQL 存储引擎都实现了事务处理。支持事务的存储引擎有:InnoDB
和 NDB Cluster
。不支持事务的存储引擎,代表有:MyISAM
。
用户可以根据业务是否需要事务处理(事务处理可以保证数据安全,但会增加系统开销),选择合适的存储引擎。
:::
事务简介
事务概念
“事务”指的是满足 ACID 特性的一组操作。事务内的 SQL 语句,要么全执行成功,要么全执行失败。可以通过 Commit
提交一个事务,也可以使用 Rollback
进行回滚。
ACID
ACID 是数据库事务正确执行的四个基本要素。
- 原子性(Atomicity)
- 事务被视为不可分割的最小单元,事务中的所有操作要么全部提交成功,要么全部失败回滚。
- 回滚可以用日志来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
- 一致性(Consistency)
- 数据库在事务执行前后都保持一致性状态。
- 在一致性状态下,所有事务对一个数据的读取结果都是相同的。
- 隔离性(Isolation)
- 一个事务所做的修改在最终提交以前,对其它事务是不可见的。
- 持久性(Durability)
- 一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。
- 可以通过数据库备份和恢复来实现,在系统发生奔溃时,使用备份的数据库进行数据恢复。
一个支持事务(Transaction)中的数据库系统,必需要具有这四种特性,否则在事务过程(Transaction processing)当中无法保证数据的正确性。
- 只有满足一致性,事务的执行结果才是正确的。
- 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。
- 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
- 事务满足持久化是为了能应对系统崩溃的情况。
事务操作
事务相关的语句如下:
BEGIN
/START TRANSACTION
- 用于标记事务的起始点。START TRANSACTION WITH CONSISTENT SNAPSHOT
- 用于标记事务的起始点。SAVEPOINT
- 用于创建保存点。方便后续针对保存点进行回滚。一个事务中可以存在多个保存点。RELEASE SAVEPOINT
- 删除某个保存点。ROLLBACK TO
- 用于回滚到指定的保存点。如果没有设置保存点,则回退到START TRANSACTION
语句处。COMMIT
- 提交事务。SET TRANSACTION
- 设置事务的隔离级别。
注意:
两种开启事务的命令,启动时机是不同的:
- 执行了
BEGIN
/START TRANSACTION
命令后,并不代表事务立刻启动,而是当执行了增删查操作时,才真正启动事务。- 执行了
START TRANSACTION WITH CONSISTENT SNAPSHOT
命令,会立刻启动事务。
事务处理示例:
(1)创建一张示例表
1 | -- 撤销表 user |
(2)执行事务操作
1 | -- 开始事务 |
(3)查询结果
1 | SELECT * FROM `user`; |
结果:
1 | mysql> SELECT * FROM user; |
AUTOCOMMIT
MySQL 默认采用隐式提交策略(autocommit
)。每执行一条语句就把这条语句当成一个事务然后进行提交。当出现 START TRANSACTION
语句时,会关闭隐式提交;当 COMMIT
或 ROLLBACK
语句执行后,事务会自动关闭,重新恢复隐式提交。
通过 set autocommit=0
可以取消自动提交,直到 set autocommit=1
才会提交;autocommit
标记是针对每个连接而不是针对服务器的。
1 | -- 查看 AUTOCOMMIT |
并发一致性问题
在并发环境下,事务的隔离性很难保证,因此会出现很多并发一致性问题。
丢失修改
“丢失修改”是指一个事务的更新操作被另外一个事务的更新操作替换。
如下图所示,T1 和 T2 两个事务对同一个数据进行修改,T1 先修改,T2 随后修改,T2 的修改覆盖了 T1 的修改。
脏读
“脏读(dirty read)”是指当前事务可以读取其他事务未提交的数据。
如下图所示,T1 修改一个数据,T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据。
不可重复读
“不可重复读(non-repeatable read)”是指一个事务内多次读取同一数据,过程中,该数据被其他事务所修改,导致当前事务多次读取的数据可能不一致。
如下图所示,T2 读取一个数据,T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。
幻读
“幻读(phantom read)”是指一个事务内多次读取同一范围的数据,过程中,其他事务在该数据范围新增了数据,导致当前事务未发现新增数据。
事务 T1 读取某个范围内的记录时,事务 T2 在该范围内插入了新的记录,T1 再次读取这个范围的数据,此时读取的结果和和第一次读取的结果不同。
事务隔离级别
事务隔离级别简介
为了解决以上提到的“并发一致性问题”,SQL 标准提出了四种“事务隔离级别”来应对这些问题。事务隔离级别等级越高,越能保证数据的一致性和完整性,但是执行效率也越低。因此,设置数据库的事务隔离级别时需要做一下权衡。
事务隔离级别从低到高分别是:
- “读未提交(read uncommitted)” - 是指,事务中的修改,即使没有提交,对其它事务也是可见的。
- “读已提交(read committed)” ** - 是指,事务提交后,其他事务才能看到它的修改**。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的。
- 读已提交解决了脏读的问题。
- 读已提交是大多数数据库的默认事务隔离级别,如 Oracle。
- “可重复读(repeatable read)” - 是指:保证在同一个事务中多次读取同样数据的结果是一样的。
- 可重复读解决了不可重复读问题。
- 可重复读是 InnoDB 存储引擎的默认事务隔离级别。
- 串行化(serializable ) - 是指,强制事务串行执行,对于同一行记录,加读写锁,一旦出现锁冲突,必须等前面的事务释放锁。
- 串行化解决了幻读问题。由于强制事务串行执行,自然避免了所有的并发问题。
- 串行化策略会在读取的每一行数据上都加锁,这可能导致大量的超时和锁竞争。这对于高并发应用基本上是不可接受的,所以一般不会采用这个级别。
事务隔离级别对并发一致性问题的解决情况:
隔离级别 | 丢失修改 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|---|
读未提交 | ✔️️️ | ❌ | ❌ | ❌ |
读已提交 | ✔️️️ | ✔️️️ | ❌ | ❌ |
可重复读 | ✔️️️ | ✔️️️ | ✔️️️ | ❌ |
可串行化 | ✔️️️ | ✔️️️ | ✔️️️ | ✔️️️ |
查看和设置事务隔离级别
可以通过 SHOW VARIABLES LIKE 'transaction_isolation'
语句查看事务隔离级别。
【示例】查看事务隔离示例
1 | mysql> SHOW VARIABLES LIKE 'transaction_isolation'; |
MySQL 提供了 SET TRANSACTION
语句,该语句可以改变单个会话或全局的事务隔离级别。语法格式如下:
1 | SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE} |
其中,SESSION
和 GLOBAL
关键字用来指定修改的事务隔离级别的范围:
SESSION
- 表示修改的事务隔离级别,将应用于当前会话内的所有事务。GLOBAL
- 表示修改的事务隔离级别,将应用于所有会话内的所有事务(即全局修改),且当前已经存在的会话不受影响;- 如果省略
SESSION
和GLOBAL
,表示修改的事务隔离级别,将应用于当前会话内的下一个还未开始的事务。
【示例】设置事务隔离示例
1 | -- 设置事务隔离级别为 READ UNCOMMITTED |
事务隔离级别实现方式
MySQL 中的事务功能是在存储引擎层实现的,并非所有存储引擎都支持事务功能。InnoDB 是 MySQL 的首先事务存储引擎。
四种隔离级别具体是如何实现的呢?
以 InnoDB 的事务实现来说明:
- 对于“读未提交”隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好了;
- 对于“串行化”隔离级别的事务来说,通过加读写锁的方式来避免并行访问;
- 对于“读提交”和“可重复读”隔离级别的事务来说,它们都是通过 ReadView 来实现的,区别仅在于创建 ReadView 的时机不同。ReadView 可以理解为一个数据快照。
- “读提交”隔离级别是在“每个语句执行前”都会重新生成一个 ReadView
- “可重复读”隔离级别是在“启动事务时”生成一个 ReadView,然后整个事务期间都在用这个 ReadView。
关于 ReadView 更多细节,将在 MVCC 章节中阐述。
MVCC
当前读和快照读
在高并发场景下,多事务同时执行,可能会出现种种并发一致性问题。最常见,也是最容易想到的解决问题思路就是:对访问的数据加锁,通过强制互斥来解决问题。但是,加锁就意味着阻塞,势必会增加响应时间,降低系统整体吞吐量。在大多数真实的业务场景中,读请求远大于写请求,由于读请求并不会修改数据,自然也不存在一致性问题,因此为占大多数的读请求加锁是一种不必要的开销。那么,我们很自然的会想到,如果只针对写操作加锁,不就能大大提升吞吐量了吗?没错,有一种名为“写时复制(Copy-On-Write,简称 COW)”的技术,正是基于这个想法而设计,并广泛应用于各种软件领域,例如:Java 中的 CopyOnWriteArrayList
等容器;Redis 中的 RDB 持久化过程。
Copy-On-Write 的核心思想是:假设有多个请求需要访问相同的数据,先为这份数据生成一个副本(也可以称为快照)。然后将读写分离,所有的读请求都直接访问原数据;所有的写请求都访问副本数据,为了实现并发一致性,写数据时需要通过加锁保证每次写操作只能由一个写请求完成。当写操作完成后,用副本数据替换原数据。
在 MySQL 中,也采用了 Copy-On-Write 设计思想,将读写分离。
- 这里的“写”指的是当前读。“当前读”,顾名思义,指的是读取记录当前的数据。为了保证读取当前数据时,没有其他事务修改,因此需要对读取记录加锁。当前读的场景有下面几种:
INSERT
- 插入操作UPDATE
- 更新操作DELETE
- 删除操作SELECT ... LOCK IN SHARE MODE
- 加共享锁(读锁)SELECT ... FOR UPDATE
- 加独享锁(写锁)
- 这里的“读”指的是快照读。“快照读”,顾名思义,指的是读取记录的某个历史快照版本。不加锁的普通
SELECT
都属于快照读,例如:SELECT ... FROM
。采用快照读的前提是,事务隔离级别不是串行化级别。串行化级别下的快照读会退化成当前读。快照读的实现是基于 MVCC。
什么是 MVCC
前文提到,快照读的实现是基于 MVCC。那么,什么是 MVCC 呢?
MVCC 是 Multi Version Concurrency Control 的缩写,即“多版本并发控制”。MVCC 的设计目标是提高数据库的并发性,采用非阻塞的方式去处理读/写并发冲突,可以将其看成一种乐观锁。
不仅是 MySQL,包括 Oracle、PostgreSQL 等其他关系型数据库都实现了各自的 MVCC,实现机制没有统一标准。MVCC 是 InnoDB 存储引擎实现事务隔离级别的一种具体方式。其主要用于实现读已提交和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,要求很低,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。
MVCC 实现原理
MVCC 的实现原理,主要基于隐式字段、UndoLog、ReadView 来实现。
隐式字段
InnoDB 存储引擎中,数据表的每行记录,除了用户显示定义的字段以外,还有几个数据库隐式定义的字段:
DB_ROW_ID
- 隐藏的自增 ID,如果数据表没有指定主键,InnoDB 会自动基于row_id
产生一个聚簇索引。DB_TRX_ID
- 最近修改的事务 ID。事务对某条聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列里;DB_ROLL_PTR
- 回滚指针,指向这条记录的上一个版本。
UndoLog
MVCC 的多版本指的是多个版本的快照,快照存储在 UndoLog 中。该日志通过回滚指针 roll_pointer
把一个数据行的所有快照链接起来,构成一个版本链。
ReadView
ReadView 就是事务进行快照读时产生的读视图(快照)。
ReadView 有四个重要的字段:
m_ids
- 指的是在创建 ReadView 时,当前数据库中“活跃事务”的事务 ID 列表。注意:这是一个列表,“活跃事务”指的就是,启动了但还没提交的事务。min_trx_id
- 指的是在创建 ReadView 时,当前数据库中“活跃事务”中事务 id 最小的事务,也就是m_ids
的最小值。max_trx_id
- 这个并不是 m_ids 的最大值,而是指创建 ReadView 时当前数据库中应该给下一个事务分配的 ID 值,也就是全局事务中最大的事务 ID 值 + 1;creator_trx_id
- 指的是创建该 ReadView 的事务的事务 ID。
在创建 ReadView 后,我们可以将记录中的 trx_id 划分为三种情况:
- 已提交事务
- 已启动但未提交的事务
- 未启动的事务
ReadView 如何判断版本链中哪个版本可见?
一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况:
trx_id == creator_trx_id
- 表示trx_id
版本记录由 ReadView 所代表的当前事务产生,当然可以访问。trx_id < min_trx_id
- 表示trx_id
版本记录是在创建 ReadView 之前已提交的事务生成的,当前事务可以访问。trx_id >= max_trx_id
- 表示trx_id
版本记录是在创建 ReadView 之后才启动的事务生成的,当前事务不可以访问。min_trx_id <= trx_id < max_trx_id
- 需要判断trx_id
是否在m_ids
列表中- 如果
trx_id
在m_ids
列表中,表示生成trx_id
版本记录的事务依然活跃(未提交事务),当前事务不可以访问。 - 如果
trx_id
不在m_ids
列表中,表示生成trx_id
版本记录的事务已提交,当前事务可以访问。
- 如果
这种通过“版本链”来控制并发事务访问同一个记录时的行为就叫 MVCC(多版本并发控制)。
MVCC 如何实现多种事务隔离级别
对于“读已提交”和“可重复读”隔离级别的事务来说,它们都是通过 MVCC 的 ReadView 机制来实现的,区别仅在于创建 ReadView 的时机不同。ReadView 可以理解为一个数据快照。
- “读已提交”隔离级别,会在“每个语句执行前”都会重新生成一个 ReadView。
- “可重复读”隔离级别,会在“启动事务时”生成一个 ReadView,然后整个事务期间都在复用这个 ReadView。
MySQL InnoDB 引擎的默认隔离级别虽然是“可重复读”,但是它很大程度上避免幻读现象(并不是完全解决了),解决的方案有两种:
- 针对快照读(普通 select 语句),通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
- 针对当前读(select … for update 等语句),通过 Next-Key Lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select … for update 语句的时候,会加上 Next-Key Lock,如果有其他事务在 Next-Key Lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。
MVCC 实现可重复读
可重复读隔离级别只有在启动事务时才会创建 ReadView,然后整个事务期间都使用这个 ReadView。这样就保证了在事务期间读到的数据都是事务启动前的记录。
举个例子,假设有两个事务依次执行以下操作:
- 初始,表中 id = 1 的 value 列值为 100。
- 事务 2 读取数据,value 为 100;
- 事务 1 将 value 设为 200;
- 事务 2 读取数据,value 为 100;
- 事务 1 提交事务;
- 事务 2 读取数据,value 依旧为 100;
以上操作,如下图所示。T2 事务在事务过程中,是否可以看到 T1 事务的修改,可以根据 ReadView 中描述的规则去判断。
从图中不难看出:
- 对于
trx_id = 100
的版本记录,比对 T2 事务 ReadView ,trx_id < min_trx_id
,因此在 T2 事务中的任意时刻都可见; - 对于
trx_id = 101
的版本记录,比对 T2 事务 ReadView ,可以看出min_trx_id <= trx_id < max_trx_id
,且trx_id
在m_ids
中,因此 T2 事务中不可见。
综上所述,在 T2 事务中,自始至终只能看到 trx_id = 100
的版本记录。
MVCC 实现读已提交
读已提交隔离级别每次读取数据时都会创建一个 ReadView。这意味着,事务期间的多次读取同一条数据,前后读取的数据可能会出现不一致——因为,这期间可能有另外一个事务修改了该记录,并提交了事务。
举个例子,假设有两个事务依次执行以下操作:
- 初始,表中 id = 1 的 value 列值为 100。
- 事务 2 读取数据(创建 ReadView),value 为 0;
- 事务 1 将 value 设为 100;
- 事务 2 读取数据(创建 ReadView),value 为 0;
- 事务 1 提交事务;
- 事务 2 读取数据(创建 ReadView),value 为 100;
以上操作,如下图所示,T2 事务在事务过程中,是否可以看到其他事务的修改,可以根据 ReadView 中描述的规则去判断。
从图中不难看出:
- 对于
trx_id = 100
的版本记录,比对 T2 事务 ReadView ,trx_id < min_trx_id
,因此在 T2 事务中的任意时刻都可见; - 对于
trx_id = 101
的版本记录,比对 T2 事务 ReadView ,可以看出第二次查询时(T1 更新未提交),min_trx_id <= trx_id < max_trx_id
,且trx_id
在m_ids
中,因此 T2 事务中不可见;而第三次查询时(T1 更新已提交),trx_id < min_trx_id
,因此在 T2 事务中可见;
综上所述,在 T2 事务中,当 T1 事务提交前,可读取到的是 trx_id = 100
的版本记录;当 T1 事务提交后,可读取到的是 trx_id = 101
的版本记录。
MVCC + Next-Key Lock 解决幻读
MySQL InnoDB 引擎的默认隔离级别虽然是“可重复读”,但是它很大程度上避免幻读现象(并不是完全解决了)。针对快照读和当前读,InnoDB 的处理方式各不相同。
快照读是如何避免幻读的?
针对快照读(普通 SELECT
语句),通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
当前读是如何避免幻读的?
针对当前读(SELECT ... FOR UPDATE
等语句),通过 Next-Key Lock(记录锁+间隙锁)方式解决了幻读,因为当执行 SELECT ... FOR UPDATE
语句的时候,会加上 Next-Key Lock,如果有其他事务在 Next-Key Lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好的避免了幻读问题。
幻读被完全解决了吗?
可重复读隔离级别下虽然很大程度上避免了幻读,但是还是没有能完全解决幻读。
【示例】幻读案例一
环境:存储引擎为 InnoDB;事务隔离级别为可重复读
1 | -- -------------------------------------------------------------------------------------- |
以上示例代码的时序图如下:
【示例】幻读案例二
1 | -- --------------------------------------------------------------------- (1)数据初始化 |
要避免这类特殊场景下发生幻读的现象的话,就是尽量在开启事务之后,马上执行 select ... for update
这类当前读的语句,因为它会对记录加 Next-Key Lock,从而避免其他事务插入一条新记录。
分布式事务
在单一数据节点中,事务仅限于对单一数据库资源的访问控制,称之为 本地事务。几乎所有的成熟的关系型数据库都提供了对本地事务的原生支持。
分布式事务指的是事务操作跨越多个节点,并且要求满足事务的 ACID 特性。
分布式事务的常见方案如下:
- 两阶段提交(2PC) - 将事务的提交过程分为两个阶段来进行处理:准备阶段和提交阶段。参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
- 三阶段提交(3PC) - 与二阶段提交不同的是,引入超时机制。同时在协调者和参与者中都引入超时机制。将二阶段的准备阶段拆分为 2 个阶段,插入了一个 preCommit 阶段,使得原先在二阶段提交中,参与者在准备之后,由于协调者发生崩溃或错误,而导致参与者处于无法知晓是否提交或者中止的“不确定状态”所产生的可能相当长的延时的问题得以解决。
- 补偿事务(TCC)
- Try - 操作作为一阶段,负责资源的检查和预留。
- Confirm - 操作作为二阶段提交操作,执行真正的业务。
- Cancel - 是预留资源的取消。
- 本地消息表 - 在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。
- MQ 事务 - 基于 MQ 的分布式事务方案其实是对本地消息表的封装。
- SAGA - Saga 事务核心思想是将长事务拆分为多个本地短事务,由 Saga 事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。
分布式事务方案分析:
- 2PC/3PC 依赖于数据库,能够很好的提供强一致性和强事务性,但相对来说延迟比较高,比较适合传统的单体应用,在同一个方法中存在跨库操作的情况,不适合高并发和高性能要求的场景。
- TCC 适用于执行时间确定且较短,实时性要求高,对数据一致性要求高,比如互联网金融企业最核心的三个服务:交易、支付、账务。
- 本地消息表/MQ 事务 都适用于事务中参与方支持操作幂等,对一致性要求不高,业务上能容忍数据不一致到一个人工检查周期,事务涉及的参与方、参与环节较少,业务上有对账/校验系统兜底。
- Saga 事务 由于 Saga 事务不能保证隔离性,需要在业务层控制并发,适合于业务场景事务并发操作同一资源较少的情况。 Saga 相比缺少预提交动作,导致补偿动作的实现比较麻烦,例如业务是发送短信,补偿动作则得再发送一次短信说明撤销,用户体验比较差。Saga 事务较适用于补偿动作容易处理的场景。
分布式事务详细说明、分析请参考:分布式事务基本原理
事务最佳实践
高并发场景下的事务到底该如何调优?
尽量使用低级别事务隔离
结合业务场景,尽量使用低级别事务隔离
避免行锁升级表锁
在 InnoDB 中,行锁是通过索引实现的,如果不通过索引条件检索数据,行锁将会升级到表锁。我们知道,表锁是会严重影响到整张表的操作性能的,所以应该尽力避免。
缩小事务范围
有时候,数据库并发访问量太大,会出现以下异常:
1 | MySQLQueryInterruptedException: Query execution was interrupted |
高并发时对一条记录进行更新的情况下,由于更新记录所在的事务还可能存在其他操作,导致一个事务比较长,当有大量请求进入时,就可能导致一些请求同时进入到事务中。
又因为锁的竞争是不公平的,当多个事务同时对一条记录进行更新时,极端情况下,一个更新操作进去排队系统后,可能会一直拿不到锁,最后因超时被系统打断踢出。
如上图中的操作,虽然都是在一个事务中,但锁的申请在不同时间,只有当其他操作都执行完,才会释放所有锁。因为扣除库存是更新操作,属于行锁,这将会影响到其他操作该数据的事务,所以我们应该尽量避免长时间地持有该锁,尽快释放该锁。又因为先新建订单和先扣除库存都不会影响业务,所以我们可以将扣除库存操作放到最后,也就是使用执行顺序 1,以此尽量减小锁的持有时间。
在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。
知道了这个设定,对我们使用事务有什么帮助呢?那就是,如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。
参考资料
Kafka 快速入门
Kafka 快速入门
Kafka 简介
Apache Kafka 是一款开源的消息引擎系统,也是一个分布式流计算平台,此外,还可以作为数据存储。
Kafka 的功能
Kafka 的核心功能如下:
- 消息引擎 - Kafka 可以作为一个消息引擎系统。
- 流处理 - Kafka 可以作为一个分布式流处理平台。
- 存储 - Kafka 可以作为一个安全的分布式存储。
Kafka 的特性
Kafka 的设计目标:
- 高性能
- 分区、分段、索引:基于分区机制提供并发处理能力。分段、索引提升了数据读写的查询效率。
- 顺序读写:使用顺序读写提升磁盘 IO 性能。
- 零拷贝:利用零拷贝技术,提升网络 I/O 效率。
- 页缓存:利用操作系统的 PageCache 来缓存数据(典型的利用空间换时间)
- 批量读写:批量读写可以有效提升网络 I/O 效率。
- 数据压缩:Kafka 支持数据压缩,可以有效提升网络 I/O 效率。
- pull 模式:Kafka 架构基于 pull 模式,可以自主控制消费策略,提升传输效率。
- 高可用
- 持久化:Kafka 所有的消息都存储在磁盘,天然支持持久化。
- 副本机制:Kafka 的 Broker 集群支持副本机制,可以通过冗余,来保证其整体的可用性。
- 选举 Leader:Kafka 基于 ZooKeeper 支持选举 Leader,实现了故障转移能力。
- 伸缩性
- 分区:Kafka 的分区机制使得其具有良好的伸缩性。
Kafka 术语
- 消息:Kafka 的数据单元被称为消息。消息由字节数组组成。
- 批次:批次就是一组消息,这些消息属于同一个主题和分区。
- 主题(Topic):Kafka 消息通过主题进行分类。主题就类似数据库的表。
- 不同主题的消息是物理隔离的;
- 同一个主题的消息保存在一个或多个 Broker 上。但用户只需指定消息的 Topic 即可生产或消费数据而不必关心数据存于何处。
- 主题有一个或多个分区。
- 分区(Partition):分区是一个有序不变的消息序列,消息以追加的方式写入分区,然后以先入先出的顺序读取。Kafka 通过分区来实现数据冗余和伸缩性。
- 消息偏移量(Offset):表示分区中每条消息的位置信息,是一个单调递增且不变的值。
- 生产者(Producer):生产者是向主题发布新消息的 Kafka 客户端。生产者可以将数据发布到所选择的主题中。生产者负责将记录分配到主题中的哪一个分区中。
- 消费者(Consumer):消费者是从主题订阅新消息的 Kafka 客户端。消费者通过检查消息的偏移量来区分消息是否已读。
- 消费者群组(Consumer Group):多个消费者共同构成的一个群组,同时消费多个分区以实现高并发。
- 每个消费者属于一个特定的消费者群组(可以为每个消费者指定消费者群组,若不指定,则属于默认的群组)。
- 群组中,一个消费者可以消费多个分区。
- 群组中,每个分区只能被指定给一个消费者。
- 再均衡(Rebalance):消费者群组内某个消费者实例挂掉后,其他消费者实例自动重新分配订阅主题分区的过程。分区再均衡是 Kafka 消费者端实现高可用的重要手段。
- Broker - 一个独立的 Kafka 服务器被称为 Broker。Broker 接受来自生产者的消息,为消息设置偏移量,并提交消息到磁盘保存;消费者向 Broker 请求消息,Broker 负责返回已提交的消息。
- 副本(Replica):Kafka 中同一条消息能够被拷贝到多个地方以提供数据冗余,这些地方就是所谓的副本。副本还分为领导者副本和追随者副本,各自有不同的角色划分。副本是在分区层级下的,即每个分区可配置多个副本实现高可用。
Kafka 发行版本
Kafka 主要有以下发行版本:
- Apache Kafka:也称社区版 Kafka。优势在于迭代速度快,社区响应度高,使用它可以让你有更高的把控度;缺陷在于仅提供基础核心组件,缺失一些高级的特性。
- Confluent Kafka:Confluent 公司提供的 Kafka。优势在于集成了很多高级特性且由 Kafka 原班人马打造,质量上有保证;缺陷在于相关文档资料不全,普及率较低,没有太多可供参考的范例。
- CDH/HDP Kafka:大数据云公司提供的 Kafka,内嵌 Apache Kafka。优势在于操作简单,节省运维成本;缺陷在于把控度低,演进速度较慢。
Kafka 重大版本
Kafka 有以下重大版本:
- 0.7 - 只提供了最基础的消息队列功能
- 0.8
- 正式引入了副本机制
- 至少升级到 0.8.2.2
- 0.9
- 增加了基础的安全认证 / 权限功能
- 用 Java 重写了新版本消费者 API
- 引入了 Kafka Connect 组件
- 新版本 Producer API 在这个版本中算比较稳定
- 0.10
- 引入了 Kafka Streams,正式升级成分布式流处理平台
- 至少升级到 0.10.2.2
- 修复了一个可能导致 Producer 性能降低的 Bug
- 0.11
- 提供幂等性 Producer API 以及事务
- 对 Kafka 消息格式做了重构
- 至少升级到 0.11.0.3
- 1.0 和 2.0 - Kafka Streams 的改进
Kafka 服务端使用入门
步骤一、获取 Kafka
下载最新的 Kafka 版本并解压到本地。
1 | $ tar -xzf kafka_2.13-2.7.0.tgz |
步骤二、启动 Kafka 环境
注意:本地必须已安装 Java8
执行以下指令,保证所有服务按照正确的顺序启动:
1 | # Start the ZooKeeper service |
打开另一个终端会话,并执行:
1 | # Start the Kafka broker service |
一旦所有服务成功启动,您就已经成功运行了一个基本的 kafka 环境。
步骤三、创建一个 TOPIC 并存储您的事件
Kafka 是一个分布式事件流处理平台,它可以让您通过各种机制读、写、存储并处理事件(events,也被称为记录或消息)
示例事件包括付款交易,手机的地理位置更新,运输订单,物联网设备或医疗设备的传感器测量等等。 这些事件被组织并存储在主题中(topics)。 简单来说,主题类似于文件系统中的文件夹,而事件是该文件夹中的文件。
因此,在您写入第一个事件之前,您必须先创建一个 Topic。执行以下指令:
1 | $ bin/kafka-topics.sh --create --topic quickstart-events --bootstrap-server localhost:9092 |
所有的 Kafka 命令行工具都有附加可选项:不加任何参数,运行 kafka-topics.sh
命令会显示使用信息。例如,会显示新 Topic 的分区数等细节。
1 | $ bin/kafka-topics.sh --describe --topic quickstart-events --bootstrap-server localhost:9092 |
步骤四、向 Topic 写入 Event
Kafka 客户端和 Kafka Broker 的通信是通过网络读写 Event。一旦收到信息,Broker 会将其以您需要的时间(甚至永久化)、容错化的方式存储。
执行 kafka-console-producer.sh
命令将 Event 写入 Topic。默认,您输入的任意行会作为独立 Event 写入 Topic:
1 | $ bin/kafka-console-producer.sh --topic quickstart-events --bootstrap-server localhost:9092 |
您可以通过
Ctrl-C
在任何时候中断kafka-console-producer.sh
步骤五、读 Event
执行 kafka-console-consumer.sh 以读取写入 Topic 中的 Event
1 | $ bin/kafka-console-consumer.sh --topic quickstart-events --from-beginning --bootstrap-server localhost:9092 |
您可以通过
Ctrl-C
在任何时候中断kafka-console-consumer.sh
由于 Event 被持久化存储在 Kafka 中,因此您可以根据需要任意多次地读取它们。 您可以通过打开另一个终端会话并再次重新运行上一个命令来轻松地验证这一点。
步骤六、通过 KAFKA CONNECT 将数据作为事件流导入/导出
您可能有大量数据,存储在传统的关系数据库或消息队列系统中,并且有许多使用这些系统的应用程序。 通过 Kafka Connect,您可以将来自外部系统的数据持续地导入到 Kafka 中,反之亦然。 因此,将已有系统与 Kafka 集成非常容易。为了使此过程更加容易,有数百种此类连接器可供使用。
需要了解有关如何将数据导入和导出 Kafka 的更多信息,可以参考:Kafka Connect section 章节。
步骤七、使用 Kafka Streams 处理事件
一旦将数据作为 Event 存储在 Kafka 中,就可以使用 Kafka Streams 的 Java / Scala 客户端。它允许您实现关键任务的实时应用程序和微服务,其中输入(和/或)输出数据存储在 Kafka Topic 中。
Kafka Streams 结合了 Kafka 客户端编写和部署标准 Java 和 Scala 应用程序的简便性,以及 Kafka 服务器集群技术的优势,使这些应用程序具有高度的可伸缩性、弹性、容错性和分布式。该库支持一次性处理,有状态的操作,以及聚合、窗口化化操作、join、基于事件时间的处理等等。
1 | KStream<String, String> textLines = builder.stream("quickstart-events"); |
Kafka Streams demo 和 app development tutorial 展示了如何从头到尾的编码并运行一个流式应用。
步骤八、终止 Kafka 环境
- 如果尚未停止,请使用
Ctrl-C
停止生产者和消费者客户端。 - 使用
Ctrl-C
停止 Kafka 代理。 - 最后,使用
Ctrl-C
停止 ZooKeeper 服务器。
如果您还想删除本地 Kafka 环境的所有数据,包括您在此过程中创建的所有事件,请执行以下命令:
1 | $ rm -rf /tmp/kafka-logs /tmp/zookeeper |
Kafka Java 客户端使用入门
引入 maven 依赖
Stream API 的 maven 依赖:
1 | <dependency> |
其他 API 的 maven 依赖:
1 | <dependency> |
Kafka 核心 API
Kafka 有 5 个核心 API
- Producer API - 允许一个应用程序发布一串流式数据到一个或者多个 Kafka Topic。
- Consumer API - 允许一个应用程序订阅一个或多个 Kafka Topic,并且对发布给他们的流式数据进行处理。
- Streams API - 允许一个应用程序作为一个流处理器,消费一个或者多个 Kafka Topic 产生的输入流,然后生产一个输出流到一个或多个 Kafka Topic 中去,在输入输出流中进行有效的转换。
- Connector API - 允许构建并运行可重用的生产者或者消费者,将 Kafka Topic 连接到已存在的应用程序或数据库。例如,连接到一个关系型数据库,捕捉表的所有变更内容。
- Admin API - 支持管理和检查 Topic,Broker,ACL 和其他 Kafka 对象。
发送消息
发送并忽略返回
代码如下,直接通过 send
方法来发送
1 | ProducerRecord<String, String> record = |
同步发送
代码如下,与“发送并忘记”的方式区别在于多了一个 get
方法,会一直阻塞等待 Broker
返回结果:
1 | ProducerRecord<String, String> record = |
异步发送
代码如下,异步方式相对于“发送并忽略返回”的方式的不同在于:在异步返回时可以执行一些操作,如记录错误或者成功日志。
首先,定义一个 callback
1 | private class DemoProducerCallback implements Callback { |
然后,使用这个 callback
1 | ProducerRecord<String, String> record = |
发送消息示例
1 | import java.util.Properties; |
消费消息流程
消费流程
具体步骤如下:
- 创建消费者。
- 订阅主题。除了订阅主题方式外还有使用指定分组的模式,但是常用方式都是订阅主题方式
- 轮询消息。通过 poll 方法轮询。
- 关闭消费者。在不用消费者之后,会执行 close 操作。close 操作会关闭 socket,并触发当前消费者群组的再均衡。
1 | // 1.构建KafkaCustomer |
创建消费者的代码如下:
1 | public Consumer buildCustomer() { |
消费消息方式
分为订阅主题和指定分组两种方式:
- 消费者分组模式。通过订阅主题方式时,消费者必须加入到消费者群组中,即消费者必须有一个自己的分组;
- 独立消费者模式。这种模式就是消费者是独立的不属于任何消费者分组,自己指定消费那些
Partition
。
(1)订阅主题方式
1 | consumer.subscribe(Arrays.asList(topic)); |
(2)独立消费者模式
通过 consumer 的 assign(Collection<TopicPartition> partitions)
方法来为消费者指定分区。
1 | public void consumeMessageForIndependentConsumer(String topic){ |
参考资料
Kafka 运维
Kafka 运维
环境要求:
- JDK8
- ZooKeeper
Kafka 单点部署
下载解压
进入官方下载地址:http://kafka.apache.org/downloads,选择合适版本。
解压到本地:
1 | tar -xzf kafka_2.11-1.1.0.tgz |
现在您已经在您的机器上下载了最新版本的 Kafka。
启动服务器
由于 Kafka 依赖于 ZooKeeper,所以运行前需要先启动 ZooKeeper
1 | $ bin/zookeeper-server-start.sh config/zookeeper.properties |
然后,启动 Kafka
1 | $ bin/kafka-server-start.sh config/server.properties |
停止服务器
执行所有操作后,可以使用以下命令停止服务器
1 | bin/kafka-server-stop.sh config/server.properties |
Kafka 集群部署
修改配置
复制配置为多份(Windows 使用 copy 命令代理):
1 | cp config/server.properties config/server-1.properties |
修改配置:
1 | config/server-1.properties: |
其中,broker.id 这个参数必须是唯一的。
端口故意配置的不一致,是为了可以在一台机器启动多个应用节点。
启动
根据这两份配置启动三个服务器节点:
1 | $ bin/kafka-server-start.sh config/server.properties & |
创建一个新的 Topic 使用 三个备份:
1 | bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 3 --partitions 1 --topic my-replicated-topic |
查看主题:
1 | $ bin/kafka-topics.sh --describe --zookeeper localhost:2181 --topic my-replicated-topic |
- leader - 负责指定分区的所有读取和写入的节点。每个节点将成为随机选择的分区部分的领导者。
- replicas - 是复制此分区日志的节点列表,无论它们是否为领导者,或者即使它们当前处于活动状态。
- isr - 是“同步”复制品的集合。这是副本列表的子集,该列表当前处于活跃状态并且已经被领导者捕获。
Kafka 命令
主题(Topic)
创建 Topic
1 | kafka-topics --create --zookeeper localhost:2181 --replication-factor 1 --partitions 3 --topic my-topic |
查看 Topic 列表
1 | kafka-topics --list --zookeeper localhost:2181 |
添加 Partition
1 | kafka-topics --zookeeper localhost:2181 --alter --topic my-topic --partitions 16 |
删除 Topic
1 | kafka-topics --zookeeper localhost:2181 --delete --topic my-topic |
查看 Topic 详细信息
1 | kafka-topics --zookeeper localhost:2181/kafka-cluster --describe |
查看备份分区
1 | kafka-topics --zookeeper localhost:2181/kafka-cluster --describe --under-replicated-partitions |
生产者(Producers)
通过控制台输入生产消息
1 | kafka-console-producer --broker-list localhost:9092 --topic my-topic |
通过文件输入生产消息
1 | kafka-console-producer --broker-list localhost:9092 --topic test < messages.txt |
通过控制台输入 Avro 生产消息
1 | kafka-avro-console-producer --broker-list localhost:9092 --topic my.Topic --property value.schema='{"type":"record","name":"myrecord","fields":[{"name":"f1","type":"string"}]}' --property schema.registry.url=http://localhost:8081 |
然后,可以选择输入部分 json key:
1 | { "f1": "value1" } |
生成消息性能测试
1 | kafka-producer-perf-test --topic position-reports --throughput 10000 --record-size 300 --num-records 20000 --producer-props bootstrap.servers="localhost:9092" |
消费者(Consumers)
消费所有未消费的消息
1 | kafka-console-consumer --bootstrap-server localhost:9092 --topic my-topic --from-beginning |
消费一条消息
1 | kafka-console-consumer --bootstrap-server localhost:9092 --topic my-topic --max-messages 1 |
从指定的 offset 消费一条消息
从指定的 offset __consumer_offsets
消费一条消息:
1 | kafka-console-consumer --bootstrap-server localhost:9092 --topic __consumer_offsets --formatter 'kafka.coordinator.GroupMetadataManager$OffsetsMessageFormatter' --max-messages 1 |
从指定 Group 消费消息
1 | kafka-console-consumer --topic my-topic --new-consumer --bootstrap-server localhost:9092 --consumer-property group.id=my-group |
消费 avro 消息
1 | kafka-avro-console-consumer --topic position-reports --new-consumer --bootstrap-server localhost:9092 --from-beginning --property schema.registry.url=localhost:8081 --max-messages 10 |
1 | kafka-avro-console-consumer --topic position-reports --new-consumer --bootstrap-server localhost:9092 --from-beginning --property schema.registry.url=localhost:8081 |
查看消费者 Group 列表
1 | kafka-consumer-groups --new-consumer --list --bootstrap-server localhost:9092 |
查看消费者 Group 详细信息
1 | kafka-consumer-groups --bootstrap-server localhost:9092 --describe --group testgroup |
配置(Config)
设置 Topic 的保留时间
1 | kafka-configs --zookeeper localhost:2181 --alter --entity-type topics --entity-name my-topic --add-config retention.ms=3600000 |
查看 Topic 的所有配置
1 | kafka-configs --zookeeper localhost:2181 --describe --entity-type topics --entity-name my-topic |
修改 Topic 的配置
1 | kafka-configs --zookeeper localhost:2181 --alter --entity-type topics --entity-name my-topic --delete-config retention.ms |
ACL
查看指定 Topic 的 ACL
1 | kafka-acls --authorizer-properties zookeeper.connect=localhost:2181 --list --topic topicA |
添加 ACL
1 | kafka-acls --authorizer-properties zookeeper.connect=localhost:2181 --add --allow-principal User:Bob --consumer --topic topicA --group groupA |
1 | kafka-acls --authorizer-properties zookeeper.connect=localhost:2181 --add --allow-principal User:Bob --producer --topic topicA |
ZooKeeper
1 | zookeeper-shell localhost:2182 ls / |
Kafka 工具
Kafka 核心配置
Broker 级别配置
存储配置
首先 Broker 是需要配置存储信息的,即 Broker 使用哪些磁盘。那么针对存储信息的重要参数有以下这么几个:
log.dirs
:指定了 Broker 需要使用的若干个文件目录路径。这个参数是没有默认值的,必须由使用者亲自指定。log.dir
:注意这是 dir,结尾没有 s,说明它只能表示单个路径,它是补充上一个参数用的。
log.dirs
具体格式是一个 CSV 格式,也就是用逗号分隔的多个路径,比如/home/kafka1,/home/kafka2,/home/kafka3
这样。如果有条件的话你最好保证这些目录挂载到不同的物理磁盘上。这样做有两个好处:
- 提升读写性能:比起单块磁盘,多块物理磁盘同时读写数据有更高的吞吐量。
- 能够实现故障转移:即 Failover。这是 Kafka 1.1 版本新引入的强大功能。要知道在以前,只要 Kafka Broker 使用的任何一块磁盘挂掉了,整个 Broker 进程都会关闭。但是自 1.1 开始,这种情况被修正了,坏掉的磁盘上的数据会自动地转移到其他正常的磁盘上,而且 Broker 还能正常工作。
zookeeper 配置
Kafka 与 ZooKeeper 相关的最重要的参数当属 zookeeper.connect
。这也是一个 CSV 格式的参数,比如我可以指定它的值为zk1:2181,zk2:2181,zk3:2181
。2181 是 ZooKeeper 的默认端口。
现在问题来了,如果我让多个 Kafka 集群使用同一套 ZooKeeper 集群,那么这个参数应该怎么设置呢?这时候 chroot 就派上用场了。这个 chroot 是 ZooKeeper 的概念,类似于别名。
如果你有两套 Kafka 集群,假设分别叫它们 kafka1 和 kafka2,那么两套集群的zookeeper.connect
参数可以这样指定:zk1:2181,zk2:2181,zk3:2181/kafka1
和zk1:2181,zk2:2181,zk3:2181/kafka2
。切记 chroot 只需要写一次,而且是加到最后的。我经常碰到有人这样指定:zk1:2181/kafka1,zk2:2181/kafka2,zk3:2181/kafka3
,这样的格式是不对的。
Broker 连接配置
listeners
:告诉外部连接者要通过什么协议访问指定主机名和端口开放的 Kafka 服务。advertised.listeners
:和 listeners 相比多了个 advertised。Advertised 的含义表示宣称的、公布的,就是说这组监听器是 Broker 用于对外发布的。host.name/port
:列出这两个参数就是想说你把它们忘掉吧,压根不要为它们指定值,毕竟都是过期的参数了。
我们具体说说监听器的概念,从构成上来说,它是若干个逗号分隔的三元组,每个三元组的格式为<协议名称,主机名,端口号>
。这里的协议名称可能是标准的名字,比如 PLAINTEXT 表示明文传输、SSL 表示使用 SSL 或 TLS 加密传输等;也可能是你自己定义的协议名字,比如CONTROLLER: //localhost:9092
。
最好全部使用主机名,即 Broker 端和 Client 端应用配置中全部填写主机名。
Topic 管理
auto.create.topics.enable
:是否允许自动创建 Topic。一般设为 false,由运维把控创建 Topic。unclean.leader.election.enable
:是否允许 Unclean Leader 选举。auto.leader.rebalance.enable
:是否允许定期进行 Leader 选举。
第二个参数unclean.leader.election.enable
是关闭 Unclean Leader 选举的。何谓 Unclean?还记得 Kafka 有多个副本这件事吗?每个分区都有多个副本来提供高可用。在这些副本中只能有一个副本对外提供服务,即所谓的 Leader 副本。
那么问题来了,这些副本都有资格竞争 Leader 吗?显然不是,只有保存数据比较多的那些副本才有资格竞选,那些落后进度太多的副本没资格做这件事。
好了,现在出现这种情况了:假设那些保存数据比较多的副本都挂了怎么办?我们还要不要进行 Leader 选举了?此时这个参数就派上用场了。
如果设置成 false,那么就坚持之前的原则,坚决不能让那些落后太多的副本竞选 Leader。这样做的后果是这个分区就不可用了,因为没有 Leader 了。反之如果是 true,那么 Kafka 允许你从那些“跑得慢”的副本中选一个出来当 Leader。这样做的后果是数据有可能就丢失了,因为这些副本保存的数据本来就不全,当了 Leader 之后它本人就变得膨胀了,认为自己的数据才是权威的。
这个参数在最新版的 Kafka 中默认就是 false,本来不需要我特意提的,但是比较搞笑的是社区对这个参数的默认值来来回回改了好几版了,鉴于我不知道你用的是哪个版本的 Kafka,所以建议你还是显式地把它设置成 false 吧。
第三个参数auto.leader.rebalance.enable
的影响貌似没什么人提,但其实对生产环境影响非常大。设置它的值为 true 表示允许 Kafka 定期地对一些 Topic 分区进行 Leader 重选举,当然这个重选举不是无脑进行的,它要满足一定的条件才会发生。严格来说它与上一个参数中 Leader 选举的最大不同在于,它不是选 Leader,而是换 Leader!比如 Leader A 一直表现得很好,但若auto.leader.rebalance.enable=true
,那么有可能一段时间后 Leader A 就要被强行卸任换成 Leader B。
你要知道换一次 Leader 代价很高的,原本向 A 发送请求的所有客户端都要切换成向 B 发送请求,而且这种换 Leader 本质上没有任何性能收益,因此我建议你在生产环境中把这个参数设置成 false。
数据留存
log.retention.{hour|minutes|ms}
:都是控制一条消息数据被保存多长时间。从优先级上来说 ms 设置最高、minutes 次之、hour 最低。通常情况下我们还是设置 hour 级别的多一些,比如log.retention.hour=168
表示默认保存 7 天的数据,自动删除 7 天前的数据。很多公司把 Kafka 当做存储来使用,那么这个值就要相应地调大。log.retention.bytes
:这是指定 Broker 为消息保存的总磁盘容量大小。这个值默认是 -1,表明你想在这台 Broker 上保存多少数据都可以,至少在容量方面 Broker 绝对为你开绿灯,不会做任何阻拦。这个参数真正发挥作用的场景其实是在云上构建多租户的 Kafka 集群:设想你要做一个云上的 Kafka 服务,每个租户只能使用 100GB 的磁盘空间,为了避免有个“恶意”租户使用过多的磁盘空间,设置这个参数就显得至关重要了。message.max.bytes
:控制 Broker 能够接收的最大消息大小。默认的 1000012 太少了,还不到 1MB。实际场景中突破 1MB 的消息都是屡见不鲜的,因此在线上环境中设置一个比较大的值还是比较保险的做法。毕竟它只是一个标尺而已,仅仅衡量 Broker 能够处理的最大消息大小,即使设置大一点也不会耗费什么磁盘空间的。
Topic 级别配置
retention.ms
:规定了该 Topic 消息被保存的时长。默认是 7 天,即该 Topic 只保存最近 7 天的消息。一旦设置了这个值,它会覆盖掉 Broker 端的全局参数值。retention.bytes
:规定了要为该 Topic 预留多大的磁盘空间。和全局参数作用相似,这个值通常在多租户的 Kafka 集群中会有用武之地。当前默认值是 -1,表示可以无限使用磁盘空间。
操作系统参数
- 文件描述符限制
- 文件系统类型
- Swappiness
- 提交时间
文件描述符系统资源并不像我们想象的那样昂贵,你不用太担心调大此值会有什么不利的影响。通常情况下将它设置成一个超大的值是合理的做法,比如ulimit -n 1000000
。其实设置这个参数一点都不重要,但不设置的话后果很严重,比如你会经常看到“Too many open files”的错误。
其次是文件系统类型的选择。这里所说的文件系统指的是如 ext3、ext4 或 XFS 这样的日志型文件系统。根据官网的测试报告,XFS 的性能要强于 ext4,所以生产环境最好还是使用 XFS。对了,最近有个 Kafka 使用 ZFS 的数据报告,貌似性能更加强劲,有条件的话不妨一试。
第三是 swap 的调优。网上很多文章都提到设置其为 0,将 swap 完全禁掉以防止 Kafka 进程使用 swap 空间。我个人反倒觉得还是不要设置成 0 比较好,我们可以设置成一个较小的值。为什么呢?因为一旦设置成 0,当物理内存耗尽时,操作系统会触发 OOM killer 这个组件,它会随机挑选一个进程然后 kill 掉,即根本不给用户任何的预警。但如果设置成一个比较小的值,当开始使用 swap 空间时,你至少能够观测到 Broker 性能开始出现急剧下降,从而给你进一步调优和诊断问题的时间。基于这个考虑,我个人建议将 swappniess 配置成一个接近 0 但不为 0 的值,比如 1。
最后是提交时间或者说是 Flush 落盘时间。向 Kafka 发送数据并不是真要等数据被写入磁盘才会认为成功,而是只要数据被写入到操作系统的页缓存(Page Cache)上就可以了,随后操作系统根据 LRU 算法会定期将页缓存上的“脏”数据落盘到物理磁盘上。这个定期就是由提交时间来确定的,默认是 5 秒。一般情况下我们会认为这个时间太频繁了,可以适当地增加提交间隔来降低物理磁盘的写操作。当然你可能会有这样的疑问:如果在页缓存中的数据在写入到磁盘前机器宕机了,那岂不是数据就丢失了。的确,这种情况数据确实就丢失了,但鉴于 Kafka 在软件层面已经提供了多副本的冗余机制,因此这里稍微拉大提交间隔去换取性能还是一个合理的做法。
Kafka 集群规划
操作系统
部署生产环境的 Kafka,强烈建议操作系统选用 Linux。
在 Linux 部署 Kafka 能够享受到零拷贝技术所带来的快速数据传输特性。
Windows 平台上部署 Kafka 只适合于个人测试或用于功能验证,千万不要应用于生产环境。
磁盘
Kafka 集群部署选择普通的机械磁盘还是固态硬盘?前者成本低且容量大,但易损坏;后者性能优势大,不过单价高。
结论是:使用普通机械硬盘即可。
Kafka 采用顺序读写操作,一定程度上规避了机械磁盘最大的劣势,即随机读写操作慢。从这一点上来说,使用 SSD 似乎并没有太大的性能优势,毕竟从性价比上来说,机械磁盘物美价廉,而它因易损坏而造成的可靠性差等缺陷,又由 Kafka 在软件层面提供机制来保证,故使用普通机械磁盘是很划算的。
带宽
大部分公司使用普通的以太网络,千兆网络(1Gbps)应该是网络的标准配置。
通常情况下你只能假设 Kafka 会用到 70% 的带宽资源,因为总要为其他应用或进程留一些资源。此外,通常要再额外预留出 2/3 的资源,因为不能让带宽资源总是保持在峰值。
基于以上原因,一个 Kafka 集群数量的大致推算公式如下:
1 | Kafka 机器数 = 单位时间需要处理的总数据量 / 单机所占用带宽 |