You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

209 lines
14 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 20 | 基于Raft的分布式KV系统开发实战如何实现代码
你好,我是韩健。
学完[上一讲](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操作和增加节点的操作那么它们是如何实现的呢
接入协议的核心实现,就是下面的样子。
![](https://static001.geekbang.org/resource/image/b7/56/b72754232480fadd7d8eeb9bfdd15e56.jpg "图1")
我带你走一遍这三个步骤,便于你加深印象。
1. 在ServeHTTP()中会根据URL路径设置相关的路由信息。比如会在handlerKeyRequest()中处理URL路径前缀为"/key"的请求会在handleJoin()中处理URL路径为"/join"的请求。
2. 在handleKeyRequest()中处理来自客户端的KV操作请求也就是基于HTTP POST请求的赋值操作、基于HTTP GET请求的查询操作、基于HTTP DELETE请求的删除操作。
3. 在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请求实现了赋值操作。
![](https://static001.geekbang.org/resource/image/ad/91/ad3f4c3955f721fe60d7f041ea9aae91.jpg "图2")
同样的,我们走一遍这个过程,加深一下印象。
1. 当接收到KV操作的请求时系统将调用handleKeyRequest()进行处理。
2. 在handleKeyRequest()函数中检测到HTTP请求类型为POST请求时确认了这是一个赋值操作将执行store.Set()函数。
3. 在Set()函数中将创建指令并通过raft.Apply()函数将指令提交给Raft。最终指令将被应用到状态机。
4. 当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请求实现了查询操作。在这里我想强调一下相比需要将指令应用到状态机的赋值操作查询操作要简单多了因为系统只需要查询内存中的数据就可以了不涉及状态机。具体的代码流程如图所示。
![](https://static001.geekbang.org/resource/image/c7/b4/c70b009d5abd3b3f63c0d1d419ede9b4.jpg "图3")
我们走一遍这个过程,加深一下印象。
1. 当接收到KV操作的请求时系统将调用handleKeyRequest()进行处理。
2. 在handleKeyRequest()函数中检测到HTTP请求类型为GET请求时确认了这是一个赋值操作将执行store.Get()函数。
3. Get()函数在内存中查询指定key对应的值。
而最后一个删除操作是基于HTTP DELETE请求来实现的。
```
curl -XDELETE http://raft-cluster-host01:8091/key/foo
```
也就是说我们是通过HTTP DELETE请求实现了删除操作。
![](https://static001.geekbang.org/resource/image/90/4e/90f99bc9c4aebb50c39f05412fa4594e.jpg "图4")
同样的,我们走一遍这个过程。
1. 当接收到KV操作的请求时系统将调用handleKeyRequest()进行处理。
2. 在handleKeyRequest()函数中检测到HTTP请求类型为DELETE请求时确认了这是一个删除操作将执行store.Delete()函数。
3. 在Delete()函数中将创建指令并通过raft.Apply()函数将指令提交给Raft。最终指令将被应用到状态机。
4. 当前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来实现写操作。也就是当跟随者接收到写请求后将拒绝处理该请求并将领导者的地址信息转发给客户端。后续客户端就可以直接访问领导者为了演示方便我们以赋值操作为例
![](https://static001.geekbang.org/resource/image/0a/58/0a79be9a402addd226c0df170268a658.jpg "图5")
我们来看一下具体的内容。
1. 调用Set()函数执行赋值操作。
2. 如果执行Set()函数成功将执行步骤3如果执行Set()函数出错且提示出错的原因是当前节点不是领导者那这就说明了当前节点不是领导者不能执行写操作将执行步骤4如果执行Set()函数出错且提示出错的原因不是因为当前节点不是领导者将执行步骤5。
3. 赋值操作执行成功,正常返回。
4. 节点将构造包含领导者地址信息的重定向响应,并返回给客户端。然后客户端直接访问领导者节点执行赋值操作。
5. 系统运行出错,返回错误信息给客户端。
需要你注意的是,赋值操作和删除操作属于写操作,必须在领导者节点上执行。而查询操作,只是查询内存中的数据,不涉及指令提交,可以在任何节点上执行。
而为了更好的利用curl客户端的HTTP重定向功能我实现了HTTP 307重定向这样你在执行赋值操作时就不需要关心访问节点是否是领导者节点了。比如你可以使用下面的命令访问节点2也就是raft-cluster-host02192.168.0.20)执行赋值操作。
```
curl -XPOST raft-cluster-host02:8091/key -d '{"foo": "bar"}' -L
```
如果当前节点也就是节点2不是领导者它将返回包含领导者地址信息的HTTP 307重定向响应给curl。这时curl根据响应信息重新发起赋值操作请求并直接访问领导者节点也就是节点1192.168.0.10。具体的过程就像下面的Wireshark截图。
![](https://static001.geekbang.org/resource/image/27/fe/27b9005d47f65ca9d231da6e5bddbafe.jpg "图6")
相比写请求必须在领导者节点上执行,虽然查询操作属于读操作,可以在任何节点上执行,但是如何实现却更加复杂,因为读操作的实现关乎着一致性的实现。那么,具体怎么实现呢?
### 读操作
我想说的是我们可以实现3种一致性模型也就是stale、default、consistent这样用户就可以根据场景特点按需选择相应的一致性级别是不是很灵活呢
具体的读操作的代码实现,就像下面的样子。
![](https://static001.geekbang.org/resource/image/42/97/42cdc5944e200f20f0cdcfef6891cc97.jpg "图7")
我们走一遍这个过程。
1. 当接收到HTTP GET的查询请求时系统会先调用level()函数,来获取当前请求的读一致性级别。
2. 调用Get()函数查询指定key和读一致性级别对应的数据。
3. 如果执行Get()函数成功将执行步骤4如果执行Get()函数出错且提示出错的原因是当前节点不是领导者节点那么这就说明了在当前节点上执行查询操作不满足读一致性级别必须要到领导者节点上执行查询操作将执行步骤5如果执行Get()函数出错且提示出错的原因不是因为当前节点不是领导者将执行步骤6。
4. 查询操作执行成功,返回查询到的值给客户端。
5. 节点将构造,包含领导者地址信息的重定向响应,并返回给客户端。然后客户端直接访问领导者节点查询数据。
6. 系统运行出错,返回错误信息给客户端。
在这里为了更好地利用curl客户端的HTTP重定向功能我同样实现了HTTP 307重定向具体原理前面已经介绍了这里就不啰嗦了。比如你可以使用下面的命令来实现一致性级别为consistent的查询操作不需要关心访问节点raft-cluster-host02是否是领导者节点。
```
curl -XGET raft-cluster-host02:8091/key/foo?level=consistent -L
```
## 内容小结
本节课我主要带你了解了接入协议、KV操作、分布式集群的实现我希望你记住下面三个重点内容
1. 我们可以借助HTTP请求类型来实现相关的操作比如我们可以通过HTTP GET请求实现查询操作通过HTTP DELETE请求实现删除操作。
2. 你可以通过HTTP 307 重定向响应来返回领导者的地址信息给客户端需要你注意的是curl已支持HTTP 307重定向使用起来很方便所以推荐你优先考虑curl在日常中执行KV操作。
3. 在Raft中我们可以通过raft.VerifyLeader()来确认当前领导者,是否仍是领导者。
在这里我还想强调的一点任何大系统都是由小系统和具体的技术组成的比如能无限扩展和支撑海量服务的QQ后台是由多个组件协议接入组件、名字服务、存储组件等组成的。而做技术最为重要的就是脚踏实地彻底吃透和掌握技术本质小系统的关键是细节技术大系统的关键是架构。所以在课程结束后我会根据你的反馈意见再延伸性地讲解大系统大型互联网后台的架构设计技巧和我之前支撑海量服务的经验。
这样一来,我希望能帮你从技术到代码、从代码到架构、从小系统到大系统,彻底掌握实战能力,跨过技术和实战的鸿沟。
虽然这个分布式KV系统比较简单但它相对纯粹聚焦在技术能帮助你很好的理解Raft算法、Hashicorp Raft实现、分布式系统开发实战等。所以我希望你不懂就问有问题多留言咱们一起讨论解决不要留下盲区。
另外我会持续维护和优化这个项目并会针对大家共性的疑问开发实现相关代码从代码和理论2个角度帮助你更透彻的理解技术。我希望你能在课下采用自己熟悉的编程语言将这个系统重新实现一遍在实战中加深自己对技术的理解。如果条件允许你可以将自己的分布式KV系统以“配置中心”、“名字服务”等形式在实际场景中落地和维护起来不断加深自己对技术的理解。
## 课堂思考
我提到了通过-join参数将新节点加入到集群中那么你不妨思考一下如何实现代码移除一个节点呢欢迎在留言区分享你的看法与我一同讨论。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。