前言
寫完【paxos 的直觀解釋】之後,網友都說療效甚好,但是也會對這篇教程中一些環節提出疑問(有疑問說明真的看懂了 ?),例如怎麼把只能確定一個值的 paxos 應用到實際場景中。
既然 Talk is cheap,那麼就 Show me the code,這次我們把教程中描述的內容直接用程式碼實現出來,希望能覆蓋到教程中的涉及的每個細節。幫助大家理解 paxos 的執行機制。
這是一個基於 paxos,200 行程式碼的 kv 儲存系統的簡單實現,作為 【 paxos 的直觀解釋 】 這篇教程中的程式碼示例部分。Paxos 的原理本文不再介紹了,本文提到的資料結構使用【protobuf】定義,網路部分使用【grpc】定義。另外 200 行 go 程式碼實現 paxos 儲存。
文中的程式碼可能做了簡化, 完整程式碼實現在【paxoskv】這個專案中(naive 分支)。
執行和使用
跑一下:
git clone https://github.com/openacid/paxoskv.git
cd paxoskv
go test -v ./...
這個專案中除了 paxos 實現,用 3 個 test case 描述了 3 個 paxos 執行的例子,
- 【TestCase1SingleProposer】:無衝突執行。
- 【TestCase2DoubleProposer】:有衝突執行。
- 【Example_setAndGetByKeyVer】作為 key-val 使用。
測試程式碼描述了幾個 paxos 執行例子的行為,執行測試可以確認 paxos 的實現符合預期。
本文中 protobuf 的資料結構定義如下:
service PaxosKV {
rpc Prepare (Proposer) returns (Acceptor) {}
rpc Accept (Proposer) returns (Acceptor) {}
}
message BallotNum {
int64 N = 1;
int64 ProposerId = 2;
}
message Value {
int64 Vi64 = 1;
}
message PaxosInstanceId {
string Key = 1;
int64 Ver = 2;
}
message Acceptor {
BallotNum LastBal = 1;
Value Val = 2;
BallotNum VBal = 3;
}
message Proposer {
PaxosInstanceId Id = 1;
BallotNum Bal = 2;
Value Val = 3;
}
以及主要的函式實現:
// struct KVServer
Storage : map[string]Versions
func Accept(c context.Context, r *Proposer) (*Acceptor, error)
func Prepare(c context.Context, r *Proposer) (*Acceptor, error)
func getLockedVersion(id *PaxosInstanceId) *Version
// struct Proposer
func Phase1(acceptorIds []int64, quorum int) (*Value, *BallotNum, error)
func Phase2(acceptorIds []int64, quorum int) (*BallotNum, error)
func RunPaxos(acceptorIds []int64, val *Value) *Value
func rpcToAll(acceptorIds []int64, action string) []*Acceptor
func ServeAcceptors(acceptorIds []int64) []*grpc.Server
從頭實現 Paxoskv
Paxos 相關的資料結構
在這個例子中我們的資料結構和服務框架使用【protobuf】和【grpc】實現,首先是最底層的 paxos 資料結構:Proposer 和 Acceptor在【slide-27】中我們介紹了 1 個 Acceptor 所需的欄位:
在儲存端(Acceptor)也有幾個概念:
- last_rnd 是 Acceptor 記住的最後一次進行寫前讀取的 Proposer(客戶端)是誰,以此來決定誰可以在後面真正把一個值寫到儲存中。
- v 是最後被寫入的值。
- vrnd 跟 v 是一對, 它記錄了在哪個 Round 中 v 被寫入了。
原文中這些名詞是參考了【paxos made simple】中的名稱,但在【Leslie Lamport】後面的幾篇 paper 中都換了名稱,為了後續方便,在【paxoskv】的程式碼實現中也做了相應的替換:
rnd ==> Bal // 每一輪paxos的編號, BallotNum
vrnd ==> VBal // 在哪個Ballot中v被Acceptor 接受(voted)
last_rnd ==> LastBal
Proposer 的欄位也很簡單,它需要記錄:
- 當前的 ballot number:Bal,
- 以及它選擇在 Phase2 執行的值:Val(【slide-29】)。
於是在這個專案中用 protobuf 定義這兩個角色的資料結構,如程式碼【paxoskv.proto】中的宣告,如下:
message Acceptor {
BallotNum LastBal = 1;
Value Val = 2;
BallotNum VBal = 3;
}
message Proposer {
PaxosInstanceId Id = 1;
BallotNum Bal = 2;
Value Val = 3;
}
其中 Proposer 還需要一個 PaxosInstanceId,來標識當前的 paxos 例項為哪個 key 的哪個 version 在做決定,【paxos made simple】中只描述了一個 paxos 例項的演算法(對應一個 key 的一次修改),要實現多次修改,就需要增加這個欄位來區分不同的 paxos 例項:
message PaxosInstanceId {
string Key = 1;
int64 Ver = 2;
}
【paxoskv.proto】還定義了一個 BallotNum,因為要保證全系統內的 BallotNum 都有序且不重複,一般的做法就是用一個本地單調遞增的整數,和一個全域性唯一的 id 組合起來實現:
message BallotNum {
int64 N = 1;
int64 ProposerId = 2;
}
定義 RPC 訊息結構
RPC 訊息定義了 Proposer 和 Acceptor 之間的通訊。
在一個 paxos 系統中,至少要有 4 個訊息:
- Phase 1 的 Prepare-request,Prepare-reply
- Phase 2 的 Accept-request,Accept-reply
如【slide-28】所描述的(原文中使用 rnd,這裡使用 Bal,都是同一個概念):
Phase- 1(Prepare):
request: Bal: int reply: LastBal: int Val: string VBal: int
Phase- 2(Accept):
request: Bal: int Val: string reply: LastBal: int
在 Prepare-request 或 Accept-request 中,傳送的是一部分或全部的 Proposer 的欄位,因此我們在程式碼中:
- 直接把 Proposer 的結構體作為 request 的結構體
- 同樣把 Acceptor 的結構體作為 reply 的結構體
在使用的時候只使用其中幾個欄位,對應我們的 RPC 服務【PaxosKV】定義如下:
service PaxosKV {
rpc Prepare (Proposer) returns (Acceptor) {}
rpc Accept (Proposer) returns (Acceptor) {}
}
使用 Protobuf 和 Grpc 生成服務框架
?
protobuf 可以將【paxoskv.proto】直接生成 go 程式碼(程式碼庫中已經包含了生成好的程式碼:【paxoskv.pb.go】,只有修改【paxoskv.proto】之後才需要重新生成)
- 首先安裝 protobuf 的編譯器 protoc,可以根據【install-protoc】中的步驟安裝, 一般簡單的一行命令就可以了:安裝好之後通過 protoc--version 確認版本,至少應該是 3.x: libprotoc 3.13.0
-
- Linux:apt install-y protobuf-compiler
- Mac:brew install protobuf
- 安裝 protoc 的 go 語言生成外掛 protoc-gen-go:go get -u github.com/golang/protobuf/protoc-gen-go
- 重新編譯 protokv.proto 檔案:直接 make gen 或:
protoc
--proto_path=proto
--go_out=plugins=grpc:paxoskv
paxoskv.proto
生成後的【paxoskv.pb.go】程式碼中可以看到,其中主要的資料結構例如 Acceptor 的定義:
type Acceptor struct {
LastBal *BallotNum ...
Val *Value ...
VBal *BallotNum ...
...
}
以及 KV 服務的 client 端和 server 端的程式碼,client 端是實現好的,server 端只有一個 interface,後面我們需要來完成它的實現:
type paxosKVClient struct {
cc *grpc.ClientConn
}
type PaxosKVClient interface {
Prepare(
ctx context.Context,
in *Proposer,
opts ...grpc.CallOption
) (*Acceptor, error)
Accept(
ctx context.Context,
in *Proposer,
opts ...grpc.CallOption
) (*Acceptor, error)
}
type PaxosKVServer interface {
Prepare(context.Context,
*Proposer) (*Acceptor, error)
Accept(context.Context,
*Proposer) (*Acceptor, error)
}
實現儲存的伺服器端
【impl.go】是所有實現部分,我們定義一個 KVServer 結構體,用來實現 grpc 服務的 interface PaxosKVServer;其中使用一個記憶體裡的 map 結構模擬資料的儲存:
type Version struct {
mu sync.Mutex
acceptor Acceptor
}
type Versions map[int64]*Version
type KVServer struct {
mu sync.Mutex
Storage map[string]Versions
}
其中 Version 對應一個 key 的一次變化,也就是對應一個 paxos 例項,Versions 對應一個 key 的一系列變化,Storage 就是所有 key 的所有變化。
實現 Acceptor 的 grpc 服務 handler
Acceptor,是這個系統裡的 server 端,監聽一個埠,等待 Proposer 發來的請求並處理,然後給出應答。
根據 paxos 的定義,Acceptor 的邏輯很簡單:在【slide-28】中描述:
根據教程裡的描述,為 KVServer 定義 handle Prepare-request 的程式碼:
func (s *KVServer) Prepare(
c context.Context,
r *Proposer) (*Acceptor, error) {
v := s.getLockedVersion(r.Id)
defer v.mu.Unlock()
reply := v.acceptor
if r.Bal.GE(v.acceptor.LastBal) {
v.acceptor.LastBal = r.Bal
}
return &reply, nil
}
這段程式碼分 3 步:
- 取得 paxos 例項,
- 生成應答:Acceptor 總是返回 LastBal,Val,VBal 這 3 個欄位,所以直接把 Acceptor 賦值給 reply。
- 最後更新 Acceptor 的狀態:然後按照 paxos 演算法描述,如果請求中的 ballot number 更大,則記錄下來,表示不在接受更小 ballot number 的 Proposer。
其中 getLockedVersion() 從 KVServer.Storage 中根據 request 發來的PaxosInstanceId 中的欄位 key 和 ver 獲取一個指定 Acceptor 的例項:
func (s *KVServer) getLockedVersion(
id *PaxosInstanceId) *Version {
s.mu.Lock()
defer s.mu.Unlock()
key := id.Key
ver := id.Ver
rec, found := s.Storage[key]
if !found {
rec = Versions{}
s.Storage[key] = rec
}
v, found := rec[ver]
if !found {
// initialize an empty paxos instance
rec[ver] = &Version{
acceptor: Acceptor{
LastBal: &BallotNum{},
VBal: &BallotNum{},
},
}
v = rec[ver]
}
v.mu.Lock()
return v
}
handle Accept-request 的處理類似,在【slide-31】中描述:
Accept() 要記錄 3 個值,
- LastBal:Acceptor 看到的最大的 ballot number;
- Val:Proposer 選擇的值,
- 以及 VBal:Proposer 的 ballot number:
func (s *KVServer) Accept(
c context.Context,
r *Proposer) (*Acceptor, error) {
v := s.getLockedVersion(r.Id)
defer v.mu.Unlock()
reply := Acceptor{
LastBal: &*v.acceptor.LastBal,
}
if r.Bal.GE(v.acceptor.LastBal) {
v.acceptor.LastBal = r.Bal
v.acceptor.Val = r.Val
v.acceptor.VBal = r.Bal
}
return &reply, nil
}
Acceptor 的邏輯到此完整了,再看 Proposer:
實現 Proposer 邏輯
Proposer 的執行分 2 個階段,Phase1 和 Phase2,與 Prepare 和 Accept 對應。
Phase1
在【impl.go】的實現中,Proposer.Phase1() 函式負責 Phase1 的邏輯:
func (p *Proposer) Phase1(
acceptorIds []int64,
quorum int) (*Value, *BallotNum, error) {
replies := p.rpcToAll(acceptorIds, "Prepare")
ok := 0
higherBal := *p.Bal
maxVoted := &Acceptor{VBal: &BallotNum{}}
for _, r := range replies {
if !p.Bal.GE(r.LastBal) {
higherBal = *r.LastBal
continue
}
if r.VBal.GE(maxVoted.VBal) {
maxVoted = r
}
ok += 1
if ok == quorum {
return maxVoted.Val, nil, nil
}
}
return nil, &higherBal, NotEnoughQuorum
}
這段程式碼首先通過 rpcToAll() 向所有 Acceptor 傳送 Prepare-request 請求, 然後找出所有的成功的 reply:
- 如果發現一個更大的 ballot number,表示一個 Prepare 失敗:有更新的Proposer 存在;
- 否則,它是一個成功的應答,再看它有沒有返回一個已經被 Acceptor 接受(voted)的值。
最後,成功應答如果達到多數派(quorum),則認為 Phase1 完成,返回最後一個被 voted 的值,也就是 VBal 最大的那個。讓上層呼叫者繼續 Phase2;如果沒有達到 quorum,這時可能是有多個 Proposer 併發執行而造成衝突,有更大的 ballot number,這時則把見到的最大 ballot number 返回,由上層呼叫者提升 ballot number 再重試。
client 與 server 端的連線
上面用到的 rpcToAll 在這個專案中的實現 client 端(Proposer)到 server 端(Acceptor)的通訊,它是一個十分 簡潔美觀 簡陋的 grpc 客戶端實現:
func (p *Proposer) rpcToAll(
acceptorIds []int64,
action string) []*Acceptor {
replies := []*Acceptor{}
for _, aid := range acceptorIds {
var err error
address := fmt.Sprintf("127.0.0.1:%d",
AcceptorBasePort+int64(aid))
conn, err := grpc.Dial(
address, grpc.WithInsecure())
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := NewPaxosKVClient(conn)
ctx, cancel := context.WithTimeout(
context.Background(), time.Second)
defer cancel()
var reply *Acceptor
if action == "Prepare" {
reply, err = c.Prepare(ctx, p)
} else if action == "Accept" {
reply, err = c.Accept(ctx, p)
}
if err != nil {
continue
}
replies = append(replies, reply)
}
return replies
}
Phase2
Proposer 執行的 Phase2 在【slide-30】中描述,比 Phase1 更簡單:
在第 2 階段 phase-2,Proposer X 將它選定的值寫入到 Acceptor 中,這個值可能是它自己要寫入的值,或者是它從某個 Acceptor 上讀到的 v(修復)。
func (p *Proposer) Phase2(
acceptorIds []int64,
quorum int) (*BallotNum, error) {
replies := p.rpcToAll(acceptorIds, "Accept")
ok := 0
higherBal := *p.Bal
for _, r := range replies {
if !p.Bal.GE(r.LastBal) {
higherBal = *r.LastBal
continue
}
ok += 1
if ok == quorum {
return nil, nil
}
}
return &higherBal, NotEnoughQuorum
}
我們看到,它只需要確認成 Phase2 的功應答數量達到 quorum 就可以了。另外同樣它也有責任在 Phase2 失敗時返回看到的更大的 ballot number,因為在 Phase1 和 Phase2 之間可能有其他 Proposer 使用更大的 ballot number 打斷了當前 Proposer 的執行,就像【slide-33】的衝突解決的例子中描述的那樣。
完整的 Paxos 邏輯
完整的 paxos 由 Proposer 負責,包括:如何選擇一個值,使得一致性得以保證。如【slide-29】中描述的:
Proposer X 收到多數(quorum)個應答,就認為是可以繼續執行的。如果沒有聯絡到多於半數的 acceptor,整個系統就 hang 住了,這也是 paxos 聲稱的只能執行少於半數的節點失效。這時 Proposer 面臨 2 種情況:所有應答中都沒有任何非空的 v,這表示系統之前是乾淨的,沒有任何值已經被其他 paxos 客戶端完成了寫入(因為一個多數派讀一定會看到一個多數派寫的結果),這時 Proposer X 繼續將它要寫的值在 phase-2 中真正寫入到多於半數的 Acceptor 中。如果收到了某個應答包含被寫入的 v 和 vrnd,這時,Proposer X 必須假設有其他客戶端(Proposer)正在執行,雖然 X 不知道對方是否已經成功結束,但任何已經寫入的值都不能被修改!所以 X 必須保持原有的值。於是 X 將看到的最大 vrnd 對應的 v 作為 X 的 phase-2 將要寫入的值。這時實際上可以認為 X 執行了一次(不知是否已經中斷的)其他客戶端(Proposer)的修復。
基於 Acceptor 的服務端和 Proposer 2 個 Phase 的實現,最後把這些環節組合到一起組成一個完整的 paxos,在我們的程式碼【RunPaxos】這個函式中完成這些事情:
func (p *Proposer) RunPaxos(
acceptorIds []int64,
val *Value) *Value {
quorum := len(acceptorIds)/2 + 1
for {
p.Val = val
maxVotedVal, higherBal, err := p.Phase1(
acceptorIds, quorum)
if err != nil {
p.Bal.N = higherBal.N + 1
continue
}
if maxVotedVal != nil {
p.Val = maxVotedVal
}
// val == nil 是一個讀操作,
// 沒有讀到voted值不需要Phase2
if p.Val == nil {
return nil
}
higherBal, err = p.Phase2(
acceptorIds, quorum)
if err != nil {
p.Bal.N = higherBal.N + 1
continue
}
return p.Val
}
}
這段程式碼完成了幾件事:執行 Phase1,有 voted 的值就選它,沒有就選自己要寫的值 val,然後執行 Phase2。
就像 Phase1 Phase2 中描述的一樣,任何一個階段,如果沒達到 quorum,就需要提升遇到的更大的 ballot number,重試去解決遇到的 ballot number 衝突。這個函式接受 2 個引數:
- 所有 Acceptor 的列表(用一個整數的 id 表示一個 Acceptor),
- 以及要提交的值。
其中,按照 paxos 的描述,這個值 val 不一定能提交:如果 paxos 在 Phase1 完成後看到了其他已經接受的值(voted value),那就要選擇已接收的值,放棄 val。遇到這種情況,在我們的系統中,例如要寫入 key=foo,ver=3 的值為 bar,如果沒能選擇 bar,就要選擇下一個版本 key=foo,ver=4 再嘗試寫入。這樣不斷的重試迴圈, 寫操作最終都能成功寫入一個值(voted value)。
實現讀操作
在我們這個 NB(naive and basic)的系統中,讀和寫一樣都要通過一次 paxos 演算法來完成。因為寫入過程就是一次 paxos 執行,而 paxos 只保證在一個 quorum 中寫入確定的值,不保證所有節點都有這個值。因此一次讀操作如果要讀到最後寫入的值,至少要進行一次多數派讀。
但多數派讀還不夠:它可能讀到一個未完成的 paxos 寫入,如【slide-11】中描述的髒讀問題,讀取到的最大 VBal 的值,可能不是確定的值(寫入到多數派)。
例如下面的狀態:
Val=foo Val=bar ?
VBal=3 VBal=2 ?
------- ------- --
A0 A1 A2
如果 Proposer 試圖讀,在 Phase1 聯絡到 A0 A1 這 2 個 Acceptor,那麼 foo 和 bar 這 2 個值哪個是確定下來的,要取決於 A2 的狀態。所以這時要再把最大VBal 的值跑完一次 Phase2,讓它被確定下來,然後才能把結果返回給上層(否則另一個 Proposer 可能聯絡到 A1 和 A2,然後認為 Val=bar 是被確定的值)。
當然如果 Proposer 在讀取流程的 Phase1 成功後沒有看到任何已經 voted 的值(例如沒有看到 foo 或 bar), 就不用跑 Phase2 了。
所以在這個版本的實現中,讀操作也是一次【RunPaxos】函式的呼叫,除了它並不 propose 任何新的值,為了支援讀操作,所以在上面的程式碼中 Phase2 之前加入一個判斷,如果傳入的 val 和已 voted 的值都為空,則直接返回:
if p.Val == nil {
return nil
}
【Example_setAndGetByKeyVer】這個測試用例展示瞭如何使用 paxos 實現一個 kv 儲存,實現讀和寫的程式碼大概這樣:
prop := Proposer{
Id: &PaxosInstanceId{
Key: "foo",
Ver: 0,
},
Bal: &BallotNum{N: 0, ProposerId: 2},
}
// 寫:
v := prop.RunPaxos(acceptorIds, &Value{Vi64: 5})
// 讀:
v := prop.RunPaxos(acceptorIds, nil)
到現在為止,本文中涉及到的功能都實現完了,完整實現在【impl.go】中。
接著我們用測試用例實現 1 下【paxos的直觀解釋】中列出的 2 個例子, 從程式碼看 poxos 的執行:
文中例子
第1個例子是 paxos 無衝突的執行【slide-32】:
把它寫成 test case,確認教程中每步操作之後的結果都如預期 【TestCase1SingleProposer】:
func TestCase1SingleProposer(t *testing.T) {
ta := require.New(t)
acceptorIds := []int64{0, 1, 2}
quorum := 2
// 啟動3個Acceptor的服務
servers := ServeAcceptors(acceptorIds)
defer func() {
for _, s := range servers {
s.Stop()
}
}()
// 用要更新的key和version定義paxos 例項的id
paxosId := &PaxosInstanceId{
Key: "i",
Ver: 0,
}
var val int64 = 10
// 定義Proposer, 隨便選個Proposer id 10.
var pidx int64 = 10
px := Proposer{
Id: paxosId,
Bal: &BallotNum{N: 0, ProposerId: pidx},
}
// 用左邊2個Acceptor執行Phase1,
// 成功, 沒有看到其他的ballot number
latestVal, higherBal, err := px.Phase1(
[]int64{0, 1}, quorum)
ta.Nil(err, "constitued a quorum")
ta.Nil(higherBal, "no other proposer is seen")
ta.Nil(latestVal, "no voted value")
// Phase1成功後, 因為沒有看到其他voted的值,
// Proposer選擇它自己的值進行後面的Phase2
px.Val = &Value{Vi64: val}
// Phase 2
higherBal, err = px.Phase2(
[]int64{0, 1}, quorum)
ta.Nil(err, "constitued a quorum")
ta.Nil(higherBal, "no other proposer is seen")
}
第 2 個例子對應 2 個 Proposer 遇到衝突並解決衝突的例子,略長不貼在文中了,程式碼可以在 【TestCase2DoubleProposer】看到。
工程
Paxos 的出色之處在於它將分散式一致性問題簡化到最核心的部分,沒有任何多餘的設計。
工程實現上我們多數時候會用一個 paxos 的變體,它需要對 paxos 中的例項擴充套件為一系列多值的操作日誌,支援完整的狀態機,以及對運維提供支援成員變更,所以 raft 在工程上更受歡迎:
https://github.com/datafuselabs/openraft
建立 openraft 這個專案的目的是:
- 優化和改良 raft 演算法本身的問題:
-
- 例如一個 term 內無法選出多個 leader,造成選舉衝突過多的問題,
- 例如不必要的 pre-vote 階段的引入,
- 例如 raft 作為一個一致性演算法對外部時鐘的依賴,
- 例如強制的 leader/candidate 階段的拆分使得換 leader 要經歷一個無法服務的 candidate-state 的階段。
openraft 正在解決的這些問題,使之不僅僅是一個為了效能和安全用 rust 重寫的專案。
- 其次在使用者介面上,提供一組語義明確的 async API。
參考連結
本文用到的程式碼在 paxoskv 專案的 naive 分支上:
【https://github.com/openacid/paxoskv/tree/naive】
- 【paxos made simple】:http://lamport.azurewebsites.net/pubs/pubs.html#paxos-simple
- 【Leslie Lamport】:http://www.lamport.org/
- 【protobuf】:https://developers.google.com/protocol-buffers
- 【install-protoc】:https://grpc.io/docs/protoc-installation/
- 【grpc】:https://grpc.io/
- 【paxos的直觀解釋】:https://blog.openacid.com/algo/paxos
- 【issue】:https://github.com/openacid/paxoskv/issues/new/choose
- 【paxoskv】:https://github.com/openacid/paxoskv/tree/naive
- 【TestCase1SinglePropose】:https://github.com/openacid/paxoskv/blob/naive/paxoskv/paxos_slides_case_test.go#L11
- 【TestCase2DoubleProposer】:https://github.com/openacid/paxoskv/blob/naive/paxoskv/paxos_slides_case_test.go#L57
- 【Example_setAndGetByKeyVer】:https://github.com/openacid/paxoskv/blob/naive/paxoskv/example_set_get_test.go
- 【Openraft】: https://github.com/datafuselabs/openraft
關於 Databend
Databend 是一款開源、彈性、低成本,基於物件儲存也可以做實時分析的新式數倉。期待您的關注,一起探索雲原生數倉解決方案,打造新一代開源 Data Cloud。
-
Databend 文件:https://databend.rs/
-
Wechat:Databend
文章首發於公眾號:Databend