Dunwu Blog

大道至简,知易行难

Elasticsearch 聚合

::: info 概述

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

Elasticsearch 将聚合分为三类:

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

本文将逐一介绍这几种聚合方式的用法和特性。

:::

阅读全文 »

Elasticsearch 搜索(下)

Elasticsearch 提供了基于 JSON 的 DSL(Domain Specific Language)来定义查询。

可以将 DSL 视为查询的 AST(抽象语法树),由两种类型的子句组成:

  • 叶子查询 - 在指定字段中查找特定值,例如:matchtermrange
  • 组合查询 - 组合其他叶子查询或组合查询,用于以逻辑方式组合多个查询(例如: booldis_max),或更改它们的行为(例如:constant_score)。

查询子句的行为会有所不同,具体取决于它们是在 query content 还是 filter context 中使用。

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

从用法角度,Elasticsearch 查询分类大致分为:

全文查询

Full Text Search(全文搜索) 支持在非结构化文本数据中搜索与查询关键字最匹配的数据。

在 ES 中,支持以下全文搜索方式:

  • 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 语法版本,适合直接向用户公开。

match(匹配查询)

match 查询用于搜索单个字段。首先,会针对检索文本进行解析(分词),分词后的任何一个词项只要被匹配,文档就会被搜到。默认情况下,相当于对分词后词项进行 or 匹配操作。

:::details match 示例

1
2
3
4
5
6
7
8
9
10
GET kibana_sample_data_ecommerce/_search
{
"query": {
"match": {
"customer_full_name": {
"query": "George Hubbard"
}
}
}
}

响应结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"took": 891, // 查询使用的毫秒数
"timed_out": false, // 是否有分片超时,也就是说是否只返回了部分结果
"_shards": {
// 总分片数、响应成功/失败数量信息
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
// 搜索结果
"total": {
// 匹配的总记录数
"value": 82,
"relation": "eq"
},
"max_score": 10.018585, // 所有匹配文档中的最大相关性分值
"hits": [
// 匹配文档列表
{
"_index": "kibana_sample_data_ecommerce", // 文档所属索引
"_type": "_doc", // 文档所属 type
"_id": "2ZUtBX4BU8KXl1YJRBrH", // 文档的唯一性标识
"_score": 10.018585, // 文档的相关性分值
"_source": {
// 文档的原始 JSON 对象
// 略
}
}
// 省略多条记录
]
}
}

:::

可以通过组合 <field>query 参数来简化匹配查询语法。下面是一个简单的示例。

:::details match 简写示例

下面的查询等价于前面的匹配查询示例:

1
2
3
4
5
6
7
8
GET kibana_sample_data_ecommerce/_search
{
"query": {
"match": {
"customer_full_name": "George Hubbard"
}
}
}

:::

在进行全文本字段检索的时候, match API 提供了 operator 和 minimum_should_match 参数:

  • operator 参数值可以为 “or” 或者 “and” 来控制检索词项间的关系,默认值为 “or”。所以上面例子中,只要书名中含有 “linux” 或者 “architecture” 的文档都可以匹配上。
  • minimum_should_match 可以指定词项的最少匹配个数,其值可以指定为某个具体的数字,但因为我们无法预估检索内容的词项数量,一般将其设置为一个百分比。

:::details minimum_should_match 示例

至少有 50% 的词项匹配的文档才会被返回:

1
2
3
4
5
6
7
8
9
10
11
12
GET kibana_sample_data_ecommerce/_search
{
"query": {
"match": {
"category": {
"query": "Women Clothing Accessories",
"operator": "or",
"minimum_should_match": "50%"
}
}
}
}

:::

match 查询提供了 fuzziness 参数,fuzziness 允许基于被查询字段的类型进行模糊匹配。请参阅 Fuzziness 的配置。

在这种情况下可以设置 prefix_lengthmax_expansions 来控制模糊匹配。如果设置了模糊选项,查询将使用 top_terms_blended_freqs_${max_expansions} 作为其重写方法,fuzzy_rewrite 参数允许控制查询将如何被重写。

默认情况下允许模糊倒转 (abba),但可以通过将 fuzzy_transpositions 设置为 false 来禁用。

:::details fuzziness 示例

1
2
3
4
5
6
7
8
9
10
11
GET kibana_sample_data_ecommerce/_search
{
"query": {
"match": {
"customer_first_name": {
"query": "Gearge",
"fuzziness": "AUTO"
}
}
}
}

:::

如果使用的分析器像 stop 过滤器一样删除查询中的所有标记,则默认行为是不匹配任何文档。可以使用 zero_terms_query 选项来改变默认行为,它接受 none(默认)和 all (相当于 match_all 查询)。

:::details zero_terms_query 示例

1
2
3
4
5
6
7
8
9
10
11
12
GET kibana_sample_data_logs/_search
{
"query": {
"match": {
"message": {
"query": "Mozilla Linux",
"operator": "and",
"zero_terms_query": "all"
}
}
}
}

:::

match_phrase(短语匹配查询)

match_phrase 查询首先会对检索内容进行分词,分词器可以自定义,同时文档还要满足以下两个条件才会被搜索到:

  1. 分词后所有词项都要出现在该字段中(相当于 and 操作)
  2. 字段中的词项顺序要一致

:::details match_phrase 示例

1
2
3
4
5
6
7
8
9
10
GET kibana_sample_data_logs/_search
{
"query": {
"match_phrase": {
"agent": {
"query": "Linux x86_64"
}
}
}
}

:::

match_phrase_prefix(短语前缀匹配查询)

查询和 match_phrase 查询类似,只不过 match_phrase_prefix 最后一个 term 会被作为前缀匹配。

:::details match_phrase_prefix 示例

匹配以 https://www.elastic.co/download 开头的短语

1
2
3
4
5
6
7
8
9
10
GET kibana_sample_data_logs/_search
{
"query": {
"match_phrase_prefix": {
"url": {
"query": "https://www.elastic.co/download"
}
}
}
}

:::

multi_match(多字段匹配查询)

multi_match 查询允许对多个字段执行相同的匹配查询。

multi_match 查询在内部执行的方式取决于 type 参数,可以设置为:

  • best_fields -(默认)将所有与查询匹配的文档作为结果返回,但是只使用评分最高的字段的评分来作为评分结果返回。
  • most_fields - 将所有与查询匹配的文档作为结果返回,并将所有匹配字段的评分累加起来作为评分结果。
  • cross_fields - 将具有相同分析器的字段视为一个大字段。在每个字段中查找每个单词。例如当需要查询英文人名的时候,可以将 first_name 和 last_name 两个字段组合起来当作 full_name 来查询。
  • phrase - 对每个字段运行 match_phrase 查询,并将最佳匹配字段的评分作为结果返回。
  • phrase_prefix - 对每个字段运行 match_phrase_prefix 查询,并将最佳匹配字段的评分作为结果返回。
  • bool_prefix - 在每个字段上创建一个 match_bool_prefix 查询,并且合并每个字段的评分作为评分结果。

:::details multi_match 示例

1
2
3
4
5
6
7
8
9
10
11
12
GET kibana_sample_data_ecommerce/_search
{
"query": {
"multi_match": {
"query": 34.98,
"fields": [
"taxful_total_price",
"taxless_total_*" # 可以使用通配符
]
}
}
}

:::

词项级别查询

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

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

词项查询有以下类型:

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

exists(字段不为空查询)

exists 返回在指定字段上有值的文档。

由于多种原因,文档字段可能不存在索引值:

  • JSON 中的字段为 null[]
  • 该字段在 mapping 中配置了 "index" : false
  • 字段值的长度超过了 mapping 中的 ignore_above 设置
  • 字段值格式错误,并且在 mapping 中定义了 ignore_malformed

:::details exists 示例

1
2
3
4
5
6
7
8
GET kibana_sample_data_ecommerce/_search
{
"query": {
"exists": {
"field": "email"
}
}
}

:::

fuzzy(模糊查询)

fuzzy 返回包含与搜索词相似的词的文档。ES 使用 Levenshtein edit distance(Levenshtein 编辑距离) 测量相似度或模糊度。

