用 Golang 快速实现 Paxos 分布式共识算法

简介:   前文《理解 Paxos》只包含伪代码,帮助了理解但又不够爽,既然现在都讲究 Talk is cheap. Show me the code.这次就把文章中的伪代码用 Go 语言实现出来,希望能帮助各位朋友更直观的感受 Paxos 论文中的细节。  但我们需要对算法做一些简化,有多简单呢?我们不持久化存储任何变量,并且用 chan直接代替 RPC 调用。

  前文《理解 Paxos》只包含伪代码,帮助了理解但又不够爽,既然现在都讲究 Talk is cheap. Show me the code.这次就把文章中的伪代码用 Go 语言实现出来,希望能帮助各位朋友更直观的感受 Paxos 论文中的细节。

  但我们需要对算法做一些简化,有多简单呢?我们不持久化存储任何变量,并且用 chan直接代替 RPC 调用。

  记得切换到 naive 分支。

  我们定义 Proposer 如下:

  type proposer struct {

  // server id

  id int

  // the largest round number the server has seen

  round int

  // proposal number=(round number, serverID)

  number int

  // proposal value

  value string

  acceptors map[int]bool

  net network

  }

  这些结构体成员都很容易理解,其中 acceptors我们主要用来存储 Acceptors 的地址,以及记录我们收到 Acceptor 的成功/失败响应。

  Acceptor 的结构体:

  type acceptor struct {

  // server id

  id int

  // the number of the proposal this server will accept, or 0 if it has never received a Prepare request

  promiseNumber int

  // the number of the last proposal the server has accepted, or 0 if it never accepted any.

  acceptedNumber int

  // the value from the most recent proposal the server has accepted, or if it has never accepted a proposal

  acceptedValue string

  learners int

  net network

  }

  主要成员解释都有注释,简单来说我们需要记录三个信息:

  promiseNumber:承诺的提案编号

  acceptedNumber:接受的提案编号

  acceptedValue:接受的提案值

  消息结构体定义了 Proposer 和 Acceptor 之间、Acceptor 和 Leaner 之间的通讯协议。最主要的还是 Paxos 的两阶段的四个消息。

  Phase 1 请求:提案编号

  Phase 1 响应:如果有被 Accepted 的提案,返回提案编号和提案值

  Phase 2 请求:提案编号和提案值

  Phase 2 响应:Accepted 的提案编号和提案值

  这样看,我们的消息结构体只需要提案编号和提案值,加上一个消息类型,用来区分是哪个阶段的消息。消息结构体定义在 message.go 文件,具体如下:

  // MsgType represents the type of a paxos phase.

  type MsgType uint8

  const (

  Prepare MsgType=iota

  Promise

  Propose

  Accept

  )

  type message struct {

  tp MsgType

  from int

  to int

  number int // proposal number

  value string // proposal value

  }

  网络上可以做的选择和优化很多,但这里为了保持简单的原则,我们将网络定义成 interface。后面完全可以改成 RPC 或 API 等其它通信方式来实现(没错,我已经实现了一个 Go RPC 的版本了)。

  type network interface {

  send(m message)

  recv(timeout time.Duration) (message, bool)

  }

  接下里我们去实现 network 接口:

  type Network struct {

  queue map[int]chan message

  }

  func newNetwork(nodes ...int) *Network {

  pn :=&Network{

  queue: make(map[int]chan message, 0),

  }

  for _, a :=range nodes {

  pn.queue[a]=make(chan message, 1024)

  }

  return pn

  }

  func (net *Network) send(m message) {

  log.Printf("net: send %+v", m)

  net.queue[m.to] <- m

  }

  func (net *Network) recvFrom(from int, timeout time.Duration) (message, bool) {

  select {

  case m :=<-net.queue[from]:

  log.Printf("net: recv %+v", m)

  return m, true

  case <-time.After(timeout):

  return message{}, false

  }

  }

  就是用 queue来记录每个节点的chan,key 则是节点的 server id。

  发送消息则将 Message发送到目标节点的chan中,接受消息直接从chan中读取数据,并等待对应的超时时间。

  不需要做其它网络地址、包相关的东西,所以非常简单。具体在 network.go文件。

  这个项目主要使用 go 单元测试来检验正确性,我们主要测试两种场景:

  TestSingleProposer(单个 Proposer)

  TestTwoProposers(多个 Proposer)

  测试代码通过运行 Paxos 后检查 Chosen 返回的减肥提案值是否符合预期。

  按照角色将文件分为 proposer.go, acceptor.go 和 learner.go,每个文件都有一个 run函数来运行程序,run函数执行条件判断,并在对应的阶段执行对应的函数。

  按照伪代码描述,我们很容易实现 Phase 1 和 Phase 2,把每个阶段的请求响应都作为一个函数,我们一步步来看。

  // Phase 1. (a) A proposer selects a proposal number n

  // and sends a prepare request with number n to

  // a majority of acceptors.

  func (p *proposer) prepare message {

  p.round++

  p.number=p.proposalNumber

  msg :=make(message, p.majority)

  i :=0

  for to :=range p.acceptors {

  msg[i]=message{

  tp: Prepare,

  from: p.id,

  to: to,

  number: p.number,

  }

  i++

  if i==p.majority {

  break

  }

  }

  return msg

  }

  // proposal number=(round number, serverID)

  func (p *proposer) proposalNumber int {

  return p.round<< 16 | p.id

  }

  Prepare 请求阶段我们将 round+1 然后发送给多数派 Acceptors。

  注:这里很多博客和教程都会将 Prepare RPC 发给所有的Acceptors,6.824 的 paxos 实验就将 RPC 发送给所有 Acceptors。这里保持和论文一致,只发送给 a majority of acceptors。

  接下来在 acceptor.go文件中处理请求:

  func (a *acceptor) handlePrepare(args message) (message, bool) {

  if a.promiseNumber >=args.number {

  return message{}, false

  }

  a.promiseNumber=args.number

  msg :=message{

  tp: Promise,

  from: a.id,

  to: args.from,

  number: a.acceptedNumber,

  value: a.acceptedValue,

  }

  return msg, true

  }

  如果 args.number大于acceptor.promiseNumber,则承诺将不会接收编号小于args.number的提案(即a.promiseNumber=args.number)。如果之前有提案被 Accepted 的话,响应还应包含 a.acceptedNumber 和 a.acceptedValue。

  否则忽略,返回 false。

  func (p *proposer) accept message {

  msg :=make(message, p.majority)

  i :=0

  for to, ok :=range p.acceptors {

  if ok {

  msg[i]=message{

  tp: Propose,

  from: p.id,

  to: to,

  number: p.number,

  value: p.value,

  }

  i++

  }

  if i==p.majority {

  break

  }

  }

  return msg

  }

  当 Proposer 收到超过半数 Acceptor 的响应后,Proposer 向多数派的 Acceptor 发起请求并带上提案编号和提案值。

  func (a *acceptor) handleAccept(args message) bool {

  number :=args.number

  if number >=a.promiseNumber {

  a.acceptedNumber=number

  a.acceptedValue=args.value

  a.promiseNumber=number

  return true

  }

  return false

  }

  Acceptor 收到 Accept请求,在这期间如果 Acceptor 没有对比 a.promiseNumber 更大的编号另行 Promise,则接受该提案。

  在 Paxos 中有一个十分容易混淆的概念:Chosen Value 和 Accepted Value,但如果你看过论文,其实已经说得非常直接了。论文的 2.3 节 Learning a Chosen Value 开头就说:

  To learn that a value has been chosen, a learner must find out that a proposal has been accepted by a majority of acceptors.

  所以 Acceptor 接受提案后,会将接受的提案广播 Leaners,一旦 Leaners 收到超过半数的 Acceptors 的 Accepted 提案,我们就知道这个提案被 Chosen 了。

  func (l *learner) chosen (message, bool) {

  acceptCounts :=make(map[int]int)

  acceptMsg :=make(map[int]message)

  for _, accepted :=range l.acceptors {

  if accepted.number !=0 {

  acceptCounts[accepted.number]++

  acceptMsg[accepted.number]=accepted

  }

  }

  for n, count :=range acceptCounts {

  if count >=l.majority {

  return acceptMsg[n], true

  }

  }

  return message{}, false

  }

  代码拉下来后,直接运行:

  go test

  之前我曾把 mit 6.824 的 Raft 答案推到自己的 Github,直到 2020 开课的时候 mit 的助教发邮件让我将我的代码转为 private,因为这样会导致学习课程的人直接搜到代码,而无法保证作业独立完成。

  确实,实验是计算机最不可或缺的环节,用 mit 6.824 2015 的 paxos 代码会导致很多学习者不去自己解决困难,直接上网搜代码,从而导致学习效果不好,违背了 mit 的初衷。

  当然,你也可以说现在网上以及很容易搜到 6.824 的各种代码了,但出于之前 mit 助教的邮件,我不会将作业代码直接发出来。

