This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View 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利用率、某一时间的环境温度等就像下面的样子
```
&gt; insert cpu_usage,host=server01,location=cn-sz user=23.0,system=57.0
&gt; select * from cpu_usage
name: cpu_usage
time host location system user
---- ---- -------- ------ ----
1557834774258860710 server01 cn-sz 55 25
&gt;
```
在我看来,时序数据最大的特点是数据量很大,可以不夸张地说是海量。时序数据主要来自监控(监控被称为业务之眼),而且在不影响业务运行的前提下,监控埋点是越多越好,这样才能及时发现问题、复盘故障。
**那么作为时序数据库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万美刀,为什么它值这个价钱?就是因为技术带来的高性能和成本优势。比如:
- 相比OpenTSDBInfluxDB的写性能是它的9.96倍存储效率是它的8.69倍查询效率是它的7.38倍。
- 相比GraphiteInfluxDB的写性能是它的12倍存储效率是6.3倍查询效率是9倍。
在这里我想说的是,数倍或者数量级的性能优势其实就是钱,而且业务规模越大,省钱效果越突出。
另外我想说的是尽管influxdb-comparisons的测试比较贴近实际场景比如它的DevOps测试模型与我们观察到常见的实际场景是一致的。但从实际效果看InfluxDB的优势更加明显成本优势更加突出。因为传统的时序数据库不仅仅是性能低而且在海量数据场景下接入和查询的痛点突出。为了缓解这些痛点引入和堆砌了更多的开源软件。比如
- 往往需要引入Kafka来缓解因突发接入流量导致的丢数据问题
- 需要引入Storm、Flink来缓解时序数据库计算性能差的问题
- 需要做热数据的内存缓存,来解决查询超时的问题。
所以在实施中除了原有的时序数据库会被替换掉还有大量的开源软件会被省掉成本优势突出。在这里我想说的是从实际实施看自研InfluxDB系统性能优势和成本优势也是符合这个预期的。
最后我想说的是我反对堆砌开源软件建议谨慎引入Kafka等缓存中间件。老话说在计算机中任何问题都可以通过引入一个中间层来解决。这句话是正确的但背后的成本是不容忽视的尤其是在海量系统中。**我的建议是直面问题,通过技术手段在代码和架构层面解决它,而不是引入和堆砌更多的开源软件。**其实InfluxDB团队也是这么做比如他们两次重构存储引擎。
## 课堂思考
我提到没有十全十美的技术,而是需要根据场景特点,权衡折中,设计出适合场景特点的分布式系统。那么你试着思考一下,假设有这样一个场景,一个存储系统,访问它的写请求不多(比如 1K QPS但访问它的读请求很多比如1M QPS而且客户端查询时对数据的一致性敏感也就是需要实现强一致性那么我们该如何设计这个系统呢为什么呢欢迎在留言区分享你的看法与我一同讨论。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View 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算法的原理你已经忘得差不多了那你可以先回顾下79讲加深印象之后再进入今天的学习。
## Hashicorp Raft如何实现领导者选举
**在我看来,阅读源码的关键,在于找到代码的入口函数,**比如在Golang代码中程序的入口函数一般为main()函数,那么领导者选举的入口函数是哪个呢?
我们知道典型的领导者选举在本质上是节点状态的变更。具体到Hashicorp Raft源码中领导者选举的入口函数run()在raft.go中以一个单独的协程运行来实现节点状态变迁就像下面的样子
```
func (r *Raft) run() {
for {
select {
// 关闭节点
case &lt;-r.shutdownCh:
r.setLeader(&quot;&quot;)
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中网络通讯是如何实现的呢欢迎在留言区分享你的看法与我一同讨论。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -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快照存储用来存储节点的快照信息
- TransportRaft节点间的通信通道
这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, &quot;raft-log.db&quot;))
```
NewBoltStore()函数只支持一个参数,也就是文件路径。
第四个参数StableStore存储的是节点的关键状态信息比如当前任期编号、最新投票时的任期编号等同样你也可以采用raft-boltdb来实现底层存储持久化存储数据。
```
stableStore, err := raftboltdb.NewBoltStore(filepath.Join(raftDir, &quot;raft-stable.db&quot;))
```
第五个参数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(&quot;tcp&quot;, 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节点呢欢迎在留言区分享你的看法与我一同讨论。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View 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, &quot;/key&quot;) {
s.handleKeyRequest(w, r)
} else if r.URL.Path == &quot;/join&quot; {
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 '{&quot;foo&quot;: &quot;bar&quot;}'
```
- **查询操作:**我们可以通过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()函数,将新节点加入到集群中,那么,你不妨思考一下,当节点故障时,如何替换一个节点呢?欢迎在留言区分享你的看法,与我一同讨论。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View 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 '{&quot;foo&quot;: &quot;bar&quot;}'
```
也就是说我们是通过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-host02192.168.0.20)执行赋值操作。
```
curl -XPOST raft-cluster-host02:8091/key -d '{&quot;foo&quot;: &quot;bar&quot;}' -L
```
如果当前节点也就是节点2不是领导者它将返回包含领导者地址信息的HTTP 307重定向响应给curl。这时curl根据响应信息重新发起赋值操作请求并直接访问领导者节点也就是节点1192.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参数将新节点加入到集群中那么你不妨思考一下如何实现代码移除一个节点呢欢迎在留言区分享你的看法与我一同讨论。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。