编辑距离是将一个术语转换为另一个术语所需的单个字符更改的数量。这些变化可能包括:

  • 改变一个字符:(box -> fox)
  • 删除一个字符:(black -> lack)
  • 插入一个字符:(sic -> sick
  • 反转两个相邻字符:(act → cat)

为了找到相似的词条,fuzzy query 会在指定的编辑距离内创建搜索词条的所有可能变体或扩展集。然后返回完全匹配任意扩展的文档。

注意:如果配置了 search.allow_expensive_queries ,则 fuzzy query 不能执行。

:::details fuzzy 示例

1
2
3
4
5
6
7
8
9
10
GET kibana_sample_data_ecommerce/_search
{
"query": {
"fuzzy": {
"customer_full_name": {
"value": "mary"
}
}
}
}

:::

prefix(前缀查询)

prefix 用于查询某个字段中包含指定前缀的文档。

比如查询 user.id 中含有以 ki 为前缀的关键词的文档,那么含有 kindkid 等所有以 ki 开头关键词的文档都会被匹配。

:::details prefix 示例

1
2
3
4
5
6
7
8
9
10
GET kibana_sample_data_ecommerce/_search
{
"query": {
"prefix": {
"customer_full_name": {
"value": "mar"
}
}
}
}

:::

range(范围查询)

range 用于匹配在某一范围内的数值型、日期类型或者字符串型字段的文档。比如搜索哪些书籍的价格在 50 到 100 之间、哪些书籍的出版时间在 2015 年到 2019 年之间。使用 range 查询只能查询一个字段,不能作用在多个字段上

range 查询支持的参数有以下几种:

  • gt - 大于
  • gte - 大于等于
  • lt - 小于
  • lte - 小于等于
  • format - 如果字段是 Date 类型,可以设置日期格式化
  • time_zone - 时区
  • relation - 指示范围查询如何匹配范围字段的值。
    • INTERSECTS (Default) - 匹配与查询字段值范围相交的文档。
    • CONTAINS - 匹配完全包含查询字段值的文档。
    • WITHIN - 匹配具有完全在查询范围内的范围字段值的文档。

:::details range 示例

数值范围查询示例:

1
2
3
4
5
6
7
8
9
10
11
GET kibana_sample_data_ecommerce/_search
{
"query": {
"range": {
"taxful_total_price": {
"gt": 10,
"lte": 50
}
}
}
}

日期范围查询示例:

1
2
3
4
5
6
7
8
9
10
11
12
GET kibana_sample_data_ecommerce/_search
{
"query": {
"range": {
"order_date": {
"time_zone": "+00:00",
"gte": "2018-01-01T00:00:00",
"lte": "now"
}
}
}
}

:::

regexp(正则匹配查询)

regexp 返回与正则表达式相匹配的词项所属的文档。正则表达式 是一种使用占位符字符匹配数据模式的方法,称为运算符。

注意:如果配置了 search.allow_expensive_queries ,则 regexp query 会被禁用。

:::details regexp 示例

1
2
3
4
5
6
7
8
GET kibana_sample_data_ecommerce/_search
{
"query": {
"regexp": {
"email": ".*@.*-family.zzz"
}
}
}

:::

term(词项匹配查询)

term 用来查找指定字段中包含给定单词的文档,term 查询不被解析,只有查询词和文档中的词精确匹配才会被搜索到,应用场景为查询人名、地名等需要精准匹配的需求。

注意:应避免 term 查询对 text 字段使用查询。默认情况下,Elasticsearch 针对 text 字段的值进行解析分词,这会使查找 text 字段值的精确匹配变得困难。要搜索 text 字段值,需改用 match 查询。

:::details term 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 1. 创建一个索引
DELETE my-index-000001
PUT my-index-000001
{
"mappings": {
"properties": {
"full_text": { "type": "text" }
}
}
}

# 2. 使用 "Quick Brown Foxes!" 关键字查 "full_text" 字段
PUT my-index-000001/_doc/1
{
"full_text": "Quick Brown Foxes!"
}

# 3. 使用 term 查询
GET my-index-000001/_search?pretty
{
"query": {
"term": {
"full_text": "Quick Brown Foxes!"
}
}
}
# 因为 full_text 字段不再包含确切的 Term —— "Quick Brown Foxes!",所以 term query 搜索不到任何结果

# 4. 使用 match 查询
GET my-index-000001/_search?pretty
{
"query": {
"match": {
"full_text": "Quick Brown Foxes!"
}
}
}

DELETE my-index-000001

:warning: 注意:应避免 term 查询对 text 字段使用查询。

默认情况下,Elasticsearch 针对 text 字段的值进行解析分词,这会使查找 text 字段值的精确匹配变得困难。

要搜索 text 字段值,需改用 match 查询。

:::

terms(多词项匹配查询)

termsterm 相同,但可以搜索多个值。

terms query 查询参数:

  • **index**:索引名
  • **id**:文档 ID
  • **path**:要从中获取字段值的字段的名称,即搜索关键字
  • **routing**(选填):要从中获取 term 值的文档的自定义路由值。如果在索引文档时提供了自定义路由值,则此参数是必需的。

:::details terms 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 1. 创建一个索引
DELETE my-index-000001
PUT my-index-000001
{
"mappings": {
"properties": {
"color": { "type": "keyword" }
}
}
}

# 2. 写入一个文档
PUT my-index-000001/_doc/1
{
"color": [
"blue",
"green"
]
}

# 3. 写入另一个文档
PUT my-index-000001/_doc/2
{
"color": "blue"
}

# 3. 使用 terms query
GET my-index-000001/_search?pretty
{
"query": {
"terms": {
"color": {
"index": "my-index-000001",
"id": "2",
"path": "color"
}
}
}
}

DELETE my-index-000001

:::

wildcard(通配符查询)

wildcard 即通配符查询,返回与通配符模式匹配的文档。

? 用来匹配一个任意字符,* 用来匹配零个或者多个字符。

注意:如果配置了 search.allow_expensive_queries ,则 wildcard query 会被禁用。

:::details wildcard 示例

示例:以下搜索返回 user.id 字段包含以 ki 开头并以 y 结尾的术语的文档。这些匹配项可以包括 kiykitykimchy

1
2
3
4
5
6
7
8
9
10
11
12
GET kibana_sample_data_ecommerce/_search
{
"query": {
"wildcard": {
"email": {
"value": "*@underwood-family.zzz",
"boost": 1,
"rewrite": "constant_score"
}
}
}
}

:::

复合查询

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

复合查询有以下类型:

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

bool (布尔查询)

bool 查询可以把任意多个简单查询组合在一起,使用 mustshouldmust_notfilter 选项来表示简单查询之间的逻辑,每个选项都可以出现 0 次到多次,它们的含义如下:

  • must - 文档必须匹配 must 选项下的查询条件,相当于逻辑运算的 AND,且参与文档相关度的评分。
  • should - 文档可以匹配 should 选项下的查询条件也可以不匹配,相当于逻辑运算的 OR,且参与文档相关度的评分。
  • must_not - 与 must 相反,匹配该选项下的查询条件的文档不会被返回;需要注意的是,must_not 语句不会影响评分,它的作用只是将不相关的文档排除
  • filter - 和 must 一样,匹配 filter 选项下的查询条件的文档才会被返回,但是 filter 不评分,只起到过滤功能,与 must_not 相反

:::details bool 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
GET kibana_sample_data_ecommerce/_search
{
"query": {
"bool": {
"filter": {
"term": {
"type": "order"
}
},
"must_not": {
"range": {
"taxful_total_price": {
"gte": 30
}
}
},
"should": [
{
"match": {
"day_of_week": "Sunday"
}
},
{
"match": {
"category": "Clothing"
}
}
],
"minimum_should_match": 1
}
}
}

:::

boosting

boosting 提供了调整相关性打分的能力。

boosting 查询包括 positivenegativenegative_boost 三个部分。positive 中的查询评分保持不变;negative 中的查询会降低文档评分;相关性算分降低的程度将由 negative_boost 参数决定,其取值范围为:[0.0, 1.0]

:::details boosting 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GET kibana_sample_data_ecommerce/_search
{
"query": {
"boosting": {
"positive": {
"term": {
"day_of_week": "Monday"
}
},
"negative": {
"range": {
"taxful_total_price": {
"gte": "30"
}
}
},
"negative_boost": 0.2
}
}
}

:::

constant_score

使用 constant_score 可以将 query 转化为 filter,可以忽略相关性算分的环节,并且 filter 可以有效利用缓存,从而提高查询的性能。

:::details constant_score 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
GET kibana_sample_data_ecommerce/_search
{
"query": {
"constant_score": {
"filter": {
"term": {
"day_of_week": "Monday"
}
},
"boost": 1.2
}
}
}

:::

dis_max

dis_max 查询与 bool 查询有一定联系也有一定区别,dis_max 查询支持多并发查询,可返回与任意查询条件子句匹配的任何文档类型。与 bool 查询可以将所有匹配查询的分数相结合使用的方式不同,dis_max 查询只使用最佳匹配查询条件的分数。

:::details dis_max 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
GET kibana_sample_data_ecommerce/_search
{
"query": {
"dis_max": {
"queries": [
{
"term": {
"currency": "EUR"
}
},
{
"term": {
"day_of_week": "Sunday"
}
}
],
"tie_breaker": 0.7
}
}
}

:::

function_score

function_score 查询可以修改查询的文档得分,这个查询在有些情况下非常有用,比如通过评分函数计算文档得分代价较高,可以改用过滤器加自定义评分函数的方式来取代传统的评分方式。

使用 function_score 查询,用户需要定义一个查询和一至多个评分函数,评分函数会对查询到的每个文档分别计算得分。

function_score 查询提供了以下几种算分函数:

  • script_score - 利用自定义脚本完全控制算分逻辑。
  • weight - 为每一个文档设置一个简单且不会被规范化的权重。
  • random_score - 为每个用户提供一个不同的随机算分,对结果进行排序。
  • field_value_factor - 使用文档字段的值来影响算分,例如将好评数量这个字段作为考虑因数。
  • decay functions - 衰减函数,以某个字段的值为标准,距离指定值越近,算分就越高。例如我想让书本价格越接近 10 元,算分越高排序越靠前。

:::details function_score 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
GET kibana_sample_data_ecommerce/_search
{
"query": {
"function_score": {
"query": { "match_all": {} },
"boost": "5",
"functions": [
{
"filter": { "match": { "day_of_week": "Sunday" } },
"random_score": {},
"weight": 23
},
{
"filter": { "match": { "day_of_week": "Monday" } },
"weight": 42
}
],
"max_boost": 42,
"score_mode": "max",
"boost_mode": "multiply",
"min_score": 42
}
}
}

:::

推荐搜索

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

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

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

Term Suggester

Term Suggester 提供了基于单词的纠错、补全功能,其工作原理是基于编辑距离(edit distance)来运作的,编辑距离的核心思想是一个词需要改变多少个字符就可以和另一个词一致。所以如果一个词转化为原词所需要改动的字符数越少,它越有可能是最佳匹配。

:::details term suggester 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET kibana_sample_data_ecommerce/_search
{
"query": {
"match": {
"day_of_week": "Sund"
}
},
"suggest": {
"my_suggest": {
"text": "Sund",
"term": {
"suggest_mode": "missing",
"field": "day_of_week"
}
}
}
}

响应结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"took": 2,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
// 略
},
"suggest": {
"my_suggest": [
{
"text": "Sund",
"offset": 0,
"length": 4,
"options": [
{
"text": "Sunday",
"score": 0.5,
"freq": 614
}
]
}
]
}
}

:::

Term Suggester API 有很多参数,比较常用的有以下几个:

  • text - 指定了需要产生建议的文本,一般是用户的输入内容。
  • field - 指定从文档的哪个字段中获取建议。
  • suggest_mode - 设置建议的模式。其值有以下几个选项:
    • missing - 如果索引中存在就不进行建议,默认的选项。
    • popular - 推荐出现频率更高的词。
    • always - 不管是否存在,都进行建议。
  • analyzer - 指定分词器来对输入文本进行分词,默认与 field 指定的字段设置的分词器一致。
  • size - 为每个单词提供的最大建议数量。
  • sort - 建议结果排序的方式,有以下两个选项 -
    • score - 先按相似性得分排序,然后按文档频率排序,最后按词项本身(字母顺序的等)排序。
    • frequency - 先按文档频率排序,然后按相似性得分排序,最后按词项本身排序。

Phrase Suggester

Phrase Suggester 在 Term Suggester 的基础上增加了一些额外的逻辑,因为是短语形式的建议,所以会考量多个 term 间的关系,比如相邻的程度、词频等

:::details phrase suggester 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GET kibana_sample_data_logs/_search
{
"suggest": {
"text": "Firefix",
"simple_phrase": {
"phrase": {
"field": "agent",
"direct_generator": [ {
"field": "agent",
"suggest_mode": "always"
} ],
"highlight": {
"pre_tag": "<em>",
"post_tag": "</em>"
}
}
}
}
}

响应结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"took" : 2,
"timed_out" : false,
"_shards" : // 略
"hits" : // 略
"suggest" : {
"simple_phrase" : [
{
"text" : "Firefix",
"offset" : 0,
"length" : 7,
"options" : [
{
"text" : "firefox",
"highlighted" : "<em>firefox</em>",
"score" : 0.2000096
}
]
}
]
}
}

:::

Phrase Suggester 可用的参数也是比较多的,下面介绍几个用得比较多的参数选项 -

  • max_error - 指定最多可以拼写错误的词语的个数。
  • confidence - 其作用是用来控制返回结果条数的。如果用户输入的数据(短语)得分为 N,那么返回结果的得分需要大于 N * confidenceconfidence 默认值为 1.0。
  • highlight - 高亮被修改后的词语。

Completion Suggester

Completion Suggester 提供了自动补全的功能

Completion Suggester 在实现的时候会将 analyze(将文本分词,并且去除没用的词语,例如 is、at 这样的词语) 后的数据进行编码,构建为 FST 并且和索引存放在一起。FST(**finite-state transducer**)是一种高效的前缀查询索引。由于 FST 天生为前缀查询而生,所以其非常适合实现自动补全的功能。ES 会将整个 FST 加载到内存中,所以在使用 FST 进行前缀查询的时候效率是非常高效的。

在使用 Completion Suggester 前需要定义 Mapping,对应的字段需要使用 “completion” type。

:::details completion suggester 示例

构造用于自动补全的测试数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 先删除原来的索引
DELETE music

# 新增 type 字段,类型为 completion,用于自动补全测试
PUT music
{
"mappings": {
"properties": {
"suggest": {
"type": "completion"
}
}
}
}

# 添加推荐
PUT music/_doc/1?refresh
{
"suggest" : {
"input": [ "Nevermind", "Nirvana" ],
"weight" : 34
}
}
PUT music/_doc/1?refresh
{
"suggest": [
{
"input": "Nevermind",
"weight": 10
},
{
"input": "Nirvana",
"weight": 3
}
]
}

获取推荐:

1
2
3
4
5
6
7
8
9
10
11
12
13
POST music/_search
{
"suggest": {
"song-suggest": {
"prefix": "nir",
"completion": {
"field": "suggest",
"size": 5,
"skip_duplicates": true
}
}
}
}

:::

Context Suggester

Context Suggester 是 Completion Suggester 的扩展,可以实现上下文感知推荐

ES 支持两种类型的上下文:

  • Category:任意字符串的分类。
  • Geo:地理位置信息。

在使用 Context Suggester 前需要定义 Mapping,然后在数据中加入相关的 Context 信息。

:::details context suggester 示例

构造用于 Context Suggester 的测试数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# 删除原来的索引
DELETE books_context

# 创建用于测试 Context Suggester 的索引
PUT books_context
{
"mappings": {
"properties": {
"book_id": {
"type": "keyword"
},
"name": {
"type": "text",
"analyzer": "standard"
},
"name_completion": {
"type": "completion",
"contexts": [
{
"name": "book_type",
"type": "category"
}
]
},
"author": {
"type": "keyword"
},
"intro": {
"type": "text"
},
"price": {
"type": "double"
},
"date": {
"type": "date"
}
}
},
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1
}
}

# 导入测试数据
PUT books_context/_doc/4
{
"book_id": "4ee82465",
"name": "Linux Programming",
"name_completion": {
"input": ["Linux Programming"],
"contexts": {
"book_type": "program"
}
},
"author": "Richard Stones",
"intro": "Happy to Linux Programming",
"price": 10.9,
"date": "2022-06-01"
}
PUT books_context/_doc/5
{
"book_id": "4ee82466",
"name": "Linus Autobiography",
"name_completion": {
"input": ["Linus Autobiography"],
"contexts": {
"book_type": "autobiography"
}
},
"author": "Linus",
"intro": "Linus Autobiography",
"price": 14.9,
"date": "2012-06-01"
}

执行 Context Suggester 查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST books_context/_search
{
"suggest": {
"my_suggest": {
"prefix": "linu",
"completion": {
"field": "name_completion",
"contexts": {
"book_type": "autobiography"
}
}
}
}
}

:::

参考资料

Spring Bean

在 Spring 中,构成应用程序主体由 Spring IoC 容器管理的对象称为 Bean。Bean 是由 Spring IoC 容器实例化、装配和管理的对象。 Bean 以及它们之间的依赖关系反映在容器使用的配置元数据中。

