前段时间,9万名开发人员参与了Stackoverflow年度调查,调查内容主要涉及开发者如何学习和升级,他们正在使用哪些工具,以及他们想要哪些工具。PostgreSQL超越MySQL成为最受欢迎的数据库。在所有的有效问卷中(76,634位受访者),有45.55%的开发者正在使用PostgreSQL,首次超越MySQL,这使得PostgreSQL登上数据库流行度的榜首。MySQL退居第二,有41.09%的开发者正在使用MySQL。其次是SQLite(占比30.9%)。Oracle位列第九,占比9.8%。
数据库排行
为什么PostgreSQL可以超过MySQL,今天就来比较下这两个数据库。

PostgreSQL vs.MySQL 是在选择开源关系型数据库管理系统时的重要决策。PostgreSQL和 MySQL 都是经过时间验证的解决方案,可以与企业级解决方案(如 Oracle 和 SQL Server) 竞争。MySQL 因其易用性和速度而闻名,而 PostgreSQL 则拥有更多高级功能,这也是 PostgreQL 经常被描述为开源版 racle 的原
下表对比了 PostgreSQL和MySQL的特性

特性对比

PostgreSQL和 MySQL 都作为关系型数据库管理系统提供了广泛的功能,但两者之间存在一些关键差异:

数据类型: PostgreSQL 支持更广泛的高级数据类型,包括数组、hstore(键值存储)和JSONB(二进制JSON)。另一方面MySQL 的数据类型集合较为有限,更适用于简单的 Web 应用程序。MySQL 的JSON 二进制存储格式可以与 PostgreSQL的JSONB 进行比较。实际上,MySQL的JSON 存储非常适合简单的JSON 结构和直接的 Web 应用程序。

地理空间支持: PostgreSQL 强力支持地理空间数据,包括用于处理地理数据的丰富的数据类型、函数和操作符。MySQL 在地理空间支持方面虽有一些功能,但在这个领域可能还需要更多的改进。

索引: 在 MySQL中,默认的索引类型是 B-tree,非常适合大多数使用情况。PostareSOL比 MySQL 拥有更为精细的索引系统包括对 B-tree、GiST(广义搜索树)和 GIN (广义反向索引)索引的支持。这些提供了更多优化查询性能和数据检索的选项。

复制: PostgreSQL和 MySQL 都支持主从数据库复制,但复制的方法和选项有所不同PostgreSQL 支持使用第三方扩展实现多主复制。MySQL 最近引入了一种名为 Group Replication 的新的复制模型,但它仍然是一个相对较新的功能,有一些限制。

事务: PostgreSQL和 MysQL lnnoDB 都使用 MVCC(多版本并发控制)来处理并发数据访问然而,PostgreSQL 提供了更先进的事务管理功能,如事务隔离级别、原子事务和保存点。相比之下,MySQL 的事务管理选项较为有限。对于需要高并发或复杂事务逻辑的应用程序,PostgreSQL可能更加合适。

存储过程: PostgreSQL和 MySQL 都支持存储过程,但存储过程的语言和功能有所不同。PostgreSQL 支持使用多种语言编写存储过程,包括 PL/pgSQL、PL/Tcl、PL/Perl 等。而MySQL 主要支持使用SQL编写存储过程。

扩展: PostgresQL 拥有一个强大的扩展框架,允许开发人员添加自定义功能并扩展数据库的核心特性。虽然 MySQL 对扩展有定支持,但与 PostgreSQL 相比,其扩展性有所不同。

放一个官网的表格

PostgreSQL MySQL
Known as The world’s most advanced open source database. The world’s most popular open source database.
Development PostgreSQL is an open source project. MySQL is an open-source product.
Pronunciation post gress queue ell my ess queue ell
Licensing MIT-style license GNU General Public License
Implementation programming language C C/C++
GUI tool PgAdmin MySQL Workbench
ACID Yes Yes
Storage engine Single storage engine Multiple storage engines e.g., InnoDB and MyISAM
Full-text search Yes Yes (Limited)
Drop a temporary table No TEMP or TEMPORARY keyword in DROP TABLE statement Support the TEMP or TEMPORARY keyword in the DROP TABLE statement that allows you to remove the temporary table only.
DROP TABLE Support CASCADE option to drop table’s dependent objects e.g., tables and views. Does not support CASCADE option.
TRUNCATE TABLE PostgreSQL TRUNCATE TABLE supports more features like CASCADE, RESTART IDENTITY, CONTINUE IDENTITY, transaction-safe, etc. MySQL TRUNCATE TABLE does not support CASCADE and transaction safe i.e,. once data is deleted, it cannot be rolled back.
Auto increment Column SERIAL AUTO_INCREMENT
Identity Column Yes No
Analytic functions Yes No
Data types Support many advanced types such as array, hstore, and user-defined type. SQL-standard types
Unsigned integer No Yes
Boolean type Yes Use TINYINT(1) internally for Boolean
IP address data type Yes No
Set default value for a column Support both constant and function call Must be a constant or CURRENT_TIMESTAMP for TIMESTAMP or DATETIME columns
CTE Yes Yes (Supported CTE since MySQL 8.0)
EXPLAIN output More detailed Less detailed
Materialized views Yes No
CHECK constraint Yes Yes (Supported since MySQL 8.0.16, Before that MySQL just ignored the CHECK constraint)
Table inheritance Yes No
Programming languages for stored procedures Ruby, Perl, Python, TCL, PL/pgSQL, SQL, JavaScript, etc. SQL:2003 syntax for stored procedures
FULL OUTER JOIN Yes No
INTERSECT Yes No
EXCEPT Yes No
Partial indexes Yes No
Bitmap indexes Yes No
Expression indexes Yes No
Covering indexes Yes (since version 9.2) Yes. MySQL supports covering indexes that allow data to be retrieved by scanning the index alone without touching the table data. This is advantageous in case of large tables with millions of rows.
Triggers Support triggers that can fire on most types of command, except for ones affecting the database globally e.g., roles and tablespaces. Limited to some commands
Partitioning RANGE, LIST RANGE, LIST, HASH, KEY, and composite partitioning using a combination of RANGE or LIST with HASH or KEY subpartitions
Task Schedule pgAgent Scheduled event
Connection Scalability Each new connection is an OS process Each new connection is an OS thread

变更数据捕捉

变更数据捕获(Change Data Capture, CDC) 方面,MySQL 二进制志和 PostgreSQL 预写日志(Write-Ahead Logs,WAL)都可以捕获对数据库所做的更改。然而,CDC的具体特性和用法可能会有所不同。DBConvert Streams 是一款软件,可以读取 MySQL和 PostgreSQL事务日志,并将记录转换为另一种方言,从而适用于实时异构数据库复制。

性能

MySQL 以其高性能和处理大量数据的能力而闻名。它经过优化,适用于读密集型工作负载,并具有快速的索引系统,有助于提高查询性能。然而,当与写操作结合时,可能会出现并发问题,如锁争用,导致性能下降。lnnoDB 存储引擎用于解决表级别锁问题。它是 MySQL 生态系统中最受欢迎和广泛使用的存储引整之一。lnnoDB 支持行级别锁定,提高了混合工作负载的并发性。此外,近期开发的高性能存储引擎 MyRocks 进一步提高了MySQL处理写入密集型工作负载的能力。

PostgreSQL 的设计更加多样化,可以处理读密集型和写密集型的工作负载,但相对于针对读密集型工作负载进行优化的 MySQL性能略低。然而,PostgreSQL 在最近的版本中改进了性能,特别是在处理复杂查询和数据处理方面。此外,与 MySQL 相比,PostgreSQL 具有更先进的索引系统,可以提高复杂查询的性能。PostgreSQL 还支持高级数据类型,如数组和JSONB,可以实现更高效的数据存储和检索。

可扩展性

MySQL和 PostgreSQL 都可以进行扩展,但在可扩展性方面它们具有不同的优势和劣势。MySQL 常常被人们喜欢是因为其水平可扩展性,也就是说可以通过向数据库集群添加更多节点来进行横向扩展。它非常适合需要处理大量并发连接的 Web 应用程序。

另一方面,PostgreSQL 以其垂直可扩展性而闻名,也就是说可以通过向单个节点添加更多资源(如内存和CPU)来处理大量数据和处理能力。它还通过分片等技术支持水平扩展,允许将大型数据集分割到多个节点上。PostgreSQL 更适合需要复杂查询和事务以及数据仓库和商业智能工作负载的应用程序。
在考虑可扩展性时,要考虑您的应用程序的具体要求。如果您需要处理大量并发连接并需要水平可扩展性,那么 MySQL 可能是个更好的选择。然而,如果您需要复杂的事务和查询,那么 PostgreSQL 可能更合适。

成本

在2023年,PostgreSQL仍然是完全开源和由社区驱动的,而MySQL在许可方面有更复杂的历史。MySQL最初是由MySQL AB开发的商业产品,提供免费和付费版本。2010年,Oracle收购了MySQL AB,这引发了开发人员对其开源状态的未来的一些担忧。然而,一些原始MySQL的开源分支,包括MariaDB和Percona,已经帮助缓解了这些担忧。

PostgreSQL的问题

尽管PostgreSQL具有先进的特性和功能,但它尚未达到MySQL的普及程度和广泛使用。这导致了第三方工具数量较少以及PostgreSQL生态系统中经验丰富的开发人员或数据库管理员数量较少。

由于其先进的特性,相对于MySQL,设置和管理PostgreSQL可能更复杂,更适合有经验的数据库管理员和开发人员。

由于其更复杂的架构和特性,PostgreSQL在某些使用情况下可能比MySQL更慢。

PostgreSQL可能需要比MySQL更多的资源,特别是在内存和CPU使用方面。

尽管PostgreSQL是开源的,但由于其先进的特性和增加的资源需求,实施和维护的成本仍然可能很高。

对于每个新的客户端连接,PostgreSQL都会分叉一个新的进程,这可能会分配大量的内存,通常每个连接约为10 MB。然而,这种架构的设计旨在提供更好的性能、可靠性和可扩展性之间的安全性和隔离性权衡。

PostgreSQL的设计注重可扩展性、标准符合性、可扩展性和数据完整性。有时,这些特性与MySQL相比可能会降低性能,特另是在简单的读密集型工作负载中。

ES的最佳实践

首先我们先来看下ES的最佳实践,这里列出了最核心的两点,分片大小的控制和分片数的设定

1. 单分片控制在50G以内(推荐日志场景在30G以内,搜索场景在10G以内)

分片大小为什么要控制呢?如果分片太大,我们在做数据迁移,reblance或者recovery的时候效率会特别低,影响集群的稳定性,同时分片太小了也不行,分片太小会导致同样数据量的情况下,集群的分片数会更多,ES的分片数越多,其实分片管理的效率会越低也会影响ES整体性能。

2. 索引的分片数位数据节点的倍数

比如有3个数据节点,那最好的分片数应该是3、6、9等等

如果能做到以上两点,整个集群的稳定性还是有一定保障的。但其实以上这两点是很难同时做到的,这中间有个矛盾就是索引分片数没办法修改,但是一般情况下数据量都是不断增长的,最总就会导致单个分片的大小超过最佳实践的推荐值。

那要如何解决索引分片数无法修改和数据持续增长的矛盾?

理想实现

那我们看下分布式系统中有没有这种比较理想的实现方式。

这边先举个例子,这里给出了一个有三个节点的数据集群,其中包含了3个shard

1691810296023

数据持续增长…

1691810457654

每个分片中的数据在不断增加,即将超过30G,这时候要怎么办呢?

方式一 分裂

1691810614196

先把分片一分为二,再把数据平分,这样每个分片中就只存了一半的数据。如果数据不断增长,又满了,那就再次分裂。

方式二 新增

1691810780548

当数据满的时候,通过新增分片的方式来存储新的数据。ES中使用的方式就是这种新增的方式。

为什么ES没有使用方式一种的分裂方式呢?主要是因为数据的路由规则问题,ES用的是hash路由的方式来确定数据在哪个分片上,如果使用分裂的方式,每次分裂就需要重新对一半数据做reindex。(当然目前的es版本支持了split这种api,但实际上使用的并不多,因为性能较差)

理想实现 rollover

1691811190314

ES种新增的过程叫rollover,就是旧的索引(index-00001)满了之后会创建一个新的索引(index-00002),但从整体上来讲,他们对于用户来讲属于同一个索引,index-00001和index-00002被同一个逻辑索引或者叫别名(alias)纳管。程序使用的时候是通过别名来进行读写,rollover和alias是同时使用的。如果index-00002也满了,就会继续滚动,创建index-00003,同时被别名纳管。

这样就实现了shard随着数据的不断增长,自身也在不断增加,同时确保每个分片的数据量保持在推荐的范围内。

理想实现 migration

那实际生产中可能还有一中场景,就是随着时间的一部分数据查询频率会降低,数据价值也会降低,这时候如果如果可以把数据迁移到低配的机器上可以大大节省成本。

1691811697352

迁移

1691816974098

这样就可以更合理使用硬件资源,提升硬件使用效率。

再回头看最佳实践,单分片大小限制和分片数不可修改的问题就完美解决了。我们只需要根据业务情况来确定滚动周期以及数据每日增量即可确定分片数以及单分片大小,系统会根据配置自动滚动,生成新的索引。

ES的索引生命周期

索引的生命周期可以归纳为下面四个阶段

Hot阶段读写频繁,使用高配服务器,Warm、Cold使用低配机器即可满足

1691817840919

我们只需要搞清楚每个阶段做了生命操作即可

1691817954788

索引一单创建肯定是进入到Hot阶段,我们可以指定一个min_age,那两天之后就会进入到Warm阶段,然后再设置4天之后进入Cold阶段,以及23天后删除。

Hot Phase

Create

​ Index Template

Rollover

​ Index Alias

​ 在索引文档数、大小、时间打到一定条件后,创建索引

​ 控制Shard大小

Warm Phase

Allocate

​ Node Attribute

​ Index Shard Allocation

​ index.routing.allocation.require.*

Read-Only

​ index.blocks.read_only:true

Fource Merge

Shrink

Cold Phase

Allocate

​ Node Attribute

​ Index Shard Allocation

​ index.routing.allocation.require.*

Delete Phase

Delete

实战

创建rollover策略

如下策略的含义为:当索引的大小达到1000GB,索引创建超过1天时,自动进行滚 动;索引创建7天后,关闭数据副本;索引创建30天后,删除该索引。

策略可以根据具体业务场景进行配置。

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
PUT _opendistro/_ism/policies/rollover_workflow
{
"policy":{
"description":"rollover test",
"default_state":"hot",
"states":[
{
"name":"hot",
"actions":[
{
"rollover":{
"min_size":"1000gb",
"min_index_age":"1d"
}
}
],
"transitions":[
{
"state_name":"warm",
"conditions":{
"min_index_age":"7d"
}
}
]
},
{
"name":"warm",
"actions":[
{
"replica_count":{
"number_of_replicas":0
}
}
],
"transitions":[
{
"state_name":"delete",
"conditions":{
"min_index_age":"30d"
}
}
]
},
{
"name":"delete",
"actions":[
{
"delete":{

}
}
]
}
]
}
}

创建好Rollover策略之后,可以通过如下命令查询策略详情:

1
GET _opendistro/_ism/policies/rollover_workflow
新建索引模板

如下模板的含义为:对于所有test开头的索引,其自动关联上面创建的rollover策略, 并且rollover时使用log_alias作为别名。 模板可以结合具体业务场景进行调整,比如:number_of_shards、refresh_interval, 以及mapping里面的参数等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PUT _template/template_test
{
"index_patterns":"test*",
"settings":{
"number_of_replicas":1,
"number_of_shards":1,
"opendistro.index_state_management.policy_id":"rollover_workflow",
"index.opendistro.index_state_management.rollover_alias":"log_alias"
},
"mappings":{
"properties":{
"name":{
"type":"text"
}
}
}
}

创建好索引模板之后,可以通过如下命令查询模板详情:

1
GET _template/template_test
创建第一个索引

第一个索引要指定aliases,并且需要配置is_write_index为true。

如下索引是<test-{now/d}-000001>的URL编码,其创建时默认会带上当天的时间,比
如假设今天为2022.6.22,那么创建出来的索引名称为:test-2022.06.02-000001。

这里必须用urlencode,不然报错

1
2
3
4
5
6
7
8
PUT %3Ctest-%7Bnow%2Fd%7D-000001%3E
{
"aliases":{
"log_alias":{
"is_write_index":true
}
}
}
写入或查询数据

写入数据或查询数据时均使用别名log_alias,其中写入时log_alias始终指向最后一个 索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
POST log_alias/_bulk
{"index":{}}
{"name":"name1"}
{"index":{}}
{"name":"name2"}
{"index":{}}
{"name":"name3"}
{"index":{}}
{"name":"name4"}
{"index":{}}
{"name":"name5"}
{"index":{}}
{"name":"name6"}

写入数据或查询数据时均使用别名log_alias,其中查询时log_alias指向所有的历史索引:

1
GET log_alias/_search

查询别名关联的索引情况:

1
GET _cat/aliases?v

假设某个索引,其每天约产生2.4TB的数据,那么其数据在ES中的组织形态如下,其中索引别名 log-alias。 查询时指向所有test开头的索引,写入时指向最新的索引。

1691818897925

数据更新的问题

在实际开发测试中发现,历史数据使用索引别名更新的时候会存在问题。

假设历史A索引中存在一条x数据,当前索引为B索引

当再次更新这个x数据的时候,不会更新A索引中的这条数据,而是会插入到B索引中,此时再去查询x数据就会查询到两条。

原因:Rollover策略实现自动滚动索引后,历史索引的is_write_index属性会被设置为false,当通过索引别名来PUT数据时,只会从当前索引中判断是同存在相同数据。由于数据存在历史索引中,所以会在新索引中创建记录

1692063483655.jpg

解决方法:更新数据时先查询当前数据所在的索引,更新时带上索引

总结

通过ES的索引生命周期管理,我们可以根据业务需求,设置分片数与滚动策略,不用担心索引分片数不够,或者分片容量太大的问题。合理使用分片策略可以满足大部分订单类系统查询需求。

简介

ES是使用 Java 编写的一种开源搜索引擎,它在内部使用 Lucene做索引与搜索,通过对
Lucene的封装,隐藏了Lucene的复杂性,取而代之的提供一套简单一致的 RESTful API
然而,Elasticsearch 不仅仅是 Lucene,并且也不仅仅只是一个全文搜索引擎。

它可以被下面这样准确的形容:

  • 一个分布式的实时文档存储,每个字段可以被索引与搜索。
  • 一个分布式实时分析搜索引擎。
  • 能胜任上百个服务节点的扩展,并支持 PB 级别的结构化或者非结构化数据。

官网对 Elasticsearch 的介绍是 Elasticsearch 是一个分布式、可扩展、近实时的搜索与数据分析引擎。

其中主要有如下几个核心术语需要理解:

  • 词条(Term): 索引里面最小的存储和查询单元,对于英文来说是一个单词,对于中文来说一般指分词后的一个词。
  • 词典(Term Dictionary): 或字典,是词条 Term 的集合。搜索引擎的通常索引单位是单词,单词词典是由文档集合中出现过的所有单词构成的字符串集合,单词词典内每条索引项记载单词本身的一些信息以及指向倒排列表的指针。
  • 倒排表(Post list):一个文档通常由多个词组成,倒排表记录的是某个词在哪些文档里出现过以及出现的位置。每条记录称为一个倒排项(Posting)。倒排表记录的不仅是文档编号,还存储了词频等信息。
  • 倒排文件(Inverted File): 所有单词的倒排列表往往顺序地存储在磁盘的某个文件里,这个文件被称之为倒排文件,倒排文件是存储倒排索引的物理文件
    **由属性值来确定记录的位置的结构就是倒排索引**。带有倒排索引的文件称为倒排文件

词典倒排表Lucene
中很重要的两种数据结构,是实现快速检索的重要基石。词典倒排文件是分两部分存储的,词典在内存中倒排文件存储在磁盘

分片,副本,映射

分片(Shards)

ES 支持 PB 级全文搜索,当索引上的数据量太大的时候,ES
通过水平拆分的方式将一个索引上的数据拆分出来分配到不同的数据块上,拆分出来的数据库块称之为一个分片。
这类似于 MySQL 的分库分表,只不过 MySQL 分库分表需要借助第三方组件而 ES 内部自身实现了此功能。

在一个多分片的索引中写入数据时,通过路由来确定具体写入哪一个分片中,所以在创建索引的时候需要指定分片的数量,并且分片的数量一旦确定就不能修改。
分片的数量和下面介绍的副本数量都是可以通过创建索引时的 Settings 来配置,ES 默认为一个索引创建 5 个主分片,
并分别为每个分片创建一个副本。

PUT /myIndex    
{    
   "settings" : {    
      "number_of_shards" : 5,    
      "number_of_replicas" : 1    
   }    
}    
  

ES 通过分片的功能使得索引在规模上和性能上都得到提升,每个分片都是 Lucene
中的一个索引文件,每个分片必须有一个主分片零到多个副本

副本(Replicas)

副本就是对分片的 Copy,每个主分片都有一个或多个副本分片,当主分片异常时,副本可以提供数据的查询等操作。
主分片和对应的副本分片是不会在同一个节点上的,所以副本分片数的最大值是 N-1(其中 N 为节点数)。
对文档的新建、索引和删除请求都是写操作,必须在主分片上面完成之后才能被复制到相关的副本分片。

ES 为了提高写入的能力这个过程是并发写的,同时为了解决并发写的过程中数据冲突的问题,ES 通过乐观锁的方式控制,每个文档都有一个
_version (版本)号,当文档被修改时版本号递增。
一旦所有的副本分片都报告写成功才会向协调节点报告成功,协调节点向客户端报告成功

ES集群

从上图可以看出为了达到高可用,Master 节点会避免将主分片和副本分片放在同一个节点上。

假设这时节点 Node1 服务宕机了或者网络不可用了,那么主节点上主分片 S0 也就不可用了。幸运的是还存在另外两个节点能正常工作,这时 ES
会重新选举新的主节点,而且这两个节点上存在我们所需要的 S0 的所有数据。我们会将 S0
的副本分片提升为主分片,这个提升主分片的过程是瞬间发生的。此时集群的状态将会为 Yellow

为什么我们集群状态是 Yellow 而不是 Green 呢?虽然我们拥有所有的 2
个主分片,但是同时设置了每个主分片需要对应两份副本分片,而此时只存在一份副本分片。所以集群不能为 Green 的状态。
如果我们同样关闭了 Node2 ,我们的程序依然可以保持在不丢失任何数据的情况下运行,因为 Node3 为每一个分片都保留着一份副本。
如果我们重新启动 Node1 ,集群可以将缺失的副本分片再次进行分配,那么集群的状态又将恢复到原来的正常状态。
如果 Node1 依然拥有着之前的分片,它将尝试去重用它们,只不过这时 Node1
节点上的分片不再是主分片而是副本分片了,如果期间有更改的数据只需要从主分片上复制修改的数据文件即可。

