mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 22:23:45 +08:00
mod
This commit is contained in:
145
极客时间专栏/分布式协议与算法实战/实战篇/16 | InfluxDB企业版一致性实现剖析:他山之石,可以攻玉.md
Normal file
145
极客时间专栏/分布式协议与算法实战/实战篇/16 | InfluxDB企业版一致性实现剖析:他山之石,可以攻玉.md
Normal file
@@ -0,0 +1,145 @@
|
||||
<audio id="audio" title="16 | InfluxDB企业版一致性实现剖析:他山之石,可以攻玉" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/32/b8/32d5aed63b92f7460a84bdbd965ea1b8.mp3"></audio>
|
||||
|
||||
你好,我是韩健。
|
||||
|
||||
学习了前面15讲的内容后,我们了解了很多常用的理论和算法(比如CAP定理、Raft算法等)。是不是理解了这些内容,就能够游刃有余地处理实际系统的问题了呢?
|
||||
|
||||
在我看来,还远远不够,因为理论和实践的中间是存在鸿沟的,比如,你可能有这样的感受,提到编程语言的语法或者分布式算法的论文,你说起来头头是道,但遇到实际系统时,还是无法写程序,开发分布式系统。
|
||||
|
||||
而我常说,实战是学习的最终目的。为了帮你更好地掌握前面的理论和算法,接下来,我用5讲的时间,分别以InfluxDB企业版一致性实现、Hashicorp Raft、KV系统开发实战为例,带你了解如何在实战中使用技术,掌握分布式的实战能力。
|
||||
|
||||
今天这一讲,我就以InfluxDB企业版为例,带你看一看系统是如何实现一致性的。有的同学可能会问了:为什么是InfluxDB企业版呢?因为它是排名第一的时序数据库,相比其他分布式系统(比如KV存储),时序数据库更加复杂,因为我们要分别设计2个完全不一样的一致性模型。当你理解了这样一个复杂的系统实现后,就能更加得心应手地处理简单系统的问题了。
|
||||
|
||||
那么为了帮你达到这个目的。我会先介绍一下时序数据库的背景知识,因为技术是用来解决实际场景的问题的,正如我之前常说的“要根据场景特点,权衡折中来设计系统”。所以当你了解了这些背景知识后,就能更好的理解为什么要这么设计了。
|
||||
|
||||
## 什么是时序数据库?
|
||||
|
||||
你可以这么理解,时序数据库,就是存储时序数据的数据库,就像MySQL是存储关系型数据的数据库。而时序数据,就是按照时间顺序记录系统、设备状态变化的数据,比如CPU利用率、某一时间的环境温度等,就像下面的样子:
|
||||
|
||||
```
|
||||
> insert cpu_usage,host=server01,location=cn-sz user=23.0,system=57.0
|
||||
> select * from cpu_usage
|
||||
name: cpu_usage
|
||||
time host location system user
|
||||
---- ---- -------- ------ ----
|
||||
1557834774258860710 server01 cn-sz 55 25
|
||||
>
|
||||
|
||||
```
|
||||
|
||||
在我看来,时序数据最大的特点是数据量很大,可以不夸张地说是海量。时序数据主要来自监控(监控被称为业务之眼),而且在不影响业务运行的前提下,监控埋点是越多越好,这样才能及时发现问题、复盘故障。
|
||||
|
||||
**那么作为时序数据库,InfluxDB企业版的架构是什么样子呢?**
|
||||
|
||||
你可能已经了解过,它是由META节点和DATA节点2个逻辑单元组成的,而且这两个节点是2个单独的程序。那你也许会问了,为什么不能合成到一个程序呢?答案是场景不同。
|
||||
|
||||
- META节点存放的是系统运行的关键元信息,比如数据库(Database)、表(Measurement)、保留策略(Retention policy)等。它的特点是一致性敏感,但读写访问量不高,需要一定的容错能力。
|
||||
- DATA节点存放的是具体的时序数据。它有这样几个特点:最终一致性、面向业务、性能越高越好,除了容错,还需要实现水平扩展,扩展集群的读写性能。
|
||||
|
||||
我想说的是,对于META节点来说,节点数的多少代表的是容错能力,一般3个节点就可以了,因为从实际系统运行观察看,能容忍一个节点故障就可以了。但对DATA节点而言,节点数的多少则代表了读写性能,一般而言,在一定数量以内(比如10个节点)越多越好,因为节点数越多,读写性能也越高,但节点数量太多也不行,因为查询时就会出现访问节点数过多而延迟大的问题。
|
||||
|
||||
所以,基于不同场景特点的考虑,2个单独程序更合适。如果META节点和DATA节点合并为一个程序,因读写性能需要,设计了一个10节点的DATA节点集群,这就意味着META节点集群(Raft集群)也是10个节点。在学了Raft算法之后,你应该知道,这时就会出现消息数多、日志提交慢的问题,肯定不行了。(对Raft日志复制不了解的同学,可以回顾一下[08讲](https://time.geekbang.org/column/article/205784))
|
||||
|
||||
现在你了解时序数据库,以及InfluxDB企业版的META节点和DATA节点了吧?那么怎么实现META节点和DATA节点的一致性呢?
|
||||
|
||||
## 如何实现META节点一致性?
|
||||
|
||||
你可以这样想象一下,META节点存放的是系统运行的关键元信息,那么当写操作发生后,就要立即读取到最新的数据。比如,创建了数据库“telegraf”,如果有的DATA节点不能读取到这个最新信息,那就会导致相关的时序数据写失败,肯定不行。
|
||||
|
||||
所以,META节点需要强一致性,实现CAP中的CP模型(对CAP理论不熟悉的同学,可以先回顾下[02讲](https://time.geekbang.org/column/article/195675))。
|
||||
|
||||
那么,InfluxDB企业版是如何实现的呢?
|
||||
|
||||
因为InflxuDB企业版是闭源的商业软件,通过[官方文档](https://docs.influxdata.com/enterprise_influxdb/v1.7/concepts/clustering/#architectural-overview),我们可以知道它使用Raft算法实现META节点的一致性(一般推荐3节点的集群配置)。那么说完META节点的一致性实现之后,我接着说一说DATA节点的一致性实现。
|
||||
|
||||
## 如何实现DATA节点一致性?
|
||||
|
||||
我们刚刚提到,DATA节点存放的是具体的时序数据,对一致性要求不高,实现最终一致性就可以了。但是,DATA节点也在同时作为接入层直接面向业务,考虑到时序数据的量很大,要实现水平扩展,所以必须要选用CAP中的AP模型,因为AP模型不像CP模型那样采用一个算法(比如Raft算法)就可以实现了,也就是说,AP模型更复杂,具体有这样几个实现步骤。
|
||||
|
||||
### 自定义副本数
|
||||
|
||||
首先,你需要考虑冗余备份,也就是同一份数据可能需要设置为多个副本,当部分节点出问题时,系统仍然能读写数据,正常运行。
|
||||
|
||||
那么,该如何设置副本呢?答案是实现自定义副本数。
|
||||
|
||||
关于自定义副本数的实现,我们在[12讲](https://time.geekbang.org/column/article/209130)介绍了,在这里就不啰嗦了。不过,我想补充一点,相比Raft算法节点和副本必须一一对应,也就是说,集群中有多少个节点就必须有多少个副本,你看,自定义副本数,是不是更灵活呢?
|
||||
|
||||
学到这里,有同学可能已经想到了,当集群支持多副本时,必然会出现一个节点写远程节点时,RPC通讯失败的情况,那么怎么处理这个问题呢?
|
||||
|
||||
### Hinted-handoff
|
||||
|
||||
我想说的是,一个节点接收到写请求时,需要将写请求中的数据转发一份到其他副本所在的节点,那么在这个过程中,远程RPC通讯是可能会失败的,比如网络不通了,目标节点宕机了,等等,就像下图的样子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/85/d1ee3381b7ae527cbc9d607d1a1f8385.jpg" alt="" title="图1">
|
||||
|
||||
那么如何处理这种情况呢?答案是实现Hinted-handoff。在InfluxDB企业版中,Hinted-handoff是这样实现的:
|
||||
|
||||
- 写失败的请求,会缓存到本地硬盘上;
|
||||
- 周期性地尝试重传;
|
||||
- 相关参数信息,比如缓存空间大小(max-szie)、缓存周期(max-age)、尝试间隔(retry-interval)等,是可配置的。
|
||||
|
||||
在这里我想补充一点,除了网络故障、节点故障外,在实际场景中,临时的突发流量也会导致系统过载,出现RPC通讯失败的情况,这时也需要Hinted-handoff能力。
|
||||
|
||||
虽然Hinted-handoff可以通过重传的方式来处理数据不一致的问题,但当写失败请求的数据大于本地缓存空间时,比如某个节点长期故障,写请求的数据还是会丢失的,最终的节点的数据还是不一致的,那么怎么实现数据的最终一致性呢?答案是反熵。
|
||||
|
||||
### 反熵
|
||||
|
||||
需要你注意的是,时序数据虽然一致性不敏感,能容忍短暂的不一致,但如果查询的数据长期不一致的话,肯定就不行了,因为这样就会出现“Flapping Dashboard”的现象,也就是说向不同节点查询数据,生成的仪表盘视图不一样,就像图2和图3的样子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/3c/535208a278935bc490e0d4f50f2ca13c.png" alt="" title="图2">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7c/21/7cc5f061f62854caa1d31aed586c8321.png" alt="" title="图3"><br>
|
||||
从上面的2个监控视图中你可以看到,同一份数据,查询不同的节点,生成的视图是不一样的。那么,如何实现最终一致性呢?
|
||||
|
||||
答案就是咱们刚刚说的反熵,而我在[11讲](https://time.geekbang.org/column/article/208182)以自研InfluxDB系统为例介绍过反熵的实现,InfluxDB企业版类似,所以在这里就不啰嗦了。
|
||||
|
||||
不过有的同学可能会存在这样的疑问,实现反熵是以什么为准来修复数据的不一致呢?我想说的是,时序数据像日志数据一样,创建后就不会再修改了,一直存放在那里,直到被删除。
|
||||
|
||||
所以,数据副本之间的数据不一致,是因为数据写失败导致数据丢失了,也就是说,存在的都是合理的,缺失的就是需要修复的。这时我们可以采用两两对比、添加缺失数据的方式,来修复各数据副本的不一致了。
|
||||
|
||||
### Quorum NWR
|
||||
|
||||
最后,有同学可能会说了,我要在公司官网上展示的监控数据的仪表板(Dashboard),是不能容忍视图不一致的情况的,也就是无法容忍任何“Flapping Dashboard”的现象。那么怎么办呢?这时我们就要实现强一致性(Werner Vogels提到的强一致性),也就是每次读操作都要能读取最新数据,不能读到旧数据。
|
||||
|
||||
那么在一个AP型的分布式系统中,如何实现强一致性呢?
|
||||
|
||||
答案是实现Quorum NWR。同样,关于Quorum NWR的实现,我们在12讲已介绍,在这里也就不啰嗦了。
|
||||
|
||||
最后我想说的是,你可以看到,实现AP型分布式系统,比实现CP型分布式要复杂的。另外,通过上面的内容学习,我希望你能注意到,技术是用来解决场景需求的,没有十全十美的技术,在实际工作中,需要我们深入研究场景特点,提炼场景需求,然后根据场景特点权衡折中,设计出适合该场景特点的分布式系统。
|
||||
|
||||
## 内容小结
|
||||
|
||||
本节课我主要带你了解时序数据库、META节点一致性的实现、DATA节点一致性的实现。以一个复杂的实际系统为例,带你将前面学习到的理论串联起来,让你知道它们如何在实际场景中使用。我希望你明确的重点如下:
|
||||
|
||||
<li>
|
||||
CAP理论是一把尺子,能辅助我们分析问题、总结归纳问题,指导我们如何做妥协折中。所以,我建议你在实践中多研究多思考,一定不能认为某某技术“真香”,十全十美了,要根据场景特点活学活用技术。
|
||||
</li>
|
||||
<li>
|
||||
通过Raft算法,我们能实现强一致性的分布式系统,能保证写操作完成后,后续所有的读操作,都能读取到最新的数据。
|
||||
</li>
|
||||
<li>
|
||||
通过自定义副本数、Hinted-handoff、反熵、Quorum NWR等技术,我们能实现AP型分布式系统,还能通过水平扩展,高效扩展集群的读写能力。
|
||||
</li>
|
||||
|
||||
最后,我想再强调下,技术是用来解决场景的需求的,只有当你吃透技术,深刻理解场景的需求,才能开发出适合这个场景的分布式系统。另外我还想让你知道的是,InfluxDB企业版一年的License费高达1.5万美刀,为什么它值这个价钱?就是因为技术带来的高性能和成本优势。比如:
|
||||
|
||||
- 相比OpenTSDB,InfluxDB的写性能是它的9.96倍,存储效率是它的8.69倍,查询效率是它的7.38倍。
|
||||
- 相比Graphite,InfluxDB的写性能是它的12倍,存储效率是6.3倍,查询效率是9倍。
|
||||
|
||||
在这里我想说的是,数倍或者数量级的性能优势其实就是钱,而且业务规模越大,省钱效果越突出。
|
||||
|
||||
另外我想说的是,尽管influxdb-comparisons的测试比较贴近实际场景,比如它的DevOps测试模型,与我们观察到常见的实际场景是一致的。但从实际效果看,InfluxDB的优势更加明显,成本优势更加突出。因为传统的时序数据库不仅仅是性能低,而且在海量数据场景下,接入和查询的痛点突出。为了缓解这些痛点,引入和堆砌了更多的开源软件。比如:
|
||||
|
||||
- 往往需要引入Kafka来缓解,因突发接入流量导致的丢数据问题;
|
||||
- 需要引入Storm、Flink来缓解,时序数据库计算性能差的问题;
|
||||
- 需要做热数据的内存缓存,来解决查询超时的问题。
|
||||
|
||||
所以在实施中,除了原有的时序数据库会被替换掉,还有大量的开源软件会被省掉,成本优势突出。在这里我想说的是,从实际实施看(自研InfluxDB系统),性能优势和成本优势也是符合这个预期的。
|
||||
|
||||
最后我想说的是,我反对堆砌开源软件,建议谨慎引入Kafka等缓存中间件。老话说,在计算机中,任何问题都可以通过引入一个中间层来解决。这句话是正确的,但背后的成本是不容忽视的,尤其是在海量系统中。**我的建议是直面问题,通过技术手段在代码和架构层面解决它,而不是引入和堆砌更多的开源软件。**其实,InfluxDB团队也是这么做,比如他们两次重构存储引擎。
|
||||
|
||||
## 课堂思考
|
||||
|
||||
我提到没有十全十美的技术,而是需要根据场景特点,权衡折中,设计出适合场景特点的分布式系统。那么你试着思考一下,假设有这样一个场景,一个存储系统,访问它的写请求不多(比如 1K QPS),但访问它的读请求很多(比如1M QPS),而且客户端查询时,对数据的一致性敏感,也就是需要实现强一致性,那么我们该如何设计这个系统呢?为什么呢?欢迎在留言区分享你的看法,与我一同讨论。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
268
极客时间专栏/分布式协议与算法实战/实战篇/17 | Hashicorp Raft(一):如何跨过理论和代码之间的鸿沟?.md
Normal file
268
极客时间专栏/分布式协议与算法实战/实战篇/17 | Hashicorp Raft(一):如何跨过理论和代码之间的鸿沟?.md
Normal file
@@ -0,0 +1,268 @@
|
||||
<audio id="audio" title="17 | Hashicorp Raft(一):如何跨过理论和代码之间的鸿沟?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0b/59/0bd6d3ca95946c5e5d26087446271059.mp3"></audio>
|
||||
|
||||
你好,我是韩健。
|
||||
|
||||
很多同学在开发系统的时候,都会有这样的感觉:明明自己看了很多资料,掌握了技术背后的原理,可在开发和调试的时候还是很吃力,这是为什么呢?
|
||||
|
||||
答案很简单,因为理论和实践本来就是两回事,实践不仅需要掌握API接口的用法,还需要理解API背后的代码实现。
|
||||
|
||||
所以,如果你在使用Raft开发分布式系统的时候,仅仅阅读Raft论文或者Raft实现的API手册,是远远不够的。你还要吃透API背后的代码实现,“不仅知其然,也要知其所以然”,这样才能“一切尽在掌握中”,从而开发实现能稳定运行的分布式系统。那么怎么做才能吃透Raft的代码实现呢?
|
||||
|
||||
要知道,任何Raft实现都承载了两个目标:实现Raft算法的原理,设计易用的API接口。所以,你不仅要从算法原理的角度理解代码实现,而且要从场景使用的角度理解API接口的用法。
|
||||
|
||||
而我会用两节课的时间,**从代码实现和接口使用两个角度,**带你循序渐进地掌握当前流行的一个Raft实现:[Hashicorp Raft](https://github.com/hashicorp/raft)(以最新稳定版v1.1.1为例)。希望你在这个过程中集中注意力,勾划重点,以便提高学习效率,吃透原理对应的技术实现,彻底掌握Raft算法的实战技巧。
|
||||
|
||||
本节课,我会从算法原理的角度,聊一聊Raft算法的核心功能(领导者选举和日志复制)在Hashicorp Raft中是如何实现的。(如果Raft算法的原理你已经忘得差不多了,那你可以先回顾下7~9讲,加深印象之后,再进入今天的学习。)
|
||||
|
||||
## Hashicorp Raft如何实现领导者选举?
|
||||
|
||||
**在我看来,阅读源码的关键,在于找到代码的入口函数,**比如在Golang代码中,程序的入口函数一般为main()函数,那么领导者选举的入口函数是哪个呢?
|
||||
|
||||
我们知道,典型的领导者选举在本质上是节点状态的变更。具体到Hashicorp Raft源码中,领导者选举的入口函数run(),在raft.go中以一个单独的协程运行,来实现节点状态变迁,就像下面的样子:
|
||||
|
||||
```
|
||||
func (r *Raft) run() {
|
||||
for {
|
||||
select {
|
||||
// 关闭节点
|
||||
case <-r.shutdownCh:
|
||||
r.setLeader("")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
switch r.getState() {
|
||||
// 跟随者
|
||||
case Follower:
|
||||
r.runFollower()
|
||||
// 候选人
|
||||
case Candidate:
|
||||
r.runCandidate()
|
||||
// 领导者
|
||||
case Leader:
|
||||
r.runLeader()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从上面这段代码中,你能看到,Follower(跟随者)、Candidate(候选人)、Leader(领导者)三个节点状态对应的功能,都被抽象成一个函数,分别是runFollower()、runCandidate()和runLeader()。
|
||||
|
||||
### 数据结构
|
||||
|
||||
在[07讲](https://time.geekbang.org/column/article/204472)中,我们先学习了节点状态,不过主要侧重理解节点状态的功能作用(比如说,跟随者相当于普通群众,领导者是霸道总裁),并没有关注它在实际代码中是如何实现的,所以我们先来看看在Hashicorp Raft中是如何实现节点状态的。
|
||||
|
||||
节点状态相关的数据结构和函数,是在state.go中实现的。跟随者、候选人和领导者的3个状态,是由RaftState定义的,一个无符号32位的只读整型数值(uint32):
|
||||
|
||||
```
|
||||
type RaftState uint32
|
||||
const (
|
||||
// 跟随者
|
||||
Follower RaftState = iota
|
||||
// 候选人
|
||||
Candidate
|
||||
// 领导者
|
||||
Leader
|
||||
// 关闭状态
|
||||
Shutdown
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,**也存在一些需要使用字符串格式的节点状态的场景(比如日志输出),**这时你可以使用RaftState.String()函数。
|
||||
|
||||
你应该还记得,每个节点都有属于本节点的信息(比如任期编号),那么在代码中如何实现这些信息呢?这就要说到raftState数据结构了。
|
||||
|
||||
raftState属于结构体类型,是表示节点信息的一个大数据结构,里面包含了只属于本节点的信息,比如节点的当前任期编号、最新提交的日志项的索引值、存储中最新日志项的索引值和任期编号、当前节点的状态等,就像下面的样子:
|
||||
|
||||
```
|
||||
type raftState struct {
|
||||
// 当前任期编号
|
||||
currentTerm uint64
|
||||
|
||||
// 最大被提交的日志项的索引值
|
||||
commitIndex uint64
|
||||
|
||||
// 最新被应用到状态机的日志项的索引值
|
||||
lastApplied uint64
|
||||
|
||||
// 存储中最新的日志项的索引值和任期编号
|
||||
lastLogIndex uint64
|
||||
lastLogTerm uint64
|
||||
|
||||
// 当前节点的状态
|
||||
state RaftState
|
||||
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
节点状态与节点信息的定义就是这么简单,这里我就不多说了。而在分布式系统中要实现领导者选举,更重要的一层内容是实现RPC消息,因为领导者选举的过程,就是一个RPC通讯的过程。
|
||||
|
||||
在理论篇中我说过,Raft算法中支持多种RPC消息(比如请求投票RPC消息、日志复制RPC消息)。所以接下来我们看一看,在Hashicorp Raft中又是怎样实现RPC消息的。又因为在一个RPC消息中,最重要的部分就是消息的内容,所以我们先来看一看RPC消息对应的数据结构。
|
||||
|
||||
RPC消息相关的数据结构是在commands.go中定义的,比如,日志复制RPC的请求消息,对应的数据结构为AppendEntriesRequest。而AppendEntriesRequest是一个结构体类型,里面包含了Raft算法论文中约定的字段,比如以下这些内容。
|
||||
|
||||
- Term:当前的任期编号。
|
||||
- PrevLogEntry:表示当前要复制的日志项,前面一条日志项的索引值。
|
||||
- PrevLogTerm:表示当前要复制的日志项,前面一条日志项的任期编号。
|
||||
- Entries:新日志项。
|
||||
|
||||
具体的结构信息,就像下面的样子:
|
||||
|
||||
```
|
||||
type AppendEntriesRequest struct {
|
||||
// 当前的任期编号,和领导者信息(包括服务器ID和地址信息)
|
||||
Term uint64
|
||||
Leader []byte
|
||||
|
||||
// 当前要复制的日志项,前面一条日志项的索引值和任期编号
|
||||
PrevLogEntry uint64
|
||||
PrevLogTerm uint64
|
||||
|
||||
// 新日志项
|
||||
Entries []*Log
|
||||
|
||||
// 领导者节点上的已提交的日志项的最大索引值
|
||||
LeaderCommitIndex uint64
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我建议你可以采用上面的思路,对照着算法原理去学习其他RPC消息的实现,这样一来你就能掌握独立学习的能力了。其他RPC消息的数据结构我就不一一描述了(如果你遇到问题,可以在留言区留言)。
|
||||
|
||||
现在,你已经了解了节点状态和RPC消息的格式,掌握了这些基础知识后,我们继续下一步,看看在Hashicorp Raft中是如何进行领导者选举的。
|
||||
|
||||
### 选举领导者
|
||||
|
||||
首先,在初始状态下,集群中所有的节点都处于跟随者状态,函数runFollower()运行,大致的执行步骤,就像下图的样子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/15/28753c692659fa4017c6a943c3d05b15.jpg" alt="">
|
||||
|
||||
我带你走一遍这五个步骤,便于你加深印象。
|
||||
|
||||
1. 根据配置中的心跳超时时长,调用randomTimeout()函数来获取一个随机值,用以设置心跳超时时间间隔。
|
||||
1. 进入到for循环中,通过select实现多路IO复用,周期性地获取消息和处理。如果步骤1中设置的心跳超时时间间隔发生了超时,执行步骤3。
|
||||
1. 如果等待心跳信息未超时,执行步骤4,如果等待心跳信息超时,执行步骤5。
|
||||
1. 执行continue语句,开始一次新的for循环。
|
||||
1. 设置节点状态为候选人,并退出runFollower()函数。
|
||||
|
||||
当节点推举自己为候选人之后,函数runCandidate()执行,大致的执行步骤,如图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/4d/989efd10cdff6825356b8a4310e5704d.jpg" alt="">
|
||||
|
||||
同样的,我们走一遍这个过程,加深一下印象。
|
||||
|
||||
1. 首先调用electSelf()发起选举,给自己投一张选票,并向其他节点发送请求投票RPC消息,请求他们选举自己为领导者。然后调用randomTimeout()函数,获取一个随机值,设置选举超时时间。
|
||||
1. 进入到for循环中,通过select实现多路IO复用,周期性地获取消息和处理。如果发生了选举超时,执行步骤3,如果得到了投票信息,执行步骤4。
|
||||
1. 发现了选举超时,退出runCandidate()函数,然后再重新执行runCandidate()函数,发起新一轮的选举。
|
||||
1. 如果候选人在指定时间内赢得了大多数选票,那么候选人将当选为领导者,调用setState()函数,将自己的状态变更为领导者,并退出runCandidate()函数。
|
||||
|
||||
当节点当选为领导者后,函数runLeader()就执行了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/3e/17619089bc683d8a1a2bd20bf678503e.jpg" alt="">
|
||||
|
||||
整个过程,主要有4个步骤。
|
||||
|
||||
1. 调用startStopReplication(),执行日志复制功能。
|
||||
1. 然后启动新的协程,调用replicate()函数,执行日志复制功能。
|
||||
1. 接着在replicate()函数中,启动一个新的协程,调用heartbeat()函数,执行心跳功能。
|
||||
1. 在heartbeat()函数中,周期性地发送心跳信息,通知其他节点,我是领导者,我还活着,不需要你们发起新的选举。
|
||||
|
||||
其实,在Hashicorp Raft中实现领导者选举并不难,你只要充分理解上述步骤,并记住,领导者选举本质上是节点状态变迁,跟随者、候选人、领导者对应的功能函数分别为runFollower()、runCandidate()、runLeader(),就可以了。
|
||||
|
||||
## Hashicorp Raft如何复制日志?
|
||||
|
||||
学习[08](https://time.geekbang.org/column/article/205784)讲之后,你应该知道了日志复制的重要性,因为Raft是基于强领导者模型和日志复制,最终实现强一致性的。那么你该如何学习日志复制的代码实现呢?和学习“如何实现领导者选举”一样,你需要先了解了日志相关的数据结构,阅读日志复制相关的代码。
|
||||
|
||||
学习了理论篇后,你应该还记得日志复制是由领导者发起的,跟随者来接收的。可能有同学已经想到了,领导者复制日志和跟随者接收日志的入口函数,应该分别在runLeader()和runFollower()函数中调用的。赞!理解正确!
|
||||
|
||||
- 领导者复制日志的入口函数为startStopReplication(),在runLeader()中,以r.startStopReplication()形式被调用,作为一个单独协程运行。
|
||||
- 跟随者接收日志的入口函数为processRPC(),在runFollower()中以r.processRPC(rpc)形式被调用,来处理日志复制RPC消息。
|
||||
|
||||
不过,在分析日志复制的代码实现之前,咱们先来聊聊日志相关的数据结构,便于你更好地理解代码实现。
|
||||
|
||||
### 数据结构
|
||||
|
||||
08讲中我提到过,一条日志项主要包含了3种信息,分别是指令、索引值、任期编号,而在Hashicorp Raft实现中,日志对应的数据结构和函数接口是在log.go中实现的,其中,日志项对应的数据结构是结构体类型的,就像下面的样子:
|
||||
|
||||
```
|
||||
type Log struct {
|
||||
// 索引值
|
||||
Index uint64
|
||||
|
||||
// 任期编号
|
||||
Term uint64
|
||||
|
||||
// 日志项类别
|
||||
Type LogType
|
||||
|
||||
// 指令
|
||||
Data []byte
|
||||
|
||||
// 扩展信息
|
||||
Extensions []byte
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我强调一下,与协议中的定义不同,日志项对应的数据结构中,包含了LogType和Extensions两个额外的字段:
|
||||
|
||||
- LogType可用于标识不同用途的日志项,比如,使用LogCommand标识指令对应的日志项,使用LogConfiguration表示成员变更配置对应的日志项。
|
||||
<li>Extensions可用于在指定日志项中存储一些额外的信息。**这个字段使用的比较少,在调试等场景中可能会用到,你知道有这么个字段就可以了。**<br>
|
||||
说完日志复制对应的数据结构,我们分步骤看一下,在Hashicorp Raft中是如何实现日志复制的。</li>
|
||||
|
||||
### 领导者复制日志
|
||||
|
||||
日志复制是由领导者发起,在runLeader()函数中执行的,主要有这样几个步骤。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c2/63/c2aa9f2c31571e8cdc4dbe13ef210663.jpg" alt="">
|
||||
|
||||
1. 在 runLeader()函数中,调用startStopReplication()函数,执行日志复制功能。
|
||||
1. 启动一个新协程,调用replicate()函数,执行日志复制相关的功能。
|
||||
1. 在replicate()函数中,调用replicateTo()函数,执行步骤4,如果开启了流水线复制模式,执行步骤5。
|
||||
1. 在replicateTo()函数中,进行日志复制和日志一致性检测,如果日志复制成功,则设置s.allowPipeline = true,开启流水线复制模式。
|
||||
1. 调用pipelineReplicate()函数,采用更高效的流水线方式,进行日志复制。
|
||||
|
||||
在这里我强调一下,在什么条件下开启了流水线复制模式,很多同学可能会在这一块儿产生困惑,因为代码逻辑上有点儿绕。**你可以这么理解,是在不需要进行日志一致性检测,复制功能已正常运行的时候,开启了流水线复制模式,**目标是在环境正常的情况下,提升日志复制性能,如果在日志复制过程中出错了,就进入RPC复制模式,继续调用replicateTo()函数,进行日志复制。
|
||||
|
||||
### 跟随者接收日志
|
||||
|
||||
领导者复制完日志后,跟随者会接收日志并开始处理日志。跟随者接收和处理日志,是在runFollower()函数中执行的,主要有这样几个步骤。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/99/96/99458f26ef6387bc8b20b16380d46896.jpg" alt="">
|
||||
|
||||
1. 在runFollower()函数中,调用processRPC()函数,处理接收到的RPC消息。
|
||||
1. 在processRPC()函数中,调用appendEntries()函数,处理接收到的日志复制RPC请求。
|
||||
1. appendEntries()函数,是跟随者处理日志的核心函数。在步骤3.1中,比较日志一致性;在步骤3.2中,将新日志项存放在本地;在步骤3.3中,根据领导者最新提交的日志项索引值,来计算当前需要被应用的日志项,并应用到本地状态机。
|
||||
|
||||
讲到这儿,你应该可以了解日志复制的代码实现了吧。关于更多的Raft原理的代码实现,你可以继续阅读源码来学习,如果在学习过程中有疑问,欢迎给我留言。
|
||||
|
||||
## 内容小结
|
||||
|
||||
本节课我主要带你了解了如何从算法原理的角度理解Hashicorp Raft实现,有几个重点我想强调一下:
|
||||
|
||||
<li>
|
||||
跟随者、候选人、领导者3种节点状态都有分别对应的功能函数,当需要查看各节点状态相关的功能实现时(比如,跟随者如何接收和处理日志),都可以将对应的函数作为入口函数,来阅读代码和研究功能实现。
|
||||
</li>
|
||||
<li>
|
||||
raft.go是Hashicorp Raft的核心代码文件,大部分的核心功能都是在这个文件中实现的,平时可以多研究这个文件中的代码,直到彻底吃透,掌握。
|
||||
</li>
|
||||
<li>
|
||||
在Hashicorp Raft中,支持两种节点间通讯机制,内存型和TCP协议型,其中,内存型通讯机制,主要用于测试,2种通讯机制的代码实现,分别在文件inmem_transport.go和tcp_transport.go中。
|
||||
</li>
|
||||
<li>
|
||||
Hashicorp Raft实现,是常用的Golang版Raft算法的实现,被众多流行软件使用,如Consul、InfluxDB、IPFS等,相信你对它并不陌生。其他的实现还有[Go-Raft](https://github.com/goraft/raft)、[LogCabin](https://github.com/logcabin/logcabin)、[Willemt-Raft](https://github.com/willemt/raft)等,不过我建议你在后续开发分布式系统时,优先考虑Hashicorp Raft,因为Hashicorp Raft实现,功能完善、代码简洁高效、流行度高,可用性和稳定性被充分打磨。
|
||||
</li>
|
||||
|
||||
最后,关于如何高效地阅读源码,我还想多说一说。在我看来,高效阅读源码的关键在于抓住重点,要有“底线”,不要芝麻和西瓜一把抓,什么都想要,最终陷入到枝节琐碎的细节中出不来。什么是重点呢?我认为重点是数据结构和关键的代码执行流程,比如在Hashicorp Raft源码中,日志项对应的数据结构、RPC消息对应的数据结构、选举领导者的流程、日志复制的流程等,这些就是重点。
|
||||
|
||||
有的同学可能还有疑问:在阅读源码的时候,如果遇到不是很明白的代码,该怎么办呢?我建议你可以通过打印日志或GDB单步调试的方式,查看上下文中的变量的内容、代码执行逻辑等,帮助理解。
|
||||
|
||||
## 课堂思考
|
||||
|
||||
在Hashicorp Raft实现中,我讲了如何实现选举领导者,以及如何复制日志等,那么在Hashicorp Raft中,网络通讯是如何实现的呢?欢迎在留言区分享你的看法,与我一同讨论。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
@@ -0,0 +1,195 @@
|
||||
<audio id="audio" title="18 | Hashicorp Raft(二):如何以“集群节点”为中心使用API?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/03/f39013733bfe184c0d9e1e478e46e303.mp3"></audio>
|
||||
|
||||
你好,我是韩健。
|
||||
|
||||
上一讲结束后,相信有的同学已经跃跃欲试,想把Hashicorp Raft使用起来了。不过,也有一些同学跟我反馈,说自己看到Hashicorp Raft的[Godoc](https://godoc.org/github.com/hashicorp/raft),阅读完接口文档后,感觉有些不知所措,无从下手,Hashicorp Raft支持了那么多的函数,自己却不知道如何将这些函数使用起来。
|
||||
|
||||
这似乎是一个共性的问题,在我看来,之所以出现这个问题,是因为文档里虽然提到了API的功能,但并没有提如何在实际场景中使用这些API,每个API都是孤立的点,缺乏一些场景化的线将它们串联起来。
|
||||
|
||||
所以,为了帮你更好地理解Hashicorp Raft的API接口,在实践中将它们用起来,我以“集群节点”为核心,通过创建、增加、移除集群节点,查看集群节点状态这4个典型的场景,具体聊一聊在Hashicorp Raft中,通过哪些API接口能创建、增加、移除集群节点,查看集群节点状态。这样一来,我们会一步一步,循序渐进地彻底吃透Hashicorp Raft的API接口用法。
|
||||
|
||||
我们知道,开发实现一个Raft集群的时候,首先要做的第一个事情就是创建Raft节点,那么在Hashicorp Raft中如何创建节点呢?
|
||||
|
||||
## 如何创建Raft节点
|
||||
|
||||
在Hashicorp Raft中,你可以通过NewRaft()函数,来创建Raft节点。我强调一下,NewRaft()是非常核心的函数,是Raft节点的抽象实现,NewRaft()函数的原型是这样的:
|
||||
|
||||
```
|
||||
func NewRaft(
|
||||
conf *Config,
|
||||
fsm FSM,
|
||||
logs LogStore,
|
||||
stable StableStore,
|
||||
snaps SnapshotStore,
|
||||
trans Transport) (*Raft, error)
|
||||
|
||||
```
|
||||
|
||||
你可以从这段代码中看到,NewRaft()函数有这么几种类型的参数,它们分别是:
|
||||
|
||||
- Config(节点的配置信息);
|
||||
- FSM(有限状态机);
|
||||
- LogStore(用来存储Raft的日志);
|
||||
- StableStore(稳定存储,用来存储Raft集群的节点信息等);
|
||||
- SnapshotStore(快照存储,用来存储节点的快照信息);
|
||||
- Transport(Raft节点间的通信通道)。
|
||||
|
||||
这6种类型的参数决定了Raft节点的配置、通讯、存储、状态机操作等核心信息,所以我带你详细了解一下,在这个过程中,你要注意是如何创建这些参数信息的。
|
||||
|
||||
Config是节点的配置信息,可通过函数DefaultConfig()来创建默认配置信息,然后按需修改对应的配置项。一般情况下,使用默认配置项就可以了。不过,有时你可能还是需要根据实际场景,来调整配置项的,比如:
|
||||
|
||||
- 如果在生产环境中部署的时候,你可以将LogLevel从DEBUG调整为WARM或ERROR;
|
||||
- 如果部署环境中网络拥堵,你可以适当地调大HeartbeatTimeout的值,比如,从1s调整为1.5s,避免频繁的领导者选举;
|
||||
|
||||
那么FSM又是什么呢?它是一个interface类型的数据结构,借助Golang Interface的泛型编程能力,应用程序可以实现自己的Apply(*Log)、Snapshot()、Restore(io.ReadCloser) 3个函数,分别实现将日志应用到本地状态机、生成快照和根据快照恢复数据的功能。FSM是日志处理的核心实现,原理比较复杂,不过不是咱们本节课的重点,现在你只需要知道这3个函数就可以了。在20讲,我会结合实际代码具体讲解的。
|
||||
|
||||
第三个参数LogStore存储的是Raft日志,你可以用[raft-boltdb](https://github.com/hashicorp/raft-boltdb)来实现底层存储,持久化存储数据。在这里我想说的是,raft-boltdb是Hashicorp团队专门为Hashicorp Raft持久化存储而开发设计的,使用广泛,打磨充分。具体用法是这样的:
|
||||
|
||||
```
|
||||
logStore, err := raftboltdb.NewBoltStore(filepath.Join(raftDir, "raft-log.db"))
|
||||
|
||||
```
|
||||
|
||||
NewBoltStore()函数只支持一个参数,也就是文件路径。
|
||||
|
||||
第四个参数StableStore存储的是节点的关键状态信息,比如,当前任期编号、最新投票时的任期编号等,同样,你也可以采用raft-boltdb来实现底层存储,持久化存储数据。
|
||||
|
||||
```
|
||||
stableStore, err := raftboltdb.NewBoltStore(filepath.Join(raftDir, "raft-stable.db"))
|
||||
|
||||
```
|
||||
|
||||
第五个参数SnapshotStore存储的是快照信息,也就是压缩后的日志数据。在Hashicorp Raft中提供了3种快照存储方式,它们分别是:
|
||||
|
||||
- DiscardSnapshotStore(不存储,忽略快照,相当于/dev/null,一般来说用于测试);
|
||||
- FileSnapshotStore(文件持久化存储);
|
||||
- InmemSnapshotStore(内存存储,不持久化,重启程序后,数据会丢失)。
|
||||
|
||||
**这3种方式,在生产环境中,建议你采用FileSnapshotStore实现快照, 使用文件持久化存储,避免因程序重启,导致快照数据丢失。**具体代码实现如下:
|
||||
|
||||
```
|
||||
snapshots, err := raft.NewFileSnapshotStore(raftDir, retainSnapshotCount, os.Stderr)
|
||||
|
||||
```
|
||||
|
||||
NewFileSnapshotStore()函数支持3个参数。也就是说,除了指定存储路径(raftDir),还要指定需要保留的快照副本的数量(retainSnapshotCount),以及日志输出的方式。**一般而言,将日志输出到标准错误IO就可以了。**
|
||||
|
||||
最后一个Transport指的是Raft集群内部节点之间的通信机制,节点之间需要通过这个通道来进行日志同步、领导者选举等等。Hashicorp Raft支持两种方式:
|
||||
|
||||
- 一种是基于TCP协议的TCPTransport,可以跨机器跨网络通信的;
|
||||
- 另一种是基于内存的InmemTransport,不走网络,在内存里面通过Channel来通信。
|
||||
|
||||
**在生产环境中,我建议你使用TCPTransport,**使用TCP进行网络通讯,突破单机限制,提升集群的健壮性和容灾能力。具体代码实现如下:
|
||||
|
||||
```
|
||||
addr, err := net.ResolveTCPAddr("tcp", raftBind)
|
||||
transport, err := raft.NewTCPTransport(raftBind, addr, maxPool, timeout, os.Stderr)
|
||||
|
||||
```
|
||||
|
||||
NewTCPTransport()函数支持5个参数,也就是,指定创建连接需要的信息。比如,要绑定的地址信息(raftBind、addr)、连接池的大小(maxPool)、超时时间(timeout),以及日志输出的方式,一般而言,将日志输出到标准错误IO就可以了。
|
||||
|
||||
以上就是这6个参数的详细内容了,既然我们已经了解了这些基础信息,那么如何使用NewRaft()函数呢?其实,你可以在代码中直接调用NewRaft()函数,创建Raft节点对象,就像下面的样子:
|
||||
|
||||
```
|
||||
raft, err := raft.NewRaft(config, (*storeFSM)(s), logStore, stableStore, snapshots, transport)
|
||||
|
||||
```
|
||||
|
||||
接口清晰,使用方便,你可以亲手试一试。
|
||||
|
||||
现在,我们已经创建了Raft节点,打好了基础,但是我们要实现的是一个多节点的集群,所以,创建一个节点是不够的,另外,创建了节点后,你还需要让节点启动,当一个节点启动后,你还需要创建新的节点,并将它加入到集群中,那么具体怎么操作呢?
|
||||
|
||||
## 如何增加集群节点
|
||||
|
||||
集群最开始的时候,只有一个节点,我们让第一个节点通过bootstrap的方式启动,它启动后成为领导者:
|
||||
|
||||
```
|
||||
raftNode.BootstrapCluster(configuration)
|
||||
|
||||
```
|
||||
|
||||
BootstrapCluster()函数只支持一个参数,也就是Raft集群的配置信息,因为此时只有一个节点,所以配置信息为这个节点的地址信息。
|
||||
|
||||
后续的节点在启动的时候,可以通过向第一个节点发送加入集群的请求,然后加入到集群中。具体来说,先启动的节点(也就是第一个节点)收到请求后,获取对方的地址(指Raft集群内部通信的TCP地址),然后调用AddVoter()把新节点加入到集群就可以了。具体代码如下:
|
||||
|
||||
```
|
||||
raftNode.AddVoter(id,
|
||||
addr, prevIndex, timeout)
|
||||
|
||||
```
|
||||
|
||||
AddVoter()函数支持4个参数,使用时,一般只需要设置服务器ID信息和地址信息 ,其他参数使用默认值0,就可以了:
|
||||
|
||||
- id(服务器ID信息);
|
||||
- addr(地址信息);
|
||||
- prevIndex(前一个集群配置的索引值,一般设置为0,使用默认值);
|
||||
- timeout(在完成集群配置的日志项添加前,最长等待多久,一般设置为0,使用默认值)。
|
||||
|
||||
当然了,也可以通过AddNonvoter(),将一个节点加入到集群中,但不赋予它投票权,让它只接收日志记录,这个函数平时用不到,你只需知道有这么函数,就可以了。
|
||||
|
||||
在这里,我想补充下,早期版本中的用于增加集群节点的函数,AddPeer()函数,已废弃,不再推荐使用。
|
||||
|
||||
你看,在创建集群或者扩容时,我们尝试着增加了集群节点,但一旦出现不可恢复性的机器故障或机器裁撤时,我们就需要移除节点,进行节点替换,那么具体怎么做呢?
|
||||
|
||||
## 如何移除集群节点
|
||||
|
||||
我们可以通过RemoveServer()函数来移除节点,具体代码如下:
|
||||
|
||||
```
|
||||
raftNode.RemoveServer(id, prevIndex, timeout)
|
||||
|
||||
```
|
||||
|
||||
RemoveServer()函数支持3个参数,使用时,一般只需要设置服务器ID信息 ,其他参数使用默认值0,就可以了:
|
||||
|
||||
- id(服务器ID信息);
|
||||
- prevIndex(前一个集群配置的索引值,一般设置为0,使用默认值);
|
||||
- timeout(在完成集群配置的日志项添加前,最长等待多久,一般设置为0,使用默认值)。
|
||||
|
||||
我要强调一下,RemoveServer()函数必须在领导者节点上运行,否则就会报错。这一点,很多同学在实现移除节点功能时会遇到,所以需要注意一下。
|
||||
|
||||
最后,我想补充下,早期版本中的用于移除集群节点的函数,RemovePeer()函数也已经废弃了,不再推荐使用。
|
||||
|
||||
关于如何移除集群节点的代码实现,也比较简单易用,通过服务器ID信息,就可以将对应的节点移除了。除了增加和移除集群节点,在实际场景中,我们在运营分布式系统时,有时需要查看节点的状态。那么该如何查看节点状态呢?
|
||||
|
||||
## 如何查看集群节点状态
|
||||
|
||||
在分布式系统中,日常调试的时候,节点的状态信息是很重要的,比如在Raft分布式系统中,如果我们想抓包分析写请求,那么必须知道哪个节点是领导者节点,它的地址信息是多少,因为在Raft集群中,只有领导者能处理写请求。
|
||||
|
||||
那么在Hashicorp Raft中,如何查看节点状态信息呢?
|
||||
|
||||
我们可以通过Raft.Leader()函数,查看当前领导者的地址信息,也可以通过Raft.State()函数,查看当前节点的状态,是跟随者、候选人,还是领导者。不过你要注意,Raft.State()函数返回的是RaftState格式的信息,也就是32位无符号整数,适合在代码中使用。**如果想在日志或命令行接口中查看节点状态信息,我建议你使用RaftState.String()函数,**通过它,你可以查看字符串格式的当前节点状态。
|
||||
|
||||
为了便于你理解,我举个例子。比如,你可以通过下面的代码,判断当前节点是否是领导者节点:
|
||||
|
||||
```
|
||||
func isLeader() bool {
|
||||
return raft.State() == raft.Leader
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
了解了节点状态,你就知道了当前集群节点之间的关系,以及功能和节点的对应关系,这样一来,你在遇到问题,需要调试跟踪时,就知道应该登录到哪台机器去调试分析了。
|
||||
|
||||
## 内容小结
|
||||
|
||||
本节课我主要以“集群节点”为核心,带你了解了Hashicorp Raft的常用API接口,我希望你明确的重点如下:
|
||||
|
||||
<li>
|
||||
除了提到的raft-boltdb做作为LogStore和StableStore,也可以调用NewInmemStore()创建内存型存储,在测试时比较方便,重新执行程序进行测试时,不需要手动清理数据存储。
|
||||
</li>
|
||||
<li>
|
||||
你还可以通过NewInmemTransport()函数,实现内存型通讯接口,在测试时比较方便,将集群通过内存进行通讯,运行在一台机器上。
|
||||
</li>
|
||||
<li>
|
||||
你可以通过Raft.Stats()函数,查看集群的内部统计信息,比如节点状态、任期编号、节点数等,这在调试或确认节点运行状况的时候很有用。
|
||||
</li>
|
||||
|
||||
我以集群节点为核心,讲解了Hashicorp Raft常用的API接口,相信现在你已经掌握这些接口的用法了,对如何开发一个分布式系统,也有了一定的感觉。既然学习是为了使用,那么我们学完这些内容,也应该用起来才是,所以,为了帮你更好地掌握Raft分布式系统的开发实战技巧,我会用接下来两节课的时间,以分布式KV系统开发实战为例,带你了解Raft的开发实战技巧。
|
||||
|
||||
## 课堂思考
|
||||
|
||||
我提到了一些常用的API接口,比如创建Raft节点、增加集群节点、移除集群节点、查看集群节点状态等,你不妨思考一下,如何创建一个支持InmemTransport的Raft节点呢?欢迎在留言区分享你的看法,与我一同讨论。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
186
极客时间专栏/分布式协议与算法实战/实战篇/19 | 基于Raft的分布式KV系统开发实战(一):如何设计架构?.md
Normal file
186
极客时间专栏/分布式协议与算法实战/实战篇/19 | 基于Raft的分布式KV系统开发实战(一):如何设计架构?.md
Normal file
@@ -0,0 +1,186 @@
|
||||
<audio id="audio" title="19 | 基于Raft的分布式KV系统开发实战(一):如何设计架构?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fd/08/fd4ca0d1156789e15bcb6fb830e70708.mp3"></audio>
|
||||
|
||||
你好,我是韩健。
|
||||
|
||||
学完前面2讲之后,相信你已经大致了解了Raft算法的代码实现(Hashcorp Raft),也掌握了常用API接口的用法,对Raft算法的理解也更深刻了。那么,是不是掌握这些,就能得心应手的处理实际场景的问题了呢?
|
||||
|
||||
在我看来,掌握的还不够,因为Raft算法的实现只是工具。而掌握了工具的用法,和能使用工具得心应手地处理实际场景的问题,是两回事。也就是说,我们还需要掌握使用Raft算法开发分布式系统的实战能力,然后才能游刃有余的处理实际场景的问题。
|
||||
|
||||
我从这个角度出发,在接下来的2节课中,我会分别从架构和代码实现的角度,以一个基本的分布式KV系统为例,具体说一说,如何基于Raft算法构建一个分布式KV系统。**那么我希望你能课下多动手,自己写一遍,不给自己留下盲区。**如果条件允许的话,你还可以按需开发实现需要的功能,并将这套系统作为自己的“配置中心”“名字路由”维护下去,不断在实战中加深自己对技术的理解。
|
||||
|
||||
可能有同学会问:“老韩,为什么不以Etcd为例呢?它不是已经在生产环境中落地了吗?”
|
||||
|
||||
我是这么考虑的,这个基本的分布式KV系统的代码比较少,相对纯粹聚焦在技术本身,涉及的KV业务层面的逻辑少,适合入门学习(比如你可以从零开始,动手编程实现),是一个很好的学习案例。
|
||||
|
||||
另外,对一些有经验的开发者来说,这部分知识能够帮助你掌握Raft算法中,一些深层次的技术实现,比如如何实现多种读一致性模型,让你更加深刻地理解Raft算法。
|
||||
|
||||
今天这节课,我会具体说一说如何设计一个基本的分布式KV系统,也就是需要实现哪些功能,以及在架构设计的时候,你需要考虑哪些点(比如跟随者是否要转发写请求给领导者?或者如何设计接入访问的API?)
|
||||
|
||||
好了,话不多说,一起进入今天的课程吧!
|
||||
|
||||
在我看来,基于技术深度、开发工作量、学习复杂度等综合考虑,一个基本的分布式KV系统,至少需要具备这样几块功能,就像下图的样子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/6d/1e8e7cac5c9159aa1dbf9a72cd90416d.jpg" alt="">
|
||||
|
||||
- 接入协议:供客户端访问系统的接入层API,以及与客户端交互的通讯协议。
|
||||
- KV操作:我们需要支持的KV操作(比如赋值操作)。
|
||||
- 分布式集群:也就是说,我们要基于Raft算法实现一个分布式存储集群,用于存放KV数据。
|
||||
|
||||
需要你注意的是,这3点就是分布式KV系统的核心功能,也就是我们需要编程实现的需求。
|
||||
|
||||
在我看来,要实现一个基本的分布式KV系统,首先要做的第一件事,就是实现访问接入的通讯协议。因为如果用户想使用这套系统,对他而言的第一件事,就是如何访问这套系统。那么,如何实现访问接入的通讯协议呢?
|
||||
|
||||
## 如何设计接入协议?
|
||||
|
||||
我想说的是,在早些时候,硬件性能低,服务也不是很多,开发系统的时候,主要矛盾是性能瓶颈,所以,更多的是基于性能的考虑,采用UDP协议和实现私有的二进制协议,比如,早期的QQ后台组件,就是这么做的。
|
||||
|
||||
现在呢,硬件性能有了很大幅度的提升,后台服务器的CPU核数都近百了,开发系统的时候,主要的矛盾已经不是性能瓶颈了,而是快速增长的海量服务和开发效率,所以这时,基于开发效率和可维护性的考虑,我们就需要优先考虑标准的协议了(比如HTTP)。
|
||||
|
||||
如果使用HTTP协议,那么就需要设计HTTP RESTful API,作为访问接口。具体怎么设计呢?
|
||||
|
||||
我想说的是,因为我们设计实现的是KV系统,肯定要涉及到KV操作,那么我们就一定需要设计个API(比如"/key")来支持KV操作。也就是说,通过访问这个API,我们能执行相关的KV操作了,就像下面的样子(查询指定key(就是foo)对应的值)。
|
||||
|
||||
```
|
||||
curl -XGET http://raft-cluster-host01:8091/key/foo
|
||||
|
||||
```
|
||||
|
||||
另外,需要你注意的是,因为这是一个Raft集群系统,除了业务层面(KV操作),我们还需要实现平台本身的一些操作的API接口,比如增加、移除集群节点等。我们现在只考虑增加节点操作的API(比如"/join"),就像下面的样子。
|
||||
|
||||
```
|
||||
http://raft-cluster-host01:8091/join
|
||||
|
||||
```
|
||||
|
||||
另外,在故障或缩容情况下,如何替换节点、移除节点,我建议你在线下对比着增加节点的操作,自主实现。
|
||||
|
||||
除此之外,在我看来,实现HTTP RESTful API,还有非常重要的一件事情要做,那就是在设计API时,考虑如何实现路由,为什么这么说呢?你这么想象一下,如果我们实现了多个API,比如"/key"和"/join",那么就需要将API对应的请求和它对应的处理函数一一映射起来。
|
||||
|
||||
我想说的是,我们可以在serveHTTP()函数(Golang)中,通过检测URL路径,来设置请求对应处理函数,实现路由。大概的原理,就像下面的样子。
|
||||
|
||||
```
|
||||
func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { // 设置HTTP请求对应的路由信息
|
||||
if strings.HasPrefix(r.URL.Path, "/key") {
|
||||
s.handleKeyRequest(w, r)
|
||||
} else if r.URL.Path == "/join" {
|
||||
s.handleJoin(w, r)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从上面代码中,我们可以看到,当检测到URL路径为“/key”时,会调用handleKeyRequest()函数,来处理KV操作请求;当检测到URL路径为"/join"时,会调用handleJoin()函数,将指定节点加入到集群中。
|
||||
|
||||
你看,通过"/key"和"/join"2个API,我们就能满足这个基本的分布式KV系统的运行要求了,既能支持来自客户端的KV操作,也能新增节点并将集群运行起来。
|
||||
|
||||
当客户端通过通讯协议访问到系统后,它最终的目标,还是执行KV操作。那么,我们该如何设计KV操作呢?
|
||||
|
||||
## 如何设计KV操作?
|
||||
|
||||
我想说的是,常见的KV操作是赋值、查询、删除,也就是说,我们实现这三个操作就可以了,其他的操作可以先不考虑。具体可以这么实现。
|
||||
|
||||
- **赋值操作:**我们可以通过HTTP POST请求,来对指定key进行赋值,就像下面的样子。
|
||||
|
||||
```
|
||||
curl -XPOST http://raft-cluster-host01:8091/key -d '{"foo": "bar"}'
|
||||
|
||||
```
|
||||
|
||||
- **查询操作:**我们可以通过HTTP GET请求,来查询指定key的值,就像下面的样子。
|
||||
|
||||
```
|
||||
curl -XGET http://raft-cluster-host01:8091/key/foo
|
||||
|
||||
|
||||
```
|
||||
|
||||
- **删除操作:**我们可以通过HTTP DELETE请求,来删除指定key和key对应的值,就像下面的样子。
|
||||
|
||||
```
|
||||
curl -XDELETE http://raft-cluster-host01:8091/key/foo
|
||||
|
||||
```
|
||||
|
||||
在这里,尤其需要你注意的是,操作需要具有幂等性。幂等性这个词儿你估计不会陌生,你可以这样理解它:同一个操作,不管执行多少次,最终的结果都是一样的,也就是,这个操作是可以重复执行的,而是重复执行不会对系统产生预期外的影响。
|
||||
|
||||
为什么操作要具有幂等性呢?
|
||||
|
||||
因为共识算法能保证达成共识后的值(也就是指令)就不再改变了,但不能保证值只被提交一次,也就是说,共识算法是一个“at least once”的指令执行模型,是可能会出现同一个指令被重复提交的情况,为什么呢?我以Raft算法为例,具体说一说。
|
||||
|
||||
比如,如果客户端接收到Raft的超时响应后,也就是这时日志项还没有提交成功,如果此时它重试,发送一个新的请求,那么这个时候Raft会创建一个新的日志项,并最终将新旧2个日志项都提交了,出现了指令重复执行的情况。
|
||||
|
||||
在这里我想强调的是,你一定要注意到这样的情况,在使用Raft等共识算法时,要充分评估操作是否具有幂等性,避免对系统造成预期外的影响,比如,直接使用“Add”操作,就会因重复提交,导致最终的执行结果不准了,影响到业务。这就可能会出现,用户购买了100Q币,系统却给他充值了500Q币,肯定不行了。
|
||||
|
||||
说完如何设计KV操作后,因为我们的最终目标是实现分布式KV系统,那么,就让我们回到分布式系统最本源的一个问题上,如何实现分布式集群?
|
||||
|
||||
## 如何实现分布式集群?
|
||||
|
||||
我想说的是,正如在09讲中提到的,我推荐使用Raft算法实现分布式集群。而实现一个Raft集群,我们首先要考虑的是如何创建集群,为了简单起见,我们暂时不考虑节点的移除和替换等。
|
||||
|
||||
**创建集群**
|
||||
|
||||
在Raft算法中,我们可以这样创建集群。
|
||||
|
||||
- 先将第一个节点,通过Bootstrap的方式启动,并作为领导者节点。
|
||||
- 其他节点与领导者节点通讯,将自己的配置信息发送给领导者节点,然后领导者节点调用AddVoter()函数,将新节点加入到集群中。
|
||||
|
||||
创建了集群后,在集群运行中,因为Raft集群的领导者不是固定不变的,而写请求是必须要在领导者节点上处理的,那么如何实现写操作,来保证写请求都会发给领导者呢?
|
||||
|
||||
**写操作**<br>
|
||||
一般而言,有2种方法来实现写操作。我来具体说说。
|
||||
|
||||
**方法1:**跟随者接收到客户端的写请求后,拒绝处理这个请求,并将领导者的地址信息返回给客户端,然后客户端直接访问领导者节点,直到该领导者退位,就像下图的样子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/c8/591314be65729ae2ca242e5e016e84c8.jpg" alt="">
|
||||
|
||||
**方法2:**跟随者接收到客户端的写请求后,将写请求转发给领导者,并将领导者处理后的结果返回给客户端,也就是说,这时跟随者在扮演“代理”的角色,就像下图的样子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/96/ac7f6e9226c0dc3f323abb70b9a3b596.jpg" alt="">
|
||||
|
||||
在我看来,虽然第一种方法需要客户端的配合,但实现起来复杂度不高;另外,第二种方法,虽然能降低客户端的复杂度,客户端像访问一个黑盒一样,访问系统,对领导者变更完全无感知。
|
||||
|
||||
但是这个方法会引入一个中间节点(跟随者),增加了问题分析排查的复杂度。而且,一般情况下,在绝大部分的时间内(比如Google Chubby团队观察到的值是数天),领导者是处于稳定状态的,某个节点一直是领导者,那么引入中间节点,就会增加大量的不必要的消息和性能消耗。所以,综合考虑,我推荐方法1。
|
||||
|
||||
学习了Raft算法后,我们知道,相比写操作(只要在领导者节点执行就可以了)而言,读操作要复杂些,因为如何实现读操作,关乎着一致性的实现,也就是说,怎么实现读操作,决定了客户端是否会读取到旧数据。那么如何实现读操作呢?
|
||||
|
||||
**读操作**<br>
|
||||
其实,在实际系统中,并不是实现了强一致性就是最好的,因为实现了强一致性,必然会限制集群的整体性能。也就是说,我们需要根据实际场景特点进行权衡折中,这样,才能设计出最适合该场景特点的读操作。比如,我们可以实现类似Consul的3种读一致性模型。
|
||||
|
||||
- default:偶尔读到旧数据。
|
||||
- consistent:一定不会读到旧数据。
|
||||
- stale:会读到旧数据。
|
||||
|
||||
如果你不记得这3种模型的含义了,你可以去09讲回顾下,在这里,我就不啰嗦了。
|
||||
|
||||
也就是说,我们可以实现多种读一致性模型,将最终的一致性选择权交给用户,让用户去选择,就像下面的样子。
|
||||
|
||||
```
|
||||
curl -XGET http://raft-cluster-host02:8091/key/foo?level=consistent -L
|
||||
|
||||
```
|
||||
|
||||
## 内容小结
|
||||
|
||||
本节课我主要带你了解了一个基本的分布式KV系统的架构,和需要权衡折中的技术细节,我希望你明确的重点如下。
|
||||
|
||||
1.在设计KV操作时,更确切的说,在实现Raft指令时,一定要考虑幂等性,因为Raf指令是可能会被重复提交和执行。
|
||||
|
||||
2.推荐你采用这种方式来实现写操作:跟随者接收到客户端的写请求时,拒绝该请求并返回领导者的地址信息给客户端,然后客户端直接访问领导者。
|
||||
|
||||
3.在Raft集群中,如何实现读操作,关乎一致性的实现,推荐实现default、consistent、stale三种一致性模型,将一致性的选择权交给用户,让用户根据实际业务特点,按需选择,灵活使用。
|
||||
|
||||
最后,我想说的是,这个基本的分布式KV系统,除了适合入门学习外,也比较适合配置中心、名字服务等小数据量的系统。另外我想补充一下,对于数据层组件,不仅性能重要,成本也很重要,而决定数据层组件的成本的最关键的一个理念是冷热分离,一般而言,可以这么设计三级缓存:
|
||||
|
||||
- 热数据:经常被访问到的数据,我们可以将它们放在内存中,提升访问效率。
|
||||
- 冷数据:有时会被访问到的数据,我们可以将它们放在SSD硬盘上,访问起来也比较快。
|
||||
- 陈旧数据:偶尔会被访问到的数据,我们可以将它们放在普通磁盘上,节省存储成本。
|
||||
|
||||
在实际系统中,你可以统计热数据的命中率,并根据命中率来动态调整冷热模型。在这里,我想强调的是,冷热分离理念在设计海量数据存储系统时尤为重要,比如,自研KV存储的成本仅为Redis数十分之一,其中系统设计时非常重要的一个理念就是冷热分离。希望你能重视这个理念,在实际场景中活学活用。
|
||||
|
||||
## 课堂思考
|
||||
|
||||
我提到了其他节点与领导者节点通讯,将自己的配置信息发送给领导者节点,然后领导者节点调用addVoter()函数,将新节点加入到集群中,那么,你不妨思考一下,当节点故障时,如何替换一个节点呢?欢迎在留言区分享你的看法,与我一同讨论。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
211
极客时间专栏/分布式协议与算法实战/实战篇/20 | 基于Raft的分布式KV系统开发实战(二):如何实现代码?.md
Normal file
211
极客时间专栏/分布式协议与算法实战/实战篇/20 | 基于Raft的分布式KV系统开发实战(二):如何实现代码?.md
Normal file
@@ -0,0 +1,211 @@
|
||||
<audio id="audio" title="20 | 基于Raft的分布式KV系统开发实战(二):如何实现代码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8e/64/8e4159f5178fbdd3144cd289b7ff2a64.mp3"></audio>
|
||||
|
||||
你好,我是韩健。
|
||||
|
||||
学完[上一讲](https://time.geekbang.org/column/article/217049)后,相信你已经了解了分布式KV系统的架构设计,同时应该也很好奇,架构背后的细节代码是怎么实现的呢?
|
||||
|
||||
别着急,今天这节课,我会带你弄明白这个问题。我会具体讲解[分布式KV系统](https://github.com/hanj4096/raftdb)核心功能点的实现细节。比如,如何实现读操作对应的3种一致性模型。而我希望你能在课下反复运行程序,多阅读源码,掌握所有的细节实现。
|
||||
|
||||
话不多说,我们开始今天的学习。
|
||||
|
||||
在上一讲中,咱们将系统划分为三大功能块(接入协议、KV操作、分布式集群),那么今天我会按顺序具体说一说每块功能的实现,帮助你掌握架构背后的细节代码。首先,先来了解一下,如何实现接入协议。
|
||||
|
||||
## 如何实现接入协议?
|
||||
|
||||
在19讲提到,我们选择了HTTP协议作为通讯协议,并设计了"/key"和"/join"2个HTTP RESTful API,分别用于支持KV操作和增加节点的操作,那么,它们是如何实现的呢?
|
||||
|
||||
接入协议的核心实现,就是下面的样子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/56/b72754232480fadd7d8eeb9bfdd15e56.jpg" alt="" title="图1">
|
||||
|
||||
我带你走一遍这三个步骤,便于你加深印象。
|
||||
|
||||
1. 在ServeHTTP()中,会根据URL路径设置相关的路由信息。比如,会在handlerKeyRequest()中处理URL路径前缀为"/key"的请求,会在handleJoin()中处理URL路径为"/join"的请求。
|
||||
1. 在handleKeyRequest()中,处理来自客户端的KV操作请求,也就是基于HTTP POST请求的赋值操作、基于HTTP GET请求的查询操作、基于HTTP DELETE请求的删除操作。
|
||||
1. 在handleJoin()中,处理增加节点的请求,最终调用raft.AddVoter()函数,将新节点加入到集群中。
|
||||
|
||||
在这里,需要你注意的是,在根据URL设置相关路由信息时,你需要考虑是路径前缀匹配(比如strings.HasPrefix(r.URL.Path, “/key”)),还是完整匹配(比如r.URL.Path == “/join”),避免在实际运行时,路径匹配出错。比如,如果对"/key"做完整匹配(比如r.URL.Path == “/key”),那么下面的查询操作会因为路径匹配出错,无法找到路由信息,而执行失败。
|
||||
|
||||
```
|
||||
curl -XGET raft-cluster-host01:8091/key/foo
|
||||
|
||||
```
|
||||
|
||||
另外,还需要你注意的是,只有领导者节点才能执行raft.AddVoter()函数,也就是说,handleJoin()函数,只能在领导者节点上执行。
|
||||
|
||||
说完接入协议后,接下来咱们来分析一下第二块功能的实现,也就是,如何实现KV操作。
|
||||
|
||||
## 如何实现KV操作?
|
||||
|
||||
上一节课,我提到这个分布式KV系统会实现赋值、查询、删除3类操作,那具体怎么实现呢?你应该知道,赋值操作是基于HTTP POST请求来实现的,就像下面的样子。
|
||||
|
||||
```
|
||||
curl -XPOST http://raft-cluster-host01:8091/key -d '{"foo": "bar"}'
|
||||
|
||||
```
|
||||
|
||||
也就是说,我们是通过HTTP POST请求,实现了赋值操作。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ad/91/ad3f4c3955f721fe60d7f041ea9aae91.jpg" alt="" title="图2">
|
||||
|
||||
同样的,我们走一遍这个过程,加深一下印象。
|
||||
|
||||
1. 当接收到KV操作的请求时,系统将调用handleKeyRequest()进行处理。
|
||||
1. 在handleKeyRequest()函数中,检测到HTTP请求类型为POST请求时,确认了这是一个赋值操作,将执行store.Set()函数。
|
||||
1. 在Set()函数中,将创建指令,并通过raft.Apply()函数将指令提交给Raft。最终指令将被应用到状态机。
|
||||
1. 当Raft将指令应用到状态机后,最终将执行applySet()函数,创建相应的key和值到内存中。
|
||||
|
||||
在这里,我想补充一下,FSM结构复用了Store结构体,并实现了fsm.Apply()、fsm.Snapshot()、fsm.Restore()3个函数。最终应用到状态机的数据,以map[string]string的形式,存放在Store.m中。
|
||||
|
||||
那查询操作是怎么实现的呢?它是基于HTTP GET请求来实现的。
|
||||
|
||||
```
|
||||
curl -XGET http://raft-cluster-host01:8091/key/foo
|
||||
|
||||
```
|
||||
|
||||
也就是说,我们是通过HTTP GET请求实现了查询操作。在这里我想强调一下,相比需要将指令应用到状态机的赋值操作,查询操作要简单多了,因为系统只需要查询内存中的数据就可以了,不涉及状态机。具体的代码流程如图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/b4/c70b009d5abd3b3f63c0d1d419ede9b4.jpg" alt="" title="图3">
|
||||
|
||||
我们走一遍这个过程,加深一下印象。
|
||||
|
||||
1. 当接收到KV操作的请求时,系统将调用handleKeyRequest()进行处理。
|
||||
1. 在handleKeyRequest()函数中,检测到HTTP请求类型为GET请求时,确认了这是一个赋值操作,将执行store.Get()函数。
|
||||
1. Get()函数在内存中查询指定key对应的值。
|
||||
|
||||
而最后一个删除操作,是基于HTTP DELETE请求来实现的。
|
||||
|
||||
```
|
||||
curl -XDELETE http://raft-cluster-host01:8091/key/foo
|
||||
|
||||
```
|
||||
|
||||
也就是说,我们是通过HTTP DELETE请求,实现了删除操作。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/4e/90f99bc9c4aebb50c39f05412fa4594e.jpg" alt="" title="图4">
|
||||
|
||||
同样的,我们走一遍这个过程。
|
||||
|
||||
1. 当接收到KV操作的请求时,系统将调用handleKeyRequest()进行处理。
|
||||
1. 在handleKeyRequest()函数中,检测到HTTP请求类型为DELETE请求时,确认了这是一个删除操作,将执行store.Delete()函数。
|
||||
1. 在Delete()函数中,将创建指令,并通过raft.Apply()函数,将指令提交给Raft。最终指令将被应用到状态机。
|
||||
1. 当前Raft将指令应用到状态机后,最终执行applyDelete()函数,删除key和值。
|
||||
|
||||
学习这部分内容的时候,有一些同学可能会遇到,不知道如何判断指定的操作是否需要在领导者节点上执行的问题,我给的建议是这样的。
|
||||
|
||||
- 需要向Raft状态机中提交指令的操作,是必须要在领导者节点上执行的,也就是所谓的写请求,比如赋值操作和删除操作。
|
||||
- 需要读取最新数据的查询操作(比如客户端设置查询操作的读一致性级别为consistent),是必须在领导者节点上执行的。
|
||||
|
||||
说完了如何实现KV操作后,来看一下最后一块功能,如何实现分布式集群。
|
||||
|
||||
## 如何实现分布式集群?
|
||||
|
||||
### 创建集群
|
||||
|
||||
实现一个Raft集群,首先我们要做的就是创建集群,创建Raft集群,主要分为两步。首先,第一个节点通过Bootstrap的方式启动,并作为领导者节点。启动命令就像下面的样子。
|
||||
|
||||
```
|
||||
$GOPATH/bin/raftdb -id node01 -haddr raft-cluster-host01:8091 -raddr raft-cluster-host01:8089 ~/.raftdb
|
||||
|
||||
```
|
||||
|
||||
这时将在Store.Open()函数中,调用BootstrapCluster()函数将节点启动起来。
|
||||
|
||||
接着,其他节点会通过-join参数指定领导者节点的地址信息,并向领导者节点发送,包含当前节点配置信息的增加节点请求。启动命令就像下面的样子。
|
||||
|
||||
```
|
||||
$GOPATH/bin/raftdb -id node02 -haddr raft-cluster-host02:8091 -raddr raft-cluster-host02:8089 -join raft-cluster-host01:8091 ~/.raftdb
|
||||
|
||||
```
|
||||
|
||||
当领导者节点接收到来自其他节点的增加节点请求后,将调用handleJoin()函数进行处理,并最终调用raft.AddVoter()函数,将新节点加入到集群中。
|
||||
|
||||
在这里,需要你注意的是,只有在向集群中添加新节点时,才需要使用-join参数。当节点加入集群后,就可以像下面这样,正常启动进程就可以了。
|
||||
|
||||
```
|
||||
$GOPATH/bin/raftdb -id node02 -haddr raft-cluster-host02:8091 -raddr raft-cluster-host02:8089 ~/.raftdb
|
||||
|
||||
```
|
||||
|
||||
集群运行起来后,因为领导者是可能会变的,那么如何实现写操作,来保证写请求都在领导者节点上执行呢?
|
||||
|
||||
### 写操作
|
||||
|
||||
在19讲中,我们选择了方法2来实现写操作。也就是,当跟随者接收到写请求后,将拒绝处理该请求,并将领导者的地址信息转发给客户端。后续客户端就可以直接访问领导者(为了演示方便,我们以赋值操作为例)。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/0a/58/0a79be9a402addd226c0df170268a658.jpg" alt="" title="图5">
|
||||
|
||||
我们来看一下具体的内容。
|
||||
|
||||
1. 调用Set()函数执行赋值操作。
|
||||
1. 如果执行Set()函数成功,将执行步骤3;如果执行Set()函数出错,且提示出错的原因是当前节点不是领导者,那这就说明了当前节点不是领导者,不能执行写操作,将执行步骤4;如果执行Set()函数出错,且提示出错的原因不是因为当前节点不是领导者,将执行步骤5。
|
||||
1. 赋值操作执行成功,正常返回。
|
||||
1. 节点将构造包含领导者地址信息的重定向响应,并返回给客户端。然后客户端直接访问领导者节点执行赋值操作。
|
||||
1. 系统运行出错,返回错误信息给客户端。
|
||||
|
||||
需要你注意的是,赋值操作和删除操作属于写操作,必须在领导者节点上执行。而查询操作,只是查询内存中的数据,不涉及指令提交,可以在任何节点上执行。
|
||||
|
||||
而为了更好的利用curl客户端的HTTP重定向功能,我实现了HTTP 307重定向,这样,你在执行赋值操作时,就不需要关心访问节点是否是领导者节点了。比如,你可以使用下面的命令,访问节点2(也就是raft-cluster-host02,192.168.0.20)执行赋值操作。
|
||||
|
||||
```
|
||||
curl -XPOST raft-cluster-host02:8091/key -d '{"foo": "bar"}' -L
|
||||
|
||||
```
|
||||
|
||||
如果当前节点(也就是节点2)不是领导者,它将返回包含领导者地址信息的HTTP 307重定向响应给curl。这时,curl根据响应信息,重新发起赋值操作请求,并直接访问领导者节点(也就是节点1,192.168.0.10)。具体的过程,就像下面的Wireshark截图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/fe/27b9005d47f65ca9d231da6e5bddbafe.jpg" alt="" title="图6">
|
||||
|
||||
相比写请求必须在领导者节点上执行,虽然查询操作属于读操作,可以在任何节点上执行,但是如何实现却更加复杂,因为读操作的实现关乎着一致性的实现。那么,具体怎么实现呢?
|
||||
|
||||
### 读操作
|
||||
|
||||
我想说的是,我们可以实现3种一致性模型(也就是stale、default、consistent),这样,用户就可以根据场景特点,按需选择相应的一致性级别,是不是很灵活呢?
|
||||
|
||||
具体的读操作的代码实现,就像下面的样子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/97/42cdc5944e200f20f0cdcfef6891cc97.jpg" alt="" title="图7">
|
||||
|
||||
我们走一遍这个过程。
|
||||
|
||||
1. 当接收到HTTP GET的查询请求时,系统会先调用level()函数,来获取当前请求的读一致性级别。
|
||||
1. 调用Get()函数,查询指定key和读一致性级别对应的数据。
|
||||
1. 如果执行Get()函数成功,将执行步骤4;如果执行Get()函数出错,且提示出错的原因是当前节点不是领导者节点,那么这就说明了,在当前节点上执行查询操作不满足读一致性级别,必须要到领导者节点上执行查询操作,将执行步骤5;如果执行Get()函数出错,且提示出错的原因不是因为当前节点不是领导者,将执行步骤6。
|
||||
1. 查询操作执行成功,返回查询到的值给客户端。
|
||||
1. 节点将构造,包含领导者地址信息的重定向响应,并返回给客户端。然后客户端直接访问领导者节点查询数据。
|
||||
1. 系统运行出错,返回错误信息给客户端。
|
||||
|
||||
在这里,为了更好地利用curl客户端的HTTP重定向功能,我同样实现了HTTP 307重定向(具体原理,前面已经介绍了,这里就不啰嗦了)。比如,你可以使用下面的命令,来实现一致性级别为consistent的查询操作,不需要关心访问节点(raft-cluster-host02)是否是领导者节点。
|
||||
|
||||
```
|
||||
curl -XGET raft-cluster-host02:8091/key/foo?level=consistent -L
|
||||
|
||||
```
|
||||
|
||||
## 内容小结
|
||||
|
||||
本节课我主要带你了解了接入协议、KV操作、分布式集群的实现,我希望你记住下面三个重点内容:
|
||||
|
||||
<li>
|
||||
我们可以借助HTTP请求类型,来实现相关的操作,比如,我们可以通过HTTP GET请求实现查询操作,通过HTTP DELETE请求实现删除操作。
|
||||
</li>
|
||||
<li>
|
||||
你可以通过HTTP 307 重定向响应,来返回领导者的地址信息给客户端,需要你注意的是,curl已支持HTTP 307重定向,使用起来很方便,所以推荐你优先考虑curl,在日常中执行KV操作。
|
||||
</li>
|
||||
<li>
|
||||
在Raft中,我们可以通过raft.VerifyLeader()来确认当前领导者,是否仍是领导者。
|
||||
</li>
|
||||
|
||||
在这里,我还想强调的一点,任何大系统都是由小系统和具体的技术组成的,比如能无限扩展和支撑海量服务的QQ后台,是由多个组件(协议接入组件、名字服务、存储组件等)组成的。而做技术最为重要的就是脚踏实地彻底吃透和掌握技术本质,小系统的关键是细节技术,大系统的关键是架构。所以,在课程结束后,我会根据你的反馈意见,再延伸性地讲解大系统(大型互联网后台)的架构设计技巧,和我之前支撑海量服务的经验。
|
||||
|
||||
这样一来,我希望能帮你从技术到代码、从代码到架构、从小系统到大系统,彻底掌握实战能力,跨过技术和实战的鸿沟。
|
||||
|
||||
虽然这个分布式KV系统比较简单,但它相对纯粹聚焦在技术,能帮助你很好的理解Raft算法、Hashicorp Raft实现、分布式系统开发实战等。所以,我希望你不懂就问,有问题多留言,咱们一起讨论解决,不要留下盲区。
|
||||
|
||||
另外,我会持续维护和优化这个项目,并会针对大家共性的疑问,开发实现相关代码,从代码和理论2个角度,帮助你更透彻的理解技术。我希望你能在课下采用自己熟悉的编程语言,将这个系统重新实现一遍,在实战中,加深自己对技术的理解。如果条件允许,你可以将自己的分布式KV系统,以“配置中心”、“名字服务”等形式,在实际场景中落地和维护起来,不断加深自己对技术的理解。
|
||||
|
||||
## 课堂思考
|
||||
|
||||
我提到了通过-join参数,将新节点加入到集群中,那么,你不妨思考一下,如何实现代码移除一个节点呢?欢迎在留言区分享你的看法,与我一同讨论。
|
||||
|
||||
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
|
||||
Reference in New Issue
Block a user