Spring Bean 定义

BeanDefinition

Spring IoC 容器本身,并不能识别配置的元数据。为此,要将这些配置信息转为 Spring 能识别的格式——BeanDefinition 对象。

BeanDefinition 是 Spring 中定义 Bean 的配置元信息接口,它包含:

  • Bean 类名
  • Bean 行为配置元素,如:作用域、自动绑定的模式、生命周期回调等
  • 其他 Bean 引用,也可称为合作者(Collaborators)或依赖(Dependencies)
  • 配置设置,如 Bean 属性(Properties)

BeanDefinition 元信息

BeanDefinition 元信息如下:

属性(Property) 说明
Class 全类名,必须是具体类,不能用抽象类或接口
Name Bean 的名称或者 ID
Scope Bean 的作用域(如:singletonprototype 等)
Constructor arguments Bean 构造器参数(用于依赖注入)
Properties Bean 属性设置(用于依赖注入)
Autowiring mode Bean 自动绑定模式(如:通过名称 byName)
Lazy initialization mode Bean 延迟初始化模式(延迟和非延迟)
Initialization method Bean 初始化回调方法名称
Destruction method Bean 销毁回调方法名称

BeanDefinition 构建

BeanDefinition 构建方式:

  • 通过 BeanDefinitionBuilder

  • 通过 AbstractBeanDefinition 以及派生类

💻 Spring Bean 定义示例源码:BeanDefinitionTests

Spring Bean 命名

Spring Bean 命名规则

每个 Bean 拥有一个或多个标识符(identifiers),这些标识符在 Bean 所在的容器必须是唯一的。通常,一个 Bean 仅有一个标识符,如果需要额外的,可考虑使用别名(Alias)来扩充。

在基于 XML 的配置元信息中,开发人员可以使用 id 属性、name 属性或来指定 Bean 标识符。通常,Bean 的标识符由字母组成,允许出现特殊字符。如果要想引入 Bean 的别名的话,可在 name 属性使用半角逗号(“,”)或分号(“;”) 来间隔。

Spring 中,为 Bean 指定 idname 属性不是必须的。如果不指定,Spring 会自动为 Bean 分配一个唯一的名称。尽管 Bean 的命名没有限制,不过官方建议采用驼峰命名法来命名 Bean

Spring Bean 命名生成器

Spring 提供了两种 Spring Bean 命名生成器:

  • DefaultBeanNameGenerator:默认通用 BeanNameGenerator 实现。
  • AnnotationBeanNameGenerator:基于注解扫描的 BeanNameGenerator 实现。
1
2
3
public interface BeanNameGenerator {
String generateBeanName(BeanDefinition definition, BeanDefinitionRegistry registry);
}

Spring Bean 别名

Spring 支持通过 <alias> 属性为 Bean 设置别名。

Bean 别名(Alias)的作用:

  • 复用现有的 BeanDefinition
  • 更具有场景化的命名方法,比如:
    • <alias name="myApp-dataSource" alias="subsystemA-dataSource"/>
    • <alias name="myApp-dataSource" alias="subsystemB-dataSource"/>
1
2
3
4
<bean id="user" class="io.github.dunwu.spring.core.bean.entity.person.User">
<!-- 属性略 -->
</bean>
<alias name="user" alias="aliasUser" />

Spring Bean 生命周期

  1. Spring 对 Bean 进行实例化(相当于 new XXX())

  2. Spring 将值和引用注入到 Bean 对应的属性中

  3. 如果 Bean 实现了 BeanNameAware 接口,Spring 将 Bean 的 ID 传递给 setBeanName 方法

    • 作用是通过 Bean 的引用来获得 Bean ID,一般业务中是很少有用到 Bean 的 ID 的
  4. 如果 Bean 实现了 BeanFactoryAware 接口,Spring 将调用 setBeanDactory 方法,并把 BeanFactory 容器实例作为参数传入。

    • 作用是获取 Spring 容器,如 Bean 通过 Spring 容器发布事件等
  5. 如果 Bean 实现了 ApplicationContextAware 接口,Spring 容器将调用 setApplicationContext 方法,把应用上下文作为参数传入

    • 作用与 BeanFactory 类似都是为了获取 Spring 容器,不同的是 Spring 容器在调用 setApplicationContext 方法时会把它自己作为 setApplicationContext 的参数传入,而 Spring 容器在调用 setBeanFactory 前需要使用者自己指定(注入)setBeanFactory 里的参数 BeanFactory
  6. 如果 Bean 实现了 BeanPostProcess 接口,Spring 将调用 postProcessBeforeInitialization 方法

    • 作用是在 Bean 实例创建成功后对其进行增强处理,如对 Bean 进行修改,增加某个功能
  7. 如果 Bean 实现了 InitializingBean 接口,Spring 将调用 afterPropertiesSet 方法,作用与在配置文件中对 Bean 使用 init-method 声明初始化的作用一样,都是在 Bean 的全部属性设置成功后执行的初始化方法。

  8. 如果 Bean 实现了 BeanPostProcess 接口,Spring 将调用 postProcessAfterInitialization 方法

    • postProcessBeforeInitialization 是在 Bean 初始化前执行的,而 postProcessAfterInitialization 是在 Bean 初始化后执行的
  9. 经过以上的工作后,Bean 将一直驻留在应用上下文中给应用使用,直到应用上下文被销毁

  10. 如果 Bean 实现了 DispostbleBean 接口,Spring 将调用它的 destory 方法,作用与在配置文件中对 Bean 使用 destory-method 属性的作用一样,都是在 Bean 实例销毁前执行的方法。

Spring Bean 注册

注册 Spring Bean 实际上是将 BeanDefinition 注册到 IoC 容器中。

XML 配置元信息

Spring 的传统配置方式。在 <bean> 标签中配置元数据内容。

缺点是当 JavaBean 过多时,产生的配置文件足以让你眼花缭乱。

注解配置元信息

使用 @Bean@Component@Import 注解注册 Spring Bean。

Java API 配置元信息

  • 命名方式:BeanDefinitionRegistry#registerBeanDefinition(String,BeanDefinition)
  • 非命名方式:BeanDefinitionReaderUtils#registerWithGeneratedName(AbstractBeanDefinition,BeanDefinitionRegistry)
  • 配置类方式:AnnotatedBeanDefinitionReader#register(Class...)

💻 Spring Bean 注册示例源码:BeanRegistryTests

Spring Bean 实例化

Spring Bean 实例化方式:

  • 常规方式
    • 通过构造器(配置元信息:XML、Java 注解和 Java API)
    • 通过静态方法(配置元信息:XML、Java 注解和 Java API)
    • 通过 Bean 工厂方法(配置元信息:XML、Java 注解和 Java API)
    • 通过 FactoryBean(配置元信息:XML、Java 注解和 Java API)
  • 特殊方式
    • 通过 ServiceLoaderFactoryBean(配置元信息:XML、Java 注解和 Java API )
    • 通过 AutowireCapableBeanFactory#createBean(java.lang.Class, int, boolean)
    • 通过 BeanDefinitionRegistry#registerBeanDefinition(String,BeanDefinition)

💻 Spring Bean 实例化示例源码:BeanInstantiationTestsBeanInstantiationSpecialTests

Spring Bean 初始化和销毁

Spring Bean 初始化和销毁的方式有以下几种:

  1. 使用 @PostConstruct@PreDestroy 注解分别指定相应的初始化方法和销毁方法。

  2. 实现 InitializingBean 接口的 afterPropertiesSet() 方法来编写初始化方法;实现 DisposableBean 接口的 destroy() 方法来编写销毁方法。

    • InitializingBean 接口包含一个 afterPropertiesSet 方法,可以通过实现该接口,然后在这个方法中编写初始化逻辑。
    • DisposableBean接口包含一个 destory 方法,可以通过实现该接口,然后在这个方法中编写销毁逻辑。
  3. 自定义初始化方法

    • XML 配置:<bean init-method="init" destroy="destroy" ... />
    • Java 注解:@Bean(initMethod = "init", destroyMethod = "destroy")
    • Java API:AbstractBeanDefinition#setInitMethodName(String)AbstractBeanDefinition#setDestroyMethodName(String) 分别定义初始化和销毁方法

注意:如果同时存在,执行顺序会按照序列执行。

Bean 的延迟初始化

  • xml 方式:<bean lazy-init="true" ... />
  • 注解方式:@Lazy

Spring 提供了一个 BeanPostProcessor 接口,提供了两个方法 postProcessBeforeInitializationpostProcessAfterInitialization。其中postProcessBeforeInitialization 在组件的初始化方法调用之前执行,postProcessAfterInitialization 在组件的初始化方法调用之后执行。它们都包含两个入参:

  • bean:当前组件对象;
  • beanName:当前组件在容器中的名称。

💻 Spring Bean 初始化和销毁示例源码:BeanInitDestroyTests

Spring Bean 垃圾回收

Spring Bean 垃圾回收步骤:

  1. 关闭 Spring 容器(应用上下文)
  2. 执行 GC
  3. Spring Bean 覆盖的 finalize() 方法被回调

Spring Bean 作用范围

Scope Description
singleton (Default) Scopes a single bean definition to a single object instance for each Spring IoC container.
prototype Scopes a single bean definition to any number of object instances.
request Scopes a single bean definition to the lifecycle of a single HTTP request. That is, each HTTP request has its own instance of a bean created off the back of a single bean definition. Only valid in the context of a web-aware Spring ApplicationContext.
session Scopes a single bean definition to the lifecycle of an HTTP Session. Only valid in the context of a web-aware Spring ApplicationContext.
application Scopes a single bean definition to the lifecycle of a ServletContext. Only valid in the context of a web-aware Spring ApplicationContext.
websocket Scopes a single bean definition to the lifecycle of a WebSocket. Only valid in the context of a web-aware Spring ApplicationContext.

参考资料

SpringBoot 之快速入门

Spring Boot 简介

Spring Boot 可以让使用者非常方便的创建 Spring 应用。

Spring Boot 的目标是:

  • 为所有 Spring 开发者提供更快且可广泛访问的入门体验。
  • 开箱即用
  • 提供一系列通用的非功能特性(例如嵌入式服务、安全、指标、健康检查和外部化配置)
  • 完全不需要代码生成,也不需要 XML 配置。

Spring Boot 系统要求

Spring Boot 的构建工具要求:

Build Tool Version
Maven 3.5+
Gradle 6.8.x, 6.9.x, and 7.x

Spring Boot 支持的 Servlet 容器:

Name Servlet Version
Tomcat 9.0 4.0
Jetty 9.4 3.1
Jetty 10.0 4.0
Undertow 2.0 4.0

部署第一个 Spring Boot 项目

本节介绍如何开发一个小的“Hello World!” web 应用示例,来展示 Spring Boot 的一些关键功能。我们使用 Maven 来构建这个项目,因为大多数 IDE 都支持它。

环境检查

Spring Boot 项目依赖于 Java 环境和 Mave,开始项目之前需要先检查一下环境。

本地是否已安装 Java:

1
2
3
4
$ java -version
java version "1.8.0_102"
Java(TM) SE Runtime Environment (build 1.8.0_102-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.102-b14, mixed mode)

本地是否已安装 Maven:

1
2
3
4
$ mvn -v
Apache Maven 3.5.4 (1edded0938998edf8bf061f1ceb3cfdeccf443fe; 2018-06-17T14:33:14-04:00)
Maven home: /usr/local/Cellar/maven/3.3.9/libexec
Java version: 1.8.0_102, vendor: Oracle Corporation

创建 pom

我们需要从创建 Maven pom.xml 文件开始。 pom.xml 是 Maven 用于构建项目的配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.example</groupId>
<artifactId>myproject</artifactId>
<version>0.0.1-SNAPSHOT</version>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.1</version>
</parent>

<!-- Additional lines to be added here... -->

</project>

使用者可以通过运行 mvn package 来测试它

添加依赖

Spring Boot 提供了许多启动器(Starters)以应对不同的使用场景。使用者可将 jars 添加到类路径中。我们的示例程序在 POM 的 parent 使用 spring-boot-starter-parent。 spring-boot-starter-parent 是一个特殊的启动器,提供有用的 Maven 默认值。它还提供了一个依赖项的版本管理,可以让使用者使用时不必显示指定版本。

其他启动器(Starters)提供了各种针对不同使用场景的功能。比如,我们需要开发一个 Web 应用程序,就可以添加了一个 spring-boot-starter-web 依赖项。在此之前,我们可以通过运行以下命令来查看我们当前拥有的 maven 依赖:

1
2
3
$ mvn dependency:tree

