分布式系统由多个节点组成,这些节点之间通过网络进行通信。要在分布式系统中实现一致性非常困难,任何节点的失效、网络延迟以及网络分区都可能导致数据的不一致。
一致性问题是分布式系统中的最基本的问题之一,分布式系统中的很多问题最终都可以归结为一致性问题(例如 leader 节点选举)。一致性算法的目的就是为了使得分布式系统中的多个节点最终能达成一致,其在保证分布式系统的一致性以及容错性方面起着非常关键的作用。
⒈Paxos 算法的机制介绍
Paxos 算法涉及到多种角色以及多阶段的提交,其关键在于由提议者(Proposer)提出的提案能够被大多数的接受者(Acceptor)所接受,并最终在整个系统中的所有节点之间达成一致。
这里所说的大多数是指超过系统中所有节点的一半以上,包括那些因为各种原因而失效的节点。如果系统中有超过一半的节点都已经失效,那么提议者提出的任何提案都不可能在系统中的节点之间达成一致。
⓵ 角色介绍
在 Paxos 中通常存在三种角色,这三种角色都是逻辑角色,在实际应用中,同一个节点可以同时扮演这三种角色。
- 提议者(Proposer)
提议者在收到客户端请求后,向其他节点提出提案。由于提案只需要大多数节点接收即可,所以这里提议者不需要向所有节点都提出提案。 - 接受者(Acceptor)
接受者负责接受或拒绝提议者提出的提案。 - 学习者(Learner)
学习者负责观察提案被接受的过程,并在节点对提案达成一致后获取最终结果。
⓶ 两阶段提交
Paxos 算法通常包括两个阶段:Prepare
阶段和 Accept
阶段:
在 Prepare
阶段,提议者向接受者提出提案,发送 prepare
消息。接受者在收到 prepare
消息后,如果接受该提案,则向提议者发送 promise
消息。
如果大多数接受者都表示接受提案,则进入 Accept
阶段。此时,提议者向接受者发送 accept
消息并携带提案值。接受者在收到 accept
消息后,如果接受该提案值,则向提议者发送 accepted
消息。
在实际应用中,有时候会把 learner 获取最终结果的过程视为第三阶段
livelock
如果有不止一个提议者同时向接受者发起提案,可能会出现任何一个提案都无法被大多数节点接受的情况。此时,这些提议者可能会因此陷入一种无限循环的状态,一直在发起提案,但提案始终无法被接受。
为了避免上述情况的出现,在实际应用中往往会给每个提案分配一个唯一的提案号,提案号的值是单调递增的,提案号的值越大,则提案越容易被接受。
⓷ 运作过程
在收到客户端的请求后,提议者首先会向接受者发送 prepare(n)
消息,其中 n
为唯一的提案号。
接受者在收到 prepare(n)
消息后会进行以下处理:
- 如果接受者在此之前已经接受了另一个提议者提出的提案号为
m
的提案,并且m > n
,则接受者此时会向提议者返回nack
消息,表示不接受该提案。 - 如果接受者在此之前已经接受了另一个提议者提出的提案号为
m
且值为u
的提案,并且m < n
,则接受者会向提议者返回promise(m, u)
消息,表示接受提案号为n
的提案,同时将最近一次接受的提案的提案号以及值返回给接受者。 - 如果接受者在此之前没有处理过任何提案,则直接返回
promise(n)
消息,表示接受提案。
如果大多数接受者都返回了 promise
消息,则提议者将继续向接受者发送 accept(n, v)
消息,其中 v
为本次提案的值。如果返回 promise
消息的节点数没有达到大多数(quorum
),则提议者会更新本次提案的提案号,然后等待一段时间后重新发起提案。
接受者在收到 accept(n, v)
消息后,首先会检查提案号 n
。如果 n
不小于接受者最近一次接受的提案的提案号,则接受者会返回 accepted(n, v)
消息,表示接受该提案的值。
如果大多数的接受者都返回了 accepted
消息,则表示提案在系统中各节点之间达成一致。此后,提议者会将最终结果通知学习者。根据实际情况,学习者可能会进行数据库或配置的更新等其他操作。
接受者通常应该将最近一次接受的提案号以及提案的值进行持久化存储,这样,即使接受者节点出现故障,那么在节点从故障中恢复后,仍然可以顺利恢复故障之前的状态。
⒉ 代码实现
首先定义用到的消息类型以及消息结构体,消息结构体中需要包括提案的提案号以及提案值。
// message.go
package main
type Message struct {
From string
To string
Type int
Id int64
Value string
}
const (
Ping = iota
Pong
Prepare
Promise
Nack
Accept
Accepted
)
通过监听不同端口模拟多个节点,节点同时是提议者、接受者以及学习者。由于 Paxos 要求提案必须有大多数节点同意才能在系统中最终达成一致,所以需要周期性的对系统中各个节点进行心跳检测,以便及时了解各个节点的活跃状态,避免发起无意义的提案(当超过半数节点都失效时,无论如何提案都不会达成一致)。
// node.go
import (
"log"
"net"
"net/rpc"
"sync"
"time"
)
const (
Live = true
Dead = false
)
// 各节点以及监听地址的映射信息
var nodeMap = map[string]string{
"node1": "127.0.0.1:8081",
"node2": "127.0.0.1:8082",
"node3": "127.0.0.1:8083",
"node4": "127.0.0.1:8084",
"node5": "127.0.0.1:8085",
}
// 各节点当前的状态
var nodeStatus = map[string]bool{
"node1": Dead,
"node2": Dead,
"node3": Dead,
"node4": Dead,
"node5": Dead,
}
// 节点信息
type Node struct {
Id string
Addr string
}
// 创建新的节点
func NewNode(id string) *Node {
node := &Node{
Id: id,
Addr: nodeMap[id],
}
return node
}
// 监听当前节点
func (node *Node) NewListener() (net.Listener, error) {
listener, err := net.Listen("tcp", node.Addr)
return listener, err
}
// 与节点进行通信
func (node *Node) CommunicateWithSibling(nodeAddr string, message Message) (Message, error) {
var response Message
var err error
rpcClient := rpcClientPool.Get().(*rpc.Client)
defer rpcClientPool.Put(rpcClient)
rpcClient, err = rpc.Dial("tcp", nodeAddr)
if err != nil {
log.Printf("与节点 %s:%s 建立连接失败:%s\n", message.To, nodeAddr, err)
return response, err
}
err = rpcClient.Call("Node.RespondTheMessage", message, &response)
if err != nil {
log.Printf("与节点通信失败:%s\n", err)
}
return response, err
}
// 响应消息
// 这个方法在这里扮演了接受者(acceptor)以及学习者(learner)的角色,同时还响应心跳检测
func (node *Node) RespondTheMessage(message Message, response *Message) error {
response.From = node.Id
response.To = message.From
switch message.Type {
case Ping:
response.Type = Pong
case Prepare:
if preProposalId == 0 {
response.Type = Promise
preProposalId = message.Id
} else if preProposalId < message.Id {
response.Type = Promise
response.Id = preProposalId
response.Value = preProposalValue
preProposalId = message.Id
} else {
response.Type = Nack
}
case Accept:
if preProposalId <= message.Id {
response.Type = Accepted
response.Id = message.Id
response.Value = message.Value
preProposalId = message.Id
preProposalValue = message.Value
} else {
response.Type = Nack
}
case Accepted:
// todo learner implement
}
return nil
}
// 节点心跳检测
func (node *Node) HeartBeat() {
message := Message{
From: node.Id,
Type: Ping,
}
ping:
for nodeId, nodeAddr := range nodeMap {
if nodeId == node.Id {
nodeStatus[node.Id] = Live
// 不检测自身
continue
}
message.To = nodeId
response, err := node.CommunicateWithSibling(nodeAddr, message)
if err != nil {
log.Printf("检测节点 %s 的心跳失败:%s\n", message.To, err)
nodeStatus[message.To] = Dead
continue
}
log.Printf("节点 %s 心跳检测响应:%v\n", message.To, response)
if response.Type == Pong {
nodeStatus[message.To] = Live
}
}
time.Sleep(5 * time.Second)
goto ping
}
// 确定系统中节点的法定人数(quorum)
func (node *Node) majority() int {
return len(nodeMap) / 2 + 1
}
// 判断系统中大多数节点是否仍处于活跃状态
func (node *Node) isMajorityNodeLived() bool {
majority := node.majority()
for _, status := range nodeStatus {
if status {
majority --
}
}
return majority <= 0
}
/*********** proposer ***********/
// 发起提案
func (node *Node) Propose(val string, reply *Message) error {
var response Message
var err error
proposal := Message{
From: node.Id,
To: "",
Type: Prepare,
Id: time.Now().UnixNano(),
Value: val,
}
retry:
// 发送 prepare 消息
if !node.isMajorityNodeLived() {
log.Println("大多数节点已经失效")
return nil
}
majority := node.majority()
for nodeId, nodeAddr := range nodeMap {
if !nodeStatus[nodeId] || nodeId == node.Id {
// 跳过失效的节点
continue
}
proposal.To = nodeId
response, err = node.CommunicateWithSibling(nodeAddr, proposal)
if err != nil {
log.Printf("向节点 %s 发送 prepare 消息失败\n", proposal.To)
continue
}
log.Printf("response %d from %s \n", response.Type, response.From)
if response.Type == Promise {
majority --
}
if majority <= 0 {
// 大多数节点返回 promise
proposal.Type = Promise
break
}
}
// 大多数节点没有返回 promise,500ms 之后重试,重试时需要更新提案号
if proposal.Type != Promise {
time.Sleep(500 * time.Millisecond)
proposal.Id = time.Now().UnixNano()
goto retry
}
// 发送 accept 消息
reply.Type = Promise
proposal.Type = Accept
if !node.isMajorityNodeLived() {
log.Println("大多数节点已失效")
return nil
}
majority = node.majority()
for nodeId, nodeAddr := range nodeMap {
if !nodeStatus[nodeId] || nodeId == node.Id {
continue
}
proposal.To = nodeId
response, err = node.CommunicateWithSibling(nodeAddr, proposal)
if err != nil {
log.Printf("向节点 %s 发送 accept 消息失败\n", nodeId)
continue
}
log.Printf("response %d from %s\n", response.Type, response.From)
if response.Type == Accepted {
majority --
}
if majority <= 0 {
// 大多数节点接受提案值
proposal.Type = Accepted
break
}
}
if proposal.Type == Accepted {
// todo 结果通知 learner
}
reply.Type = proposal.Type
return nil
}
程序启动之初需要指定节点 ID,然后监听当前节点的端口,之后进行周期性的心跳检测。
// main.go
package main
import (
"log"
"net/rpc"
"os"
"os/signal"
"time"
)
func main() {
if len(os.Args) != 2 {
log.Fatal("传参错误!")
}
nodeId := os.Args[1]
if nodeMap[nodeId] == "" {
log.Fatal("节点 ID 异常!")
}
node := NewNode(nodeId)
listener, err := node.NewListener()
if err != nil {
log.Fatal("监听当前节点失败:", err.Error())
}
defer listener.Close()
rpcServer := rpc.NewServer()
rpcServer.Register(node)
go rpcServer.Accept(listener)
log.Println("等待系统启动……")
time.Sleep(10 * time.Second)
// 系统启动后首先进行心跳检测,确认系统中各个节点的活跃状态
go node.HeartBeat()
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
<- ch
}
为测试方便,通过监听本机的不同端口来模拟分布式系统的多个节点,省去了服务注册以及发现机制,节点信息直接硬编码到代码中。
以下为客户端请求的测试代码。
package main
import (
"log"
"net/rpc"
)
type Message struct {
From string
To string
Type int
Id int64
Value string
}
func main() {
rpcClient, err := rpc.Dial("tcp", "127.0.0.1:8081")
if err != nil {
log.Printf("建立连接失败:%v\n", err)
}
var response Message
err = rpcClient.Call("Node.Propose", "Hello World", &response)
if err != nil {
log.Println("与节点通信失败")
} else {
log.Printf("response = %+v\n", response)
}
}