小结:

  • 将数据分片是为了提高可处理数据的容量和易于进行水平扩展,为分片做副本是为了提高集群的稳定性和提高并发量。
  • 副本是乘法,越多消耗越大,但也越保险。分片是除法,分片越多,单分片数据就越少也越分散。
  • 副本越多,集群的可用性就越高,但是由于每个分片都相当于一个 Lucene 的索引文件,会占用一定的文件句柄、内存及 CPU。并且分片间的数据同步也会占用一定的网络带宽,所以索引的分片数和副本数也不是越多越好。

映射(Mapping)

映射是用于定义 ES 对索引中字段的存储类型、分词方式和是否存储等信息,就像数据库中的Schema
,描述了文档可能具有的字段或属性、每个字段的数据类型。
只不过关系型数据库建表时必须指定字段类型,而 ES 对于字段类型可以不指定然后动态对字段类型猜测,也可以在创建索引时具体指定字段的类型。
对字段类型根据数据格式自动识别的映射称之为动态映射(Dynamic Mapping),我们创建索引时具体定义字段类型的映射称之为静态映射或显示映射(Explicit Mapping)。

在讲解动态映射和静态映射的使用前,我们先来了解下 ES 中的数据有哪些字段类型?之后我们再讲解为什么我们创建索引时需要建立静态映射而不使用动态映射。

ES(v6.8)中字段数据类型主要有以下几类:

类别 数据类型
核心类型 text,keywords,long,integer,short,double,data,boolean
复杂类型 Object,Nested
地理类型 geo_point,geo_shape
特殊类型 ip,completion,token_count,join

Text 用于索引全文值的字段,例如电子邮件正文或产品说明。这些字段是被分词的,它们通过分词器传递
,以在被索引之前将字符串转换为单个术语的列表。分析过程允许 Elasticsearch
搜索单个单词中每个完整的文本字段。文本字段不用于排序,很少用于聚合。
Keyword 用于索引结构化内容的字段,例如电子邮件地址,主机名,状态代码,邮政编码或标签。它们通常用于过滤,排序,和聚合。Keyword
字段只能按其确切值进行搜索。

通过对字段类型的了解我们知道有些字段需要明确定义的,例如某个字段是 Text 类型还是 Keyword
类型差别是很大的,时间字段也许我们需要指定它的时间格式,还有一些字段我们需要指定特定的分词器等等。

如果采用动态映射是不能精确做到这些的,自动识别常常会与我们期望的有些差异。所以创建索引的时候一个完整的格式应该是指定分片和副本数以及 Mapping的定义,如下:

PUT my_index     
{    
   "settings" : {    
      "number_of_shards" : 5,    
      "number_of_replicas" : 1    
   }    
  "mappings": {    
    "_doc": {     
      "properties": {     
        "title":    { "type": "text"  },     
        "name":     { "type": "text"  },     
        "age":      { "type": "integer" },      
        "created":  {    
          "type":   "date",     
          "format": "strict_date_optional_time||epoch_millis"    
        }    
      }    
    }    
  }    
}    
  

ES机制原理

写索引原理

下图描述了 3 个节点的集群,共拥有 12 个分片,其中有 4 个主分片(S0、S1、S2、S3)和 8
个副本分片(R0、R1、R2、R3),每个主分片对应两个副本分片,节点 1 是主节点(Master 节点)负责整个集群的状态。

3个节点的ES集群

写索引只能写在主分片上,然后同步到副本分片。这里有四个主分片,一条数据 ES 是根据什么规则写到特定分片上的呢?
这条索引数据为什么被写到 S0 上而不写到 S1 或 S2 上?那条数据为什么又被写到 S3 上而不写到 S0 上了?

首先这肯定不会是随机的,否则将来要获取文档的时候我们就不知道从何处寻找了。实际上,这个过程是根据下面这个公式决定的:

shard = hash(routing) % number_of_primary_shards    
  

Routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值。
Routing 通过 Hash 函数生成一个数字,然后这个数字再除以 number_of_primary_shards
(主分片的数量)后得到余数。这个在 0number_of_primary_shards-1 之间的余数,就是我们所寻求的文档所在分片的位置。

这就解释了为什么我们要在创建索引的时候就确定好主分片的数量并且永远不会改变这个数量:**因为如果数量变化了,那么所有之前路由的值都会无效,文档也再也找不到了**

由于在 ES 集群中每个节点通过上面的计算公式都知道集群中的文档的存放位置,所以每个节点都有处理读写请求的能力。
在一个写请求被发送到某个节点后,该节点即为前面说过的协调节点协调节点会根据路由公式计算出需要写到哪个分片上,再将请求转发到该分片的主分片节点上

写请求

假如此时数据通过路由计算公式取余后得到的值是

shard=hash(routing)%4=0  
  

则具体流程如下:

  • 客户端向 ES1 节点(协调节点)发送写请求,通过路由计算公式得到值为 0,则当前数据应被写到主分片 S0 上。
  • ES1 节点将请求转发到 S0 主分片所在的节点 ES3,ES3 接受请求并写入到磁盘。
  • 并发将数据复制到两个副本分片 R0 上,其中通过乐观并发控制数据的冲突。一旦所有的副本分片都报告成功,则节点 ES3 将向协调节点报告成功,协调节点向客户端报告成功。

存储原理

上面介绍了在 ES 内部索引的写处理流程,这个流程是在 ES
内存中执行的,数据被分配到特定的分片副本上之后,最终是存储到磁盘上的,这样在断电的时候就不会丢失数据。
具体的存储路径可在配置文件 ../config/elasticsearch.yml 中进行设置,默认存储在安装目录的 Data 文件夹下。
建议不要使用默认值,因为若 ES 进行了升级,则有可能导致数据全部丢失:

path.data: /path/to/data  //索引数据    
path.logs: /path/to/logs  //日志记录    
  

分段存储

索引文档以的形式存储在磁盘上,索引文件被拆分为多个子文件,则每个子文件叫作,每一个段本身都是一个倒排索引,并且段具有不变性,一旦索引的数据被写入硬盘,就不可再修改。

在底层采用了分段存储模式,使它在读写时几乎完全避免了锁的出现,大大提升了读写性能。
段被写入到磁盘后会生成一个提交点提交点是一个用来记录所有提交后段信息的文件。

一个段一旦拥有了提交点,就说明这个段只有读的权限,失去了写的权限 。相反,
**当段在内存中时,就只有写的权限,而不具备读数据的权限,意味着不能被检索**。

段的概念提出主要是因为:在早期全文检索中为整个文档集合建立了一个很大的倒排索引,并将其写入磁盘中。如果索引有更新,就需要重新全量创建一个索引来替换原来的索引。这种方式在数据量很大时效率很低,并且由于创建一次索引的成本很高,所以对数据的更新不能过于频繁,也就不能保证时效性。

索引文件分段存储并且不可修改,那么新增、更新和删除如何处理呢?

  • 新增,新增很好处理,由于数据是新的,所以只需要对当前文档新增一个段就可以了。
  • 删除,由于不可修改,所以对于删除操作,不会把文档从旧的段中移除而是通过新增一个 .del 文件,文件中会列出这些被删除文档的段信息。这个被标记删除的文档仍然可以被查询匹配到, 但它会在最终结果被返回前从结果集中移除。
  • 更新,不能修改旧的段来进行反映文档的更新,其实更新相当于是删除新增这两个动作组成。会将旧的文档在 .del 文件中标记删除,然后文档的新版本被索引到一个新的段中。可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就会被移除。

段被设定为不可修改具有一定的优势也有一定的缺点,优势主要表现在:

  • 不需要锁,如果从来不更新索引,那就不需要担心多进程同时修改数据的问题。
  • 一旦索引被读入内核的文件系统缓存,便会留在哪里,由于其不变性。只要文件系统缓存中还有足够的空间,那么大部分读请求会直接请求内存,而不会命中磁盘。这提供了很大的性能提升。
  • 其它缓存(像 Filter 缓存),在索引的生命周期内始终有效。它们不需要在每次数据改变时被重建,因为数据不会变化。
  • 写入单个大的倒排索引允许数据被压缩,减少磁盘 I/O 和需要被缓存到内存的索引的使用量。

段的不变性的缺点如下:

  • 当对旧数据进行删除时,旧数据不会马上被删除,而是在 .del 文件中被标记为删除。而旧数据只能等到段更新时才能被移除,这样会造成大量的空间浪费。
  • 若有一条数据频繁的更新,每次更新都是新增新的标记旧的,则会有大量的空间浪费。
  • 每次新增数据时都需要新增一个段来存储数据。当段的数量太多时,对服务器的资源例如文件句柄的消耗会非常大。
  • 在查询的结果中包含所有的结果集,需要排除被标记删除的旧数据,这增加了查询的负担。

延迟写策略

介绍完了存储的形式,那么索引写入到磁盘的过程是怎样的?是否是直接调 Fsync物理性地写入磁盘?
答案是显而易见的,如果是直接写入到磁盘上,磁盘的 I/O 消耗上会严重影响性能。那么当写数据量大的时候会造成 ES
停顿卡死,查询也无法做到快速响应。如果真是这样 ES 也就不会称之为近实时全文搜索引擎了。

为了提升写的性能,ES
并没有每新增一条数据就增加一个段到磁盘上,而是采用延迟写的策略。每当有新增的数据时,就将其先写入到内存中,在内存磁盘之间是文件系统缓存
当达到默认的时间(1 秒钟)或者内存的数据达到一定量时,会触发一次刷新(Refresh),将内存中的数据生成到一个新的段上并缓存到文件缓存系统
上,稍后再被刷新到磁盘中并生成提交点。
这里的内存使用的是 ESJVM 内存,而文件缓存系统使用的是操作系统的内存。

新的数据会继续的被写入内存,但内存中的数据并不是以段的形式存储的,因此不能提供检索功能。由内存刷新到文件缓存系统的时候会生成新的段,并将段打开以供搜索使用,而不需要等到被刷新到磁盘。

Elasticsearch 中,写入和打开一个新段的轻量的过程叫做 Refresh
(即内存刷新到文件缓存系统)。默认情况下每个分片会每秒自动刷新一次。这就是为什么我们说 Elasticsearch
近实时搜索,因为文档的变化并不是立即对搜索可见,但会在一秒之内变为可见。
我们也可以手动触发 RefreshPOST /_refresh 刷新所有索引,POST /nba/_refresh 刷新指定的索引。

注意:尽管刷新是比提交轻量很多的操作,它还是会有性能开销。当写测试的时候,
手动刷新很有用,但是不要在生产环境下每次索引一个文档都去手动刷新。而且并不是所有的情况都需要每秒刷新。

假如正在使用 Elasticsearch 索引大量的日志文件, 想优化索引速度而不是近实时搜索。这时可以在创建索引时在 Settings 中通过调大
refresh_interval = "30s" 的值 , 降低每个索引的刷新频率,设值时需要注意后面带上时间单位,否则默认是毫秒。当
refresh_interval=-1 时表示关闭索引的自动刷新。

虽然通过延时写的策略可以减少数据往磁盘上写的次数并提升了整体的写入能力,但是我们知道文件缓存系统也是内存空间,属于操作系统的内存,只要是内存都存在断电或异常情况下丢失数据的危险。
为了避免丢失数据,Elasticsearch 添加了事务日志(Translog),事务日志记录了所有还没有持久化到磁盘的数据

事务日志后写索引的流程

添加了事务日志后整个写索引的流程如上图所示:

  • 一个新文档被索引之后,先被写入到内存中,但是为了防止数据的丢失,会追加一份数据到事务日志中。
  • 不断有新的文档被写入到内存,同时也都会记录到事务日志中。这时新数据还不能被检索和查询。
  • 当达到默认的刷新时间或内存中的数据达到一定量后,会触发一次 Refresh,将内存中的数据以一个新段形式刷新到文件缓存系统中并清空内存。这时虽然新段未被提交到磁盘,但是可以提供文档的检索功能且不能被修改。
  • 随着新文档索引不断被写入,当日志数据大小超过 512M 或者时间超过 30 分钟时,会触发一次 Flush
  • 内存中的数据被写入到一个新段同时被写入到文件缓存系统,文件系统缓存中数据通过 Fsync 刷新到磁盘中,生成提交点,日志文件被删除,创建一个空的新日志。

通过这种方式当断电或需要重启时,ES 不仅要根据提交点去加载已经持久化过的段,还需要工具 Translog
里的记录,把未持久化的数据重新持久化到磁盘上,避免了数据丢失的可能。

段合并

由于自动刷新流程每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。而段数目太多会带来较大的麻烦。每一个段都会消耗文件句柄、内存和 CPU
运行周期。更重要的是,每个搜索请求都必须轮流检查每个段然后合并查询结果,所以段越多,搜索也就越慢。

Elasticsearch 通过在后台定期进行段合并来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段。

段合并的时候会将那些旧的已删除文档从文件系统中清除。被删除的文档不会被拷贝到新的大段中。合并的过程中不会中断索引和搜索。

段合并

段合并在进行索引和搜索时会自动进行,合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中,**这些段既可以是未提交的也可以是已提交的**
合并结束后老的段会被删除,新的段被 Flush 到磁盘,同时写入一个包含新段且排除旧的和较小的段的新提交点,新的段被打开可以用来搜索。

段合并的计算量庞大, 而且还要吃掉大量磁盘 I/O,段合并会拖累写入速率,如果任其发展会影响搜索性能。
Elasticsearch 在默认情况下会对合并流程进行资源限制,所以搜索仍然有足够的资源很好地执行。

性能优化

存储设备

磁盘在现代服务器上通常都是瓶颈。Elasticsearch 重度使用磁盘,磁盘能处理的吞吐量越大,节点就越稳定。

这里有一些优化磁盘 I/O 的技巧:

  • 使用 SSD。比机械磁盘优秀多了。
  • 使用 RAID 0。条带化 RAID 会提高磁盘 I/O,代价显然就是当一块硬盘故障时整个就故障了。不要使用镜像或者奇偶校验 RAID 因为副本已经提供了这个功能。
  • 使用多块硬盘,并允许 Elasticsearch 通过多个 path.data 目录配置把数据条带化分配到它们上面。
  • 不要使用远程挂载的存储,比如 NFS 或者 SMB/CIFS。这个引入的延迟对性能来说完全是背道而驰的。

内部索引优化

Elasticsearch 为了能快速找到某个 Term,先将所有的 Term 排个序,然后根据二分法查找 Term,时间复杂度为
logN,就像通过字典查找一样,这就是 Term Dictionary
现在再看起来,似乎和传统数据库通过 B-Tree 的方式类似。但是如果 Term 太多,Term Dictionary
也会很大,放内存不现实,于是有了 Term Index
就像字典里的索引页一样,A 开头的有哪些 Term,分别在哪页,可以理解 Term Index是一棵树。这棵树不会包含所有的 Term,它包含的是
Term 的一些前缀。通过 Term Index 可以快速地定位到 Term Dictionary 的某个
Offset,然后从这个位置再往后顺序查找。

在内存中用 FST 方式压缩 Term IndexFST 以字节的方式存储所有的 Term,这种压缩方式可以有效的缩减存储空间,使得
Term Index 足以放进内存,但这种方式也会导致查找时需要更多的 CPU 资源。

对于存储在磁盘上的倒排表同样也采用了压缩技术减少存储所占用的空间。

FST

调整配置参数

调整配置参数建议如下:

  • 给每个文档指定有序的具有压缩良好的序列模式 ID,避免随机的 UUID-4这样的 ID,这样的 ID 压缩比很低,会明显拖慢 Lucene。
  • 对于那些不需要聚合和排序的索引字段禁用 Doc valuesDoc Values 是有序的基于 document=>field value 的映射列表。
  • 不需要做模糊检索的字段使用 Keyword 类型代替 Text 类型,这样可以避免在建立索引前对这些文本进行分词。
  • 如果搜索结果不需要近实时的准确度,考虑把每个索引的 index.refresh_interval 改到 30s
  • 如果在做大批量导入,导入期间可以通过设置这个值为 -1 关掉刷新,还可以通过设置 index.number_of_replicas: 0 关闭副本。别忘记在完工的时候重新开启它。
  • 避免深度分页查询建议使用 Scroll 进行分页查询。普通分页查询时,会创建一个 from+size 的空优先队列,每个分片会返回 from+size 条数据,默认只包含文档 ID 和得分 Score 给协调节点。
  • 如果有 N 个分片,则协调节点再对(from+size)×n条数据进行二次排序,然后选择需要被取回的文档。当 from 很大时,排序过程会变得很沉重,占用 CPU 资源严重。
  • 减少映射字段,只提供需要检索,聚合或排序的字段。其他字段可存在其他存储设备上,例如 Hbase,在 ES 中得到结果后再去 Hbase 查询这些字段。
  • 创建索引和查询时指定路由 Routing 值,这样可以精确到具体的分片查询,提升查询效率。路由的选择需要注意数据的分布均衡。

首先放TiDB的gitbook,里面对于tidb的大部分内容都有详细的介绍:https://book.tidb.io/

其次实际的应用当然需要PingCAP的官方文档:https://docs.pingcap.com/zh/tidb/stable/overview,因为自己只是想了解下TiDB,感觉这个数据库有点火,大致了解一下工作原理,自己在上一家公司有实际使用接触过,但是因为当时自己经验不足,不觉得他有什么特别或者不同,就没有过多了解。现在在美团公司内部有自己的分布式数据库,但是我负责的业务场景实际用不到,还是以Mysql为主,所以在这里根据网络上的这些资料,对TiDB做一个简单的学习和总结。

TiDB简介

TiDB 是 [PingCAP](https://pingcap.com/about-
cn/) 公司自主设计、研发的开源分布式关系型数据库,是一款同时支持在线事务处理与在线分析处理 (Hybrid Transactional and
Analytical Processing, HTAP) 的融合型分布式数据库产品,具备水平扩容或者缩容、金融级高可用、实时
HTAP、云原生的分布式数据库、兼容 MySQL 5.7 协议和 MySQL 生态等重要特性。目标是为用户提供一站式 OLTP (Online
Transactional Processing)、OLAP (Online Analytical Processing)、HTAP 解决方案。TiDB
适合高可用、强一致要求较高、数据规模较大等各种应用场景。

五大核心特性

  • 一键水平扩容或者缩容

得益于 TiDB 存储计算分离的架构的设计,可按需对计算、存储分别进行在线扩容或者缩容,扩容或者缩容过程中对应用运维人员透明。

  • 金融级高可用

数据采用多副本存储,数据副本通过 Multi-Raft
协议同步事务日志,多数派写入成功事务才能提交,确保数据强一致性且少数副本发生故障时不影响数据的可用性。可按需配置副本地理位置、副本数量等策略满足不同容灾级别的要求。

  • 实时 HTAP

提供行存储引擎 TiKV、列存储引擎
TiFlash
两款存储引擎,TiFlash 通过 Multi-Raft Learner 协议实时从 TiKV 复制数据,确保行存储引擎 TiKV 和列存储引擎
TiFlash 之间的数据强一致。TiKV、TiFlash 可按需部署在不同的机器,解决 HTAP 资源隔离的问题。

  • 云原生的分布式数据库

专为云而设计的分布式数据库,通过 [TiDB Operator](https://docs.pingcap.com/zh/tidb-in-
kubernetes/stable/tidb-operator-overview) 可在公有云、私有云、混合云中实现部署工具化、自动化。

  • 兼容 MySQL 5.7 协议和 MySQL 生态

兼容 MySQL 5.7 协议、MySQL 常用的功能、MySQL 生态,应用无需或者修改少量代码即可从 MySQL 迁移到
TiDB。提供丰富的[数据迁移工具](https://docs.pingcap.com/zh/tidb/stable/ecosystem-tool-
user-guide)帮助应用便捷完成数据迁移。

四大核心应用场景

  • 对数据一致性及高可靠、系统高可用、可扩展性、容灾要求较高的金融行业属性的场景

众所周知,金融行业对数据一致性及高可靠、系统高可用、可扩展性、容灾要求较高。传统的解决方案是同城两个机房提供服务、异地一个机房提供数据容灾能力但不提供服务,此解决方案存在以下缺点:资源利用率低、维护成本高、RTO
(Recovery Time Objective) 及 RPO (Recovery Point Objective) 无法真实达到企业所期望的值。TiDB
采用多副本 + Multi-Raft 协议的方式将数据调度到不同的机房、机架、机器,当部分机器出现故障时系统可自动进行切换,确保系统的 RTO <= 30s
及 RPO = 0。

  • 对存储容量、可扩展性、并发要求较高的海量数据及高并发的 OLTP 场景

随着业务的高速发展,数据呈现爆炸性的增长,传统的单机数据库无法满足因数据爆炸性的增长对数据库的容量要求,可行方案是采用分库分表的中间件产品或者 NewSQL
数据库替代、采用高端的存储设备等,其中性价比最大的是 NewSQL 数据库,例如:TiDB。TiDB
采用计算、存储分离的架构,可对计算、存储分别进行扩容和缩容,计算最大支持 512 节点,每个节点最大支持 1000 并发,集群容量最大支持 PB 级别。

  • Real-time HTAP 场景

随着 5G、物联网、人工智能的高速发展,企业所生产的数据会越来越多,其规模可能达到数百 TB 甚至 PB 级别,传统的解决方案是通过 OLTP
型数据库处理在线联机交易业务,通过 ETL 工具将数据同步到 OLAP 型数据库进行数据分析,这种处理方案存在存储成本高、实时性差等多方面的问题。TiDB
在 4.0 版本中引入列存储引擎 TiFlash 结合行存储引擎 TiKV 构建真正的 HTAP
数据库,在增加少量存储成本的情况下,可以在同一个系统中做联机交易处理、实时数据分析,极大地节省企业的成本。

  • 数据汇聚、二次加工处理的场景

当前绝大部分企业的业务数据都分散在不同的系统中,没有一个统一的汇总,随着业务的发展,企业的决策层需要了解整个公司的业务状况以便及时做出决策,故需要将分散在各个系统的数据汇聚在同一个系统并进行二次加工处理生成
T+0 或 T+1 的报表。传统常见的解决方案是采用 ETL + Hadoop 来完成,但 Hadoop
体系太复杂,运维、存储成本太高无法满足用户的需求。与 Hadoop 相比,TiDB 就简单得多,业务通过 ETL 工具或者 TiDB 的同步工具将数据同步到
TiDB,在 TiDB 中可通过 SQL 直接生成报表。

TiDB整体架构

在内核设计上,TiDB 分布式数据库将整体架构拆分成了多个模块,各模块之间互相通信,组成完整的 TiDB 系统。对应的架构图如下:

![architecture](https://download.pingcap.com/images/docs-cn/tidb-
architecture-v6.png)

  • TiDB Server:SQL 层,对外暴露 MySQL 协议的连接 endpoint,负责接受客户端的连接,执行 SQL 解析和优化,最终生成分布式执行计划。TiDB 层本身是无状态的,实践中可以启动多个 TiDB 实例,通过负载均衡组件(如 LVS、HAProxy 或 F5)对外提供统一的接入地址,客户端的连接可以均匀地分摊在多个 TiDB 实例上以达到负载均衡的效果。TiDB Server 本身并不存储数据,只是解析 SQL,将实际的数据读取请求转发给底层的存储节点 TiKV(或 TiFlash)。
  • PD (Placement Driver) Server:整个 TiDB 集群的元信息管理模块,负责存储每个 TiKV 节点实时的数据分布情况和集群的整体拓扑结构,提供 TiDB Dashboard 管控界面,并为分布式事务分配事务 ID。PD 不仅存储元信息,同时还会根据 TiKV 节点实时上报的数据分布状态,下发数据调度命令给具体的 TiKV 节点,可以说是整个集群的“大脑”。此外,PD 本身也是由至少 3 个节点构成,拥有高可用的能力。建议部署奇数个 PD 节点。
  • 存储节点
    • TiKV Server:负责存储数据,从外部看 TiKV 是一个分布式的提供事务的 Key-Value 存储引擎。存储数据的基本单位是 Region,每个 Region 负责存储一个 Key Range(从 StartKey 到 EndKey 的左闭右开区间)的数据,每个 TiKV 节点会负责多个 Region。TiKV 的 API 在 KV 键值对层面提供对分布式事务的原生支持,默认提供了 SI (Snapshot Isolation) 的隔离级别,这也是 TiDB 在 SQL 层面支持分布式事务的核心。TiDB 的 SQL 层做完 SQL 解析后,会将 SQL 的执行计划转换为对 TiKV API 的实际调用。所以,数据都存储在 TiKV 中。另外,TiKV 中的数据都会自动维护多副本(默认为三副本),天然支持高可用和自动故障转移。
    • TiFlash:TiFlash 是一类特殊的存储节点。和普通 TiKV 节点不一样的是,在 TiFlash 内部,数据是以列式的形式进行存储,主要的功能是为分析型的场景加速。

存储

Key-Value Pairs (键值对)

作为保存数据的系统,首先要决定的是数据的存储模型,也就是数据以什么样的形式保存下来。TiKV 的选择是 Key-Value 模型,并且提供有序遍历方法。
TiKV 数据存储的两个关键点:

  1. 这是一个巨大的 Map(可以类比一下 C++ 的 std::map),也就是存储的是 Key-Value Pairs(键值对)
  2. 这个 Map 中的 Key-Value pair 按照 Key 的二进制顺序有序,也就是可以 Seek 到某一个 Key 的位置,然后不断地调用 Next 方法以递增的顺序获取比这个 Key 大的 Key-Value。

有人可能会问,这里讲的存储模型和 SQL 中表是什么关系?在这里有一件重要的事情需要强调:

TiKV 的 KV 存储模型和 SQL 中的 Table 无关!

现在让我们忘记 SQL 中的任何概念,专注于讨论如何实现 TiKV 这样一个高性能、高可靠性、分布式的 Key-Value 存储。

本地存储(RocksDB)

任何持久化的存储引擎,数据终归要保存在磁盘上,TiKV 也不例外。但是 TiKV 没有选择直接向磁盘上写数据,而是把数据保存在 RocksDB
中,具体的数据落地由 RocksDB 负责。这个选择的原因是开发一个单机存储引擎工作量很大,特别是要做一个高性能的单机引擎,需要做各种细致的优化,而
RocksDB 是由 Facebook 开源的一个非常优秀的单机 KV 存储引擎,可以满足 TiKV 对单机引擎的各种要求。这里可以简单的认为
RocksDB 是一个单机的持久化 Key-Value Map。

Raft 协议

接下来 TiKV 的实现面临一件更难的事情:如何保证单机失效的情况下,数据不丢失,不出错?

简单来说,需要想办法把数据复制到多台机器上,这样一台机器挂了,其他的机器上的副本还能提供服务;
复杂来说,还需要这个数据复制方案是可靠和高效的,并且能处理副本失效的情况。TiKV 选择了 Raft 算法。Raft 是一个一致性协议,它和 Multi
Paxos 实现一样的功能,但是更加易于理解。这里 是 Raft
的论文,感兴趣的可以看一下。下面对 Raft 做一个简要的介绍,细节问题可以参考论文。 Raft 提供几个重要的功能:

  1. Leader(主副本)选举
  2. 成员变更(如添加副本、删除副本、转移 Leader 等操作)
  3. 日志复制

TiKV 利用 Raft 来做数据复制,每个数据变更都会落地为一条 Raft 日志,通过 Raft
的日志复制功能,将数据安全可靠地同步到复制组的每一个节点中。不过在实际写入中,根据 Raft 的协议,只需要同步复制到多数节点,即可安全地认为数据写入成功。

1.png

总结一下,通过单机的 RocksDB,TiKV 可以将数据快速地存储在磁盘上;通过 Raft,将数据复制到多台机器上,以防单机失效。数据的写入是通过
Raft 这一层的接口写入,而不是直接写 RocksDB。通过实现 Raft,TiKV 变成了一个分布式的 Key-Value
存储,少数几台机器宕机也能通过原生的 Raft 协议自动把副本补全,可以做到对业务无感知。

Region

讲到这里,我们需要提到一个非常重要的概念:Region。这个概念是理解后续一系列机制的基础,请仔细阅读这一小节。 前面提到,我们将 TiKV
看做一个巨大的有序的 KV Map,那么为了实现存储的水平扩展,我们需要将数据分散在多台机器上。这里提到的数据分散在多台机器上和 Raft
的数据复制不是一个概念,在这一节我们先忘记 Raft,假设所有的数据都只有一个副本,这样更容易理解。 对于一个 KV
系统,将数据分散在多台机器上有两种比较典型的方案:

  • Hash:按照 Key 做 Hash,根据 Hash 值选择对应的存储节点
  • Range:按照 Key 分 Range,某一段连续的 Key 都保存在一个存储节点上

TiKV 选择了第二种方式,将整个 Key-Value 空间分成很多段,每一段是一系列连续的 Key,将每一段叫做一个 Region,并且会尽量保持每个
Region 中保存的数据不超过一定的大小,目前在 TiKV 中默认是 96MB。每一个 Region 都可以用 [StartKey,EndKey)
这样一个左闭右开区间来描述。

2.png

注意,这里的 Region 还是和 SQL 中的表没什么关系! 请各位继续忘记 SQL,只谈 KV。 将数据划分成 Region 后,TiKV
将会做两件重要的事情:

  • 以 Region 为单位,将数据分散在集群中所有的节点上,并且尽量保证每个节点上服务的 Region 数量差不多
  • 以 Region 为单位做 Raft 的复制和成员管理

这两点非常重要,我们一点一点来说。 先看第一点,数据按照 Key 切分成很多 Region,每个 Region
的数据只会保存在一个节点上面(暂不考虑多副本)。TiDB 系统会有一个组件(PD)来负责将 Region
尽可能均匀的散布在集群中所有的节点上,这样一方面实现了存储容量的水平扩展(增加新的节点后,会自动将其他节点上的 Region
调度过来),另一方面也实现了负载均衡(不会出现某个节点有很多数据,其他节点上没什么数据的情况)。同时为了保证上层客户端能够访问所需要的数据,系统中也会有一个组件(PD)记录
Region 在节点上面的分布情况,也就是通过任意一个 Key 就能查询到这个 Key 在哪个 Region 中,以及这个 Region
目前在哪个节点上(即 Key 的位置路由信息)。至于负责这两项重要工作的组件(PD),会在后续介绍。

对于第二点,TiKV 是以 Region 为单位做数据的复制,也就是一个 Region 的数据会保存多个副本,TiKV 将每一个副本叫做一个
Replica。Replica 之间是通过 Raft 来保持数据的一致,一个 Region 的多个 Replica 会保存在不同的节点上,构成一个 Raft
Group。其中一个 Replica 会作为这个 Group 的 Leader,其他的 Replica 作为 Follower。所有的读和写都是通过
Leader 进行,读操作在 Leader 上即可完成,而写操作再由 Leader 复制给 Follower。 大家理解了 Region
之后,应该可以理解下面这张图:

3.png

以 Region 为单位做数据的分散和复制,就有了一个分布式的具备一定容灾能力的 KeyValue
系统,不用再担心数据存不下,或者是磁盘故障丢失数据的问题。

MVCC

很多数据库都会实现多版本并发控制(MVCC),TiKV 也不例外。设想这样的场景,两个客户端同时去修改一个 Key 的
Value,如果没有数据的多版本控制,就需要对数据上锁,在分布式场景下,可能会带来性能以及死锁问题。 TiKV 的 MVCC 实现是通过在 Key
后面添加版本号来实现,简单来说,没有 MVCC 之前,可以把 TiKV 看做这样的:

Key1 -> Value  
Key2 -> Value  
……  
KeyN -> Value  
  

有了 MVCC 之后,TiKV 的 Key 排列是这样的:

Key1_Version3 -> Value  
Key1_Version2 -> Value  
Key1_Version1 -> Value  
……  
Key2_Version4 -> Value  
Key2_Version3 -> Value  
Key2_Version2 -> Value  
Key2_Version1 -> Value  
……  
KeyN_Version2 -> Value  
KeyN_Version1 -> Value  
……  
  

注意,对于同一个 Key 的多个版本,我们把版本号较大的放在前面,版本号小的放在后面(回忆一下 Key-Value 一节我们介绍过的 Key
是有序的排列),这样当用户通过一个 Key + Version 来获取 Value 的时候,可以通过 Key 和 Version 构造出 MVCC 的
Key,也就是 Key_Version。然后可以直接通过 RocksDB 的 SeekPrefix(Key_Version)
API,定位到第一个大于等于这个 Key_Version 的位置。

分布式 ACID 事务

TiKV 的事务采用的是 Google 在 BigTable
中使用的事务模型:Percolator,TiKV
根据这篇论文实现,并做了大量的优化。这个在后续的章节中会有详细的介绍。

在 TiKV 层的事务 API 的语义类似下面的伪代码:

tx = tikv.Begin()  
    tx.Set(Key1, Value1)  
    tx.Set(Key2, Value2)  
    tx.Set(Key3, Value3)  
tx.Commit()  
  

这个事务中包含3条 Set 操作,TiKV 能保证这些操作要么全部成功,要么全部失败,不会出现中间状态或脏数据。 就如前面提到的,TiDB 的 SQL
层会将 SQL 的执行计划转换成多个 KV 操作,对于上层的同一个业务层的 SQL 事务,在底层也是对应一个 KV 层的事务,这是 TiDB 实现
MySQL 的事务语义的关键。

计算

表数据与 Key-Value 的映射关系

本小节介绍 TiDB 中数据到 (Key, Value) 键值对的映射方案。这里的数据主要包括以下两个方面:

  • 表中每一行的数据,以下简称表数据
  • 表中所有索引的数据,以下简称索引数据

表数据与 Key-Value 的映射关系

在关系型数据库中,一个表可能有很多列。要将一行中各列数据映射成一个 (Key, Value) 键值对,需要考虑如何构造 Key。首先,OLTP
场景下有大量针对单行或者多行的增、删、改、查等操作,要求数据库具备快速读取一行数据的能力。因此,对应的 Key 最好有一个唯一 ID(显示或隐式的
ID),以方便快速定位。其次,很多 OLAP 型查询需要进行全表扫描。如果能够将一个表中所有行的 Key
编码到一个区间内,就可以通过范围查询高效完成全表扫描的任务。

基于上述考虑,TiDB 中的表数据与 Key-Value 的映射关系作了如下设计:

  • 为了保证同一个表的数据放在一起,方便查找,TiDB 会为每个表分配一个表 ID,用 TableID 表示。表 ID 是一个整数,在整个集群内唯一。
  • TiDB 会为表中每行数据分配一个行 ID,用 RowID 表示。行 ID 也是一个整数,在表内唯一。对于行 ID,TiDB 做了一个小优化,如果某个表有整数型的主键,TiDB 会使用主键的值当做这一行数据的行 ID。

每行数据按照如下规则编码成 (Key, Value) 键值对:

Key:   tablePrefix{TableID}_recordPrefixSep{RowID}  
Value: [col1, col2, col3, col4]  
  

其中 tablePrefixrecordPrefixSep 都是特定的字符串常量,用于在 Key
空间内区分其他数据。其具体值在后面的小结中给出。

索引数据和 Key-Value 的映射关系

TiDB 同时支持主键和二级索引(包括唯一索引和非唯一索引)。与表数据映射方案类似,TiDB 为表中每个索引分配了一个索引 ID,用 IndexID
表示。

对于主键和唯一索引,需要根据键值快速定位到对应的 RowID,因此,按照如下规则编码成 (Key, Value) 键值对:

Key:   tablePrefix{tableID}_indexPrefixSep{indexID}_indexedColumnsValue  
Value: RowID  
  

对于不需要满足唯一性约束的普通二级索引,一个键值可能对应多行,需要根据键值范围查询对应的 RowID。因此,按照如下规则编码成 (Key, Value)
键值对:

Key:   tablePrefix{TableID}_indexPrefixSep{IndexID}_indexedColumnsValue_{RowID}  
Value: null  
  

映射关系小结

上述所有编码规则中的 tablePrefixrecordPrefixSepindexPrefixSep 都是字符串常量,用于在 Key
空间内区分其他数据,定义如下:

tablePrefix     = []byte{'t'}  
recordPrefixSep = []byte{'r'}  
indexPrefixSep  = []byte{'i'}  
  

另外请注意,上述方案中,无论是表数据还是索引数据的 Key 编码方案,一个表内所有的行都有相同的 Key
前缀,一个索引的所有数据也都有相同的前缀。这样具有相同的前缀的数据,在 TiKV 的 Key
空间内,是排列在一起的。因此只要小心地设计后缀部分的编码方案,保证编码前和编码后的比较关系不变,就可以将表数据或者索引数据有序地保存在 TiKV
中。采用这种编码后,一个表的所有行数据会按照 RowID 顺序地排列在 TiKV 的 Key
空间中,某一个索引的数据也会按照索引数据的具体的值(编码方案中的 indexedColumnsValue)顺序地排列在 Key 空间内。

Key-Value 映射关系示例

最后通过一个简单的例子,来理解 TiDB 的 Key-Value 映射关系。假设 TiDB 中有如下这个表:

CREATE TABLE User (  
    ID int,  
    Name varchar(20),  
    Role varchar(20),  
    Age int,  
    PRIMARY KEY (ID),  
    KEY idxAge (Age)  
);  

假设该表中有 3 行数据:

1, "TiDB", "SQL Layer", 10  
2, "TiKV", "KV Engine", 20  
3, "PD", "Manager", 30  
  

首先每行数据都会映射为一个 (Key, Value) 键值对,同时该表有一个 int 类型的主键,所以 RowID 的值即为该主键的值。假设该表的
TableID 为 10,则其存储在 TiKV 上的表数据为:

t10_r1 --> ["TiDB", "SQL Layer", 10]  
t10_r2 --> ["TiKV", "KV Engine", 20]  
t10_r3 --> ["PD", "Manager", 30]  

除了主键外,该表还有一个非唯一的普通二级索引 idxAge,假设这个索引的 IndexID 为 1,则其存储在 TiKV 上的索引数据为:

t10_i1_10_1 --> null  
t10_i1_20_2 --> null  
t10_i1_30_3 --> null  
  

以上例子展示了 TiDB 中关系模型到 Key-Value 模型的映射规则,以及选择该方案背后的考量。

元信息管理

TiDB 中每个 DatabaseTable 都有元信息,也就是其定义以及各项属性。这些信息也需要持久化,TiDB 将这些信息也存储在了
TiKV 中。

每个 Database/Table 都被分配了一个唯一的 ID,这个 ID 作为唯一标识,并且在编码为 Key-Value 时,这个 ID
都会编码到 Key 中,再加上 m_ 前缀。这样可以构造出一个 Key,Value 中存储的是序列化后的元信息。

除此之外,TiDB 还用一个专门的 (Key, Value) 键值对存储当前所有表结构信息的最新版本号。这个键值对是全局的,每次 DDL
操作的状态改变时其版本号都会加 1。目前,TiDB 把这个键值对持久化存储在 PD Server 中,其 Key 是
“/tidb/ddl/global_schema_version”,Value 是类型为 int64 的版本号值。TiDB 采用 Online Schema
变更算法,有一个后台线程在不断地检查 PD Server 中存储的表结构信息的版本号是否发生变化,并且保证在一定时间内一定能够获取版本的变化。

SQL 层简介

TiDB 的 SQL 层,即 TiDB Server,负责将 SQL 翻译成 Key-Value 操作,将其转发给共用的分布式 Key-Value 存储层
TiKV,然后组装 TiKV 返回的结果,最终将查询结果返回给客户端。

这一层的节点都是无状态的,节点本身并不存储数据,节点之间完全对等。

SQL 运算

最简单的方案就是通过上一节所述的[表数据与 Key-Value
的映射关系](https://docs.pingcap.com/zh/tidb/stable/tidb-
computing#%E8%A1%A8%E6%95%B0%E6%8D%AE%E4%B8%8E-key-
value-%E7%9A%84%E6%98%A0%E5%B0%84%E5%85%B3%E7%B3%BB)方案,将 SQL 查询映射为对 KV 的查询,再通过
KV 接口获取对应的数据,最后执行各种计算。

比如 select count(*) from user where name = "TiDB" 这样一个 SQL
语句,它需要读取表中所有的数据,然后检查 name 字段是否是 TiDB,如果是的话,则返回这一行。具体流程如下:

  1. 构造出 Key Range:一个表中所有的 RowID 都在 [0, MaxInt64) 这个范围内,使用 0MaxInt64 根据行数据的 Key 编码规则,就能构造出一个 [StartKey, EndKey)的左闭右开区间。
  2. 扫描 Key Range:根据上面构造出的 Key Range,读取 TiKV 中的数据。
  3. 过滤数据:对于读到的每一行数据,计算 name = "TiDB" 这个表达式,如果为真,则向上返回这一行,否则丢弃这一行数据。
  4. 计算 Count(*):对符合要求的每一行,累计到 Count(*) 的结果上面。

整个流程示意图如下:

![naive sql flow](https://download.pingcap.com/images/docs-cn/tidb-computing-
native-sql-flow.jpeg)

这个方案是直观且可行的,但是在分布式数据库的场景下有一些显而易见的问题:

  • 在扫描数据的时候,每一行都要通过 KV 操作从 TiKV 中读取出来,至少有一次 RPC 开销,如果需要扫描的数据很多,那么这个开销会非常大。
  • 并不是所有的行都满足过滤条件 name = "TiDB",如果不满足条件,其实可以不读取出来。
  • 此查询只要求返回符合要求行的数量,不要求返回这些行的值。

分布式 SQL 运算

为了解决上述问题,计算应该需要尽量靠近存储节点,以避免大量的 RPC 调用。首先,SQL 中的谓词条件 name = "TiDB"
应被下推到存储节点进行计算,这样只需要返回有效的行,避免无意义的网络传输。然后,聚合函数 Count(*)
也可以被下推到存储节点,进行预聚合,每个节点只需要返回一个 Count(*) 的结果即可,再由 SQL 层将各个节点返回的 Count(*)
的结果累加求和。

以下是数据逐层返回的示意图:

![dist sql flow](https://download.pingcap.com/images/docs-cn/tidb-computing-
dist-sql-flow.png)

SQL 层架构

通过上面的例子,希望大家对 SQL 语句的处理有一个基本的了解。实际上 TiDB 的 SQL
层要复杂得多,模块以及层次非常多,下图列出了重要的模块以及调用关系:

![tidb sql layer](https://download.pingcap.com/images/docs-cn/tidb-computing-
tidb-sql-layer.png)

用户的 SQL 请求会直接或者通过 Load Balancer 发送到 TiDB Server,TiDB Server 会解析 MySQL Protocol Packet,获取请求内容,对 SQL 进行语法解析和语义分析,制定和优化查询计划,执行查询计划并获取和处理数据。数据全部存储在
TiKV 集群中,所以在这个过程中 TiDB Server 需要和 TiKV 交互,获取数据。最后 TiDB Server 需要将查询结果返回给用户。

TiDB和Mysql的区别

TiDB 作为开源 NewSQL 数据库的典型代表之一,同样支持 SQL,支持事务 ACID 特性。在通讯协议上,TiDB 选择与 MySQL
完全兼容,并尽可能兼容 MySQL 的语法。因此,基于 MySQL 数据库开发的系统,大多数可以平滑迁移至
TiDB,而几乎不用修改代码。对用户来说,迁移成本极低,过渡自然。

然而,仍有一些 MySQL 的特性和行为,TiDB 目前暂时不支持或表现与 MySQL 有差异。除此之外,TiDB
提供了一些扩展语法和功能,为用户提供更多的便利。

TiDB 仍处在快速发展的道路上,对 MySQL 功能和行为的支持方面,正按 [路线图](https://pingcap.com/docs-
cn/stable/roadmap/) 的规划在前行。

兼容策略

先从总体上概括 TiDB 和 MySQL 兼容策略,如下表:

通讯协议 SQL语法 功能和行为
完全兼容 兼容绝大多数 兼容大多数

截至 4.0 版本,TiDB 与 MySQL 的区别总结如下表:

| MySQL | TiDB
—|—|—
隔离级别 | 支持读未提交、读已提交、可重复读、串行化,默认为可重复读 | 乐观事务支持快照隔离,悲观事务支持快照隔离和读已提交
锁机制 | 悲观锁 | 乐观锁、悲观锁
存储过程 | 支持 | 不支持
触发器 | 支持 | 不支持
事件 | 支持 | 不支持
自定义函数 | 支持 | 不支持
窗口函数 | 支持 | 部分支持
JSON | 支持 | 不支持部分 MySQL 8.0 新增的函数
外键约束 | 支持 | 忽略外键约束
字符集 | | 只支持 ascii、latin1、binary、utf8、utf8mb4
增加/删除主键 | 支持 | 通过 [alter-primary-key](https://pingcap.com/docs-
cn/dev/reference/configuration/tidb-server/configuration-file/#alter-primary-
key) 配置开关提供
CREATE TABLE tblName AS SELECT stmt | 支持 | 不支持
CREATE TEMPORARY TABLE | 支持 | TiDB 忽略 TEMPORARY 关键字,按照普通表创建
DML affected rows | 支持 | 不支持
AutoRandom 列属性 | 不支持 | 支持
Sequence 序列生成器 | 不支持 | 支持

区别点详述及应对方案

字符集支持

TiDB 目前支持以下字符集:

tidb> SHOW CHARACTER SET;  
+---------|---------------|-------------------|--------+  
| Charset | Description   | Default collation | Maxlen |  
+---------|---------------|-------------------|--------+  
| utf8    | UTF-8 Unicode | utf8_bin          |      3 |  
| utf8mb4 | UTF-8 Unicode | utf8mb4_bin       |      4 |  
| ascii   | US ASCII      | ascii_bin         |      1 |  
| latin1  | Latin1        | latin1_bin        |      1 |  
| binary  | binary        | binary            |      1 |  
+---------|---------------|-------------------|--------+  
5 rows in set (0.00 sec)  
  

注意:TiDB 的默认字符集为 utf8mb4,MySQL 5.7 中为 latin1,MySQL 8.0 中修改为 utf8mb4
当指定的字符集为 utf8utf8mb4 时,TiDB 仅支持合法的 UTF8 字符。对于不合法的字符,会报错:incorrect utf8 value,该字符合法性检查与 MySQL 8.0 一致。对于 MySQL 5.7 及以下版本,会存在允许插入非法 UTF8 字符,但同步到 TiDB
报错的情况。此时,可以通过 TiDB 配置
[“tidb_skip_utf8_check”](https://pingcap.com/docs/stable/faq/upgrade/#issue-3-error-1366-hy000-incorrect-
utf8-value-f09f8c80-for-column-a) 跳过 UTF8 字符合法性检查强制写入 TiDB。