[INFO] com.example:myproject:jar:0.0.1-SNAPSHOT

mvn dependency:tree 命令打印项目依赖项的层级结构。可以看到 spring-boot-starter-parent 本身没有提供任何依赖。要添加必要的依赖,需要编辑 pom.xml 并在 <dependencies> 部分添加 spring-boot-starter-web 依赖项:

1
2
3
4
5
6
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

编写代码

要运行应用程序,我们需要创建一个启动类。默认情况下,Maven 从 src/main/java 编译源代码,因此您需要创建该目录结构,然后添加一个名为 src/main/java/MyApplication.java 的文件以包含以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@EnableAutoConfiguration
public class MyApplication {

@RequestMapping("/")
String home() {
return "Hello World!";
}

public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}

}

说明:

@RestController 注解告诉 Spring,这个类是用来处理 Rest 请求的。

@RequestMapping 注解提供了“路由”信息。它告诉 Spring 任何带有 / 路径的 HTTP 请求都应该映射到 home 方法。 @RestController 注解告诉 Spring 将结果字符串直接呈现给调用者。

@EnableAutoConfiguration 注解告诉 Spring Boot 根据你添加的 jar 依赖去自动装配 Spring。

自动配置旨在与“Starters”配合使用,但这两个概念并没有直接联系。您可以自由选择 starters 之外的 jar 依赖项。 Spring Boot 仍然尽力自动配置您的应用程序。

Spring Boot 的 main 方法通过调用 run 委托给 Spring Boot 的 SpringApplication 类。 SpringApplication 引导我们的应用程序,启动 Spring,进而启动自动配置的 Tomcat Web 服务器。我们需要将 MyApplication.class 作为参数传递给 run 方法,以告诉 SpringApplication 哪个是入口类。还传递 args 数组以公开任何命令行参数。

运行示例

此时,您的应用程序应该可以工作了。由于您使用了 spring-boot-starter-parent POM,因此您有一个有用的运行目标,可用于启动应用程序。从项目根目录键入 mvn spring-boot:run 以启动应用程序。您应该会看到类似于以下内容的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ mvn spring-boot:run

. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.6.1)
....... . . .
....... . . . (log output here)
....... . . .
........ Started MyApplication in 2.222 seconds (JVM running for 6.514)

如果您打开 Web 浏览器访问 localhost:8080,您应该会看到以下输出:

1
Hello World!

要正常退出应用程序,请按 ctrl-c

创建可执行 jar

要创建一个可执行的 jar,我们需要将 spring-boot-maven-plugin 添加到我们的 pom.xml 中。为此,请在依赖项部分下方插入以下行:

1
2
3
4
5
6
7
8
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

保存 pom.xml 并从命令行运行 mvn package,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ mvn package

[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building myproject 0.0.1-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO] .... ..
[INFO] --- maven-jar-plugin:2.4:jar (default-jar) @ myproject ---
[INFO] Building jar: /Users/developer/example/spring-boot-example/target/myproject-0.0.1-SNAPSHOT.jar
[INFO]
[INFO] --- spring-boot-maven-plugin:2.6.1:repackage (default) @ myproject ---
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

如果您查看 target 目录,应该会看到 myproject-0.0.1-SNAPSHOT.jar。该文件的大小应约为 10 MB。如果想看里面,可以使用 jar tvf,如下:

1
$ jar tvf target/myproject-0.0.1-SNAPSHOT.jar

您还应该在目标目录中看到一个更小的名为 myproject-0.0.1-SNAPSHOT.jar.original 的文件。这是 Maven 在 Spring Boot 重新打包之前创建的原始 jar 文件。

要运行该应用程序,请使用 java -jar 命令,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ java -jar target/myproject-0.0.1-SNAPSHOT.jar

. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.6.1)
....... . . .
....... . . . (log output here)
....... . . .
........ Started MyApplication in 2.536 seconds (JVM running for 2.864)

和以前一样,要退出应用程序,请按 ctrl-c

通过 SPRING INITIALIZR 创建 Spring Boot 项目

创建项目

通过 SPRING INITIALIZR 工具产生基础项目

  1. 访问:http://start.spring.io/
  2. 选择构建工具Maven Project、Spring Boot 版本 1.5.10 以及一些工程基本信息,可参考下图所示:

img

  1. 点击Generate Project下载项目压缩包
  2. 解压压缩包,包中已是一个完整的项目。

如果你使用 Intellij 作为 IDE,那么你可以直接使用 SPRING INITIALIZR,参考下图操作:

img

项目说明

重要文件

  • src/main/java 路径下的 Chapter1Application 类 :程序入口
  • src/main/resources 路径下的 application.properties :项目配置文件
  • src/test/java 路径下的 Chapter01ApplicationTests :程序测试入口

pom.xml

pom 中指定 parent 为以下内容,表示此项目继承了 spring-boot-starter-parent 的 maven 配置(主要是指定了常用依赖、插件的版本)。

1
2
3
4
5
6
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.10.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

此外,pom 中默认引入两个依赖包,和一个插件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
  • spring-boot-starter-web:核心模块,包括自动配置支持、日志和 YAML。
  • spring-boot-starter-test:测试模块,包括 JUnit、Hamcrest、Mockito。
  • spring-boot-maven-plugin:spring boot 插件, 提供了一系列 spring boot 相关的 maven 操作。
    • spring-boot:build-info,生成 Actuator 使用的构建信息文件 build-info.properties
    • spring-boot:repackage,默认 goal。在 mvn package 之后,再次打包可执行的 jar/war,同时保留 mvn package 生成的 jar/war 为.origin
    • spring-boot:run,运行 Spring Boot 应用
    • spring-boot:start,在 mvn integration-test 阶段,进行 Spring Boot 应用生命周期的管理
    • spring-boot:stop,在 mvn integration-test 阶段,进行 Spring Boot 应用生命周期的管理

编写 REST 服务

  • 创建 package ,名为 io.github.zp.springboot.chapter1.web(根据项目情况修改)
  • 创建 HelloController 类,内容如下:
1
2
3
4
5
6
7
8
9
@RestController
public class HelloController {

@RequestMapping("/hello")
public String index() {
return "Hello World";
}

}
  • 启动主程序 XXXApplication,打开浏览器访问http://localhost:8080/hello ,可以看到页面输出Hello World

编写单元测试用例

XXXApplicationTests 类中编写一个简单的单元测试来模拟 HTTP 请求,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = MockServletContext.class)
@WebAppConfiguration
public class SpringBootHelloWorldApplicationTest {

private MockMvc mvc;

@Before
public void setUp() {
mvc = MockMvcBuilders.standaloneSetup(new HelloController()).build();
}

@Test
public void getHello() throws Exception {
mvc.perform(MockMvcRequestBuilders.get("/hello").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().string(equalTo("Hello World")));
}

}

使用MockServletContext来构建一个空的WebApplicationContext,这样我们创建的HelloController就可以在@Before函数中创建并传递到MockMvcBuilders.standaloneSetup()函数中。

  • 注意引入下面内容,让statuscontentequalTo函数可用
1
2
3
import static org.hamcrest.Matchers.equalTo;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

至此已完成目标,通过 Maven 构建了一个空白 Spring Boot 项目,再通过引入 web 模块实现了一个简单的请求处理。

示例源码

示例源码:spring-boot-web-helloworld

参考资料

CAP 和 BASE

一致性

一致性(Consistency)指的是多个数据副本是否能保持一致的特性。

在一致性的条件下,分布式系统在执行写操作成功后,如果所有用户都能够读取到最新的值,该系统就被认为具有强一致性。

数据一致性又可以分为以下几点:

  • 强一致性 - 数据更新操作结果和操作响应总是一致的,即操作响应通知更新失败,那么数据一定没有被更新,而不是处于不确定状态。
  • 弱一致性 - 系统在写入数据成功后,不承诺立即能读到最新的值,也不承诺什么时候能读到,但是过一段时间之后用户可以看到更新后的值。那么用户读不到最新数据的这段时间被称为“不一致窗口时间”。
  • 最终一致性 - 最终一致性作为弱一致性中的特例,强调的是所有数据副本,在经过一段时间的同步后,最终能够到达一致的状态,不需要实时保证系统数据的强一致性。

ACID

ACID 是数据库事务正确执行的四个基本要素的单词缩写:

  • 原子性(Atomicity)
    • 原子是指不可分解为更小粒度的东西。事务的原子性意味着:事务中的所有操作要么全部成功,要么全部失败
    • 回滚可以用日志来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
  • 一致性(Consistency)
    • 数据库在事务执行前后都保持一致性状态。
    • 在一致性状态下,所有事务对一个数据的读取结果都是相同的。
  • 隔离性(Isolation)
    • 同时运行的事务互不干扰。换句话说,一个事务所做的修改在最终提交以前,对其它事务是不可见的。
  • 持久性(Durability)
    • 一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。
    • 可以通过数据库备份和恢复来实现,在系统发生奔溃时,使用备份的数据库进行数据恢复。

一个支持事务(Transaction)的数据库系统,必需要具有这四种特性,否则在事务过程(Transaction processing)当中无法保证数据的正确性。

  • 只有满足一致性,事务的执行结果才是正确的。
  • 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。
  • 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
  • 事务满足持久化是为了能应对系统崩溃的情况。

CAP 定理

CAP 简介

1998 年,Brewer 提出了分布式系统领域大名鼎鼎的 CAP 定理。

CAP 定理提出:分布式系统有三个指标,这三个指标不能同时做到:

  • 一致性(Consistency) - 在任何给定时间,网络中的所有节点都具有完全相同(最近)的值。
  • 可用性(Availability) - 对网络的每个请求都会返回响应,但不能保证返回的数据是最新的。
  • 分区容错性(Partition Tolerance) - 即使任意数量的节点出现故障,网络仍会继续运行。

CAP 就是取 Consistency、Availability、Partition Tolerance 的首字母而命名。

在分布式系统中,分区容错性是一个既定的事实:因为分布式系统总会出现各种各样的问题,如由于网络原因而导致节点失联;发生机器故障;机器重启或升级等等。因此,CAP 定理实际上是要在可用性(A)和一致性(C)之间做权衡

AP 模式

对网络的每个请求都会收到响应,即使由于网络分区(故障节点)而无法保证数据一定是最新的。

选择 AP 模式,偏向于保证服务的高可用性。用户访问系统的时候,都能得到响应数据,不会出现响应错误;但是,当出现分区故障时,相同的读操作,访问不同的节点,得到响应数据可能不一样。

CP 模式

如果由于网络分区(故障节点)而无法保证特定信息是最新的,则系统将返回错误或超时。

选择 CP 模式,一旦因为消息丢失、延迟过高发生了网络分区,就会影响用户的体验和业务的可用性。因为为了防止数据不一致,系统将拒绝新数据的写入。

CAP 定理的应用

CAP 定理在分布式系统设计中,可以被应用与哪些方面?

一个最具代表性的问题是:服务注册中心应该选择 AP 还是 CP?

在微服务架构下,服务注册和服务发现机制中主要有三种角色:

  • 服务提供者(RPC Server / Provider)
  • 服务消费者(RPC Client / Consumer)
  • 服务注册中心(Registry)

注册中心负责协调服务注册和服务发现,显然它是核心中的核心。主流的注册中心有很多,如:ZooKeeper、Nacos、Eureka、Consul、etcd 等。在针对注册中心进行技术选型时,其 CAP 设计也是一个比较的维度。

  • CP 模型代表:ZooKeeper、etcd。系统强调数据的一致性,当数据一致性无法保证时(如:正在选举主节点),系统拒绝请求。
  • AP 模型代表:Nacos、Eureka。系统强调可用性,牺牲一定的一致性(即服务节点上的数据不保证是最新的),来保证整体服务可用。

对于服务注册中心而言,即使不同节点保存的服务注册信息存在差异,也不会造成灾难性的后果,仅仅是信息滞后而已。但是,如果为了追求数据一致性,使得服务发现短时间内不可用,负面影响更严重。所以,对于服务注册中心而言,可用性比一致性更重要,一般应该选择 AP 模型。

CAP 定理的误导

CAP 定理在分布式系统领域大名鼎鼎,以至于被很多人视为了真理。然而,CAP 定理真的正确吗?

网络分区是一种故障,不管喜欢还是不喜欢,它都可能发生,所以无法选择或逃避分区的问题。在网络正常的时候,系统可以同时保证一致性(线性化)和可用性。而一旦发生了网络故障,必须要么选择一致性,要么选择可用性。因此,对 CAP 更准确的理解应该是:当发生网络分区(P)的情况下,可用性(A)和一致性(C)二者只能选其一

