raft理論與實踐[6]-lab3a-基於raft構建分佈式容錯kv服務

準備工作

前言

  • 在之前的文章中,我們實現了raft算法的基本框架

  • 在本實驗中,我們將基於raft算法實現分佈式容錯的kv服務器

  • 客戶端用於交互raft服務器

  • kvraft/client.go文件用於書寫我們的客戶端代碼,調用Clerk的Get/Put/Append方法爲系統提供強一致性的保證

  • 這裏的強一致性指的是,如果我們一個一個的調用(而不是併發)Clerk的Get/Put/Append方法,那麼我們的系統就好像是隻有一個raft服務器存在一樣,並且調用是序列的,即後面的調用比前面的調用後執行

  • 對於併發調用,最終狀態可能難以預料,但是必須與這些方法按某種順序序列化後執行一次的結果相同

  • 如果調用在時間上重疊,則這些調用是併發的。例如,如果客戶端X調用Clerk.Put(),同時客戶端Y調用Clerk.Append()

  • 同時,後面的方法在執行之前,必須保證已經觀察到前面所有方法執行後的狀態(技術上叫做線性化(linearizability))

  • 強一致性保證對應用程序很方便,因爲這意味着所有客戶端都看到相同的最新狀態

  • 對於單個服務器,強一致性相對簡單。多臺的副本服務器卻相對困難,因爲所有服務器必須爲併發請求選擇相同的執行順序,並且必須避免使用最新狀態來回復客戶端

本服務實現的功能

  • 本服務支持3種基本的操作,Put(key, value)Append(key, arg), and Get(key)

  • 維護着一個簡單的鍵/值對數據庫

  • Put(key, value)將數據庫中特定key的值綁定爲value

  • Append(key, arg)添加,將arg與key對應。如果key的值不存在,則其行爲類似於Put

  • Get(key) 獲取當前key的值

  • 在本實驗中,我們將實現服務具體的功能,而不必擔心Raft log日誌會無限增長

實驗思路

  • 對lab2中的raft服務器架構進行封裝,封裝上一些數據庫、數據庫快照、並會處理log的具體執行邏輯。

  • 對於數據庫執行的Get/Put/Append方法都對其進行序列化並放入到lab2 raft的體系中,這樣就能保證這些方法的一致性

獲取源代碼

  • 假設讀者已經閱讀了準備工作中的一系列文章

  • 在此基礎上我們增加了本實驗的基本框架kvraft文件以及linearizability文件

  • 讀者需要在kvraft文件夾中,實驗本實驗的具體功能

  • 獲取實驗代碼如下

git clone [email protected]:dreamerjackson/golang-deep-distributed-lab.git
git reset --hard   d345b34bc

客戶端

  • Clerk結構體存儲了所有raft服務器的客戶端servers []*labrpc.ClientEnd,因此我們可以通過Clerk結構體與所有raft服務器通信

  • 我們需要爲Clerk結構體實現Put(key, value)Append(key, arg)Get(key)方法

  • Clerk結構體是我們連接raft服務器的橋樑

  • 注意Clerk必須將方法發送到當前的leader節點中,由於其可能並不會知道哪一個節點爲leader,因此需要重試。但是記住保存上一個leader的id會加快這一過程,因爲leader在穩定的系統裏面是不會變的。

  • 客戶端必須要等到此操作不僅爲commit,而且已經被完全應用後,才能夠返回,這才能夠保證下次get操作能夠得到最新的

  • 需要注意的是,如果raft服務器出現了分區,可能會陷入一直等待,直到分區消失

補充Clerk

  • leader記錄最後一個leader的序號

  • seq 記錄rpc的序號

  • id記錄客戶端的唯一id

type Clerk struct {
    ...
    leader int   // remember last leader
    seq    int   // RPC sequence number
    id     int64 // client id
}

補充Get方法

  • Get方法會遍歷訪問每一個raft服務,直到找到leader

  • 調用時會陷入堵塞,等待rpc方法返回

  • 設置有超時時間,一旦超時,會重新發送

  • 爲了保證Get方法到的數據是準確最新的,也必須要將其加入到raft算法中

  • 客戶端必須要等到此操作不僅爲commit,而且已經被完全應用後,才能夠返回,這才能夠保證下次get操作能夠得到最新的。