每一个字符集,都有一个默认的 Collation,例如 utf8 的默认 Collation 为 utf8_bin,TiDB 中字符集的默认
Collation 与 MySQL 不一致,具体如下:

字符集 TiDB 默认 Collation MySQL 5.7 默认 Collation MySQL 8.0 默认 Collation
utf8 utf8_bin utf8_general_ci utf8_general_ci
utf8mb4 utf8mb4_bin utf8mb4_general_ci utf8mb4_0900_ai_ci
ascii ascii_bin ascii_general_ci ascii_general_ci
latin1 latin1_bin latin1_swedish_ci latin1_swedish_ci
binary binary binary binary

在 4.0 版本之前,TiDB 中可以任意指定字符集对应的所有 Collation,并把它们按照默认 Collation
处理,即以编码字节序为字符定序。同时,并未像 MySQL 一样,在比较前按照 Collation 的 PADDING
属性将字符补齐空格。因此,会造成以下的行为区别:

tidb> create table t(a varchar(20) charset utf8mb4 collate utf8mb4_general_ci primary key);  
Query OK, 0 rows affected  
tidb> insert into t values ('A');  
Query OK, 1 row affected  
tidb> insert into t values ('a');  
Query OK, 1 row affected // MySQL 中,由于 utf8mb4_general_ci 大小写不敏感,报错 Duplicate entry 'a'.  
tidb> insert into t1 values ('a ');  
Query OK, 1 row affected // MySQL 中,由于补齐空格比较,报错 Duplicate entry 'a '  

TiDB 4.0 新增了完整的 Collation 支持框架,允许实现所有 MySQL 中的 Collation,并新增了配置开关
new_collation_enabled_on_first_boostrap,在集群初次初始化时决定是否启用新 Collation
框架。在该配置开关打开之后初始化集群,可以通过 mysql.tidb 表中的 new_collation_enabled 变量确认新
Collation 是否启用:

tidb> select VARIABLE_VALUE from mysql.tidb where VARIABLE_NAME='new_collation_enabled';  
+----------------+  
| VARIABLE_VALUE |  
+----------------+  
| True           |  
+----------------+  
1 row in set (0.00 sec)  

在新 Collation 启用后,TiDB 修正了 utf8mb4_general_binutf8_general_bin
PADDING 行为,会将字符串补齐空格后比较;同时支持了 utf8mb4_general_ciutf8_general_ci,这两个
Collation 与 MySQL 保持兼容。

系统时区

在 MySQL 中,系统时区 system_time_zone 在 MySQL 服务启动时通过 环境变量 TZ 或命令行参数
--timezone

指定。

对于 TiDB 而言,作为一个分布式数据库,TiDB 需要保证整个集群的系统时区始终一致。因此 TiDB 的系统时区在集群初始化时,由负责初始化的 TiDB
节点环境变量 TZ 决定。集群初始化后,固定在集群状态表 mysql.tidb 中:

tidb> select VARIABLE_VALUE from mysql.tidb where VARIABLE_NAME='system_tz';  
+----------------+  
| VARIABLE_VALUE |  
+----------------+  
| Asia/Shanghai  |  
+----------------+  
1 row in set (0.00 sec)  
  

通过查看 system_time_zone 变量,可以看到该值与状态表中的 system_tz 保持一致:

tidb> select @@system_time_zone;  
+--------------------+  
| @@system_time_zone |  
+--------------------+  
| Asia/Shanghai      |  
+--------------------+  
1 row in set (0.00 sec)  

请注意,这意味着 TiDB 的系统时区在初始化后不再更改。若需要改变集群的时区,可以显式指定 time_zone 系统变量,例如:

tidb> set @@global.time_zone='UTC';  
Query OK, 0 rows affected (0.00 sec)  

事务

乐观事务

事务是数据库的基础,提供高效的、支持完整 ACID 的分布式事务更是分布式数据库的立足之本。本章节会首先介绍事务的基本概念,然后介绍 TiDB 基于
Percolator 实现的乐观事务以及在使用上的最佳实践。

隔离级别

对用户来说,最友好的并发事务执行顺序为每个事务独占整个数据库,并发事务执行的结果与一个个串行执行相同,也就是串行化,能够避免所有的异常情况。但在这种隔离级别下,并发执行的事务性能较差,提供更弱保证的隔离级别能够显著提升系统的性能。根据允许出现的异常,SQL-92
标准定义了 4 种隔离级别:读未提交 (READ UNCOMMITTED)、读已提交 (READ COMMITTED)、可重复读 (REPEATABLE
READ)、串行化 (SERIALIZABLE)。详见下表:

Isolation Level Dirty Write Dirty Read Fuzzy Read Phantom
READ UNCOMMITTED Not Possible Possible Possible Possible
READ COMMITTED Not Possible Not possible Possible Possible
REPEATABLE READ Not Possible Not possible Not possible Possible
SERIALIZABLE Not Possible Not possible Not possible Not possible

并发控制

数据库有多种并发控制方法,这里只介绍以下两种:

  • 乐观并发控制(OCC):在事务提交阶段检测冲突
  • 悲观并发控制(PCC):在事务执行阶段检测冲突

乐观并发控制期望事务间数据冲突不多,只在提交阶段检测冲突能够获取更高的性能。悲观并发控制更适合数据冲突较多的场景,能够避免乐观事务在这类场景下事务因冲突而回滚的问题,但相比乐观并发控制,在没有数据冲突的场景下,性能相对要差。

TiDB 乐观事务实现

TiDB 基于 Google [Percolator](https://storage.googleapis.com/pub-tools-public-
publication-data/pdf/36726.pdf) 实现了支持完整 ACID、基于快照隔离级别(Snapshot
Isolation)的分布式乐观事务。TiDB 乐观事务需要将事务的所有修改都保存在内存中,直到提交时才会写入 TiKV 并检测冲突。

Snapshot Isolation

Percolator 使用多版本并发控制(MVCC)来实现快照隔离级别,与可重复读的区别在于整个事务是在一个一致的快照上执行。TiDB 使用
PD 作为全局授时服务(TSO)来提供单调递增的版本号:

  • 事务开始时获取 start timestamp,也是快照的版本号;事务提交时获取 commit timestamp,同时也是数据的版本号
  • 事务只能读到在事务 start timestamp 之前最新已提交的数据
  • 事务在提交时会根据 timestamp 来检测数据冲突

两阶段提交(2PC)

TiDB 使用两阶段提交(Two-Phase Commit)来保证分布式事务的原子性,分为 Prewrite 和 Commit 两个阶段:

  • Prewrite:对事务修改的每个 Key 检测冲突并写入 lock 防止其他事务修改。对于每个事务,TiDB 会从涉及到改动的所有 Key 中选中一个作为当前事务的 Primary Key,事务提交或回滚都需要先修改 Primary Key,以它的提交与否作为整个事务执行结果的标识。
  • Commit:Prewrite 全部成功后,先同步提交 Primary Key,成功后事务提交成功,其他 Secondary Keys 会异步提交。

Percolator
将事务的所有状态都保存在底层支持高可用、强一致性的存储系统中,从而弱化了传统两阶段提交中协调者(Coordinator)的作用,所有的客户端都可以根据存储系统中的事务状态对事务进行提交或回滚。

两阶段提交过程

事务的两阶段提交过程如下:

1.png

  1. 客户端开始一个事务。

  2. TiDB 向 PD 获取 tso 作为当前事务的 start timestamp。

  3. 客户端发起读或写请求。

  4. 客户端发起 Commit。

  5. TiDB 开始 两阶段提交 ,保证分布式事务的原子性,让数据真正落盘。

i. TiDB 从当前要写入的数据中选择一个 Key 作为当前事务的 Primary Key。

ii. TiDB 并发地向所有涉及的 TiKV 发起 Prewrite 请求。TiKV 收到 Prewrite
请求后,检查数据版本信息是否存在冲突,符合条件的数据会被加锁。

iii. TiDB 收到所有 Prewrite 响应且所有 Prewrite 都成功。

iv. TiDB 向 PD 获取第二个全局唯一递增版本号,定义为本次事务的 commit timestamp。

v. TiDB 向 Primary Key 所在 TiKV 发起第二阶段提交。TiKV 收到 Commit 操作后,检查锁是否存在并清理 Prewrite
阶段留下的锁。

  1. TiDB 向客户端返回事务提交成功的信息。

  2. TiDB 异步清理本次事务遗留的锁信息。

悲观事务

乐观事务模型在分布式系统中有着极大的性能优势,但为了让 TiDB 的使用方式更加贴近传统单机数据库,更好的适配用户场景,TiDB v3.0
及之后版本在乐观事务模型的基础上实现了悲观事务模型。本文将介绍 TiDB 悲观事务模型特点。

悲观锁解决的问题

通过支持悲观事务,降低用户修改代码的难度甚至不用修改代码:

  • 在 v3.0.8 之前,TiDB 默认使用的乐观事务模式会导致事务提交时因为冲突而失败。为了保证事务的成功率,需要修改应用程序,加上重试的逻辑。
  • 乐观事务模型在冲突严重的场景和重试代价大的场景无法满足用户需求,支持悲观事务可以 弥补这方面的缺陷,拓展 TiDB 的应用场景。

以发工资场景为例:对于一个用人单位来说,发工资的过程其实就是从企业账户给多个员工的个人账户转账的过程,一般来说都是批量操作,在一个大的转账事务中可能涉及到成千上万的更新,想象一下如果这个大事务执行的这段时间内,某个个人账户发生了消费(变更),如果这个大事务是乐观事务模型,提交的时候肯定要回滚,涉及上万个个人账户发生消费是大概率事件,如果不做任何处理,最坏的情况是这个大事务永远没办法执行,一直在重试和回滚(饥饿)。

基于 Percolator 的悲观事务

悲观事务在 Percolator 乐观事务基础上实现,在 Prewrite 之前增加了 Acquire Pessimistic Lock 阶段用于避免
Prewrite 时发生冲突:

  • 每个 DML 都会加悲观锁,锁写到 TiKV 里,同样会通过 raft 同步。
  • 悲观事务在加悲观锁时检查各种约束,如 Write Conflict、key 唯一性约束等。
  • 悲观锁不包含数据,只有锁,只用于防止其他事务修改相同的 Key,不会阻塞读,但 Prewrite 后会阻塞读(和 Percolator 相同,但有了大事务支持后将不会阻塞读)。
  • 提交时同 Percolator,悲观锁的存在保证了 Prewrite 不会发生 Write Conflict,保证了提交一定成功。

1.png

等锁顺序

TiKV 中实现了 Waiter Manager 用于管理等锁的事务,当悲观事务加锁遇到其他事务的锁时,将会进入 Waiter Manager
中等待锁被释放,TiKV 会尽可能按照事务 start timestamp 的顺序来依次获取锁,从而避免事务间无用的竞争。

分布式死锁检测

Waiter Manager 中等待锁的事务间可能发生死锁,而且可能发生在不同的机器上,TiDB 采用分布式死锁检测来解决死锁问题:

  • 在整个 TiKV 集群中,有一个死锁检测器 leader。
  • 当要等锁时,其他节点会发送检测死锁的请求给 leader。

2.png

死锁检测器基于 Raft 实现了高可用,等锁事务也会定期发送死锁检测请求给死锁检测器的 leader,从而保证了即使之前 leader
宕机的情况下也能检测到死锁。

1.神秘命名
如果想不出一个好的名字,说明背后很可能隐藏着更深的设计问题
看最新的《阿里开发规范1.7 黄山版》
https://developer.aliyun.com/article/888697

2.重复代码
优化:禁止复制粘贴,巧用Method Exact。
1692107737850
坏味道

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
/**
* 计算水果总价(同一个类的两个函数含有相同的表达式)
*
*/
public class FruitsCost {
public double computeMoneyWithoutPrivileges(String type, int numbers) {
double prices;
switch (type) {
case "apple":
prices = 5.5;
break;
case "banana":
prices = 4.0;
break;
case "strawberry":
prices = 10.5;
break;
default:
throw new IllegalArgumentException("Illegal type : " + type);
}
return prices * numbers;
}

public double computeMoneyWithPrivileges(String type, double numbers, double discount) {
double prices;
switch (type) {
case "apple":
prices = 5.5;
break;
case "banana":
prices = 4.0;
break;
case "strawberry":
prices = 10.5;
break;
default:
throw new IllegalArgumentException("Illegal type : " + type);
}
return prices * numbers * discount;
}
}

好味道

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class FruitsCost {
public double computeMoneyWithoutPrivileges(String type, int numbers) {
double prices = getPrices(type);
return prices * numbers;
}
private double getPrices(String type) {
double prices;
switch (type) {
case "apple":
prices = 5.5;
break;
case "banana":
prices = 4.0;
break;
case "strawberry":
prices = 10.5;
break;
default:
throw new IllegalArgumentException("Illegal type : " + type);
}
return prices;
}
public double computeMoneyWithPrivileges(String type, double numbers, double discount) {
double prices = getPrices(type);
return prices * numbers * discount;
}
}

3.过长函数
优化:每个函数建议不超过80行代码,条件、循环、公共集中的过程提取处理
坏味道

1
2
3
4
5
6
7
public void doSth3(){
List<String> list = new ArrayList<>();
for(String sjs : list){
//doSth,60行
sjs = sjs + "";
}
}

好味道

1
2
3
4
5
6
7
8
9
10
public void doSth3(){
List<String> list = new ArrayList<>();
for(String sjs : list){
doloop(sjs);
}
}
private static void doloop(String sjs) {
//doSth,60行
sjs = sjs + "";
}

4.过长参数列表
优化:使用对象合并参数
坏味道

1
2
3
4
5
public class Case2 {
public void createUser(String username,String password , Float height , Float weight,Integer age){

}
}

好味道

1
2
3
4
5
6
7
8
9
10
11
好味道
public class Case2 {
public void createUser(User user){

}
}
public class User {
private String username
private String password;
//...Getter and Setter
}

5.全局数据
优化:合并数据到方法、类成员中,最小范围原则
坏味道
代码问题

代码影响
好味道
封装变量

6.可变数据
优化:函数式编程、数据永不改变
什么是可变数据(Mutable Data)
定义:可变数据——对数据的修改经常导致出乎意料的结果和难以发现的Bug。

影响:影响可维护性,在一处修改数据,却在另一处造成难以发现的破坏

改进目标:应用“数据不可变”:不可变性是强大的代码防腐剂

方法:封装变量、拆分变量、提炼函数、移除设值函数、查询取代派生、Builder模式创建不可变对象、引用对象改为值对象、函数式编程等

注:并非所有可变类型都是不良的,这里关注描述数据的可变类型

坏味道
可变数据1
好味道
可变数据2
可变数据3

可变数据症状
一个变量用作不同目的
数据类型中,用public修饰非final成员
有set方法(或其它改变数据的方法)
一个成员变量的值可通过其他字段计算得到
对外暴露内部变量的可变引用

改进手法
封装变量
拆分变量(用提取函数):Ctrl+Atl+M
移除设值函数:用内联移除、直接删除(Ctrl+Alt+N、Alt+Del)
Builder模式创建不可变对象

前言

在谈效能之前,我想先谈谈作为一个技术人或者技术TL,研发的核心价值是什么?

之前看了一篇文章,比较有意思,分享一下观念:

  • T外包公司:最核心的竞争力不是技术,而是快速响应、资源调配整合、项目成本控制等方面。
  • 企业信息化公司:研发的核心价值有三个层次:第一层是运用技术更好的去支撑业务;第二层是用技术推动业务,用自身业务经验(服务很多客户)帮助客户;第三层是去用经验积累去影响行业。
  • 解决特定场景和问题的产品公司:核心价值就在于技术,专注与做技术深度。

那我们的核心价值是什么?

(1)高效支撑业务:一个是支撑,一个是高效

  • 支撑:对我们的要求就是:阶段性与业务目标,落地产品对齐。
  • 高效:研发效能的思考落地。用数据说明我们的价值

(2)用技术影响行业:这个需要持续的积累和思考。

(3)建技术壁垒和技术基建,确保系统稳定,业务健康和持久发展。

很多时候,我们一直在思考如何高效支撑业务这个课题上。阿里技术分享平台或者网上都有非常多的文章分享,每个TL针对自己团队的状况也有一套自己的方法论。今天我结合自己所面临的状况,把自己的思考总结分享一下。

个人思考

从几个维度分别进行思考:

1、研发的流程效能:拆分阶段,发现关键问题,提出方案

1)需求阶段

问题:需求反复,需求不清晰,需求争议

解决方案:

(1)确保需求的有效期,明确业务价值和需求范围,并确保需求理解的一致性。

(2)需求内容,先小范围内沟通对焦;等需求讨论清晰,再拉落地相关同学参与。

(3)需求有争议时,为避免反复,拉上相关同学一起对焦。

(4)日常需求,定期排期,通晒。

2)研发阶段:这个在研发的生产效能单独说明。

3)联调与测试阶段:

问题:联调效率低,环境不可用,提测代码质量低

解决方案:

(1)前后端分离,各自具备Mock的能力

(2)日常环境治理,降级系统服务的依赖,确保测试环境问题

(3)基准测试用例,冒烟测试,自动化脚本测试,确保提测质量。

4)发布阶段:

问题:发布频率高;定位问题耗时;部署系统时长

解决方案:

(1)对同一时间的发布系统做适当合并。

(2)与业务约定发布频率,控制发布周期。

(3)系统依赖治理,缩短部署时长;并提前打包

(4)针对排查问题耗时,提供相应的问题排查工具。

2、研发的生产效能

针对研发的过程生产,我们从如下几个方面去提升研发效能:

(1)全链路技术规范:
全链路技术规范

每一个过程,通过规范文档统一标准,并通过宣传和考试的方式,让大家都熟知内容。并通过典型案例通晒,强化规范内容的价值。

(2)全链路业务可配置

  • u页面可配:表单页,列表页,详情页,单据页等中后台页面可配置。
  • 典型案例:宜搭的页面搭建
  • u逻辑可配:业务参数可配置;业务规则可配置;
  • 典型案例:类目特征;
  • u模型可配:用元数据方式定义数据表或业务模型+扩展字段两种方式
  • 典型案例:类目属性 ;NBF建模平台
  • u服务&流程编排:基于原子服务能力进行二次组合。
  • 典型案例:AECP的服务编排;NBF的服务编排等

基于上诉的原子工具能力,再结合产品工厂和解决方案能力,把横向和纵向的业务配置化能力链接在一起,形成一个分层的全链路业务可配置视图。

全链路业务可配置视图

(3)统一横向支撑工具

拉通团队内部共性的支撑工具,共建一份。比如excel批量导入导出,审批流,通知工具等。

(4)非功能性代码

  • 应用脚手架:一键生成应用工程,包括依赖的jar包等。
  • 代码规范的模板:错误码模板;日志模板;工程结构等
  • 中间件隔离层:包括使用的案例
  • 通用工具类:外部开源的工具类;业务抽象的通用类;自研抽象的通用工具类;
  • 业务标准拦截:用户登录;服务异常处理;dao的执行时间等
  • 稳定性:限流;应用自检;

把所有应用的共性代码抽象,沉淀为jar,在工程中默认引入。

(5)部署交付:部署手册指导;统一的问题排查工具;

(6)接口文档化:web层统一遵循swagger规范;能力中心/工具遵循接口规范的文档和client的使用案例说明等。

(7)系统协作的清晰边界

我们在落地过程中,往往会存在系统调用,系统依赖等问题,建议在启动初期,把这些关键问题定义出来,然后寻找TL或pm协调解决。

3、研发的协作效能

通过流程规范和模板,约定大家的协作模式

1)日常答疑的协作流程
日常答疑的协作流程

2)项目过程管理

项目过程管理

通过如下战役模板,同步项目进展:

战役模板

3)日常需求排期

约定需求排期周期,固定时间,并沟通需求通过协作管理工具。

后续可通过协作管理工具,分析统计数据。

4、研发的人员效能

(1)学习与培训:

通过持续的学习,或借力其他团队的方式,提升技术水平。

通过沉淀团队规范,文档,案例等方案,让大家补齐知识。

通过复盘总结,沉淀典型案例,内部分享,避免重复采坑。

(2)考试

光学习肯定是不行的,还需要通过考试的方式强化。可以使用这个工具:阿里在线考试系统。

效能指标

制定过程的指标数据,每月通晒与对比,分析数据,定位效能问题。关键指标可参考:

(1)需求交付周期,需求交付数,项目数

(2)代码提交量

(3)Bug数

(4)线上问题与故障

总结

通过优化研发过程,并有明确的数据量化,证明研发对业务的价值。
(转自阿里云)

因为工作中用到了通过Hive从公司数据仓库拉取数据,而之前也没有接触过大数据相关知识,了解了一下Hadoop相关原理,并没有实际使用Hadoop做过什么,或者实现Hive直接功能实现,所以这篇总结可能也很皮毛

Hadoop是什么

自己了解Hive之前先读了一遍《Hadoop权威指南》,里面的基本下载启动是照着做了,不过有的代码编写没做,Hadoop的总结写到这里吧,毕竟刚入门,能总结的东西也不是很多很深入。

hadoop架构

我们遇到的问题很简单:在硬盘存储容量多年不断提升的同时,访问速度(硬盘数据读取速度)却没有与时俱进。1990年,一个普通硬盘可以存储1370MB的数据,传输速度为4.4MB/S,因此只需要5分钟就可以读完整个硬盘中的数据。20年过了,1TB的硬盘已然成为主流,氮气数据传输速度为100MB/S,读完整个硬盘中的数据至少得花2.5个小时。

读完整个硬盘中的数据需要更长时间。写入数据就更别提了。一个很简单的,减少读取时间的办法是同时从多个硬盘上读数据。试想,如果我们有100个硬盘。每个硬盘存储1%的数据,并行读取,那么不到两分钟就可以读完所有数据。

仅使用硬盘容量的1%似乎很浪费,但是我们可以存储100个数据集,每个数据集1TB。并实现共享硬盘的读取。可以想象,用户肯定很乐于通过硬盘共享来缩短数据分析时间;并且,从统计角度来看,用户的分析工作都是在不同时间点进行的,所以彼此之间的干扰并不大。

虽然如此,但要对多个硬盘中的数据并行进行读写数据,还有更多问题要解决。第一个需要解决的是硬件故障问题,一旦开始使用多个硬件,其中个别硬件就很有可能发生故障。为了避免数据丢失,最常用的做法是复制:系统保存数据的复本。一旦有系统发生故障,就可以使用另外保存的复本。例如,冗余硬盘阵列(RAID)就是按这个原理实现的,另外,Hadoop文件系统(HDFS,Hadoop
Distributed[分布式的] File System)也是一类,不过它采用的方法稍有不同,这个在后面会详细说明。

第二个问题是大多数分析任务需要以某种方式结合大部分数据来共同完成分析,即从一个硬盘读取的数据可能需要与从另外99个硬盘中读取的数据结合使用,各种分布式系统允许结合不同来源的数据进行分析,但保证其正确性是一个非常大的挑战。MapReduce提出一个编程模型,该模型抽象出这些硬盘读写问题并将其转化为对一个数据集(由键值对组成)的计算。后面会详细讨论这个模型,这样的计算由map和reduce两部分组成,而且只有这两部分提供对外的接口。与HDFS类似,MapReduce自身也有很高的可靠性。