CAP 定理所描述的模型实际上局限性很大,它只考虑了一种一致性模型和一种故障(网络分区故障),而没有考虑网络延迟、节点失效等情况。因此,它对于指导一个具体的分布式系统设计来说,没有太大的实际价值。

值得一提的是,在 CAP 定理提出十二年之后,其提出者也发表了一篇文章 CAP Twelve Years Later: How the “Rules” Have Changed,来阐述 CAP 定理的局限性。

BASE 定理

BASE 是 基本可用(Basically Available)软状态(Soft State)最终一致性(Eventually Consistent) 三个短语的缩写。BASE 定理是对 CAP 定理中可用性(A)和一致性(C)权衡的结果。

BASE 定理的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。

  • 基本可用(Basically Available) - 分布式系统在出现故障的时候,保证核心可用,允许损失部分可用性。例如,电商在做促销时,为了保证购物系统的稳定性,部分消费者可能会被引导到一个降级的页面。
  • 软状态(Soft State) - 指允许系统中的数据存在中间状态,并认为该中间状态不会影响系统整体可用性,即允许系统不同节点的数据副本之间进行同步的过程存在延时
  • 最终一致性(Eventually Consistent) - 强调的是所有数据副本,在经过一段时间的同步后,最终能够到达一致的状态,不需要实时保证系统数据的强一致性。

BASE vs. ACID

BASE 定理的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。

ACID 要求强一致性,通常运用在传统的数据库系统上。而 BASE 要求最终一致性,通过牺牲强一致性来达到可用性,通常运用在大型分布式系统中。

在实际的分布式场景中,不同业务单元和组件对一致性的要求是不同的,因此 ACID 和 BASE 往往会结合在一起使用。

参考资料

分布式简介

分布式系统的发展历程

罗马不是一天建成的,同理,现代分布式系统架构也不是一蹴而就的,而是逐步发展的演化过程。随着业务的不断发展,用户体量的增加,系统的复杂度势必不断攀升,最终迫使系统架构进化,以应对挑战。

了解分布式系统架构的演化过程,有利于我们了解架构进化的发展规律和业界一些成熟的应对方案。帮助我们在实际工作中,如何去思考架构,如何去凝练解决方案。

单机架构

  • 场景:网站运营初期,访问用户少,一台服务器绰绰有余。
  • 特征应用程序、数据库、文件等所有的资源都在一台服务器上。
  • 描述:通常服务器操作系统使用 linux,应用程序使用 PHP 开发,然后部署在 Apache 上,数据库使用 Mysql,通俗称为 LAMP。汇集各种免费开源软件以及一台廉价服务器就可以开始系统的发展之路了。

应用服务和数据服务分离

  • 场景:越来越多的用户访问导致性能越来越差,越来越多的数据导致存储空间不足,一台服务器已不足以支撑。
  • 特征应用服务器、数据库服务器、文件服务器分别独立部署。
  • 描述:三台服务器对性能要求各不相同:
    • 应用服务器要处理大量业务逻辑,因此需要更快更强大的 CPU;
    • 数据库服务器需要快速磁盘检索和数据缓存,因此需要更快的硬盘和更大的内存;
    • 文件服务器需要存储大量文件,因此需要更大容量的硬盘。

使用缓存改善性能

  • 场景:随着用户逐渐增多,数据库压力太大导致访问延迟。
  • 特征:由于网站访问和财富分配一样遵循二八定律:_80% 的业务访问集中在 20% 的数据上_。将数据库中访问较集中的少部分数据缓存在内存中,可以减少数据库的访问次数,降低数据库的访问压力。
  • 描述:缓存分为两种:应用服务器上的本地缓存和分布式缓存服务器上的远程缓存。
    • 本地缓存访问速度更快,但缓存数据量有限,同时存在与应用程序争用内存的情况。
    • 分布式缓存可以采用集群方式,理论上可以做到不受内存容量限制的缓存服务。

负载均衡

  • 场景:使用缓存后,数据库访问压力得到有效缓解。但是单一应用服务器能够处理的请求连接有限,在访问高峰期,成为瓶颈。
  • 特征多台服务器通过负载均衡同时向外部提供服务,解决单一服务器处理能力和存储空间不足的问题。
  • 描述:使用集群是系统解决高并发、海量数据问题的常用手段。通过向集群中追加资源,提升系统的并发处理能力,使得服务器的负载压力不再成为整个系统的瓶颈。

数据库读写分离

  • 场景:网站使用缓存后,使绝大部分数据读操作访问都可以不通过数据库就能完成,但是仍有一部分读操作和全部的写操作需要访问数据库,在网站的用户达到一定规模后,数据库因为负载压力过高而成为网站的瓶颈。
  • 特征:目前大部分的主流数据库都提供主从热备功能,通过配置两台数据库主从关系,可以将一台数据库服务器的数据更新同步到一台服务器上。网站利用数据库的主从热备功能,实现数据库读写分离,从而改善数据库负载压力。
  • 描述:应用服务器在写操作的时候,访问主数据库,主数据库通过主从复制机制将数据更新同步到从数据库。这样当应用服务器在读操作的时候,访问从数据库获得数据。为了便于应用程序访问读写分离后的数据库,通常在应用服务器端使用专门的数据访问模块,使数据库读写分离的对应用透明。

多级缓存

  • 场景:中国网络环境复杂,不同地区的用户访问网站时,速度差别也极大。
  • 特征采用 CDN 和反向代理加快系统的静态资源访问速度。
  • 描述:CDN 和反向代理的基本原理都是缓存,区别在于:
    • CDN 部署在网络提供商的机房,使用户在请求网站服务时,可以从距离自己最近的网络提供商机房获取数据;
    • 而反向代理则部署在网站的中心机房,当用户请求到达中心机房后,首先访问的服务器时反向代理服务器,如果反向代理服务器中缓存着用户请求的资源,就将其直接返回给用户。

业务拆分

  • 场景:大型网站的业务场景日益复杂,分为多个产品线。
  • 特征:采用分而治之的手段将整个网站业务分成不同的产品线。系统上按照业务进行拆分改造,应用服务器按照业务区分进行分别部署。
  • 描述:应用之间可以通过超链接建立关系,也可以通过消息队列进行数据分发,当然更多的还是通过访问同一个数据存储系统来构成一个关联的完整系统。
    • 纵向拆分将一个大应用拆分为多个小应用,如果新业务较为独立,那么就直接将其设计部署为一个独立的 Web 应用系统。纵向拆分相对较为简单,通过梳理业务,将较少相关的业务剥离即可。
    • 横向拆分将复用的业务拆分出来,独立部署为分布式服务,新增业务只需要调用这些分布式服务横向拆分需要识别可复用的业务,设计服务接口,规范服务依赖关系。

分库分表

  • 场景:随着大型网站业务持续增长,数据库经过读写分离,从一台服务器拆分为两台服务器,依然不能满足需求。
  • 特征数据库采用分布式数据库。
  • 描述:分布式数据库是数据库拆分的最后方法,只有在单表数据规模非常庞大的时候才使用。不到不得已时,更常用的数据库拆分手段是业务分库,将不同的业务数据库部署在不同的物理服务器上。

分布式组件

  • 场景:随着网站业务越来越复杂,对数据存储和检索的需求也越来越复杂。
  • 特征系统引入 NoSQL 数据库及搜索引擎。
  • 描述:NoSQL 数据库及搜索引擎对可伸缩的分布式特性具有更好的支持。应用服务器通过统一数据访问模块访问各种数据,减轻应用程序管理诸多数据源的麻烦。

微服务

  • 场景:随着业务越拆越小,存储系统越来越庞大,应用系统整体复杂程度呈指数级上升,部署维护越来越困难。由于所有应用要和所有数据库系统连接,最终导致数据库连接资源不足,拒绝服务。
  • 特征公共业务提取出来,独立部署。由这些可复用的业务连接数据库,通过分布式服务提供共用业务服务。
  • 描述:大型网站的架构演化到这里,基本上大多数的技术问题都得以解决,诸如跨数据中心的实时数据同步和具体网站业务相关的问题也都可以组合改进现有技术架构来解决。

分布式指标

分布式系统的目标是提升系统的整体性能和吞吐量,另外还要尽量保证分布式系统的容错性

由分布式系统的目标很容易得出分布式系统的关键指标:性能、可用性、可扩展性。这些指标,正对应着耳熟能详的分布式系统“三高”特性——高并发、高性能、高可用。

性能(Performance)

性能用于衡量一个系统处理各种任务的能力。

常见的性能指标有:

  • 吞吐量(Throughput) - 系统在一定时间内可以处理的任务数。常见的吞吐量指标有:
    • QPS - Queries Per Second 的缩写,即每秒查询数。
    • TPS - Transactions Per Second 的缩写,即每秒事务数。
  • 响应时间(Response Time) - 执行一个请求从开始到最后收到响应数据所花费的总体时间,即从客户端发起请求到收到服务器响应结果的时间。
  • 并发数(Concurrency) - 并发数是指系统能同时处理请求的数量,这个也反映了系统的负载能力。并发意味着可以同时进行多个处理。并发在现代编程中无处不在,网络中有多台计算机同时存在,一台计算机上同时运行着多个应用程序。

以上三个指标的关系大致为:

1
2
QPS(TPS)= 并发数 / 平均响应时间
并发数 = QPS(TPS) * 平均响应时间

可用性(Availability)

可用性:指的是系统在面对各种异常时可以正确提供服务的能力。

系统的可用性可以用系统停止服务的时间与总的时间之比衡量。

行业内一般用几个 9 表示可用性指标,对应用的可用性程度一般衡量标准有三个 9 到五个 9;一般我们的系统至少要到 4 个 9(99.99%)的可用性才能谈得上高可用。

可用性 年故障时间
99.9999% 32 秒
99.999% 5 分 15 秒
99.99% 52 分 34 秒
99.9% 8 小时 46 分
99% 3 天 15 小时 36 分

而所谓的高可用,就是:在任何情况下,让服务尽最大可能对外提供服务

可扩展性(Scalability)

可扩展性(Scalability)指的是分布式系统通过扩展集群机器规模提高系统性能 (吞吐、响应时间、 完成时间)、存储容量、计算能力的特性,是分布式系统的特有性质。

系统扩展可以分为垂直扩展、水平扩展。

  • 垂直扩展,即提升单机的硬件处理能力,比如 CPU 处理能力,内存容量,磁盘等方面。但是,单机是有性能瓶颈的,一旦触及瓶颈,再想提升,付出的成本和代价会极高。通俗来说,就三个字:得加钱
  • 水平扩展:采用分而治之的思想,通过集群来分担吞吐量。集群中的应用机器(节点)通常被设计成无状态,用户可以请求任何一个节点,这些节点共同分担访问压力。水平扩展有两个要点:
    • 集群化、分区化:将一个完整的应用化整为零,如果是无状态应用,可以直接集群化部署;如果是有状态应用,可以将状态数据分区(分片),然后部署到多台机器上。
    • 负载均衡:集群化、分区化后,要解决的问题是,请求应该被分发(寻址)到哪台机器上。这就需要通过某种策略来控制分发,这种技术就是负载均衡。

分布式系统分类

分布式技术错综复杂、知识庞杂,且各种技术相互耦合,所以不容易划分层次。

从应用的维度来看,大致可以将分布式系统分为以下四类:

  • 分布式计算:解决应用的分布式计算问题。基于分布式计算模式,包括批处理计算、离线计算、在线计算、融合计算等,根据应用类型构建高效智能的分布式计算框架。
  • 分布式存储:解决数据的分布式和多元化问题。包括分布式数据库、分布式文件系统、分布式缓存等,支持不同类型的数据的存储和管理。
  • 分布式通信:解决进程间的分布式通信问题。通过消息队列、远程调用等方式,实现简单高效的通信。
  • 分布式资源管理:解决资源的分布式和异构性问题。将 CPU、内存、IO 等物理资源虚拟化,新城逻辑资源池,以便统一管理。

此外,分布式系统都需要面对一些共性问题,可以视为分布式系统技术的基石:

  • 分布式协同 - 解决分布式状态及数据一致性的问题。代表技术:分布式互斥、分布式共识、分布式选举、分布式选举等。
  • 分布式调度 - 解决分布式系统资源、请求分配调度的问题。代表技术:服务注册和发现、服务路由、负载均衡、流量控制等。
  • 分布式容错 - 解决分布式系统中故障分析、处理的问题,保证系统整体可靠性。代表技术:链路追踪、故障隔离、故障转移等。
  • 分布式部署 - 解决分布式系统部署问题。代表技术:CI/CD、容器化等。

分布式系统的挑战

当程序运行在单机上时,通常会以一种可预测的方式运行:要么正常,要么异常。