目录
相关文章
|
12天前
|
算法 Go
[go 面试] 雪花算法与分布式ID生成
[go 面试] 雪花算法与分布式ID生成
|
18天前
|
存储 算法 NoSQL
(七)漫谈分布式之一致性算法下篇:一文从根上儿理解大名鼎鼎的Raft共识算法!
Raft通过一致性检查,能在一定程度上保证集群的一致性,但无法保证所有情况下的一致性,毕竟分布式系统各种故障层出不穷,如何在有可能发生各类故障的分布式系统保证集群一致性,这才是Raft等一致性算法要真正解决的问题。
47 11
|
18天前
|
存储 算法 索引
(六)漫谈分布式之一致性算法上篇:用二十六张图一探Raft共识算法奥妙之处!
现如今,大多数分布式存储系统都投向了Raft算法的怀抱,而本文就来聊聊大名鼎鼎的Raft算法/协议!
|
18天前
|
存储 算法 Java
(五)漫谈分布式之一致性算法篇:谁说Paxos晦涩难懂?你瞧这不一学就会!
没在时代发展的洪流中泯然于众的道理很简单,是因为它们并不仅是空中楼阁般的高大上理论,而是有着完整落地的思想,它们已然成为构建分布式系统不可或缺的底层基石,而本文则来好好聊聊分布式与一致性思想的落地者:Paxos与Raft协议(算法)。
|
30天前
|
算法
分布式篇问题之避免陷入死循环,保证Paxos算法的活性问题如何解决
分布式篇问题之避免陷入死循环,保证Paxos算法的活性问题如何解决
|
27天前
|
存储 缓存 NoSQL
Redis常见面试题(二):redis分布式锁、redisson、主从一致性、Redlock红锁;Redis集群、主从复制,哨兵模式,分片集群;Redis为什么这么快,I/O多路复用模型
redis分布式锁、redisson、可重入、主从一致性、WatchDog、Redlock红锁、zookeeper;Redis集群、主从复制,全量同步、增量同步;哨兵,分片集群,Redis为什么这么快,I/O多路复用模型——用户空间和内核空间、阻塞IO、非阻塞IO、IO多路复用,Redis网络模型
Redis常见面试题(二):redis分布式锁、redisson、主从一致性、Redlock红锁;Redis集群、主从复制,哨兵模式,分片集群;Redis为什么这么快,I/O多路复用模型
|
30天前
|
NoSQL Java Redis
分布式锁实现原理问题之使用Redis的setNx命令来实现分布式锁问题如何解决
分布式锁实现原理问题之使用Redis的setNx命令来实现分布式锁问题如何解决
|
1天前
|
缓存 NoSQL Java
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解、如何添加锁解决缓存击穿问题?分布式情况下如何添加分布式锁
这篇文章介绍了如何在SpringBoot项目中整合Redis,并探讨了缓存穿透、缓存雪崩和缓存击穿的问题以及解决方法。文章还提供了解决缓存击穿问题的加锁示例代码,包括存在问题和问题解决后的版本,并指出了本地锁在分布式情况下的局限性,引出了分布式锁的概念。
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解、如何添加锁解决缓存击穿问题?分布式情况下如何添加分布式锁
|
1天前
|
缓存 NoSQL Java
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解分布式情况下如何添加分布式锁 【续篇】
这篇文章是关于如何在SpringBoot应用中整合Redis并处理分布式场景下的缓存问题,包括缓存穿透、缓存雪崩和缓存击穿。文章详细讨论了在分布式情况下如何添加分布式锁来解决缓存击穿问题,提供了加锁和解锁的实现过程,并展示了使用JMeter进行压力测试来验证锁机制有效性的方法。
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解分布式情况下如何添加分布式锁 【续篇】
|
1天前
|
NoSQL 安全 Java
nicelock--一个注解即可使用Redis分布式锁!
Nicelock的引入为分布式系统中的资源同步访问提供了一个简单高效和可靠的解决方案。通过注解的方式,简化了锁的实现和使用,使开发人员可以将更多精力专注于业务逻辑的实现,而不是锁的管理。此外,Nicelock在保持简单易用的同时,也提供了足够的灵活性和可靠性,满足了不同应用场景下对分布式锁的需求。
8 1