简而言之,Hadoop为我们提供了一个可靠的共享存储和分析系统,HDFS实现数据的存储,MapReduce实现数据的分析和处理。虽然Hadoop还有其他功能,但HDFS和MapReduce是它的核心价值。

MapReduce看似采用一种蛮力方法。每个查询需要处理整个数据集或至少一个数据集的绝大部分。但反过来想,这也正是它的能力。MapReduce是一个批量查询处理器,能够在合理的时间范围内处理针对整个数据集的动态查询【这里指查询的内容是动态的,Hadoop并不能处理动态的数据,处理动态的数据用Spark】。它改变了我们队数据的传统看法,解放了以前只是保存在磁带和硬盘上的数据。它让我们有机会对数据进行创新。以前需要很长时间处理才能获得结果的问题,到现在变得顷刻之间就迎刃而解,同时还可以引发新的问题和新的见解。

1.3.1关系型数据库管理系统(简称RDBMS r=relationnal关系 db=database数据库 m=management管理
s=system系统)

为什么不能用数据库来对大量硬盘上的大规模数据进行批量分析呢?我们为什么需要MapReduce?

这两个问题的答案来自于计算机硬盘的另一个发展趋势,寻址时间的提升远远不低敌于传输速率的提升。寻址是将磁头移动到特定硬盘位置进行读写操作的过程。它是导致磁盘操作延迟的主要原因,而传输速率取决于硬盘的贷款。

如果数据访问模式中包含大量的硬盘寻址,那么读取大量数据集j就必然会花更长的时间(相较于流数据读取模式,流读取主要取决于传输速率)。另一方面,如果数据库系统只更新一小部分记录,那么传统的B树就更有优势(关系型数据库中使用的一种数据结构,受限于寻址的比例)。但数据库系统如果有大量数据更新时,B树的效率就明显落后于MapReduce,因为需要使用“排序/合并”(sort/merge:合并)来重建数据库。

在许多情况下,可以将MapReduce视为关系型数据库管理系统的补充。两个系统之间的差异如表:
关系型数据库和MapReduce差异

MapReduce比较适合批处理方式处理需要分析整个数据集的问题,尤其是动态分析。RDBMS适用于点查询和更新,数据集被索引之后,数据库系统能够提供延迟的数据检索和快速的少量数据更新。MapReduce适合一次写入、多次读取数据的应用,关系型数据库则更适合持续更新的数据集。

MapReduce和关系型数据库之间的另一个区别在于它们所操作的数据集的机构化程度。结构化程度是具有既定格式的实体化数据,如XML文档或满足特定格式的数据库表。这是RDBMS包括的内容。另一方面,半结构化数据比较松散,虽然可能有格式,但经常被忽略,所以它只能作为对数据结构的一般性指导。例如电子表格,它在结构上是由单元格组成的网格,但是每个单元格内可以保证任何形式的数据。非结构化数据没有什么特别的内部结构,例如纯文本或图像数据。MapReduce对非结构化或半结构化数据非常有效,因为它是在处理数据时才对数据进行解释。换句话说,MapReduce输入的键和值并不是数据固有的属性,而是分析数据的人来选的。

关系型数据往往是规范的,以保证其数据的完整性且不含冗余。规范给MapReduce带来了问题,因为它使记录读取成为非本地操作,而MapReduce的核心假设之一偏偏就是可以进行(高速的)流读写操作。

Web服务器日志是典型非规范化数据记录(例如,每次都需要记录客户端主机全名,只会导致同一客户端的全名多次出现),这也是MapReduce非常适用于分析各种日志文件的原因之一。

MapReduce是一种线性的可伸缩编程模型。程序员要写两个函数,分别为map函数和reduce函数,每个函数定义从一个键值对集合到另一个键值对集合的映射,这些函数不必关注数据集及其所用集群的大小,可以原封不动地应用于小规模数据集或大规模的数据集。更重要的是,如果输入的数据量是原来的两倍,那么运行的时间也需要两倍,但如果集群是原来的两倍,作业的运行速度却仍然与原来一样快。SQL查询一般不具备该特性。

但是,在不久的将来,关系型数据库和MapReduce系统之间的差异很可能变得模糊。关系型数据库都开始吸收MapReduce的一些思路m另一方面,基于MapReduce的高级查询语言使传统数据库的程序员更容易接受MapReduce系统。

MapReduce

MapReduce是一种 适合处理大量数据的编程模型
。Hadoop能够运行用各种语言编写的MapReduce程序:Java,Ruby,Python和C++。 MapReduce程序本质上是并行的
,因此对于使用群集中的多台机器执行大规模数据分析非常有用。

处理流程

MapReduce 处理数据过程主要分成 MapReduce 两个阶段。首先执行 Map 阶段,再执行 Reduce 阶段。Map 和
Reduce 的处理逻辑由用户自定义实现,但要符合 MapReduce 框架的约定。处理流程如下所示:

  1. 在正式执行 Map 前,需要将输入数据进行 分片 。所谓分片,就是将输入数据切分为大小相等的数据块,每一块作为单个 Map Task 的输入被处理,以便于多个 Map Task 同时工作。
  2. 分片完毕后,多个 Map Task 便可同时工作。每个 Map Task 在读入各自的数据后,进行计算处理,最终输出给 Reduce。Map Task 在输出数据时,需要为每一条输出数据指定一个 Key,这个 Key 值决定了这条数据将会被发送给哪一个 Reduce Task。 Key 值和 Reduce Task 是多对一的关系 ,具有相同 Key 的数据会被发送给同一个 Reduce Task,单个 Reduce Task 有可能会接收到多个 Key 值的数据。
  3. 在进入 Reduce 阶段之前,MapReduce 框架会对数据按照 Key 值 排序 ,使得具有相同 Key 的数据彼此相邻。如果指定了 合并操作(Combiner) ,框架会调用 Combiner,它负责对中间过程的输出具有相同 Key 的数据进行本地的聚集,这会有助于降低从Mapper到 Reducer数据传输量。Combiner 的逻辑可以自定义实现。这部分的处理通常也叫做 洗牌(Shuffle)
  4. 接下来进入 Reduce 阶段。相同 Key 的数据会到达同一个 Reduce Task。同一个 Reduce Task 会接收来自多个 Map Task 的数据。每个 Reduce Task 会对 Key 相同的多个数据进行 Reduce 操作。最后,一个 Key 的多条数据经过 Reduce 的作用后,将变成一个值。

Map任务处理

reduce任务处理

MapReduce运行过程的深入了解

从前面的WordCount可以看出, 一个MapReduce作业经过了input,map,combine,reduce,output
五个阶段,其中combine阶段并不一定发生, map输出的中间结果被分发到reducer的过程被称为shuffle (数据混洗)。

从输入到输出的状态

Mapreduce运行过程

在shuffle阶段还会发生copy(复制)和sort(排序)。
在MapReduce
的过程中,一个作业被分成Map和Reduce计算两个阶段,它们分别由两个或者多个Map任务和Reduce任务组成,这个在前面已经说过了。Reduce任务默认会在Map任务数量完成5%后才开始启动。

Map任务的执行过程可概括为:

  1. 首先通过用户指定的Inputformat类中的getSplits方法和next方法将输入文件切片并解析成键值对作为map函数的输入。
  2. 然后map函数经过处理之后输出并将中间结果交给指定的Partitioner处理,确保中间结果分发到指定的Reduce任务处理,此时如果用户指定了Combiner,将执行combine操作。
  3. 最后map函数将中间结果保存到本地。

Reduce 任务的执行过程可概括为:

首先需要将已经完成的Map任务的中问结果复制到Reduce任务所在的节点,待数据复制完成后,再以key进行排序,通过排序,将所有key相同的数据交给reduce函数处理,处理完成后,结果直接输出到HDFS上。


。。。这里更详细的有看到,但是没仔细看,又到了源码部分,暂时先了解这么多。


mapReduce的局限性

  1. 从MapReduce 的特点可以看出MapReduce 的优点非常明显,但是MapReduce 也有其局限性,井不是处理海量数据的普适方法。它的局限性主要体现在以下几点:
    MapReduce
    的执行速度慢。一个普通的MapReduce作业一般在分钟级别完成,复杂的作业或者数据量更大的情况下,也可能花费一小时或者更多,好在离线计算对于时间远没有OLTP那么敏感。所以MapReduce
    现在不是,以后也不会是关系型数据库的终结者。MapReduce的慢主要是由于磁盘I/0 , MapReduce
    作业通常都是数据密集型作业,大量的中间结果需要写到磁盘上并通过网络进行传输,这耗去了大量的时间。

  2. MapReduce过于底层。与SQL相比,MapReduce显得过于底层。对于普通的查询,一般人是不会希望写一个map 函数和reduce函数的。对于习惯于关系型数据库的用户,或者数据分析师来说,编写map 函数和reduce 函数无疑是一件头疼的事情。好在Hive的出现,大大改善了这种状况。

  3. 不是所有算法都能用MapReduce 实现。这意味着,不是所有算法都能实现并行。例如机器学习的模型训练, 这些算法需要状态共享或者参数间有依赖,且需要集中维护和更新。

HDFS

HDFS是Hadoop项目的核心子项目,是分布式计算中数据存储管理的基础,是基于流数据模式访问和处理超大文件的需求而开发的,可以运行于廉价的商用服务器上。它所具有的高容错、高可靠性、高可扩展性、高获得性、高吞吐率等特征为海量数据提供了不怕故障的存储,为超大数据集(Large
Data Set)的应用处理带来了很多便利。

这里重点介绍其中涉及到的几个概念:(1) 超大文件 。目前的hadoop集群能够存储几百TB甚至PB级的数据。(2) 流式数据访问
。HDFS的访问模式是: 一次写入,多次读取 ,更加关注的是读取整个数据集的整体时间。(3) 商用硬件。
HDFS集群的设备不需要多么昂贵和特殊,只要是一些日常使用的普通硬件即可,正因为如此,hdfs节点故障的可能性还是很高的,所以
必须要有机制来处理这种单点故障 ,保证数据的可靠。(4) 不支持低时间延迟的数据访问
。hdfs关心的是高数据吞吐量,不适合那些要求低时间延迟数据访问的应用。(5) 单用户写入,不支持任意修改。
hdfs的数据以读为主,只支持单个写入者,并且写操作总是以添加的形式在文末追加,不支持在任意位置进行修改。

HDFS数据块

每个磁盘都有默认的数据块大小,这是文件系统进行数据读写的最小单位。这涉及到磁盘的相应知识,这里我们不多讲,后面整理一篇博客来记录一下磁盘的相应知识。

HDFS同样也有数据块的概念,默认一个块(block)的大小为128MB(HDFS的块这么大主要是为了最小化寻址开销),要在HDFS中存储的文件可以划分为多个分块,每个分块可以成为一个独立的存储单元。与本地磁盘不同的是,HDFS中小于一个块大小的文件并不会占据整个HDFS数据块。

对HDFS存储进行分块有很多好处:

  • 一个文件的大小可以大于网络中任意一个磁盘的容量,文件的块可以利用集群中的任意一个磁盘进行存储。
  • 使用抽象的块,而不是整个文件作为存储单元,可以简化存储管理,使得文件的元数据可以单独管理。
  • 冗余备份。数据块非常适合用于数据备份,进而可以提供数据容错能力和提高可用性。每个块可以有多个备份(默认为三个),分别保存到相互独立的机器上去,这样就可以保证单点故障不会导致数据丢失。

namenode和datanode

HDFS集群的节点分为两类:namenode和datanode,以管理节点-
工作节点的模式运行,即一个namenode和多个datanode,理解这两类节点对理解HDFS工作机制非常重要。

namenode作为管理节点,它负责整个文件系统的命名空间,并且维护着文件系统树和整棵树内所有的文件和目录,这些信息以两个文件的形式(命名空间镜像文件和编辑日志文件)永久存储在namenode
的本地磁盘上。除此之外,同时,namenode也记录每个文件中各个块所在的数据节点信息,但是不永久存储块的位置信息,因为块的信息可以在系统启动时重新构建。

datanode作为文件系统的工作节点,根据需要存储并检索数据块,定期向namenode发送他们所存储的块的列表。

nameNode和dateNode

由此可见,namenode作为管理节点,它的地位是非同寻常的,一旦namenode宕机,那么所有文件都会丢失,因为namenode是唯一存储了元数据、文件与数据块之间对应关系的节点,所有文件信息都保存在这里,namenode毁坏后无法重建文件。因此,必须高度重视namenode的容错性。

为了使得namenode更加可靠,hadoop提供了两种机制:

  • 第一种机制是备份那些组成文件系统元数据持久状态的文件,比如:将文件系统的信息写入本地磁盘的同时,也写入一个远程挂载的网络文件系统(NFS),这些写操作实时同步并且保证原子性。

  • 第二种机制是运行一个辅助namenode,用以保存命名空间镜像的副本,在namenode发生故障时启用。(也可以使用热备份namenode代替辅助namenode)。

nameNode和dataNode

块缓存

数据通常情况下都保存在磁盘,但是对于访问频繁的文件,其对应的数据块可能被显式的缓存到datanode的内存中,以堆外缓存的方式存在,一些计算任务(比如mapreduce)可以在缓存了数据的datanode上运行,利用块的缓存优势提高读操作的性能。

联邦HDFS

namenode在内存中保存了文件系统中每个文件和每个数据块的引用关系,这意味着,当文件足够多时,namenode的内存将成为限制系统横向扩展的瓶颈。hadoop2.0引入了联邦HDFS允许系统通过添加namenode的方式实现扩展,每个namenode管理文件系统命名空间中的一部分,比如:一个namenode管理/usr下的文件,另外一个namenode管理/share目录下的文件。

HDFS的高可用性

通过备份namenode存储的文件信息或者运行辅助namenode可以防止数据丢失,但是依旧没有保证了系统的高可用性。一旦namenode发生了单点失效,那么必须能够快速的启动一个拥有文件系统信息副本的新namenode,而这个过程需要以下几步:(1)将命名空间的副本映像导入内存
(2)重新编辑日志 (3)接收足够多来自datanode的数据块报告,从而重建起数据块与位置的对应关系。

上述实际上就是一个namenode的冷启动过程,但是在数据量足够大的情况下,这个冷启动可能需要30分钟以上的时间,这是无法忍受的。

Hadoop2.0开始,增加了对高可用性的支持。采用了双机热备份的方式。同时使用一对活动-
备用namenode,当活动namenode失效后,备用namenode可以迅速接管它的任务,这中间不会有任何的中断,以至于使得用户根本无法察觉。

为了实现这种双机热备份,HDFS架构需要作出以下几个改变:

  • 两个namenode之间要通过高可用共享存储来实现编辑日志的共享
  • datanode要同时向两个namenode发送数据块的报告信息
  • 客户端要使用特定机制来处理namenode的失效问题
  • 备用namenode要为活动namenode设置周期性的检查点,从中判断活动namenode是否失效

HDFS系统中运行着一个故障转移控制器,管理着将活动namenode转移为备用namenode的转换过程。同时,每一个namenode也运行着一个轻量级的故障转移控制器,主要目的就是监视宿主namenode是否失效,并在失效时实现迅速切换。

Hive

传统mysql无法处理大数据,而大数据文件系统HDFS不能使用SQL,Hive就是一种可以用类SQL语句对大数据文件系统中的结构化数据进行操作的工具

Hive的系统架构

hive的系统架构

  • 用户接口,包括CLI,JDBC/ODBC,WebUI
  • metastore,Hive将元数据存储在数据库中(metastore),目前只支持 mysql、derby(Derby引擎的缺点:一次只能打开一个会话。使用Mysql作为外置存储引擎,多用户同时访问)。Hive 中的元数据包括表名、列、分区及其属性、表的属性(是否为外部表等)、表数据所在目录等
  • Driver 解释器、编译器、优化器完成 HQL 查询语句从词法分析、语法分析、编译、优化以及查询计划(plan)的生成。生成的查询计划存储在 HDFS 中,并在随后有 MapReduce 调用执行
  • Hive的数据存储在HDFS中,大部分的查询由MapReduce完成(包含 * 的查询,比如select * from tb不会生成MapReduce任务)

Hive和普通数据库的异同

  1. 查询语言 。由于SQL被广泛的应用在数据仓库中,因此,专门针对Hive的特性设计了类SQL的查询语言HQL。熟悉SQL开发就很容易入手Hive开发。
  2. 数据仓库位置 。Hive是建立在Hadoop之上的,所有Hive的数据都是存储在HDFS中的。而数据库则可以将数据保存在块设备或者本地文件系统中。
  3. 数据格式 。 Hive中没有定义专门的数据格式,数据格式可以由用户指定,用户定义数据格式需要指定三个属性,列分隔符(通常为空格)、行分隔符(“\n”)以及读取文件数据的方法(Hive中默认有三个文件格式TextFile,SequenceFile以及RCFile)。由于在加载数据的过程中,不需要从用户数据格式到Hive定义的数据格式的转换,因此,Hive在加载的过程中不会对数据本身进行任何修改,而只是将数据内容复制或者移动到相应的HDFS目录中。
  4. 数据更新 。由于Hive是针对数据仓库应用设计的,而数据仓库的内容是读多写少的。因此, Hive中不支持对数据的改写和添加 ,所有的数据都是在加载的时候中确定好的。而数据库中的数据通常是需要经常进行修改的,因此可以使用 INSERT INTO … VALUES 添加数据,使用 UPDATE … SET修改数据。
  5. 索引 。Hive在加载数据的过程中不会对数据进行任何处理,甚至不会对数据进行扫描,因此也没有对数据中的某些Key建立索引。Hive要访问数据中满足条件的特定值时,需要暴力扫描整个数据,因此访问延迟较高。由于 MapReduce 的引入, Hive 可以并行访问数据,因此即使没有索引,对于大数据量的访问,Hive 仍然可以体现出优势。数据库中,通常会针对一个或者几个列建立索引,因此对于少量的特定条件的数据的访问,数据库可以有很高的效率,较低的延迟。由于数据的访问延迟较高,决定了 Hive 不适合在线数据查询。
  6. 执行 。Hive中大多数查询的执行是通过 Hadoop 提供的 MapReduce 来实现的(类似 select * from tbl的查询不需要MapReduce)。而数据库通常有自己的执行引擎。
  7. 执行延迟 。Hive 在查询数据的时候,由于没有索引,需要扫描整个表,因此延迟较高。另外一个导致 Hive 执行延迟高的因素是 MapReduce框架。由于MapReduce 本身具有较高的延迟,因此在利用MapReduce 执行Hive查询时,也会有较高的延迟。相对的,数据库的执行延迟较低。当然,这个低是有条件的,即数据规模较小,当数据规模大到超过数据库的处理能力的时 候,Hive的并行计算显然能体现出优势。
  8. 可扩展性 。由于Hive是建立在Hadoop之上的,因此Hive的可扩展性是和Hadoop的可扩展性是一致的。而数据库由于 ACID 语义的严格限制,扩展行非常有限。目前最先进的并行数据库 Oracle 在理论上的扩展能力也只有100台左右。
  9. 规模 。由于Hive建立在集群上并可以利用MapReduce进行并行计算,因此可以支持很大规模的数据;对应的,数据库可以支持的数据规模较小。

数据类型

在对Hive进行操作之前,首先要明白Hive数据类型有哪些。

  • tinyint/smallint/int/bigint
  • float/double
  • boolean
  • DECIMAL -用户可以指定范围和小数点位数
  • STRING -在特定的字符集中的一个字符串序列
  • VARCHAR -在特定的字符集中的一个有最大长度限制的字符串序列
  • CHAR -在特定的字符集中的一个指定长度的字符串序列
  • BINARY -一个二进制位序列
  • 结构体类型(Stuct): 使用点(.)来访问类型内部的元素。例如,有一列c,它是一个结构体类型{a INT; b INT},字段a可以使用表达式c.a来访问。
  • Map(key-value键值对):使用[‘元素名’]来访问元素。例如,有一个MapM,包含’group’->gid的映射,则gid的值可以使用M[‘group’]来访问。
  • 数组:数组中的元素是相同的类型。可以使用[n]来访问数组元素,n是数组下标,以0开始。例如有一个数组A,有元素[‘a’,’b’,’c’],则A[1]返回’b’。

基础架构及术语

话不多说,先看图,通过这张图我们来捋一捋相关的概念及之间的关系:

Kafka基础架构

Producer :Producer即生产者,消息的产生者,是消息的入口。

kafka cluster
Broker
:Broker是kafka实例,每个服务器上有一个或多个kafka的实例,我们姑且认为每个broker对应一台服务器。每个kafka集群内的broker都有一个
不重复 的编号,如图中的broker-0、broker-1等……
Topic :消息的主题,可以理解为消息的分类,kafka的数据就保存在topic。在每个broker上都可以创建多个topic。
Partition
:Topic的分区,每个topic可以有多个分区,分区的作用是做负载,提高kafka的吞吐量。同一个topic在不同的分区的数据是不重复的,partition的表现形式就是一个一个的文件夹!
Replication
:每一个分区都有多个副本,副本的作用是做备胎。当主分区(Leader)故障的时候会选择一个备胎(Follower)上位,成为Leader。在kafka中默认副本的最大数量是10个,且副本的数量不能大于Broker的数量,follower和leader绝对是在不同的机器,同一机器对同一个分区也只可能存放一个副本(包括自己)。
Message :每一条发送的消息主体。

Leader: 每个分区多个副本的“主”生产者发送数据的对象,以及消费者消费数据的对象都是Leader

Follower:每个分区多个副本中的“从”,实时从Leader中同步数据,保持和Leader数据的同步。Leader发生故障时,某个Follower会成为新的Leader。

Consumer :消费者,即消息的消费方,是消息的出口。
Consumer Group
:我们可以将多个消费组组成一个消费者组,在kafka的设计中同一个分区的数据只能被消费者组中的某一个消费者消费。同一个消费者组的消费者可以消费同一个topic的不同分区的数据,这也是为了提高kafka的吞吐量!

Zookeeper :kafka集群依赖zookeeper来保存集群的的元信息,来保证系统的可用性。

工作流程分析

上面介绍了kafka的基础架构及基本概念,不知道大家看完有没有对kafka有个大致印象,如果对还比较懵也没关系!我们接下来再结合上面的结构图分析kafka的工作流程,最后再回来整个梳理一遍我相信你会更有收获!

发送数据

我们看上面的架构图中,producer就是生产者,是数据的入口。注意看图中的红色箭头,Producer在写入数据的时候 永远的找leader
,不会直接将数据写入follower!那leader怎么找呢?写入的流程又是什么样的呢?我们看下图:

Kafka发送数据

发送的流程就在图中已经说明了,就不单独在文字列出来了!需要注意的一点是,消息写入leader后,follower是主动的去leader进行同步的!producer采用push模式将数据发布到broker,每条消息追加到分区中,顺序写入磁盘,所以保证
同一分区 内的数据是有序的!写入示意图如下:

每条消息追加到分区中

上面说到数据会写入到不同的分区,那kafka为什么要做分区呢?相信大家应该也能猜到,分区的主要目的是:
1、 方便扩展 。因为一个topic可以有多个partition,所以我们可以通过扩展机器去轻松的应对日益增长的数据量。
2、 提高并发 。以partition为读写单位,可以多个消费者同时消费数据,提高了消息的处理效率。