一旦程序运行在多台机器上时,面临的场景就会变得复杂而难以预料。在分布式系统中,系统的某些部分可能会出现不可预知的故障,这被称为部分失效(partial failure)。问题的难点就在于部分失效是不确定性的。你甚至不确定请求是否成功了,因为消息通过网络传播的时间也是不确定的!这种不确定性和部分失效的可能性,使得分布式系统难以工作。

扩展阅读:The Eight Fallacies of Distributed Computing - Tech Talk 一文中提出了分布式系统新手常有的 8 种误区。

为什么我们要深刻地认识这 8 个错误?这是因为,我们需要清楚地认识到——在分布式系统中,故障是不可避免的。因此,如果要构建一个可靠的分布式系统,就必须要建立容错机制。很可能大部分组件在大部分时间都正常工作。然而,迟早会有一部分系统出现故障,软件必须以某种方式处理。故障处理必须是软件设计的一部分,并且作为软件的运维,你需要知道在发生故障的情况下,软件可能会表现出怎样的行为。

不可靠的网络

互联网以及大多数数据中心的内部网络(通常是以太网)都是异步网络。当通过网络发送数据包时,数据包可能会丢失或者延迟;同样,回复也可能会丢失或延迟。所以如果没有收到回复,并不能确定消息是否发送成功。传输的过程中,可能有各种各样的问题:

  1. 请求可能已经丢失(可能是被拔掉了网线)。
  2. 请求可能正在某个队列中等待,无法马上发送(可能是网络或接收方已经超负荷)。
  3. 远程接收节点可能已经失效(可能是崩愤或关机)。
  4. 远程节点可能暂时无法响应(例如正在运行长时间的垃圾回收)。
  5. 远程接收节点已经完成了请求处理,但回复却在网络中丢失(例如网络交换机配置错误)。
  6. 远程接收节点已经完成了请求处理,但回复却被延迟处理(例如网络或者发送者的机器过载)。

如果发送请求并没有得到响应,则无法区分(a)请求是否丢失,(b)远程节点是否关闭,或(c)响应是否丢失

在大多数情况下,系统并没有准确判断节点是否发生故障的机制。因此,分布式系统中,一般通过超时检测来判断远程节点是否可用。但是,超时无法区分网络和节点故障,且可变的网络延迟有时会导致节点被误认为发生崩溃。

超时检测的一个关键点是超时大小的设置:

  • 超时时间如果设置过大,意味着等待时间更久,才能判定节点失效(在此期间,用户只能等待或拿到错误信息)。

  • 超时时间如果设置过小,虽然可以更快检测故障,但增加了误判的可能——节点可能实际上是活着的。当一个节点被宣告为失效,其承担的职责要交给到其他节点,这个过程会给其他节点以及网络带来额外负担,特别是如果此时系统已经处于高负荷状态。

对此,可以先设置一个经验值,然后通过实验逐步调整:先在多台机器上,多次测量往返时间,以确定延迟的大概范围;然后结合应用特点,在故障检测与过早超时风险之间选择一个合适的中间值。更好的做法是:持续测量响应时间及其变化(抖动),然后根据最新的响应时间分布来动态调整。

不可靠的时钟

时钟和计时非常重要。有许多应用程序以各种方式依赖于时钟,例如:

  1. 某个请求是否超时了?
  2. 某项服务的 99 %的响应时间是多少?
  3. 在过去的五分钟内,服务平均每秒处理多少个查询?
  4. 用户在我们的网站上浏览花了多段时间?
  5. 这篇文章什么时候发表?
  6. 在什么时间发送提醒邮件?
  7. 这个缓存条目何时过期?
  8. 日志文件中错误消息的时间戳是多少?

在分布式系统中,时间总是件棘手的问题,由于跨节点通信不可能即时完成,消息经由网络从一台机器到另一台机器总是需要花费时间。收到消息的时间应该晚于发送的时间,但是由于网络的不确定延迟,精确测量面临着很多挑战。这些情况使得多节点通信时很难确定事情发生的先后顺序。

为了保证每台机器的时间同步,最常用的机制是 网络时间协议(Network Time Protocol, NTP),它可以根据一组专门的时间服务器来调整本地时间。需要注意的是,即使使用了 NTP 进行时间同步,但是依然会存在一些误差:一方面受限于 NTP 本身的同步精度,此外还受限于网络通信的延迟。

如果想要保证时序,另一种方案是采用逻辑时钟。逻辑时钟(logic clock)是基于递增计数器,对于排序事件来说是更安全的选择。逻辑时钟仅测量事件的相对顺序(无论一个事件发生在另一个事件之前还是之后)。

参考资料

拜占庭将军问题

拜占庭将军问题是由莱斯利·兰波特在其同名论文中提出的分布式对等网络通信容错问题。其实是借拜占庭将军的例子,抛出了分布式共识性问题,并探讨和论证了解决的方法。

分布式计算中,不同的节点通过通讯交换信息达成共识而按照同一套协作策略行动。但有时候,系统中的节点可能出错而发送错误的信息,用于传递信息的通讯网络也可能导致信息损坏,使得网络中不同的成员关于全体协作的策略得出不同结论,从而破坏系统一致性。拜占庭将军问题被认为是容错性问题中最难的问题类型之一。

问题描述

一群拜占庭将军各领一支军队共同围困一座城市。

为了简化问题,军队的行动策略只有两种:进攻(Attack,后面简称 A)或 撤退(Retreat,后面简称 R)。如果这些军队不是统一进攻或撤退,就可能因兵力不足导致失败。因此,将军们通过投票来达成一致策略:同进或同退

因为将军们分别在城市的不同方位,所以他们只能通过信使互相联系。在投票过程中,每位将军都将自己的投票信息(A 或 R)通知其他所有将军,这样一来每位将军根据自己的投票和其他所有将军送来的信息就可以分析出共同的投票结果而决定行动策略。

这个抽象模型的问题在于:将军中可能存在叛徒,他们不仅会发出误导性投票,还可能选择性地发送投票信息

由于将军之间需要通过信使通讯,叛变将军可能通过伪造信件来以其他将军的身份发送假投票。而即使在保证所有将军忠诚的情况下,也不能排除信使被敌人截杀,甚至被敌人间谍替换等情况。因此很难通过保证人员可靠性及通讯可靠性来解决问题。

假使那些忠诚(或是没有出错)的将军仍然能通过多数决定来决定他们的战略,便称达到了拜占庭容错。在此,票都会有一个默认值,若消息(票)没有被收到,则使用此默认值来投票。

上述的故事可以映射到分布式系统中,_将军代表分布式系统中的节点;信使代表通信系统;叛徒代表故障或异常_。

问题分析

兰伯特针对拜占庭将军问题,给出了两个解决方案:口头协议和书面协议。

本文介绍一下口头协议。

在口头协议中,拜占庭将军问题被简化为将军 - 副官模型,其核心规则如下:

  • 忠诚的副官遵守同一命令。
  • 若将军是忠诚的,所有忠诚的副官都执行他的命令。
  • 如果叛徒人数为 m,将军人数不能少于 3m + 1 ,那么拜占庭将军问题就能解决了。——关于这个公式,可以不必深究,如果对推导过程感兴趣,可以参考论文。

示例一、叛徒人数为 1,将军人数为 3

这个示例中,将军人数不满足 3m + 1,无法保证忠诚的副官都执行将军的命令。

示例二、叛徒人数为 1,将军人数为 4

这个示例中,将军人数满足 3m + 1,无论是副官中有叛徒,还是将军是叛徒,都能保证忠诚的副官执行将军的命令。

参考资料

分布式综合面试

逻辑时钟

扩展:

【初级】为什么需要逻辑时钟?

:::details 要点

经典问题

  • 为什么需要逻辑时钟?
  • 分布式系统中以系统时间来确定事件顺序有什么问题?

知识点

不同节点的物理时钟无法完全保持一致。即使引入一个全局时钟(例如:NTP)来进行校准,由于网络通信延迟的不确定性,以及时钟计时的偏差,无法保证每个节点的时间完全一致。

在分布式系统中,由于网络通信延迟的不确定性, 仅仅以接收顺序作为整个分布式系统中事件的发生顺序是不可取的

:::

【中级】什么是偏序?什么是全序?

:::details 要点

全序和偏序是数学上的术语,按照数学内容阐述比较晦涩,简单来说:

  • 偏序是部分可比较的有序关系。
  • 全序是在偏序基础上,要求全部元素必须可比较的有序关系。

:::

【高级】什么是逻辑时钟?

:::details 要点

经典问题

  • 什么是逻辑时钟?
  • 逻辑时钟是如何工作的?

知识点

1978 年,Lamport 在 Time, Clocks, and the Ordering of Events in a Distributed System 中提出了逻辑时钟的概念,来解决分布式系统中区分事件发生的时序问题。

逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序

分布式系统中按是否存在节点交互可分为三类事件,一类发生于节点内部,二是发送事件,三是接收事件。Lamport 时间戳原理如下:

Lamport timestamps space time (图片来源: wikipedia)_

  1. 每个事件对应一个 Lamport 计数器,初始值为 0
  2. 如果事件在节点内发生,计数器加 1
  3. 如果事件属于发送事件,计数器加 1 并在消息中带上该计数器
  4. 如果事件属于接收事件,计数器 = Max(本地计数器,消息中的计数器) + 1

综上,Lamport 逻辑时钟构建了一个全序时钟来描述事件顺序

Lamport 逻辑时钟的缺陷是无法描述同时发生的事件

扩展:

Time, Clocks, and the Ordering of Events in a Distributed System

:::

【高级】什么是向量时钟?

:::details 要点

向量时钟其实是在逻辑时钟的基础上进行了演进,算法逻辑类似,只是不仅记录了本节点的时间戳,还记录了其他节点的时间戳。其本质在于将逻辑时钟的全序计数器改造为向量时钟的偏序大小关系:向量有序,则事件有序;向量平行,则事件并发。

Vector clock space time (图片来源: wikipedia)

向量时钟可以发现数据冲突,但不能解决数据冲突

:::

【高级】什么是版本向量时钟?

:::details 要点

在向量时钟算法中, 消息传播后,发送方的向量一定会小于接收者的向量, 是因为接收者对齐了发送者的原因。

版本向量在此基础上,做了一点加强:消息传播后,发送方也对齐接收者的向量,也就是双向对齐,在版本向量中,叫做同步

发送消息和接收消息的时候不再自增向量中的自己的计数器,而是只做双方的向量对齐操作。 也就是,只有在更新数据的时候做向量自增

:::

一致性

【初级】什么是强一致性?什么是弱一致性?什么是最终一致性?

:::details 要点

一致性(Consistency)指的是多个数据副本是否能保持一致的特性。

数据一致性又可以分为以下几点:

  • 强一致性 - 数据更新操作结果和操作响应总是一致的,即操作响应通知更新失败,那么数据一定没有被更新,而不是处于不确定状态。通俗的说,分布式系统在执行写操作成功后,如果所有用户都能够读取到最新的值,该系统就被认为具有强一致性。
  • 弱一致性 - 系统在写入数据成功后,不承诺立即能读到最新的值,也不承诺什么时候能读到,但是过一段时间之后用户可以看到更新后的值。那么用户读不到最新数据的这段时间被称为“不一致窗口时间”。
  • 最终一致性 - 最终一致性作为弱一致性中的特例,强调的是所有数据副本,在经过一段时间的同步后,最终能够到达一致的状态,不需要实时保证系统数据的强一致性。

:::

【初级】什么是 ACID?

:::details 要点

那么,什么是 ACID 特性呢?ACID 是数据库事务正确执行的四个基本要素的单词缩写:

  • 原子性(Atomicity)
    • 原子是指不可分解为更小粒度的东西。事务的原子性意味着:事务中的所有操作要么全部成功,要么全部失败
    • 回滚可以用日志来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
    • ACID 中的原子性并不关乎多个操作的并发性,它并没有描述多个线程试图访问相同的数据会发生什么情况,后者其实是由 ACID 的隔离性所定义。
  • 一致性(Consistency)
    • 数据库在事务执行前后都保持一致性状态。
    • 在一致性状态下,所有事务对一个数据的读取结果都是相同的。
    • 一致性本质上要求应用层来维护状态一致(或者恒等),应用程序有责任正确地定义事务来保持一致性。这不是数据库可以保证的事情。
  • 隔离性(Isolation)
    • 同时运行的事务互不干扰。换句话说,一个事务所做的修改在最终提交以前,对其它事务是不可见的。
  • 持久性(Durability)
    • 一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。
    • 可以通过数据库备份和恢复来实现,在系统发生奔溃时,使用备份的数据库进行数据恢复。