func (ck *Clerk) Get(key string) string {
    DPrintf("Clerk: Get: %q\n", key)
    cnt := len(ck.servers)
    for {
        args := &GetArgs{Key: key, ClientID: ck.id, SeqNo: ck.seq}
        reply := new(GetReply)

        ck.leader %= cnt
        done := make(chan bool, 1)
        go func() {
            ok := ck.servers[ck.leader].Call("KVServer.Get", args, reply)
            done <- ok
        }()
        select {
        case <-time.After(200 * time.Millisecond): // rpc timeout: 200ms
            ck.leader++
            continue
        case ok := <-done:
            if ok && !reply.WrongLeader {
                ck.seq++
                if reply.Err == OK {
                    return reply.Value
                }
                return ""
            }
            ck.leader++
        }
    }

    return ""
}

補充Append和Put方法

  • 調用同一個PutAppend方法,但是最後一個參數用於標識具體的操作

func (ck *Clerk) Put(key string, value string) {
    ck.PutAppend(key, value, "Put")
}
func (ck *Clerk) Append(key string, value string) {
    ck.PutAppend(key, value, "Append")
}
  • 和Get方法相似,遍歷訪問每一個raft服務,直到找到leader

  • 調用時會陷入堵塞,等待rpc方法返回

  • 設置有超時時間,一旦超時,會重新發送

  • 客戶端必須要等到此操作不僅爲commit,而且已經被完全應用後,才能夠返回,這才能夠保證下次get操作能夠得到最新的。

func (ck *Clerk) PutAppend(key string, value string, op string) {
    // You will have to modify this function.
    DPrintf("Clerk: PutAppend: %q => (%q,%q) from: %d\n", op, key, value, ck.id)
    cnt := len(ck.servers)
    for {
        args := &PutAppendArgs{Key: key, Value: value, Op: op, ClientID: ck.id, SeqNo: ck.seq}
        reply := new(PutAppendReply)

        ck.leader %= cnt
        done := make(chan bool, 1)
        go func() {
            ok := ck.servers[ck.leader].Call("KVServer.PutAppend", args, reply)
            done <- ok
        }()
        select {
        case <-time.After(200 * time.Millisecond): // rpc timeout: 200ms
            ck.leader++
            continue
        case ok := <-done:
            if ok && !reply.WrongLeader && reply.Err == OK {
                ck.seq++
                return
            }
            ck.leader++
        }
    }
}

Server

  • kvraft/server.go文件用於書寫我們的客戶端代碼

  • KVServer結構是對於之前書寫的raft架構的封裝

  • applyCh chan raft.ApplyMsg 用於狀態虛擬機應用coommit log,執行操作

  • db map[string]string 是模擬的一個數據庫

  • notifyChs map[int]chan struct{} commandID => notify chan 狀態虛擬機應用此command後,會通知此通道

  • duplicate map[int64]*LatestReply 檢測重複請求

type KVServer struct {
    ...
    rf      *raft.Raft
    applyCh chan raft.ApplyMsg
    // Your definitions here.
    persist       *raft.Persister
    db            map[string]string
    notifyChs     map[int]chan struct{} // per log
    // duplication detection table
    duplicate map[int64]*LatestReply
}

完成PutAppend、Get方法

  • 下面以PutAppend爲例,Get方法類似

  • 檢測當前是否leader狀態

  • 檢測是否重複請求

  • 將此command通過rf.Start(cmd) 放入raft中

  • select等待直到ch被激活,即command index被此kv服務器應用

  • ch被激活後,需要再次檢測當前節點是否爲leader

    • 如果不是,說明leader更換,立即返回錯誤,這時由於如果不再是leader,那麼雖然此kv服務器應用了此command index,但不一定是相同的command

    • 這個時候會堵塞直到序號爲commandIndex的命令被應用,但是,如果leader更換,此commandIndex的命令不一定就是我們的當前的命令

    • 但是完全有可能新的leader已經應用了此狀態,我們這時候雖然仍然返回錯誤,希望客戶端重試,這是由於操作是冪等的並且重複操作無影響。

    • 優化方案是爲command指定一個唯一的標識,這樣就能夠明確此特定操作是否被應用