熟悉负载均衡的朋友应该知道,当我们向某个服务器发送请求的时候,服务端可能会对请求做一个负载,将流量分发到不同的服务器,那在kafka中,如果某个topic有多个partition,producer又怎么知道该将数据发往哪个partition呢?kafka中有几个原则:
1、 partition在写入的时候可以指定需要写入的partition,如果有指定,则写入对应的partition。
2、 如果没有指定partition,但是设置了数据的key,则会根据key的值hash出一个partition。
3、 如果既没指定partition,又没有设置key,则会轮询选出一个partition。

保证消息不丢失是一个消息队列中间件的基本保证,那producer在向kafka写入消息的时候,怎么保证消息不丢失呢?其实上面的写入流程图中有描述出来,那就是通过ACK应答机制!在生产者向队列写入数据的时候可以设置参数来确定是否确认kafka接收到数据,这个参数可设置的值为
01all
0代表producer往集群发送数据不需要等到集群的返回,不确保消息发送成功。安全性最低但是效率最高。
1代表producer往集群发送数据只要leader应答就可以发送下一条,只确保leader发送成功。
all代表producer往集群发送数据需要所有的follower都完成从leader的同步才会发送下一条,确保leader发送成功和所有的副本都完成备份。安全性最高,但是效率最低。

最后要注意的是,如果往不存在的topic写数据,能不能写入成功呢?kafka会自动创建topic,分区和副本的数量根据默认配置都是1。

数据传递语义

  • 至少一次(AtLeastOnce)=ACK级别设置为-1+分区副本大于等于2+ISR里应答的最小副本数量大于等于2。可以保证数据不丢失,但是不能保证数据不重复;
  • 最多一次(AtMostOnce)=ACK级别设置为0。可以保证数据不重复,但是不能保证数据不丢失。
  • 精确一次(ExactlyOnce): 精确一次(ExactlyOnce)=幂等性+至少一次(ack=-1+分区副本数 >=2+ISR最小副本数量>=2)。对于一些非常重要的信息,比如和钱相关的数据, 要求数据既不能重复也不丢失。

Kafka0.11版本以后,引入了一项重大特性: 幂等性和事务。

幂等性

幂等性:就是指Producer不论向Broker发送多少次重复数据,Broker端都只会持久化一条,保证了不重复

重复数据的判断标准 :具有<**PID,Partition,SeqNumber**>相同主键的消息提交时,Broker只会持久化一条。其中
PID是Kafka每次重启都会分配一个新的Partition表示分区号;SequenceNumber是单调自增的。

所以幂等性只能保证的是在单分区单会话内不重复。

Kafka幂等性

如何使用幂等性?

开启参数enable.idempotence 默认为true,false关闭。

// 8.开启幂等性(开启事务,必须开启幂等性,默认为true)  
properties.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG,true);  

生产者事务

1)Kafka事务原理开启事务,必须开启幂等性。

Kafka事务一共有5个API

/**  
     * See {@link KafkaProducer#initTransactions()}  
     */  
    void initTransactions();  
  
    /**  
     * See {@link KafkaProducer#beginTransaction()}  
     */  
    void beginTransaction() throws ProducerFencedException;  
  
    /**  
     * See {@link KafkaProducer#sendOffsetsToTransaction(Map, String)}  
     */  
    @Deprecated  
    void sendOffsetsToTransaction(Map<TopicPartition, OffsetAndMetadata> offsets,  
                                  String consumerGroupId) throws ProducerFencedException;  
  
    /**  
     * See {@link KafkaProducer#sendOffsetsToTransaction(Map, ConsumerGroupMetadata)}  
     */  
    void sendOffsetsToTransaction(Map<TopicPartition, OffsetAndMetadata> offsets,  
                                  ConsumerGroupMetadata groupMetadata) throws ProducerFencedException;  
  
    /**  
     * See {@link KafkaProducer#commitTransaction()}  
     */  
    void commitTransaction() throws ProducerFencedException;  
  
    /**  
     * See {@link KafkaProducer#abortTransaction()}  
     */  
    void abortTransaction() throws ProducerFencedException;   

3)使用kafka事务配置如下

// 7.设置事务id(必须)  
properties.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG,"transaction_id_0");  
  
// 8.开启幂等性(开启事务,必须开启幂等性,默认为true)  
properties.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG,true);   

4)自定类 :CustomProducerTransactions 实现kafka事务

import ch.qos.logback.classic.Level;  
import ch.qos.logback.classic.Logger;  
import ch.qos.logback.classic.LoggerContext;  
import org.apache.kafka.clients.producer.Callback;  
import org.apache.kafka.clients.producer.KafkaProducer;  
import org.apache.kafka.clients.producer.ProducerRecord;  
import org.apache.kafka.clients.producer.RecordMetadata;  
import org.slf4j.LoggerFactory;  
  
import java.util.List;  
  
/**  
 * @author huangdh  
 * @version 1.0  
 * @description:  
 * @date 2022-11-10 21:45  
 */  
public class CustomProducerTransactions {  
  
    // 修改日志打印级别,默认为debug级别  
    static {  
        LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();  
        List<Logger> loggerList = loggerContext.getLoggerList();  
        loggerList.forEach(logger -> {  
            logger.setLevel(Level.INFO);  
        });  
    }  
  
    public static void main(String[] args) {  
  
        KafkaProducer<String, String> producer = KafkaProducerFactory.getProducer();  
  
        // 初始化事务  
        producer.initTransactions();  
        // 开启事务  
        producer.beginTransaction();  
        try {  
            for (int i = 0; i < 5; i++) {  
                producer.send(new ProducerRecord<>("kafka", "study hard everyday" + i), new Callback() {  
                    @Override  
                    public void onCompletion(RecordMetadata recordMetadata, Exception e) {  
                        if (e == null) {  
                            System.out.println("主题:" + recordMetadata.topic() + "->" + "分区:" + recordMetadata.partition());  
                        } else {  
                            e.printStackTrace();  
                        }  
                    }  
                });  
            }  
  
//            int i = 1/0;  
            // 提交事务  
            producer.commitTransaction();  
        } catch (Exception e) {  
            // 终止事务  
            producer.abortTransaction();  
            e.printStackTrace();  
        }finally {  
            producer.close();  
        }  
    }  
}  
  

执行结果如下:

23:04:15.842 [kafka-producer-network-thread | producer-transaction_id_0] INFO org.apache.kafka.clients.producer.internals.TransactionManager - [Producer clientId=producer-transaction_id_0, transactionalId=transaction_id_0] Discovered transaction coordinator 8.8.80.8:9092 (id: 0 rack: null)  
23:04:15.979 [kafka-producer-network-thread | producer-transaction_id_0] INFO org.apache.kafka.clients.producer.internals.TransactionManager - [Producer clientId=producer-transaction_id_0, transactionalId=transaction_id_0] ProducerId set to 1000 with epoch 4  
主题:kafka->分区:1  
主题:kafka->分区:1  
主题:kafka->分区:1  
主题:kafka->分区:1  
主题:kafka->分区:1  

保存数据

Producer将数据写入kafka后,集群就需要对数据进行保存了!kafka将数据保存在磁盘,可能在我们的一般的认知里,写入磁盘是比较耗时的操作,不适合这种高并发的组件。Kafka初始会单独开辟一块磁盘空间,顺序写入数据(效率比随机写入高)。

Partition 结构
前面说过了每个topic都可以分为一个或多个partition,如果你觉得topic比较抽象,那partition就是比较具体的东西了!Partition在服务器上的表现形式就是一个一个的文件夹,每个partition的文件夹下面会有多组segment文件,每组segment文件又包含.index文件、.log文件、.timeindex文件(早期版本中没有)三个文件,
log文件就实际是存储message的地方,而index和timeindex文件为索引文件,用于检索消息。

Partition结构

如上图,这个partition有三组segment文件,每个log文件的大小是一样的,但是存储的message数量是不一定相等的(每条的message大小不一致)。文件的命名是以该segment最小offset来命名的,如000.index存储offset为0~368795的消息,kafka就是利用分段+索引的方式来解决查找效率的问题。

Message结构
上面说到log文件就实际是存储message的地方,我们在producer往kafka写入的也是一条一条的message,那存储在log中的message是什么样子的呢?消息主要包含消息体、消息大小、offset、压缩类型……等等!我们重点需要知道的是下面三个:
1、 offset:offset是一个占8byte的有序id号,它可以唯一确定每条消息在parition内的位置!
2、 消息大小:消息大小占用4byte,用于描述消息的大小。
3、 消息体:消息体存放的是实际的消息数据(被压缩过),占用的空间根据具体的消息而不一样。

存储策略
无论消息是否被消费,kafka都会保存所有的消息。那对于旧数据有什么删除策略呢?
1、 基于时间,默认配置是168小时(7天)。
2、 基于大小,默认配置是1073741824。
需要注意的是,kafka读取特定消息的时间复杂度是O(1),所以这里删除过期的文件并不会提高kafka的性能!

Kafka副本

kafka副本

  1. Kafka副本作用:提高数据可靠性。
  2. Kafka默认副本1个,生产环境一般配置为2个,保证数据可靠性;太多副本会增加磁盘存储空间,增加网络上数据传输,降低效率。
  3. Kafka中副本分为:Leader和Follower。Kafka生产者只会把数据发往Leader,然后Follower找Leader进行同步数据。
  4. Kafka分区中的所有副本统称为AR(Assigned Repllicas),AR = ISR + OSR。
    1. ISR,表示和Leader保持同步的Follower集合如果Follower长时间未向Leader发送通信请求或同步数据,则该Follower将被踢出ISR** 。该时间阈值由replica.lag.time.max.ms参数设定,默认30s**。Leader发生故障之后,就会从ISR中选举新的Leader。
    2. OSR,表示Follower与Leader副本同步时,延迟过多的副本。

Leader选举流程

Kafka集群中有一个broker的Controller会被选举为Controller Leader,
负责管理集群broker的上下线,所有topic的分区副本分配和Leader选举等工作
。Controller的信息同步工作是依赖于Zookeeper的。

Leader和Follower故障处理细节

LEO(Log End Offset):每个副本的最后一个offset,LEO其实就是最新的offset+1。

HW(High Watermark):所有副本中最小的LEO。

Leader和Follower故障处理细节

Follower故障

(1)Follower发生故障后会被临时踢出ISR。

(2)这个期间Leader和Follower继续接收数据。

Follower故障

(3)待该Follower恢复后,Follower会读取本地磁盘记录上次的HW,并将log文件高于HW的部分截取掉,从HW开始向Leader进行同步。

从HW开始向Leader进行同步

(4)等该Follower的LEO大于等于该Partition的HW,即Follower追上Leader之后,就可以重新加入ISR了。

Follower追上Leader

Leader故障处理机制

(1)Leader发生故障之后,会从ISR中选出一个新的Leader。

从ISR中选出一个新的Leader

(2)为保证多个副本之间的数据一致性,其余的Follower会先将各自的log文件高于HW的部分截掉,然后从新的Leader同步数据。

高于HW的部分截掉

注意:这只能保证副本之间的数据一致性,并不能保证数据不丢失或者不重复。

消费数据

消息存储在log文件后,消费者就可以进行消费了。与生产消息相同的是,消费者在拉取消息的时候也是 找leader 去拉取。

多个消费者可以组成一个消费者组(consumer
group),每个消费者组都有一个组id!同一个消费组者的消费者可以消费同一topic下不同分区的数据,但是不会组内多个消费者消费同一分区的数据!!!是不是有点绕。我们看下图:

消费数据

图示是消费者组内的消费者小于partition数量的情况,所以会出现某个消费者消费多个partition数据的情况,消费的速度也就不及只处理一个partition的消费者的处理速度!如果是消费者组的消费者多于partition的数量,那会不会出现多个消费者消费同一个partition的数据呢?上面已经提到过不会出现这种情况!多出来的消费者不消费任何partition的数据。所以在实际的应用中,建议
消费者组的consumer的数量与partition的数量一致
在保存数据的小节里面,我们聊到了partition划分为多组segment,每个segment又包含.log、.index、.timeindex文件,存放的每条message包含offset、消息大小、消息体……我们多次提到segment和offset,查找消息的时候是怎么利用segment+offset配合查找的呢?假如现在需要查找一个offset为368801的message是什么样的过程呢?我们先看看下面的图:

Kafka查找消息

1、 先找到offset的368801message所在的segment文件(利用二分法查找),这里找到的就是在第二个segment文件。
2、
打开找到的segment中的.index文件(也就是368796.index文件,该文件起始偏移量为368796+1,我们要查找的offset为368801的message在该index内的偏移量为368796+5=368801,所以这里要查找的
相对offset
为5)。由于该文件采用的是稀疏索引的方式存储着相对offset及对应message物理偏移量的关系,所以直接找相对offset为5的索引找不到,这里同样利用二分法查找相对offset小于或者等于指定的相对offset的索引条目中最大的那个相对offset,所以找到的是相对offset为4的这个索引。
3、
根据找到的相对offset为4的索引确定message存储的物理偏移位置为256。打开数据文件,从位置为256的那个地方开始顺序扫描直到找到offset为368801的那条Message。

这套机制是建立在offset为有序的基础上,利用 segment + 有序offset + 稀疏索引 + 二分查找 +
顺序查找
等多种手段来高效的查找数据!至此,消费者就能拿到需要处理的数据进行处理了。那每个消费者又是怎么记录自己消费的位置呢?在早期的版本中,消费者将消费到的offset维护zookeeper中,consumer每间隔一段时间上报一次,这里容易导致重复消费,且性能不好!在新的版本中消费者消费到的offset已经直接维护在kafka集群的__consumer_offsets这个topic中!

Kafka Broker

Kafka Broker工作流程

Zookeeper存储的Kafka信息

Zookeeper存储的Kafka信息

  1. /kafka/brokers/ids:[0,1,2],记录有哪些服务器。
  2. /kafka/brokers/topics/first/partitions/0/state:{“leader”:1,”isr”:[1,0,2] } 记录谁是Leader,有哪些服务器可用。
  3. /kafka/controller:{“brokerid”:0} 辅助选举Leader

Kafka Broker工作流程

Broker工作流程

  1. broker启动后在zk中注册
  2. controller谁先注册,谁说了算
  3. 由选举出来的controller监听brokers节点变化
  4. controller决定Leader选举
  5. controller将节点信息上传到zk中
  6. 其他controller从zk同步相关信息
  7. 假设broker1中Leader挂了
  8. controller监听到节点发生变化
  9. 获取ISR
  10. 选举新的Leader(在isr中存活为前提,按照AR中排在前面的优先,例如:ar[1,0,2],那么leader就会按照1,0,2的顺序轮询)
  11. 更新Leader及ISR

Kafka核心特性

压缩

我们上面已经知道了Kafka支持以集合(batch)为单位发送消息,在此基础上,Kafka还支持对消息集合进行压缩,Producer端可以通过GZIP或Snappy格式对消息集合进行压缩。Producer端进行压缩之后,在Consumer端需进行解压。压缩的好处就是减少传输的数据量,减轻对网络传输的压力,在对大数据处理上,瓶颈往往体现在网络上而不是CPU(压缩和解压会耗掉部分CPU资源)。
那么如何区分消息是压缩的还是未压缩的呢,Kafka在消息头部添加了一个描述压缩属性字节,这个字节的后两位表示消息的压缩采用的编码,如果后两位为0,则表示消息未被压缩。

消息可靠性

在消息系统中,保证消息在生产和消费过程中的可靠性是十分重要的,在实际消息传递过程中,可能会出现如下三中情况:

  • 一个消息发送失败
  • 一个消息被发送多次
  • 最理想的情况:exactly-once ,一个消息发送成功且仅发送了一次

有许多系统声称它们实现了exactly-
once,但是它们其实忽略了生产者或消费者在生产和消费过程中有可能失败的情况。比如虽然一个Producer成功发送一个消息,但是消息在发送途中丢失,或者成功发送到broker,也被consumer成功取走,但是这个consumer在处理取过来的消息时失败了。
从Producer端看:Kafka是这么处理的,当一个消息被发送后,Producer会等待broker成功接收到消息的反馈(可通过参数控制等待时间),如果消息在途中丢失或是其中一个broker挂掉,Producer会重新发送(我们知道Kafka有备份机制,可以通过参数控制是否等待所有备份节点都收到消息)。
从Consumer端看:前面讲到过partition,broker端记录了partition中的一个offset值,这个值指向Consumer下一个即将消费message。当Consumer收到了消息,但却在处理过程中挂掉,此时Consumer可以通过这个offset值重新找到上一个消息再进行处理。Consumer还有权限控制这个offset值,对持久化到broker端的消息做任意处理。

备份机制

备份机制是Kafka0.8版本的新特性,备份机制的出现大大提高了Kafka集群的可靠性、稳定性。有了备份机制后,Kafka允许集群中的节点挂掉后而不影响整个集群工作。一个备份数量为n的集群允许n-1个节点失败。在所有备份节点中,有一个节点作为lead节点,这个节点保存了其它备份节点列表,并维持各个备份间的状体同步。下面这幅图解释了Kafka的备份机制:

Kafka备份机制

Kafka高效性相关设计

高效读写数据

  1. Kafka是分布式集群,可以采用分区技术,并行度高。
  2. 读数据采用稀疏索引,可以快速定位到要消费的数据。
  3. 顺序写磁盘。

Kafka的producer生产数据,要 写入到log文件中,写的过程是一直追加到文件末端,为顺序写
官网有数据表明,同样的磁盘,顺序写能到600M/s,而随机写只有100K/s
。这与磁盘的机械机构有关,顺序写之所以快,是因为其省去了大量磁头寻址的时间。

页缓存 + 零拷贝技术

零拷贝: Kafka的数据加工处理操作交由Kafka生产者和Kafka消费者处理。Kafka
Broker应用层不关心存储的数据,所以就不用走应用层,传输效率高。

PageCache页缓存
:Kafka重度依赖底层操作系统提供的PageCache功能。当上层有写操作时,操作系统只是将数据写入PageCache。当读操作发生时,先从PageCache中查找,如果找不到,再去磁盘中读取。
实际上PageCache是把尽可能多的空闲内存都当做了磁盘缓存来使用。

是否零拷贝工作流程对比

ZooKeeper之前也是Hadoop下的一个子项目,在读《Hadoop权威指南》时也有简单的介绍,感觉ZooKeeper还是在很多大公司属于经常用的基础,现在公司有用到但是自己接触的比较少,所以也正好网上查查相关资料做一个大概的总结。

分布式协调技术

在介绍ZooKeeper之前先来给大家介绍一种技术——分布式协调技术。那么什么是分布式协调技术?其实分布式协调技术主要用来解决分布式环境当中多个进程之间的同步控制,让他们有序的去访问某种临界资源,防止造成”脏数据”的后果。这时,有人可能会说这个简单,写一个调度算法就轻松解决了。说这句话的人,可能对分布式系统不是很了解,所以才会出现这种误解。如果这些进程全部是跑在一台机上的话,相对来说确实就好办了,问题就在于他是在一个分布式的环境下,这时问题又来了,那什么是分布式呢?来咱们看一下这张图

分布式系统图

在这图中有三台机器,每台机器各跑一个应用程序。然后我们将这三台机器通过网络将其连接起来,构成一个系统来为用户提供服务,对用户来说这个系统的架构是透明的,他感觉不到我这个系统是一个什么样的架构。那么我们就可以把这种系统称作一个
分布式系统

那我们接下来再分析一下,在这个分布式系统中如何对进程进行调度,我假设在第一台机器上挂载了一个资源,然后这三个物理分布的进程都要竞争这个资源,但我们又不希望他们同时进行访问,这时候我们就需要一个
协调器 ,来让他们有序的来访问这个资源。这个协调器就是我们经常提到的那个
,比如说”进程-1”在使用该资源的时候,会先去获得锁,”进程1”获得锁以后会对该资源保持 独占
,这样其他进程就无法访问该资源,”进程1”用完该资源以后就将锁释放掉,让其他进程来获得锁,那么通过这个锁机制,我们就能保证了分布式系统中多个进程能够有序的访问该临界资源。那么我们把这个分布式环境下的这个锁叫作
分布式锁 。这个分布式锁也就是我们 分布式协调技术 实现的核心内容,那么如何实现这个分布式呢,那就是我们后面要讲的内容。

好我们知道,为了防止分布式系统中的多个进程之间相互干扰,我们需要一种分布式协调技术来对这些进程进行调度。而这个分布式协调技术的核心就是来实现这个分
布式锁 。那么这个锁怎么实现呢?这实现起来确实相对来说比较困难的。

有人可能会感觉这不是很难。无非是将原来在同一台机器上对进程调度的原语,通过网络实现在分布式环境中。是的,表面上是可以这么说。但是问题就在网络这,在分布式系统中,所有在同一台机器上的假设都不存在:因为网络是不可靠的。

比如,在同一台机器上,你对一个服务的调用如果成功,那就是成功,如果调用失败,比如抛出异常那就是调用失败。但是在分布式环境中,由于网络的不可靠,你对一个服务的调用失败了并不表示一定是失败的,可能是执行成功了,但是响应返回的时候失败了。还有,A和B都去调用C服务,在时间上
A还先调用一些,B后调用,那么最后的结果是不是一定A的请求就先于B到达呢?
这些在同一台机器上的种种假设,我们都要重新思考,我们还要思考这些问题给我们的设计和编码带来了哪些影响。还有,在分布式环境中为了提升可靠性,我们往往会部署多套服务,但是如何在多套服务中达到一致性,这在同一台机器上多个进程之间的同步相对来说比较容易办到,但在分布式环境中确实一个大难题。

所以分布式协调远比在同一台机器上对多个进程的调度要难得多,而且如果为每一个分布式应用都开发一个独立的协调程序。一方面,协调程序的反复编写浪费,且难以形成通用、伸缩性好的协调器。另一方面,协调程序开销比较大,会影响系统原有的性能。所以,急需一种高可靠、高可用的通用协调机制来用以协调分布式应用。

目前,在分布式协调技术方面做得比较好的就是Google的Chubby还有Apache的ZooKeeper他们都是分布式锁的实现者。有人会问既然有了Chubby为什么还要弄一个ZooKeeper,难道Chubby做得不够好吗?不是这样的,主要是Chbby是非开源的,Google自家用。后来雅虎模仿Chubby开发出了ZooKeeper,也实现了类似的分布式锁的功能,并且将ZooKeeper作为一种开源的程序捐献给了Apache,那么这样就可以使用ZooKeeper所提供锁服务。而且在分布式领域久经考验,它的可靠性,可用性都是经过理论和实践的验证的。所以我们在构建一些分布式系统的时候,就可以以这类系统为起点来构建我们的系统,这将节省不少成本,而且bug也
将更少。

什么是ZooKeeper

从本质上来说,Zookeeper就是一种分布式协调服务,在分布式环境中协调和管理服务是一个复杂的过程。ZooKeeper通过其简单的架构和API解决了这个问题。
ZooKeeper允许开发人员专注于核心应用程序逻辑,而不必担心应用程序的分布式特性。Zookeeper最早的应用是在Hadoop生态中,Apache
HBase使用ZooKeeper跟踪分布式数据的状态。