一个支持事务(Transaction)中的数据库系统,必需要具有这四种特性,否则在事务过程(Transaction processing)当中无法保证数据的正确性。

  • 只有满足一致性,事务的执行结果才是正确的。
  • 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。
  • 在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
  • 事务满足持久化是为了能应对系统崩溃的情况。

:::

CAP & BASE

扩展:

【中级】什么是 CAP 定理?

:::details 要点

CAP 定理提出:分布式系统有三个指标,这三个指标不能同时做到:

  • 一致性(Consistency) - 在任何给定时间,网络中的所有节点都具有完全相同(最近)的值。
  • 可用性(Availability) - 对网络的每个请求都会返回响应,但不能保证返回的数据是最新的。
  • 分区容错性(Partition Tolerance) - 即使任意数量的节点出现故障,网络仍会继续运行。

CAP 就是取 Consistency、Availability、Partition Tolerance 的首字母而命名。

在分布式系统中,分区容错性是一个既定的事实:因为分布式系统总会出现各种各样的问题,如由于网络原因而导致节点失联;发生机器故障;机器重启或升级等等。因此,CAP 定理实际上是要在可用性(A)和一致性(C)之间做权衡

扩展:Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services解读 - 经典的 CAP 定理,即:在一个分布式系统中,当发生网络分区时,那么强一致性和可用性只能二选一。

:::

【中级】选择 CP 还是 AP?

:::details 要点

在分布式系统中,分区容错性是一个既定的事实:因为分布式系统总会出现各种各样的问题,如由于网络原因而导致节点失联;发生机器故障;机器重启或升级等等。因此,CAP 定理实际上是要在可用性(A)和一致性(C)之间做权衡

  • 选择 AP 模式,偏向于保证服务的高可用性。用户访问系统的时候,都能得到响应数据,不会出现响应错误;但是,当出现分区故障时,相同的读操作,访问不同的节点,得到响应数据可能不一样。
  • 选择 CP 模式,一旦因为消息丢失、延迟过高发生了网络分区,就会影响用户的体验和业务的可用性。因为为了防止数据不一致,系统将拒绝新数据的写入。

一个最具代表性的问题是:服务注册中心应该选择 AP 还是 CP?

在微服务架构下,服务注册和服务发现机制中主要有三种角色:

  • 服务提供者(RPC Server / Provider)
  • 服务消费者(RPC Client / Consumer)
  • 服务注册中心(Registry)

注册中心负责协调服务注册和服务发现,显然它是核心中的核心。主流的注册中心有很多,如:ZooKeeper、Nacos、Eureka、Consul、etcd 等。在针对注册中心进行技术选型时,其 CAP 设计也是一个比较的维度。

  • CP 模型代表:ZooKeeper、etcd。系统强调数据的一致性,当数据一致性无法保证时(如:正在选举主节点),系统拒绝请求。
  • AP 模型代表:Nacos、Eureka。系统强调可用性,牺牲一定的一致性(即服务节点上的数据不保证是最新的),来保证整体服务可用。

对于服务注册中心而言,即使不同节点保存的服务注册信息存在差异,也不会造成灾难性的后果,仅仅是信息滞后而已。但是,如果为了追求数据一致性,使得服务发现短时间内不可用,负面影响更严重。所以,对于服务注册中心而言,可用性比一致性更重要,一般应该选择 AP 模型。

:::

【中级】CAP 定理真的正确吗?

:::details 要点

CAP 定理在分布式系统领域大名鼎鼎,以至于被很多人视为了真理。然而,CAP 定理真的正确吗?

网络分区是一种故障,不管喜欢还是不喜欢,它都可能发生,所以无法选择或逃避分区的问题。在网络正常的时候,系统可以同时保证一致性(线性化)和可用性。而一旦发生了网络故障,必须要么选择一致性,要么选择可用性。因此,对 CAP 更准确的理解应该是:当发生网络分区(P)的情况下,可用性(A)和一致性(C)二者只能选其一

CAP 定理所描述的模型实际上局限性很大,它只考虑了一种一致性模型和一种故障(网络分区故障),而没有考虑网络延迟、节点失效等情况。因此,它对于指导一个具体的分布式系统设计来说,没有太大的实际价值。

值得一提的是,在 CAP 定理提出十二年之后,其提出者也发表了一篇文章 CAP Twelve Years Later: How the “Rules” Have Changed,来阐述 CAP 定理的局限性。

扩展:- CAP Twelve Years Later: How the “Rules” Have Changed, 解读 - CAP 定理的新解读,并阐述 CAP 定理的一些常见误区。

:::

【中级】什么是 BASE 定理?

:::details 要点

BASE 是 基本可用(Basically Available)软状态(Soft State)最终一致性(Eventually Consistent) 三个短语的缩写。BASE 定理是对 CAP 定理中可用性(A)和一致性(C)权衡的结果。

BASE 定理的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。

ACID 要求强一致性,通常运用在传统的数据库系统上。而 BASE 要求最终一致性,通过牺牲强一致性来达到可用性,通常运用在大型分布式系统中。

在实际的分布式场景中,不同业务单元和组件对一致性的要求是不同的,因此 ACID 和 BASE 往往会结合在一起使用。

扩展:- BASE: An Acid Alternative译文 - BASE 定理是对 CAP 中一致性和可用性的权衡,提出采用适当的方式来使系统达到最终一致性。

:::

Paxos

扩展:

【高级】Paxos 是怎样工作的?

:::details 要点

Paxos 是一种基于消息传递且具有容错性的共识性(consensus)算法

Paxos 算法运行在允许宕机故障的异步系统中,不要求可靠的消息传递,可容忍消息丢失、延迟、乱序以及重复。

Paxos 利用多数派 (Majority) 机制保证了一定的容错能力,即 N 个节点的系统最多允许 N / 2 - 1 个节点同时出现故障。

Paxos 算法包含 2 个部分:

  • Basic Paxos 算法:描述的是多节点之间如何就某个值达成共识。
  • Multi Paxos 思想:描述的是执行多个 Basic Paxos 实例,就一系列值达成共识。

Basic Paxos 算法

Basic Paxos 是通过二阶段提交的方式来达成共识的

Paxos 将分布式系统中的节点分 Proposer、Acceptor、Learner 三种角色。

  • 提议者(Proposer):发出提案(Proposal),用于投票表决。Proposal 信息包括提案编号 (Proposal ID) 和提议的值 (Value)。在绝大多数场景中,集群中收到客户端请求的节点,才是提议者。这样做的好处是,对业务代码没有入侵性,也就是说,我们不需要在业务代码中实现算法逻辑。
  • 接受者(Acceptor):对每个 Proposal 进行投票,若 Proposal 获得多数 Acceptor 的接受,则称该 Proposal 被批准。一般来说,集群中的所有节点都在扮演接受者的角色,参与共识协商,并接受和存储数据。
  • 学习者(Learner):不参与接受,从 Proposers/Acceptors 学习、记录最新达成共识的提案(Value)。一般来说,学习者是数据备份节点,比如主从架构中的从节点,被动地接受数据,容灾备份。

Paxos 算法有 3 个阶段,其中,前 2 个阶段负责协商并达成共识:

  1. 准备(Prepare)阶段:Proposer 向 Acceptors 发出 Prepare 请求,Acceptors 针对收到的 Prepare 请求进行 Promise 承诺。
  2. 接受(Accept)阶段:Proposer 收到多数 Acceptors 承诺的 Promise 后,向 Acceptors 发出 Propose 请求,Acceptors 针对收到的 Propose 请求进行 Accept 处理。
  3. 学习(Learn)阶段:Proposer 在收到多数 Acceptors 的 Accept 之后,标志着本次 Accept 成功,决议形成,将形成的决议发送给所有 Learners。

Multi Paxos 思想

Basic Paxos 有以下问题,导致它不能应用于实际:

  • Basic Paxos 算法只能对一个值形成决议
  • Basic Paxos 算法会消耗大量网络带宽。Basic Paxos 中,决议的形成至少需要两次网络通信,在高并发情况下可能需要更多的网络通信,极端情况下甚至可能形成活锁。如果想连续确定多个值,Basic Paxos 搞不定了。

Multi Paxos 基于 Basic Paxos 做了两点改进:

  • 针对每一个要确定的值,运行一次 Paxos 算法实例(Instance),形成决议。每一个 Paxos 实例使用唯一的 Instance ID 标识。
  • 在所有 Proposer 中选举一个 Leader,由 Leader 唯一地提交 Proposal 给 Acceptor 进行表决。这样没有 Proposer 竞争,解决了活锁问题。在系统中仅有一个 Leader 进行 Value 提交的情况下,Prepare 阶段就可以跳过,从而将两阶段变为一阶段,提高效率。

:::

Raft

扩展:

【高级】Raft 是怎样工作的?

:::details 要点

Raft 是一种为了管理日志复制的分布式共识性算法。从本质上说,Raft 算法是通过一切以领导者为准的方式,实现一系列值的共识和各节点日志的一致

Raft 出现之前,Paxos 一直是分布式共识性算法的标准。Paxos 难以理解,更难以实现。Raft 的设计目标是简化 Paxos,使得算法既容易理解,也容易实现

Raft 将一致性问题分解成了三个子问题:

  • 选举 Leader
  • 日志复制
  • 安全性

Raft 概念

(1)服务器角色

在 Raft 中,任何时刻,每个服务器都处于这三个角色之一 :

  • Leader - 领导者,通常一个系统中是一主(Leader)多从(Follower)。Leader 负责处理所有的客户端请求
  • Follower - 跟随者,不会发送任何请求,只是简单的 响应来自 Leader 或者 Candidate 的请求
  • Candidate - 参选者,选举新 Leader 时的临时角色。

(2)任期

Raft 把时间分割成任意长度的 任期(Term),任期用连续的整数标记。每一段任期从一次选举开始。Raft 保证了在一个给定的任期内,最多只有一个领导者

任期在 Raft 算法中充当逻辑时钟的作用,使得服务器节点可以查明一些过期的信息(比如过期的 Leader)。每个服务器节点都会存储一个当前任期号,这一编号在整个时期内单调的增长。当服务器之间通信的时候会交换当前任期号。

(3)选举 Leader

领导者心跳消息:Raft 使用一种心跳机制来触发 Leader 选举。Leader 需要周期性的向所有 Follower 发送心跳消息,以此维持 Leader 身份。

随机的竞选超时时间:每个 Follower 都设置了一个随机的竞选超时时间,一般为 150ms ~ 300ms,如果在竞选超时时间内没有收到 Leader 的心跳消息,就会认为当前 Term 没有可用的 Leader,并发起选举来选出新的 Leader。开始一次选举过程,Follower 先要增加自己的当前 Term 号,并转换为 Candidate