func (kv *KVServer) PutAppend(args *PutAppendArgs, reply *PutAppendReply) {
    // Your code here.
    // not leader
    if _, isLeader := kv.rf.GetState(); !isLeader {
        reply.WrongLeader = true
        reply.Err = ""
        return
    }

    DPrintf("[%d]: leader %d receive rpc: PutAppend(%q => (%q,%q), (%d-%d).\n", kv.me, kv.me,
        args.Op, args.Key, args.Value, args.ClientID, args.SeqNo)

    kv.mu.Lock()
    // duplicate put/append request
    if dup, ok := kv.duplicate[args.ClientID]; ok {
        // filter duplicate
        if args.SeqNo <= dup.Seq {
            kv.mu.Unlock()
            reply.WrongLeader = false
            reply.Err = OK
            return
        }
    }

    // new request
    cmd := Op{Key: args.Key, Value: args.Value, Op: args.Op, ClientID: args.ClientID, SeqNo: args.SeqNo}
    index, term, _ := kv.rf.Start(cmd)
    ch := make(chan struct{})
    kv.notifyChs[index] = ch
    kv.mu.Unlock()

    reply.WrongLeader = false
    reply.Err = OK

    // wait for Raft to complete agreement
    select {
    case <-ch:
        // lose leadership
        curTerm, isLeader := kv.rf.GetState()
        if !isLeader || term != curTerm {
            reply.WrongLeader = true
            reply.Err = ""
            return
        }
    case <-kv.shutdownCh:
        return
    }
}

完成對於log的應用操作

  • &lt;-kv.applyCh 是當log成爲commit狀態時,狀態機對於log的應用操作

  • 本系列構建的爲kv-raft服務,根據不同的服務其應用操作的方式不同

  • 下面的操作是簡單的操作內存map數據庫

  • 同時,將最後一個操作記錄下來,避免同一個log應用了兩次。

func (kv *KVServer) applyDaemon() {
    for {
        select {
        case msg, ok := <-kv.applyCh:
            if ok {
                // have client's request? must filter duplicate command
                if msg.Command != nil {
                    cmd := msg.Command.(Op)
                    kv.mu.Lock()
                    if dup, ok := kv.duplicate[cmd.ClientID]; !ok || dup.Seq < cmd.SeqNo {
                        switch cmd.Op {
                        case "Get":
                            kv.duplicate[cmd.ClientID] = &LatestReply{Seq: cmd.SeqNo,
                                Reply: GetReply{Value: kv.db[cmd.Key],}}
                        case "Put":
                            kv.db[cmd.Key] = cmd.Value
                            kv.duplicate[cmd.ClientID] = &LatestReply{Seq: cmd.SeqNo,}
                        case "Append":
                            kv.db[cmd.Key] += cmd.Value
                            kv.duplicate[cmd.ClientID] = &LatestReply{Seq: cmd.SeqNo,}
                        default:
                            DPrintf("[%d]: server %d receive invalid cmd: %v\n", kv.me, kv.me, cmd)
                            panic("invalid command operation")
                        }
                        if ok {
                            DPrintf("[%d]: server %d apply index: %d, cmd: %v (client: %d, dup seq: %d < %d)\n",
                                kv.me, kv.me, msg.CommandIndex, cmd, cmd.ClientID, dup.Seq, cmd.SeqNo)
                        }
                    }
                    // notify channel
                    if notifyCh, ok := kv.notifyChs[msg.CommandIndex]; ok && notifyCh != nil {
                        close(notifyCh)
                        delete(kv.notifyChs, msg.CommandIndex)
                    }
                    kv.mu.Unlock()
                }
            }
        }
    }
}

測試

> go test -v -run=3A
  • 注意,如果上面的測試出現錯誤也不一定是程序本身的問題,可能是單個進程運行多個測試程序帶來的影響

  • 同時,我們可以運行多次避免偶然的影響

  • 因此,如果出現了這種情況,我們可以爲單個測試程序獨立的運行n次,保證正確性,下面是每10個測試程序獨立運行,運行n次的腳本

rm -rf res
mkdir res
set int j = 0
for ((i = 0; i < 2; i++))
do
    for ((c = $((i*10)); c < $(( (i+1)*10)); c++))
    do
         (go test -v -run TestPersistPartitionUnreliableLinearizable3A) &> ./res/$c &
    done

    sleep 40

    if grep -nr "FAIL.*raft.*" res; then
        echo "fail"
    fi

done

總結

  • 在本實驗中,我們封裝了lab2a raft框架實現了容錯的kv服務

  • 如果出現了問題,需要仔細查看log,思考問題出現的原因

  • 下一個實驗中,我們將實現日誌的壓縮

參考資料


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章