实际上从它的名字上就很好理解,Zoo - 动物园,Keeper -
管理员,动物园中有很多种动物,这里的动物就可以比作分布式环境下多种多样的服务,而Zookeeper做的就是管理这些服务。

ZooKeeper 的设计目标是将那些复杂且容易出错的分布式一致性服务封装起来,构成一个高效可靠的原语集,并以一系列简单易用的接口提供给用户使用。

原语:
操作系统或计算机网络用语范畴。是由若干条指令组成的,用于完成一定功能的一个过程。具有不可分割性·即原语的执行必须是连续的,在执行过程中不允许被中断。

Zookeeper提供服务主要就是通过:数据结构 + 原语集 + watcher机制达到的。

分布式应用程序结合Zookeeper可以实现诸如
数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master选举、分布式锁和分布式队列 等功能。

ZooKeeper的数据模型

ZooKeeper数据模型

从上图可以看到,Zookeeper的数据模型和Unix的文件系统目录树很类似,拥有一个层次的命名空间。这里面的每一个节点都被称为 - ZNode,
节点可以拥有子节点,同时也允许少量数据节点存储在该节点之下。(可以理解成一个允许一个文件也可以是一个目录的文件系统)

节点引用方式

ZNode通过路径引用,如同Unix中的文件路径。路径必须是绝对的,因此他们必须有斜杠字符/来开头,除此之外,路径名必须是唯一的,且不能更改。

ZNode结构

Node兼具文件和目录两种特点,既像文件一样维护着数据、元信息、ACL、时间戳等数据结构,又像目录一样可以作为路径标识的一部分。

ZNode由以下几部分组成:

  1. Stat数据结构
* 操作控制列表(ACL) - 每个节点都有一个ACL来做节点的操作控制,这个列表规定了用户的权限,限定了特定用户对目标节点的操作

  * CREATE - 创建子节点的权限
  * READ - 获取节点数据和子节点列表的权限
  * WRITE - 更新节点数据的权限
  * DELETE - 删除子节点的权限
  * ADMIN - 设置节点ACL的权限
* 版本 - ZNode有三个数据版本

  * **version** \- 当前ZNode的版本
  * **cversion** \- 当前ZNode子节点的版本
  * **aversion** \- 当前ACL列表的版本
* Zxid

  * 可以理解成Zookeeper中 **时间戳的一种表现形式** ,也可以理解成 **事务ID** 的概念

  * 如果Zxid1的值小于Zxid2的值,那么Zxid1所对应的事件发生在Zxid2所对应的事件之前。

  * ZooKeeper的每个节点维护着三个Zxid值,分别为:cZxid、mZxid、pZxid。

    * **cZxid** :节点创建时间 create
    * **mZxid** :节点最近一次修改时间 modify
    * **pZxid** :该节点的子节点列表最后一次被修改时的时间,子节点内容变更不会变更pZxid
  1. data域

  2. children节点

其中,

Stat。是状态信息/节点属性;

Data域,Zookeeper中每个节点存储的数据要被 原子性的操作
,也就是说读操作将获取与节点相关的所有数据,写操作也将替换掉节点的所有数据。值得注意的是,Zookeeper虽然可以存储数据,但是
从设计目的上,并不是为了做数据库或者大数据存储,相反,它是用来管理调度数据,比如分布式应用中的配置文件信息、状态信息、汇集位置等
,这些数据通常是很小的数据,KB为大小单位。ZNode对数据大小也有限制,至多1M。实际上从这里,就可以推导出Zookeeper用于分布式配置中心的可行性。

Zxid,在ZooKeeper中,
能改变ZooKeeper服务器状态的操作称为事务操作。一般包括数据节点创建与删除、数据内容更新和客户端会话创建与失效等操作
。对应每一个事务请求,ZooKeeper都会为其分配一个全局唯一的 事务ID ,用Zxid表示。Zxid是一个64位的数字。 前32位
叫做epoch,用来 标识Zookeeper 集群中的Leader节点,当Leader节点更换时,就会有一个新的epoch
后32位 则为递增序列。从这些Zxid中可以间接地识别出ZooKeeper处理这些事务操作请求的全局顺序。

节点类型

ZNode节点类型严格来说有四种: 持久节点、临时节点、持久顺序节点、临时顺序节点

  • PERSISTENT 持久节点 - 该节点的生命周期不依赖于session,创建之后客户端断开连接,节点依旧存在,只有客户端执行删除操作,节点才能被删除;
  • EPHEMERAL 临时节点 - 该节点的声明周期依赖于session,客户端断开连接,临时节点就会自动删除。另外, 临时节点不允许有子节点。
  • SEQUENTIAL 顺序节点 - 当选择创建顺序节点时,ZooKeeper通过将10位的序列号附加到原始名称来设置znode的路径。例如,如果将具有路径 /myapp 的znode创建为顺序节点,则ZooKeeper会将路径更改为 /myapp0000000001 ,并将下一个序列号设置为0000000002。如果两个顺序节点是同时创建的,那么ZooKeeper不会对每个znode使用相同的数字。 顺序节点在锁定和同步中起重要作用。 这个计数 对于此节点的父节点来说 是唯一的,它的格式为”%10d”(10位数字,没有数值的数位用0补充,例如”0000000001”)。当计数值大于2^32-1时,计数器将溢出。

节点属性

znode节点属性

观察

客户端可以在节点上设置watch,我们称之为 监视器
。当节点状态发生改变时(Znode的增、删、改)将会触发watch所对应的操作。当watch被触发时,ZooKeeper将会向客户端发送且仅发送一条通知,因为watch只能被触发一次,这样可以减少网络流量。

Zookeeper服务基本操作

ZooKeeper服务中操作

更新ZooKeeper操作是有限制的。delete或setData必须明确要更新的Znode的版本号,我们可以调用exists找到。如果版本号不匹配,更新将会失败。

更新ZooKeeper操作是非阻塞式的。因此客户端如果失去了一个更新(由于另一个进程在同时更新这个Znode),他可以在不阻塞其他进程执行的情况下,选择重新尝试或进行其他操作。

尽管ZooKeeper可以被看做是一个文件系统,但是处于便利,摒弃了一些文件系统地操作原语。因为文件非常的小并且使整体读写的,所以不需要打开、关闭或是寻地的操作。

Sessions

在 ZooKeeper 中,一个客户端连接是指客户端和服务器之间的一个 TCP 长连接 。客户端启动的时候,首先会与服务器建立一个 TCP
连接,从第一次连接建立开始,客户端会话的生命周期也开始了。
通过这个连接,客户端能够通过心跳检测与服务器保持有效的会话,也能够向Zookeeper服务器发送请求并接受响应,同时还能够通过该连接接收来自服务器的Watch事件通知。

客户端以特定的时间间隔发送心跳以保持会话有效。如果ZooKeeper Server
Ensembles在超过服务器开启时指定的期间(会话超时)都没有从客户端接收到心跳,则它会判定客户端死机。

会话超时通常以毫秒为单位。当会话由于任何原因结束时,在该会话期间创建的临时节点也会被删除。

Watches

Watches - 监听事件 ,是Zookeeper中一个很重要的特性,也是实现Zookeeper大多数功能的核心特性之一。简单来说,
Zookeeper允许Client端在指定节点上注册Watches,在某些特定事件触发的时候,Zookeeper服务端会将事件异步通知到感兴趣(即注册了Watches)的客户端上去
。可以理解成一个订阅/发布系统,是不是。

Znode更改是与znode相关的数据的修改或znode的子项中的更改。只触发一次watches。如果客户端想要再次通知,则必须通过另一个读取操作来完成。当连接会话过期时,客户端将与服务器断开连接,相关的watches也将被删除。

下面说完简单的,来说点复杂的部分。

几个特性先了解下:

  • One-time trigger 一次watch时间只会被触发一遍,如果节点再次发生变化,除非之前有重新设置过watches,不然会收到通知;
  • Sent to Client 当watch的对象状态发生改变时,将会触发此对象上watch所对应的事件。watch事件将被异步地发送给客户端,并且ZooKeeper为watch机制提供了**有序的一致性保证(Ordering guarantee)**。
  • The data for which the watch was set 发送给客户端的数据信息,实际上就是你这个watch监视的类型,见下文介绍

Zookeeper的Watches 分为两种, 数据监听器(Data Watches)和子节点监听器(Children Watches)
。即你可以对某个节点的Data设置watches,也可以对某个子节点设置watches。

可以看下Zookeeper Java 客户端 Zkclient 中的设置watches的代码:

// listener 监听器  
// path 节点路径  
  
// 子节点监听器  
private List<String> addTargetChildListener(String path, IZkChildListener listener) {  
    return client.subscribeChildChanges(path, listener);  
}  
  
// 节点数据的监听器  
public void addChildDataListener(String path, IZkDataListener listener) {  
    try {  
        // 递归创建节点  
        client.subscribeDataChanges(path, listener);  
    } catch (ZkNodeExistsException e) {  
    }  
}  
  

作为开发者,需要知道监控节点的什么操作会触发你设置的watches。

  1. 一个成功的setData操作将触发Znode的数据watches
  2. 一个成功的create操作将触发Znode的数据watches以及子节点watches
  3. 一个成功的delete操作将触发Znode的数据watches和子节点watches

再看下ZkClient中的数据监听器接口IZkDataListener

public interface IZkDataListener {  
    // 监控节点数据更新的时候会触发 这段逻辑  
    public void handleDataChange(String dataPath, Object data) throws Exception;  
    // 监控节点被删除的时候会触发 这段逻辑  
    public void handleDataDeleted(String dataPath) throws Exception;  
}  
  

再看下ZkClient中的子节点监听器接口IZkChildListener

public interface IZkChildListener {  
  
    /**  
     * Called when the children of the given path changed.  
     * 监控节点的子节点列表改变时会触发这段逻辑  
     *   
     * @param parentPath  
     *            The parent path  
     * @param currentChilds  
     *            The children or null if the root node (parent path) was deleted.  
     * @throws Exception  
     */  
    public void handleChildChange(String parentPath, List<String> currentChilds) throws Exception;  
}  

实际上看到这就能联想到,Zookeeper是可以当做分布式配置中心来使用的,只不过你需要自己扩展他异步通知节点数据变化之后的逻辑,更新你的配置。

(1) watch概述

ZooKeeper可以为所有的 读操作
设置watch,这些读操作包括:exists()、getChildren()及getData()。watch事件是 一次性的触发器
,当watch的对象状态发生改变时,将会触发此对象上watch所对应的事件。watch事件将被 异步
地发送给客户端,并且ZooKeeper为watch机制提供了有序的 一致性保证
。理论上,客户端接收watch事件的时间要快于其看到watch对象状态变化的时间。

(2) watch类型

ZooKeeper所管理的watch可以分为两类:

数据watch(data watches): getDataexists 负责设置数据watch
孩子watch(child watches): getChildren 负责设置孩子watch

我们可以通过操作 返回的数据 来设置不同的watch:

① getData和exists: 返回关于节点的数据信息
② getChildren: 返回孩子列表

因此

一个成功的 setData操作 将触发Znode的数据watch

一个成功的 create操作 将触发Znode的数据watch以及孩子watch

一个成功的 delete操作 将触发Znode的数据watch以及孩子watch

(3) watch注册与处触发

watch设置操作及相应的触发器

exists操作上的watch,在被监视的Znode 创建删除数据更新 时被触发。
getData操作上的watch,在被监视的Znode 删除数据更新
时被触发。在被创建时不能被触发,因为只有Znode一定存在,getData操作才会成功。
getChildren操作上的watch,在被监视的Znode的子节点 创建删除 ,或是这个Znode自身被
删除
时被触发。可以通过查看watch事件类型来区分是Znode,还是他的子节点被删除:NodeDelete表示Znode被删除,NodeDeletedChanged表示子节点被删除。

Watch由客户端所连接的ZooKeeper服务器在本地维护,因此watch可以非常容易地设置、管理和分派。当客户端连接到一个新的服务器时,任何的会话事件都将可能触发watch。另外,当从服务器断开连接的时候,watch将不会被接收。但是,当一个客户端重新建立连接的时候,任何先前注册过的watch都会被重新注册。

(4) 需要注意的几点

Zookeeper的watch实际上要处理两类事件:

① 连接状态事件 (type=None, path=null)

这类事件不需要注册,也不需要我们连续触发,我们只要处理就行了。

② 节点事件

节点的建立,删除,数据的修改。它是one time trigger,我们需要不停的注册触发,还可能发生事件丢失的情况。

上面2类事件都在Watch中处理,也就是重载的 process(Event event)

节点事件的触发,通过函数exists,getData或getChildren来处理这类函数,有双重作用:

① 注册触发事件

② 函数本身的功能

函数的本身的功能又可以用异步的回调函数来实现,重载processResult()过程中处理函数本身的的功能。

Zookeeper特性总结

现在我们再回过头来看看Zookeeper的特性:

① 顺序一致性 从同一个客户端发起的事务请求,最终将会严格按照其发起顺序被应用到ZooKeeper中。

② 原子性
所有事务请求的结果在集群中所有机器上的应用情况是一致的,也就是说要么整个集群所有集群都成功应用了某一个事务,要么都没有应用,一定不会出现集群中部分机器应用了该事务,而另外一部分没有应用的情况。

③ 单一视图 无论客户端连接的是哪个ZooKeeper服务器,其看到的服务端数据模型都是一致的。

④ 可靠性 一旦服务端成功地应用了一个事务,并完成对客户端的响应,那么该事务所引起的服务端状态变更将会被一直保留下来,除非有另一个事务又对其进行了变更。

⑤ 实时性
通常人们看到实时性的第一反应是,一旦一个事务被成功应用,那么客户端能够立即从服务端上读取到这个事务变更后的最新数据状态。这里需要注意的是,ZooKeeper仅仅保证在一定的时间段内,客户端最终一定能够从服务端上读取到最新的数据状态。

顺序一致性 是通过ZXid来实现的,全局唯一,顺序递增,同一个session中请求是FIFO的; 可靠性
的描述也可以通过今天的知识进行理解,一次事务的应用,服务端状态的变更会以Zxid、Znode数据版本、数据、节点路径的形式保存下来。剩下的几种特性是怎么实现的,后面会进行介绍

Zookeeper运行模式

Zookeeper 有两种运行模式, 单点模式集群模式

  • 单点模式(standalone mode)- Zookeeper 只运行在单个服务器上,常用于开发测试阶段,这种模式比较简单,但是不能保证Zookeeper服务的高可用性和恢复性。

  • 集群模式(replicated mode)- 英文原文这种模式叫做“复制模式”;这个模式下,Zookeeper运行于一个集群上,适合生产环境。

同一个集群下的server节点被称为 quorum ,翻译过来就是“一个正式会议的法定人数”,如果你看完下一章介绍的 ZAB协议的两种模式
之后,应该会觉得这个比喻实际上很形象。

NOTE:
在集群模式下,最少需要三个server节点。并且官方推荐你使用奇数数量的server节点来组成集群。至于为什么,和Zookeeper的读写策略和一致性协议有关,在后面的章节会介绍。

Zookeeper的集群架构

Zookeeper集群架构

Zookeeper集群中的角色

Zookeeper中,
能改变ZooKeeper服务器状态的操作称为事务操作。一般包括数据节点创建与删除、数据内容更新和客户端会话创建与失效等操作

  • Leader 领导者 :Leader 节点负责Zookeeper集群内部投票的发起和决议(一次事务操作),更新系统的状态;同时它也能接收并且响应Client端发送的请求。
  • Learner 学习者
    • Follower 跟随者 : Follower 节点用于接收并且响应Client端的请求,如果是事务操作,会将请求转发给Leader节点,发起投票,参与集群的内部投票,
    • Observer 观察者 :Observer 节点功能和Follower相同,只是Observer 节点不参与投票过程,只会同步Leader节点的状态。
  • Client 客户端

Zookeeper 通过 复制 来实现 高可用 。在上面提到的集群模式(replicated
mode)下,以Leader节点为准,Zookeeper的ZNode树上面的每一个修改都会被同步(复制)到其他的Server 节点上面。

上面实际上只是一个概念性的简单叙述,在看完下文的 读写机制ZAB协议的两种模式 之后,你就会对这几种角色有一个更加深刻的认识。

Zookeeper 读写机制

下图就是集群模式下一个Zookeeper Server节点提供读写服务的一个流程。

读写流程

如上图所示,每个Zookeeper
Server节点除了包含一个请求处理器来处理请求以外,都会有一个**内存数据库(ReplicatedDatabase)**用于持久化数据。ReplicatedDatabase
包含了整个Data Tree。

来自于Client的读服务(Read Requst),是直接由对应Server的本地副本来进行服务的。

至于来自于Client的写服务(Write
Requst),因为Zookeeper要保证每台Server的本地副本是一致的(单一系统映像),需要通过一致性协议(后文提到的ZAB协议)来处理,成功处理的写请求(数据更新)会先序列化到每个Server节点的本地磁盘(为了再次启动的数据恢复)再保存到内存数据库中。

集群模式下,Zookeeper使用简单的同步策略,通过以下三条基本保证来实现 数据的一致性

  • 全局 串行化 所有的 写操作

串行化可以把变量包括对象,转化成连续bytes数据. 你可以将串行化后的变量存在一个文件里或在网络上传输. 然后再反串行化还原为原来的数据。

  • 保证 同一客户 端的指令被FIFO执行(以及消息通知的FIFO)

FIFO -先入先出

  • 自定义的原子性消息协议

简单来说,对数据的写请求,都会被转发到Leader节点来处理,Leader节点会对这次的更新发起投票,并且发送提议消息给集群中的其他节点,当半数以上的Follower节点将本次修改持久化之后,Leader
节点会认为这次写请求处理成功了,提交本次的事务。

乐观锁

Zookeeper 的核心思想就是,提供一个 非锁机制的Wait Free 的用于分布式系统同步的核心服务 。其核心对于文件、数据的读写服务,并
不提供加锁互斥的服务

但是由于Zookeeper的每次更新操作都会更新ZNode的版本,也就是客户端可以自己基于版本的对比,来实现更新数据时的加锁逻辑。例如下图。

zookeeper流程

就像我们更新数据库时,会新增一个version字段,通过更新前后的版本对比来实现乐观锁。

ZAB协议

ZAB 协议是为分布式协调服务ZooKeeper专门设计的一种 支持崩溃恢复一致性协议
,这个机制保证了各个server之间的同步。全称 Zookeeper Atomic Broadcast Protocol - Zookeeper
原子广播协议。

两种模式

Zab协议有两种模式,它们分别是 恢复模式广播模式

广播模式

广播模式类似于分布式事务中的 Two-phase commit
(两阶段式提交),因为Zookeeper中一次写操作就是被当做一个事务,所以这实际上本质是相同的。

广播模式 ,一次写请求要经历以下的步骤

  1. ZooKeeper Server接受到Client的写请求
  2. 写请求都被转发给Leader节点
  3. Leader节点先将更新持久化到本地
  4. Leader节点将此次更新提议(propose)给Followers,进入收集选票的流程
  5. Follower节点接收请求,成功将修改持久化到本地,发送一个ACK给Leader
  6. Leader接收到半数以上的ACK时,Leader将广播commit消息并在本地deliver该消息。
  7. 当收到Leader发来的commit消息时,Follower也会deliver该消息。

广播协议在所有的通讯过程中使用TCP的FIFO信道,通过使用该信道,使保持有序性变得非常的容易。通过FIFO信道,消息被有序的deliver。只要收到的消息一被处理,其顺序就会被保存下来。

但是这种模式下,如果Leader自身发生了故障,Zookeeper的集群不就提供不了写服务了吗?这就引入了下面的恢复模式。

恢复模式

简单点来说,当集群中的Leader 故障 或者 服务启动
的时候,ZAB就会进入恢复模式,其中包括Leader选举和完成其他Server和Leader之间的 状态同步

单点故障

在分布式锁服务中,有一种最典型应用场景,就是通过对集群进行 Master选举 ,来解决分布式系统中的 单点故障
。什么是分布式系统中的单点故障:通常分布式系统采用主从模式,就是一个主控机连接多个处理节点。主节点负责分发任务,从节点负责处理任务,当我们的主节点发生故障时,那么整个系统就都瘫痪了,那么我们把这种故障叫作单点故障。

主从模式分布式系统单点故障1

传统解决方案

传统方式是采用一个备用节点,这个备用节点定期给当前主节点发送ping包,主节点收到ping包以后向备用节点发送回复Ack,当备用节点收到回复的时候就会认为当前主节点还活着,让他继续提供服务。

传统解决方案

当主节点挂了,这时候备用节点收不到回复了,然后他就认为主节点挂了接替他成为主节点

传统解决方案1

但是这种方式就是有一个隐患,就是网络问题,来看一看网络问题会造成什么后果

网络故障

也就是说我们的主节点的并没有挂,只是在回复的时候网络发生故障,这样我们的备用节点同样收不到回复,就会认为主节点挂了,然后备用节点将他的Master实例启动起来,这样我们的分布式系统当中就有了两个主节点也就是—
双Master
,出现Master以后我们的从节点就会将它所做的事一部分汇报给了主节点,一部分汇报给了从节点,这样服务就全乱了。为了防止出现这种情况,我们引入了ZooKeeper,它虽然不能避免网络故障,但它能够保证每时每刻只有一个Master。我么来看一下ZooKeeper是如何实现的。

zookeeper解决方案

首先每个Server在工作过程中有四种状态:
LOOKING:竞选状态,当前Server不知道leader是谁,正在搜寻。
LEADING:领导者状态,表明当前服务器角色是leader。
FOLLOWING:随从状态,表明当前服务器角色是follower,同步leader状态,参与投票。
OBSERVING,观察状态,表明当前服务器角色是observer,同步leader状态,不参与投票。

选举流程

假设我们目前有一个3个节点构成的ZooKeeper集群,myid的编号分别是0,1,2,又因为集群当前是一个空的集群,所以
每个节点的ZXID初始都为0 ,该集群启动的时候Leader的选举流程如下:

  1. 我们首先启动myid为0的服务,但是目前只有一台ZooKeeper服务,所以是无法完成Leader选举的,ZooKeeper集群要求Leader进行投票选举条件是至少有2台服务才行,不然都没法进行通信投票。

  2. 启动myid为1的服务,第二台启动了以后,这两台ZooKeeper就可以相互通信了,接下来就可以进行投票选举了。

  3. 2台ZooKeeper进行投票选举的时候,第一次都是推荐自己为Leader,投票包含的信息是:服务器本身的myid和ZXID。比如第一台投自己的话,它会发送给第二台机器的投票是(0,0),第一个0代表的是机器的myid,第二个0代表是的ZXID。故两台机器收到的投票情况如下:

第一台:(1,0)

第二台:(0,0)

  1. 两台服务器在接收到投票后,将别人的票和自己的投票进行PK。PK的是规则是:

(a)优先对比ZXID,ZXID大的优先作为Leader(ZXID大的表示数据多)

(b)如果ZXID一样的话,那么就比较myid,让myid大的作为Leader服务器。