Candidate 会并行的向集群中的所有服务器节点发送投票请求(RequestVote RPC,它会保持当前状态直到以下三件事情之一发生:

  • 自己成为 Leader
  • 其他的服务器成为 Leader
  • 没有任何服务器成为 Leader

Raft 算法通过:领导者心跳消息、随机选举超时时间、得到大多数选票才通过原则、任期最新者优先、先来先服务等投票原则,保证了一个任期只有一位领导,也极大地减少了选举失败的情况。

日志复制

  1. Leader 负责处理所有客户端的请求。
  2. Leader 把请求作为日志条目加入到它的日志中,然后并行的向其他服务器发送 AppendEntries RPC 请求,要求 Follower 复制日志条目。
  3. Follower 复制成功后,返回确认消息。
  4. 当这个日志条目被半数以上的服务器复制后,Leader 提交这个日志条目到它的复制状态机,并向客户端返回执行结果。

安全性

  • 选举限制:拥有最新的已提交的日志条目的 Follower 才有资格成为 Leader。
  • 提交旧任期的日志条目Raft 永远不会通过计算副本数目的方式去提交一个之前 Term 内的日志条目
  • 日志压缩:Raft 采用对整个系统进行快照来解决,快照之前的日志都可以丢弃。以此,避免日志无限膨胀,导致故障恢复过久。

:::

ZAB

扩展:

【高级】ZAB 是怎样工作的?

:::details 要点

ZAB 协议是 Zookeeper 专门设计的一种支持故障恢复的原子广播协议

ZAB 协议是 ZooKeeper 的数据一致性和高可用解决方案。

ZAB 协议定义了两个可以无限循环的流程:

  • 选举 Leader - 用于故障恢复,从而保证高可用。
  • 原子广播 - 用于主从同步,从而保证数据一致性。

选举 Leader

ZooKeeper 集群采用一主(称为 Leader)多从(称为 Follower)模式,主从节点通过副本机制保证数据一致。

  • 如果 Follower 节点挂了 - ZooKeeper 集群中的每个节点都会单独在内存中维护自身的状态,并且各节点之间都保持着通讯,只要集群中有半数机器能够正常工作,那么整个集群就可以正常提供服务
  • 如果 Leader 节点挂了 - 如果 Leader 节点挂了,系统就不能正常工作了。此时,需要通过 ZAB 协议的选举 Leader 机制来进行故障恢复。

ZAB 协议的选举 Leader 机制简单来说,就是:基于过半选举机制产生新的 Leader,之后其他机器将从新的 Leader 上同步状态,当有过半机器完成状态同步后,就退出选举 Leader 模式,进入原子广播模式。

原子广播

ZooKeeper 通过副本机制来实现高可用

那么,ZooKeeper 是如何实现副本机制的呢?答案是:ZAB 协议的原子广播。

ZAB 协议的原子广播要求:

**所有的写请求都会被转发给 Leader,Leader 会以原子广播的方式通知 Follow。当半数以上的 Follow 已经更新状态持久化后,Leader 才会提交这个更新,然后客户端才会收到一个更新成功的响应**。这有些类似数据库中的两阶段提交协议。

在整个消息的广播过程中,Leader 服务器会每个事务请求生成对应的 Proposal,并为其分配一个全局唯一的递增的事务 ID(ZXID),之后再对其进行广播。

:::

Gossip

【高级】Gossip 是怎样工作的?

扩展:

:::details 要点

Gossip 也叫 Epidemic Protocol (流行病协议),这个协议基于最终一致性以及去中心化设计思想。主要用于分布式节点之间进行信息交换和数据同步,这种场景的一个最大特点就是组成的网络的节点都是对等节点,是非结构化网络(去中心化)。

Gossip 过程是由种子节点发起,当一个种子节点有状态需要更新到网络中的其他节点时,它会随机的选择周围几个节点散播消息,收到消息的节点也会重复该过程,直至最终网络中所有的节点都收到了消息。这个过程可能需要一定的时间,由于不能保证某个时刻所有节点都收到消息,但是理论上最终所有节点都会收到消息,因此它是一个最终一致性协议。

Gossip 过程是异步的,也就是说发消息的节点不会关注对方是否收到,即不等待响应;不管对方有没有收到,它都会每隔 1 秒向周围节点发消息。异步是它的优点,而消息冗余则是它的缺点

Goosip 协议的信息传播和扩散通常需要由种子节点发起。整个传播过程可能需要一定的时间,由于不能保证某个时刻所有节点都收到消息,但是理论上最终所有节点都会收到消息,因此它是一个最终一致性协议。

Gossip 有两种类型:

  • Anti-Entropy(反熵)以固定的概率传播所有的数据。反熵时通讯成本会很高,可以通过引入校验和等机制,降低需要对比的数据量和通讯消息等。反熵不适合动态变化或节点数比较多的分布式环境。
  • Rumor-Mongering(谣言传播)仅传播新到达的数据。谣言传播模型指的是当一个节点有了新数据后,这个节点变成活跃状态,并周期性地联系其他节点向其发送新数据,直到所有的节点都存储了该新数据。在谣言传播模型下,消息可以发送得更频繁,因为消息只包含最新 update,体积更小。而且,一个谣言消息在某个时间点之后会被标记为 removed,并且不再被传播,因此,谣言传播模型下,系统有一定的概率会不一致。而由于,谣言传播模型下某个时间点之后消息不再传播,因此消息是有限的,系统开销小。

:::

如何设计系统

系统设计过程

步骤一、约束和用例

对于任何系统设计,第一件应该做的事是:阐明系统的约束并确定系统需要满足哪些用例。

永远不要假设没有明确说明的事情。一定要尽力收集、理解需求,并设计一个很好地涵盖这些要求的解决方案。

例如,URL 缩短服务可能只为几千个用户提供服务,但每个用户都可能共享数百万个 URL。它可能旨在处理对缩短的 URL 的数百万次点击或数十次点击。该服务可能必须提供有关每个缩短的 URL 的大量统计信息(这会增加您的数据大小),或者可能根本不需要统计信息。

您还必须考虑预期会发生的用例。您的系统将根据其预期功能进行设计。不要忘记确保你知道面试官一开始没有告诉你的所有要求。

步骤二、顶层设计

一旦确定了要设计的系统的范围,接下来就要做顶层设计:概述系统架构中所需的所有重要组件。

此时,应该绘制出主要组件以及它们之间的连接。通常,这种顶层设计是基于主流技术的组合。这就要求设计必须熟悉这些技术,了解其利弊以及适合使用的场景。

步骤三、分析瓶颈

顶层设计很可能会遇到一个或多个瓶颈。这完全没问题,不要指望一个新系统可以立即处理世界上的所有负载。它只需要可扩展,以便您能够使用一些标准工具和技术对其进行改进。

现在有了顶层设计,就要考虑这些组件在系统扩展时面临的瓶颈。也许,系统需要一个负载均衡器和集群来处理用户请求。或者,由于数据容量庞大,以至于需要将数据库分库分表(分布在多台机器上)。这些方案有什么利弊,是否适用?数据库是否太慢,是否需要一些内存缓存?

通常每个解决方案都是某种权衡和取舍。改变某事会使其他事情恶化。然而,重要的是能够讨论这些权衡,并根据定义的约束和用例来衡量它们对系统的影响。

一旦分析清楚核心瓶颈,就可以着手在下一步中去解决它们。

步骤四、扩展设计

首先,你需要了解以下技术手段:

  • 垂直扩展
  • 水平罗占
  • 缓存
  • 负载均衡
  • 数据库复制
  • 数据库分区
  • 异步
  • NoSql

在系统设计方面,回顾现实中的架构非常有用。注意使用了哪些技术。继续研究每一项新技术,看看它解决了什么问题,它的替代品是什么,它擅长的地方,以及失败的地方。

一切都是权衡的结果——这是系统设计中最基本的概念之一。

一些推荐的学习资料

  • 生产中的深度学习:关于 EyeEm 如何构建在大量图像上运行多个深度学习模型的生产系统的精彩故事
  • Uber:一篇关于 Uber 如何快速扩展的好文章,关于将您的服务分解为分布在许多存储库中的许多微服务。
  • Facebook:Facebook 如何在直播中同时处理 800,000 名观众
  • Kraken.io:如何大规模缩放图像优化,本文将更详细地看一些具体使用的硬件方案,以及部署、监控等重要方面
  • Twitter:Twitter 如何处理每秒 3,000 张图片上传以及为什么它使用的旧方式现在行不通
  • 最后,Twitter 子组件的一些很好的例子:存储数据(video | text)和时间轴(video | text)。
  • 有关更高级的示例,请查看 Google、Youtube(video | text)、TumblrStackOverflowDatashift 上的这些帖子。

参考资料

权限认证综述

认证

认证是指根据声明者所特有的识别信息,确认声明者的身份。认证在英文中对应于 identification 这个单词。

最常见的认证实现方式是通过用户名和密码,但认证方式不限于此。下面都是当前常见到的认证技术:

  • 身份证
  • 用户名和密码认证
  • 用户手机认证:手机短信、手机二维码扫描、手势密码
  • 用户邮箱认证
  • 基于时间序列和用户相关的一次性口令
  • 用户的生物学特征认证:指纹、语音、眼睛虹膜
  • 用户的大数据识别认证
  • 等等

为了确认用户的身份,防止伪造,在安全要求高的场合,经常会使用组合认证(或者叫多因素认证),也就是同时使用多个认证方式对用户的身份进行校验。

授权

简单来说,授权一般是指获取用户的委派权限。在英文中对应于 authorization 这个单词。

在信息安全领域,授权是指资源所有者委派执行者,赋予执行者指定范围的资源操作权限,以便执行者代理执行对资源的相关操作。这里面包含有如下四个重要概念,

  • 资源所有者:拥有资源的所有权利,一般就是资源的拥有者。
  • 资源执行者:被委派去执行资源的相关操作。
  • 操作权限:可以对资源进行的某种操作。
  • 资源:有价值的信息或数据等,受到安全保护。

需要说明的是,资源所有者和执行者可以是自然人,就是普通用户,但不限于自然人。在信息安全领域,资源所有者和执行者,很多时候是应用程序或者机器。比如用户在浏览器上登录一个网站,那么这个浏览器就成为一个执行者,它在用户登录后获取了用户的授权,代表着用户执行各种指令,进行购物、下单、付钱、转账等等操作。

同时,资源所有者和执行者可以是分开的不同实体,也可以是同一个。若是分开的两者,则资源执行者是以资源所有者的代理形式而存在。

授权的实现方式非常多也很广泛,我们常见的银行卡、门禁卡、钥匙、公证书,这些都是现实生活中授权的实现方式。其实现方式主要通过一个共信的媒介完成,这个媒介不可被篡改,不可随意伪造,很多时候需要受保护,防止被窃取。

在互联网应用开发领域,授权所用到的授信媒介主要包括如下几种,

  • 通过 web 服务器的 session 机制,一个访问会话保持着用户的授权信息
  • 通过 web 浏览器的 cookie 机制,一个网站的 cookie 保持着用户的授权信息
  • 颁发授权令牌(token),一个合法有效的令牌中保持着用户的授权信息

前面两者常见于 web 开发,需要有浏览器的支持。

鉴权

鉴权是指对于一个声明者所声明的身份权利,对其所声明的真实性进行鉴别确认的过程。在英文中对应于 authentication 这个单词。

鉴权主要是对声明者所声明的真实性进行校验。若从授权出发,则会更加容易理解鉴权。授权和鉴权是两个上下游相匹配的关系,先授权,后鉴权。授权和鉴权两个词中的“权”,是同一个概念,就是所委派的权利,在实现上即为授信媒介的表达形式。

因此,鉴权的实现方式是和授权方式有一一对应关系。对授权所颁发授信媒介进行解析,确认其真实性。下面是鉴权的一些实现方式,

  • 门禁卡:通过门禁卡识别器
  • 钥匙:通过相匹配的锁
  • 银行卡:通过银行卡识别器
  • 互联网 web 开发领域的 session/cookie/token:校验 session/cookie/token 的合法性和有效性

鉴权是一个承上启下的一个环节,上游它接受授权的输出,校验其真实性后,然后获取权限(permission),这个将会为下一步的权限控制做好准备。

权限控制

权限控制是指对可执行的各种操作组合配置为权限列表,然后根据执行者的权限,若其操作在权限范围内,则允许执行,否则禁止。权限控制在英文中对应于 access/permission control。

对于权限控制,可以分为两部分进行理解:一个是权限,另一个是控制。权限是抽象的逻辑概念,而控制是具体的实现方式。

先看权限(Permission),这是一个抽象的概念,一般预先定义和配置好,以便控制的具体实现。权限的定义,若简单点,可以直接对应于一个可执行的操作集合。而一般情况下,会有基于角色的方式来定义权限,由角色来封装可执行的操作集合。

若以门禁卡的权限实现为例,上述两种定义方式则可以各自表达为,

  • 这是一个门禁卡,拥有开公司所有的门的权限
  • 这是一个门禁卡,拥有管理员角色的权限,因而可以开公司所有的门

可以看到,权限作为一个抽象的概念,将执行者和可具体执行的操作相分离。

在上文的讨论中,鉴权的输出是权限(Permission)。一旦有了权限,便知道了可执行的操作,接下来就是控制的事情了。

对于控制,是根据执行者的权限,对其所执行的操作进行判断,决定允许或禁止当前操作的执行。现实生活中控制的实现方式,多种多样,

  • 门禁:控制门的开关
  • 自行车锁:控制车轮
  • 互联网 web 后端服务:控制接口访问,允许或拒绝访问请求

认证和鉴权

认证、授权、鉴权和权限控制这四个环节是一个前后依次发生、上下游的关系,

认证–>授权–>鉴权–>权限控制

需要说明的是,这四个环节在有些时候会同时发生。 例如在下面的几个场景,

  • 使用门禁卡开门:认证、授权、鉴权、权限控制四个环节一气呵成,在瞬间同时发生
  • 用户的网站登录:用户在使用用户名和密码进行登录时,认证和授权两个环节一同完成,而鉴权和权限控制则发生在后续的请求访问中,比如在选购物品或支付时。

无论怎样,若从时间顺序方面来看,这四个环节是按时间前后、依次相继发生的关系。

认证和鉴权的关系:

这两个概念在很多时候是被混淆最多的概念。被混淆的主要原因,如上文所述,很多时候认证、授权、鉴权和权限控制一同发生,以至于被误解为,认证就是鉴权,鉴权就是认证。

其实两者是不一样的概念,两者都有对身份的确认过程,但是两者的主要区别在于,

  • 认证是确认声明者的本身身份,其作为授权的上游衔接而存在
  • 鉴权是对声明者所声明的真实性进行确认的过程,其作为授权的下游衔接而存在