那根据这个规则的话,第一台服务器,接受到的投票是(1,0),跟自己的投票(0,0)比,ZXID是一样的,但是myid比接收到的投票的小,所以第一台原先是推荐自己投票为(0,0),现在进行了PK以后,投票修改为(1,0)。第二台服务器,接受到的投票是(0,0),跟自己的投票(1,0)比,ZXID是一样的,但是myid是比接受到的投票的大,所以坚持自己的投票(1,0)。两台服务器再次进行投票。

  1. 每次投票以后,服务器都会统计所有的投票,只要过半的机器投了相同的机器,那么Leader就选举成功了,上面的两台服务器进行第二次投票之后,两台服务器都会收到相同的投票(1,0)。那么此时myid为1的服务器就是Leader了。

如上的Leader选举其实在集群启动的过程中只需要几毫秒就完成了,所以如果有搭建ZooKeeper集群经验的同学会发现,我们如果按顺序启动服务的话,启动到第二台机器的时候,Leader就已经选出来了,所以大家会看到一般第二台就是Leader。第三台启动的时候就作为Follower。

上面我们描述的是集群在初始化过程中Leader的选举流程,如果集群在运行的过程中Follower节点宕机了,对Leader节点是不影响的,如果集群在运行的过程中Leader节点宕机了,就会进行重新选举,重新选举的流程跟上述一致。

leader选举流程

关于Zookeeper 集群的一些其他讨论

Zookeeper(读性能)可伸缩性 和 Observer节点

一个集群的可伸缩性即可以引入更多的集群节点,来提升某种性能。Zookeeper实际上就是提供读服务和写服务。在最早的时候,Zookeeper是通过引入Follower节点来提升
读服务 的性能。但是根据我们之前学习过的读写机制和ZAB协议的内容,引入新的Follower节点,会造成Zookeeper
写服务的下降,因为Leader发起的投票是要半数以上的Follower节点响应才会成功,你Follower多了,就增加了协议中投票过程的压力,可能会拖慢整个投票响应的速度。结果就是,**Follower节点增加,集群的写操作吞吐会下降**。

在这种情况下,Zookeeper
在3.3.3版本之后,在集群架构中引入了Observer角色,和Follower唯一的区别的就是不参与投票不参与选主。这样既提升了读性能,又不会影响写性能。

另外提一句,Zookeeper的写性能是不能被扩展的,这也是他不适合当做服务注册发现中心的一个原因之一,在服务发现和健康监测场景下,随着服务规模的增大,无论是应用频繁发布时的服务注册带来的写请求,还是刷毫秒级的服务健康状态带来的写请求,都会Zookeeper带来很大的写压力,因为它本身的写性能是无法扩展的。后文引的文章会详细介绍。

Zookeeper 与 CAP 理论

分布式领域中存在CAP理论:

  • C:Consistency ,一致性,数据一致更新,所有数据变动都是同步的。
  • A:Availability ,可用性,系统具有好的响应性能。
  • P:Partition tolerance ,分区容错性。以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择,也就是说无论任何消息丢失,系统都可用。

CAP 定理的含义 – 阮一峰

该理论已被 证明 :任何分布式系统只可同时满足两点,无法三者兼顾。
因此,将精力浪费在思考如何设计能满足三者的完美系统上是愚钝的,应该根据应用场景进行适当取舍。

根据我们前面学习过的读写机制和ZAB协议的内容,Zookeeper本质应该是一个偏向CP的分布式系统。因为广播协议本质上是牺牲了系统的响应性能的。另外从它的以下几个特点也可以看出。也就是在第一章最后提出的几个特点。

① 顺序一致性 从同一个客户端发起的事务请求,最终将会严格按照其发起顺序被应用到ZooKeeper中。

② 原子性
所有事务请求的结果在集群中所有机器上的应用情况是一致的,也就是说要么整个集群所有集群都成功应用了某一个事务,要么都没有应用,一定不会出现集群中部分机器应用了该事务,而另外一部分没有应用的情况。

③ 单一视图 无论客户端连接的是哪个ZooKeeper服务器,其看到的服务端数据模型都是一致的。

④ 可靠性 一旦服务端成功地应用了一个事务,并完成对客户端的响应,那么该事务所引起的服务端状态变更将会被一直保留下来,除非有另一个事务又对其进行了变更。

Zookeeper 作为 服务注册中心的局限性

直接引一篇阿里中间件的文章吧,讲的比我好。实际在生产情况下,大多数公司没有达到像大公司那样的微服务量级,Zookeeper是完全能满足服务注册中心的需求的。

阿里巴巴为什么不用 ZooKeeper 做服务发现?

现在的公司基本上都是HTTP调用,个别服务会有RPC,突然发现之前自己知道这两个调用方式是不同的,具体有哪些不同还有点模糊,了解了之后发现难怪大厂都用RPC调用,包括gRPC和dubbo。

先说一下他们最本质的区别,就是RPC主要是基于TCP/IP协议的,而HTTP服务主要是基于HTTP协议的,我们都知道HTTP协议是在传输层协议TCP之上的,所以效率来看的话,RPC当然是要更胜一筹啦!当然RPC也可以用HTTP调用,像Feign,不过也因此他被叫做伪RPC。下面来具体说一说RPC服务和HTTP服务。

RPC服务

RPC架构

一个完整的RPC架构里面包含了四个核心的组件,分别是Client ,Server,Client Stub以及Server
Stub,这个Stub大家可以理解为存根。分别说说这几个组件:

  • 客户端(Client),服务的调用方。
  • 服务端(Server),真正的服务提供者。
    客户端存根,存放服务端的地址消息,再将客户端的请求参数打包成网络消息,然后通过网络远程发送给服务方。
    服务端存根,接收客户端发送过来的消息,将消息解包,并调用本地的方法。

RPC架构

RPC主要是用在大型企业里面,因为大型企业里面系统繁多,业务线复杂,而且效率优势非常重要的一块,这个时候RPC的优势就比较明显了。实际的开发当中是这么做的,项目一般使用maven来管理。

比如我们有一个处理订单的系统服务,先声明它的所有的接口(这里就是具体指Java中的interface),然后将整个项目打包为一个jar包,服务端这边引入这个二方库,然后实现相应的功能,客户端这边也只需要引入这个二方库即可调用了。

为什么这么做?主要是为了减少客户端这边的jar包大小,因为每一次打包发布的时候,jar包太多总是会影响效率。另外也是将客户端和服务端解耦,提高代码的可移植性。

同步调用和异步调用

同步调用就是客户端等待调用执行完成并返回结果。异步调用就是客户端不等待调用执行完成返回结果,不过依然可以通过回调函数等接收到返回结果的通知。如果客户端并不关心结果,则可以变成一个单向的调用。

这个过程有点类似于Java中的callable和runnable接口,我们进行异步执行的时候,如果需要知道执行的结果,就可以使用callable接口,并且可以通过Future类获取到异步执行的结果信息。如果不关心执行的结果,直接使用runnable接口就可以了,因为它不返回结果,当然啦,callable也是可以的,我们不去获取Future就可以了。

流行的RPC框架

  1. gRPC是Google最近公布的开源软件,基于最新的HTTP2.0协议,并支持常见的众多编程语言。我们知道HTTP2.0是基于二进制的HTTP协议升级版本,目前各大浏览器都在快马加鞭的加以支持。这个RPC框架是基于HTTP协议实现的,底层使用到了Netty框架的支持。下面放两个文档

https://doc.oschina.net/grpc?t=56831

https://www.grpc.io/docs/

  1. Thrift是Facebook的一个开源项目,主要是一个跨语言的服务开发框架。它有一个代码生成器来对它所定义的IDL定义文件自动生成服务代码框架。用户只要在其之前进行二次开发就行,对于底层的RPC通讯等都是透明的。不过这个对于用户来说的话需要学习特定领域语言这个特性,还是有一定成本的。
  2. Dubbo是阿里集团开源的一个极为出名的RPC框架,在很多互联网公司和企业应用中广泛使用。协议和序列化框架都可以插拔是及其鲜明的特色。同样 的远程接口是基于Java Interface,并且依托于spring框架方便开发。可以方便的打包成单一文件,独立进程运行,和现在的微服务概念一致。

http://dubbo.apache.org/zh-cn/docs/user/quick-start.html

关于gRPC和Dubbo暂时可能不会写文章总结,因为文档足够详细,自己也还没看完,等看完了并且有了超出文档的理解再进行总结。

HTTP服务

其实在很久以前,我对于企业开发的模式一直定性为HTTP接口开发,也就是我们常说的RESTful风格的服务接口。的确,对于在接口不多、系统与系统交互较少的情况下,解决信息孤岛初期常使用的一种通信手段;优点就是简单、直接、开发方便。

利用现成的http协议进行传输。我们记得之前实习在公司做后台开发的时候,主要就是进行接口的开发,还要写一大份接口文档,严格地标明输入输出是什么?说清楚每一个接口的请求方法,以及请求参数需要注意的事项等。

接口可能返回一个JSON字符串或者是XML文档。然后客户端再去处理这个返回的信息,从而可以比较快速地进行开发。

但是对于大型企业来说,内部子系统较多、接口非常多的情况下,RPC框架的好处就显示出来了,首先就是长链接,不必每次通信都要像http一样去3次握手什么的,减少了网络开销,虽然HTTP1.1之后也可以长连接了;

其次就是RPC框架一般都有注册中心,有丰富的监控管理;发布、下线接口、动态扩展等,对调用方来说是无感知、统一化的操作。

顺便总结下HTTP版本差别

HTTP 0.9

HTTP 0.9 是一个最古老的版本

  • 只支持GET请求方式:由于不支持其他请求方式,因此客户端是没办法向服务端传输太多的信息
  • 没有请求头概念:所以不能在请求中指定版本号,服务端也只具有返回 HTML字符串的能力
  • 服务端相响应之后,立即关闭TCP连接

HTTP 1.0

随着 HTTP 1.0 的发布,这个版本:

  • 请求方式新增了POST,DELETE,PUT,HEADER等方式
  • 增添了请求头和响应头的概念,在通信中指定了 HTTP 协议版本号,以及其他的一些元信息 (比如: 状态码、权限、缓存、内容编码)
  • 扩充了传输内容格式,图片、音视频资源、二进制等都可以进行传输

在这个版本主要的就是对请求和响应的元信息进行了扩展,客户端和服务端有更多的获取当前请求的所有信息,进而更好更快的处理请求相关内容。

请求头

一个简单请求的头信息

GET / HTTP/1.0  
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5)  
Accept: */*  

可以看到在请求方法之后有 请求资源的位置 + 请求协议版本,之后是一些客户端的信息配置

响应头

一个简单响应的头信息(v1.0)

HTTP/1.0 200 OK  
Content-Type: text/plain  
Content-Length: 137582  
Expires: Thu, 05 Dec 1997 16:00:00 GMT  
Last-Modified: Wed, 5 August 1996 15:55:28 GMT  
// 这是一个空行  
...数据内容  

服务端的响应头第一个就是 请求协议版本,后面紧跟着是这次请求的状态码、以及状态码的描述,之后的内容是一些关于返回内容的描述。

Content-Type

在 HTTP 1.0 的时候,任何的资源都可以被传输,传输的格式呢也是多种多样的,客户端在收到响应体的内容的时候就是根据这个 Content-Type
去进行解析的。所以服务端返回时候必须带着这个字段。

一些常见的 Content-Type 可以参考 对照表。 这些
Content-Type 有一个总称叫做MIME type

关于MIME type,这里想播插一个小插曲:

在 chrome 浏览器中,当跨域请求回来的数据 MIME type 同跨域标签应有的 MIME type 不匹配时,浏览器会启动 CORB
保护数据不被泄漏。被保护的数据有: html、xml、json。(eg: script、img 标签所支持的 MIME
type和他们都不一致),所以服务端在返回资源的时候一定要对应返回正确的 Content-Type,以免浏览器屏蔽返回结果。

特性

  • 无状态:服务器不跟踪不记录请求过的状态
  • 无连接:浏览器每次请求都需要建立tcp连接
无状态

对于无状态的特性可以借助cookie/session机制来做身份认证和状态记录

无连接

无连接导致的性能缺陷有两种:

  • 无法复用连接
    每次发送请求,都需要进行一次tcp连接(即3次握手4次挥手),使得网络的利用率非常低

  • 队头阻塞
    HTTP 1.0 规定在前一个请求响应到达之后下一个请求才能发送,如果前一个阻塞,后面的请求也给阻塞的

HTTP 1.1

HTTP 1.1 是在 1.0 发布之后的半年就推出了,完善了 1.0 版本。目前也还有很多的互联网项目基于 HTTP 1.1 在向外提供服务。

特性

  • 长连接:新增Connection字段,可以设置keep-alive值保持连接不断开
  • 管道化:基于上面长连接的基础,管道化可以不等第一个请求响应继续发送后面的请求,但响应的顺序还是按照请求的顺序返回
  • 缓存处理:新增字段cache-control
  • 断点传输
长连接

HTTP 1.1默认保持长连接,数据传输完成保持tcp连接不断开,继续用这个通道传输数据

管道化

基于长连接的基础,我们先看没有管道化请求响应:

tcp没有断开,用的同一个通道

请求1 > 响应1 --> 请求2 > 响应2 --> 请求3 > 响应3  
  

管道化的请求响应:

请求1 --> 请求2 --> 请求3 > 响应1 --> 响应2 --> 响应3  

即使服务器先准备好响应2,也是按照请求顺序先返回响应1

虽然管道化,可以一次发送多个请求,但是响应仍是顺序返回,仍然无法解决队头阻塞的问题

缓存处理

当浏览器请求资源时,先看是否有缓存的资源,如果有缓存,直接取,不会再发请求,如果没有缓存,则发送请求。 通过设置字段cache-control来控制缓存。

断点传输

在上传/下载资源时,如果资源过大,将其分割为多个部分,分别上传/下载,如果遇到网络故障,可以从已经上传/下载好的地方继续请求,不用从头开始,提高效率

HTTP 2

2015 年,HTTP/2 发布。HTTP/2 是现行 HTTP 协议(HTTP/1.x)的替代,但它不是重写,HTTP 方法/状态码/语义都与
HTTP/1.x 一样。HTTP/2 基于 SPDY3,专注于 性能 ,最大的一个目标是在用户和网站间只用一个连接(connection)。

HTTP/2 由两个规范(Specification)组成:

  1. Hypertext Transfer Protocol version 2 - RFC7540

  2. HPACK - Header Compression for HTTP/2 - RFC7541

二进制分帧

HTTP/2 采用二进制格式传输数据,而非 HTTP 1.x 的文本格式,二进制协议解析起来更高效。 HTTP / 1
的请求和响应报文,都是由起始行,首部和实体正文(可选)组成,各部分之间以文本换行符分隔。 HTTP/2
将请求和响应数据分割为更小的帧,并且它们采用二进制编码

接下来我们介绍几个重要的概念:

  • 流:流是连接中的一个虚拟信道,可以承载双向的消息;每个流都有一个唯一的整数标识符(1、2…N);
  • 消息:是指逻辑上的 HTTP 消息,比如请求、响应等,由一或多个帧组成。
  • 帧:HTTP 2.0 通信的最小单位,每个帧包含帧首部,至少也会标识出当前帧所属的流,承载着特定类型的数据,如 HTTP 首部、负荷,等等

HTTP2二进制传输

HTTP/2
中,同域名下所有通信都在单个连接上完成,该连接可以承载任意数量的双向数据流。每个数据流都以消息的形式发送,而消息又由一个或多个帧组成。多个帧之间可以乱序发送,根据帧首部的流标识可以重新组装。

多路复用

在 HTTP/2 中引入了多路复用的技术。多路复用很好的解决了浏览器限制同一个域名下的请求数量的问题,同时也接更容易实现全速传输,毕竟新开一个 TCP
连接都需要慢慢提升传输速度。

大家可以通过 该链接 直观感受下 HTTP/2 比 HTTP/1 到底快了多少。

HTTP2多路复用

在 HTTP/2 中,有了二进制分帧之后,HTTP /2 不再依赖 TCP 链接去实现多流并行了,在 HTTP/2 中:

  • 同域名下所有通信都在单个连接上完成。
  • 单个连接可以承载任意数量的双向数据流。
  • 数据流以消息的形式发送,而消息又由一个或多个帧组成,多个帧之间可以乱序发送,因为根据帧首部的流标识可以重新组装。

这一特性,使性能有了极大提升:

  • 同个域名只需要占用一个 TCP 连接,使用一个连接并行发送多个请求和响应,消除了因多个 TCP 连接而带来的延时和内存消耗。
  • 并行交错地发送多个请求,请求之间互不影响。
  • 并行交错地发送多个响应,响应之间互不干扰。
  • 在 HTTP/2 中,每个请求都可以带一个 31bit 的优先值,0 表示最高优先级, 数值越大优先级越低。有了这个优先值,客户端和服务器就可以在处理不同的流时采取不同的策略,以最优的方式发送流、消息和帧。

HTTP2多路复用

如上图所示,多路复用的技术可以只通过一个 TCP 连接就可以传输所有的请求数据。

头部压缩

在 HTTP/1 中,我们使用文本的形式传输 header,在 header 携带 cookie 的情况下,可能每次都需要重复传输几百到几千的字节。

为了减少这块的资源消耗并提升性能, HTTP/2 对这些首部采取了压缩策略:

  • HTTP/2 在客户端和服务器端使用“首部表”来跟踪和存储之前发送的键-值对,对于相同的数据,不再通过每次请求和响应发送;
  • 首部表在 HTTP/2 的连接存续期内始终存在,由客户端和服务器共同渐进地更新;
  • 每个新的首部键-值对要么被追加到当前表的末尾,要么替换表中之前的值

例如下图中的两个请求, 请求一发送了所有的头部字段,第二个请求则只需要发送差异数据,这样可以减少冗余数据,降低开销

HTTP2头部压缩

服务端推送

Server Push 即服务端能通过 push 的方式将客户端需要的内容预先推送过去,也叫“cache push”。

可以想象以下情况,某些资源客户端是一定会请求的,这时就可以采取服务端 push
的技术,提前给客户端推送必要的资源,这样就可以相对减少一点延迟时间。当然在浏览器兼容的情况下你也可以使用 prefetch。
例如服务端可以主动把 JS 和 CSS 文件推送给客户端,而不需要客户端解析 HTML 时再发送这些请求。

HTTP2服务推送

服务端可以主动推送,客户端也有权利选择是否接收。如果服务端推送的资源已经被浏览器缓存过,浏览器可以通过发送 RST_STREAM
帧来拒收。主动推送也遵守同源策略,换句话说,服务器不能随便将第三方资源推送给客户端,而必须是经过双方确认才行。

HTTP3

虽然 HTTP/2 解决了很多之前旧版本的问题,但是它还是存在一个巨大的问题,主要是底层支撑的 TCP 协议造成的。

上文提到 HTTP/2 使用了多路复用,一般来说同一域名下只需要使用一个 TCP 连接。但当这个连接中出现了丢包的情况,那就会导致 HTTP/2
的表现情况反倒不如 HTTP/1 了。

因为在出现丢包的情况下,整个 TCP 都要开始等待重传,也就导致了后面的所有数据都被阻塞了。但是对于 HTTP/1.1 来说,可以开启多个 TCP
连接,出现这种情况反到只会影响其中一个连接,剩余的 TCP 连接还可以正常传输数据。

那么可能就会有人考虑到去修改 TCP 协议,其实这已经是一件不可能完成的任务了。因为 TCP
存在的时间实在太长,已经充斥在各种设备中,并且这个协议是由操作系统实现的,更新起来不大现实。

基于这个原因, Google 就更起炉灶搞了一个基于 UDP 协议的 QUIC 协议,并且使用在了 HTTP/3 上 ,HTTP/3 之前名为
HTTP-over-QUIC,从这个名字中我们也可以发现,HTTP/3 最大的改造就是使用了 QUIC。

QUIC 虽然基于 UDP,但是在原本的基础上新增了很多功能,接下来我们重点介绍几个 QUIC 新功能。

0RTT

通过使用类似 TCP 快速打开的技术,缓存当前会话的上下文,在下次恢复会话的时候,只需要将之前的缓存传递给服务端验证通过就可以进行传输了。 0RTT
建连可以说是 QUIC 相比 HTTP2 最大的性能优势
。那什么是 0RTT 建连呢?

这里面有两层含义:

  • 传输层 0RTT 就能建立连接。
  • 加密层 0RTT 就能建立加密连接。

HTTPS和QUIC建立过程

上图左边是 HTTPS 的一次完全握手的建连过程,需要 3 个 RTT。就算是会话复用也需要至少 2 个 RTT。

而 QUIC 呢?由于建立在 UDP 的基础上,同时又实现了 0RTT 的安全握手,所以在大部分情况下,只需要 0 个 RTT
就能实现数据发送,在实现前向加密的基础上,并且 0RTT 的成功率相比 TLS 的会话记录单要高很多。

多路复用

虽然 HTTP/2 支持了多路复用,但是 TCP 协议终究是没有这个功能的。QUIC
原生就实现了这个功能,并且传输的单个数据流可以保证有序交付且不会影响其他的数据流,这样的技术就解决了之前 TCP 存在的问题。

同 HTTP2.0 一样,同一条 QUIC 连接上可以创建多个 stream,来发送多个 HTTP 请求,但是,QUIC 是基于 UDP
的,一个连接上的多个 stream 之间没有依赖。比如下图中 stream2 丢了一个 UDP 包,不会影响后面跟着 Stream3 和
Stream4,不存在 TCP 队头阻塞。虽然 stream2 的那个包需要重新传,但是 stream3、stream4 的包无需等待,就可以发给用户。

QUIC多路复用

另外 QUIC 在移动端的表现也会比 TCP 好。因为 TCP 是基于 IP 和端口去识别连接的,这种方式在多变的移动端网络环境下是很脆弱的。但是 QUIC
是通过 ID 的方式去识别一个连接,不管你网络环境如何变化,只要 ID 不变,就能迅速重连上。

加密认证的报文

TCP
协议头部没有经过任何加密和认证,所以在传输过程中很容易被中间网络设备篡改,注入和窃听。比如修改序列号、滑动窗口。这些行为有可能是出于性能优化,也有可能是主动攻击。

但是 QUIC 的 packet 可以说是武装到了牙齿。除了个别报文比如 PUBLIC_RESET 和 CHLO,所有报文头部都是经过认证的,报文 Body
都是经过加密的。

这样只要对 QUIC 报文任何修改,接收端都能够及时发现,有效地降低了安全风险。

报文头部

如上图所示,红色部分是 Stream Frame 的报文头部,有认证。绿色部分是报文内容,全部经过加密。

向前纠错机制

QUIC 协议有一个非常独特的特性,称为向前纠错 (Forward Error
Correction,FEC),每个数据包除了它本身的内容之外,还包括了部分其他数据包的数据,因此少量的丢包可以通过其他包的冗余数据直接组装而无需重传。向前纠错牺牲了每个数据包可以发送数据的上限,但是减少了因为丢包导致的数据重传,因为数据重传将会消耗更多的时间(包括确认数据包丢失、请求重传、等待新数据包等步骤的时间消耗)

假如说这次我要发送三个包,那么协议会算出这三个包的异或值并单独发出一个校验包,也就是总共发出了四个包。当出现其中的非校验包丢包的情况时,可以通过另外三个包计算出丢失的数据包的内容。
当然这种技术只能使用在丢失一个包的情况下,如果出现丢失多个包就不能使用纠错机制了,只能使用重传的方式了

0%