2022-6.824-Lab3:KVRaft

0. 準備工作

lab 地址:https://pdos.csail.mit.edu/6.824/labs/lab-kvraft.html
github 地址:https://github.com/lawliet9712/MIT-6.824

Lab3 需要基於之前實現的 Lab2 Raft 來實現一個可靠的 Key-Value Database,分別對 Client 和 Server 進行實現,Client 主要通過 3 種操作對 DB 進行操作:

  • Put :對指定 Key 直接設置 Value
  • Append :對指定 Key 追加 Value
  • Get :獲取指定 Key 的 Value

其整體結構大致如下:

KVServer 除了提供 DB 的功能外,要保證可用性,一致性和安全性,因此要利用 Raft 來實現一個可靠的 KVServer。KVServer 以 5 個節點爲一組,每組 KVServer(在該 Lab 只會有一組 KVServer)會有一個 Leader(只有 Leader 纔會處理 Client RPC 請求),和四個 Follwer,每次收到 Client 端的 DB 操作時,需要將操作封裝成 Command 後通過 Raft.Start 接口投遞到 Raft 層,然後 Raft 層將操作通過基本的心跳同步給 Follwer 以增加安全性和可靠性。

1. Part A: Key/value service without snapshots (moderate/hard)

PartA 主要提供沒有快照的鍵值服務,Client 將 Put,Append,和 Get RPC 發送到其關聯的 Raft 是 Leader 的 KVServer。KVServer 代碼將 Put/Append/Get 操作提交給 Raft,這樣 Raft 日誌就會保存一系列 Put/Append/Get 操作。所有的 kvservers 按順序從 Raft 日誌執行操作,將操作應用到它們的鍵/值數據庫;目的是讓服務器維護鍵/值數據庫的相同副本。
Client 有時不知道哪個 KVServer 是 Raft leader 。如果 Client 將 RPC 發送到錯誤的 KVServer,或者無法到達 KVServer,Client 應該通過發送到不同的 kvserver 來重試。如果鍵/值服務將操作提交到其 Raft 日誌(並因此將操作應用於鍵/值狀態機),Leader 通過響應其 RPC 將結果報告給 Client 。如果操作提交失敗(例如,如果 Leader 被替換),服務器會報告錯誤,然後 Client 會嘗試使用不同的服務器。

1.1 分析

  1. 當我們收到請求時,我們應該先通過 Start 接口將請求的操作同步到各個節點,然後各個節點再根據請求具體的操作 (Put/Append/Get)來執行對應的邏輯,這樣就可以保證各個節點的一致性
    1. 這裏有個問題,爲什麼 Get 也需要執行 Start 同步,這是爲了保證客戶端操作的歷史一致性,假設有如下情況:


假設嚴格按照編號時序執行,理論上 Client-B 應該 Get 到 A 的 Put 操作結果,但是由於 Put 需要通過 Start 去同步給其他節點,同步成功後(超過半數節點順利同步),纔會返回給 KVServer,KVServer 才能返回給 Client-A,而如果 Get 操作不需要 Start,直接返回,就有可能導致歷史一致性錯誤。

  1. 需要注意 Hint:
    :::info
    After calling Start(), your kvservers will need to wait for Raft to complete agreement. Commands that have been agreed upon arrive on the applyCh. Your code will need to keep reading applyCh while PutAppend() and Get() handlers submit commands to the Raft log using Start(). Beware of deadlock between the kvserver and its Raft library.
    :::
    在 Start 過後需要等待 Start 結果,收到結果後才能返回給 Client 操作是否成功(這裏單個 Client 的操作不會併發,只有上一個操作結束了纔會繼續執行下一個)

  2. 由於 Start 操作是異步的,該操作無法保證成功(Leader 可能會 Change),因此在等待的過程中,需要設定一個等待超時時間,超時後需要檢查當前是否還是 Leader,如果還是 Leader 則繼續等待。

  3. 需要增加一個 Client Request Unique Id,或者說一個 Sequence Id,以保證 Client 的操作不會被重複執行。

基於上述分析,可以得到 KVServer 處理 Request 的大致流程:

而需要有另一處地方去處理 Start 成功後,Apply Channel 返回的成功的 ApplyMsg,這裏選擇另起一個 processMsg 的 goroutine。

1.2 實現

1.2.1 各個 RPC 請求參數設計

如下,主要新增了 2 個參數,一個 SeqId,用來防止請求重複執行,另一個 ClerkId 用來標識唯一的客戶端 UID,這裏 Put/Append 在 Lab 中被合併爲一個請求。

// Put or Append
type PutAppendArgs struct {
	Key   string
	Value string
	Op    string // "Put" or "Append"
	// You'll have to add definitions here.
	// Field names must start with capital letters,
	// otherwise RPC will break.
	ClerkId int64
	SeqId int
}

type PutAppendReply struct {
	Err Err
}

type GetArgs struct {
	Key string
	// You'll have to add definitions here.
	ClerkId int64
	SeqId int
}

type GetReply struct {
	Err   Err
	Value string
}

1.2.2 Op 結構設計

這個 Op 結構主要封裝需要用到的信息後投遞給 Raft 層,用來同步使用。

type Op struct {
	// Your definitions here.
	// Field names must start with capital letters,
	// otherwise RPC will break.
	Key         string
	Value       string
	Command     string
	ClerkId     int64
	SeqId       int
	Server      int // 基本無用
}

1.2.3 Clerk 結構

這裏爲了方便,爲每個 Client 在 Server 端增加了一個結構,大致如下:

type ClerkOps struct {
	seqId       int // clerk current seq id
	getCh       chan Op
	putAppendCh chan Op
	msgUniqueId int // rpc waiting msg uid
}

1.2.4 Client 請求實現

Client 請求實現很簡單,基本模板就是 for loop 直到請求完成就退出。以 PutAppend 爲例。

func (ck *Clerk) PutAppend(key string, value string, op string) {
    // You will have to modify this function.
    args := PutAppendArgs{
        Key:     key,
        Value:   value,
        Op:      op,
        ClerkId: ck.ckId,
        SeqId:   ck.allocSeqId(),
    }
    reply := PutAppendReply{}
    server := ck.leaderId
    for {
        DPrintf("[Clerk-%d] call [PutAppend] request key=%s value=%s op=%s, seq=%d, server=%d", ck.ckId, key, value, op, args.SeqId, server%len(ck.servers))
        ok := ck.SendPutAppend(server%len(ck.servers), &args, &reply)
        if ok {
            if reply.Err == ErrWrongLeader {
                server += 1
                time.Sleep(50 * time.Millisecond)
                DPrintf("[Clerk-%d] call [PutAppend] faild, try next server id =%d ... retry args=%v", ck.ckId, server, args)
                continue
            }
            ck.leaderId = server
            DPrintf("[Clerk-%d] call [PutAppend] response server=%d, ... reply = %v, args=%v", ck.ckId, server%len(ck.servers), reply, args)
            break
        } else {
            // Send Request failed ... retry
            server += 1
            DPrintf("[Clerk][PutAppend] %d faild, call result=false, try next server id =%d ... retry reply=%v", ck.ckId, server, reply)
        }
        time.Sleep(50 * time.Millisecond)
    }
}

這裏有一點特殊的是,每次請求成功後,會記錄一下請求成功的 ServerId 作爲 LeaderId,後續請求就直接請求該 Id 的 Server,這樣可以減少一些無效請求。

1.2.5 Server 處理 RPC

1.2.5.1 Put/Append

RPC 處理主要分爲兩部分,第一部分收集必要的信息,投遞到 Raft 層同步,第二部分等待同步結果並返回客戶端。

func (kv *KVServer) PutAppend(args *PutAppendArgs, reply *PutAppendReply) {
	// Your code here.
	// step 1 : start a command, wait raft to commit command
	// not found then init
	// check msg
	kv.mu.Lock()
	ck := kv.GetCk(args.ClerkId)
	// already process
	if ck.seqId > args.SeqId {
		kv.mu.Unlock()
		reply.Err = OK
		return
	}
	DPrintf("[KVServer-%d] Received Req PutAppend %v, SeqId=%d ", kv.me, args, args.SeqId)
	// start a command
	logIndex, _, isLeader := kv.rf.Start(Op{
		Key:     args.Key,
		Value:   args.Value,
		Command: args.Op,
		ClerkId: args.ClerkId,
		SeqId:   args.SeqId,
		Server:  kv.me,
	})
	if !isLeader {
		reply.Err = ErrWrongLeader
		kv.mu.Unlock()
		return
	}

	ck.msgUniqueId = logIndex
	DPrintf("[KVServer-%d] Received Req PutAppend %v, waiting logIndex=%d", kv.me, args, logIndex)
	kv.mu.Unlock()
	// step 2 : wait the channel
	reply.Err = OK
	Msg, err := kv.WaitApplyMsgByCh(ck.putAppendCh, ck)
	kv.mu.Lock()
	defer kv.mu.Unlock()
	DPrintf("[KVServer-%d] Recived Msg [PutAppend] from ck.putAppendCh args=%v, SeqId=%d, Msg=%v", kv.me, args, args.SeqId, Msg)
	reply.Err = err
	if err != OK {
		DPrintf("[KVServer-%d] leader change args=%v, SeqId=%d", kv.me, args, args.SeqId)
		return
	}
}

這裏主要需要關注下等待部分,等待部分的思路是:

  1. 每個 Client 有個 Clerk 結構,其有兩個關鍵字段:MsgUniqueId 和對應請求的 Channel(getCh/putAppendCh),處理 RPC 時先設置這個 Clerk.MsgUniqueId 爲 Start 後返回的 LogIndex
  2. 隨後 Read 對應操作的 Channel 進行阻塞等待。
  3. 在處理 Raft 層返回的結果時(在一個單獨的 goroutinue 中執行),檢查 Clerk.MsgUniqueId 是否與這個 ApplyMsg 的 CommandIndex 相等且當前節點爲 Leader,是就表示需要通知。投遞消息到 Clerk 的 Channel

其 Wait 和 Notify 的 Code 如下:

func (kv *KVServer) WaitApplyMsgByCh(ch chan Op, ck *ClerkOps) (Op, Err) {
	startTerm, _ := kv.rf.GetState()
	timer := time.NewTimer(1000 * time.Millisecond)
	for {
		select {
		case Msg := <-ch:
			return Msg, OK
		case <-timer.C:
			curTerm, isLeader := kv.rf.GetState()
			if curTerm != startTerm || !isLeader {
				kv.mu.Lock()
				ck.msgUniqueId = 0
				kv.mu.Unlock()
				return Op{}, ErrWrongLeader
			}
			timer.Reset(1000 * time.Millisecond)
		}
	}
}

func (kv *KVServer) NotifyApplyMsgByCh(ch chan Op, Msg Op) {
	// we wait 200ms 
	// if notify timeout, then we ignore, because client probably send request to anthor server
	timer := time.NewTimer(200 * time.Millisecond)
	select {
	case ch <- Msg:
		return
	case <-timer.C:
		DPrintf("[KVServer-%d] NotifyApplyMsgByCh Msg=%v, timeout", kv.me, Msg)
		return
	}
}

1.2.5.2 Get

Get 操作與前者類似,不過多複述

func (kv *KVServer) Get(args *GetArgs, reply *GetReply) {
	// Your code here.
	// step 1 : start a command, check kv is leader, wait raft to commit command
	//_, isLeader := kv.rf.GetState()
	// check msg
	kv.mu.Lock()
	ck := kv.GetCk(args.ClerkId)
	DPrintf("[KVServer-%d] Received Req Get %v", kv.me, args)
	// start a command
	logIndex, _, isLeader := kv.rf.Start(Op{
		Key:     args.Key,
		Command: "Get",
		ClerkId: args.ClerkId,
		SeqId:   args.SeqId,
		Server:  kv.me,
	})
	if !isLeader {
		reply.Err = ErrWrongLeader
		ck.msgUniqueId = 0
		kv.mu.Unlock()
		return
	}
	DPrintf("[KVServer-%d] Received Req Get %v, waiting logIndex=%d", kv.me, args, logIndex)
	ck.msgUniqueId = logIndex
	kv.mu.Unlock()
	// step 2 : parse op struct
	getMsg, err := kv.WaitApplyMsgByCh(ck.getCh, ck)
	kv.mu.Lock()
	defer kv.mu.Unlock()
	DPrintf("[KVServer-%d] Received Msg [Get] 	 args=%v, SeqId=%d, Msg=%v", kv.me, args, args.SeqId, getMsg)
	reply.Err = err
	if err != OK {
		// leadership change, return ErrWrongLeader
		return
	}

	_, foundData := kv.dataSource[getMsg.Key]
	if !foundData {
		reply.Err = ErrNoKey
		return
	} else {
		reply.Value = kv.dataSource[getMsg.Key]
		DPrintf("[KVServer-%d] Excute Get %s is %s", kv.me, getMsg.Key, reply.Value)
	}
}

1.2.6 處理 Raft 層消息

該結構的主要邏輯與分析類似,無限循環去 Read Apply Channel,根據 Sequence Id 判斷是否需要執行操作,根據 CommandIndex 的對比判斷是否有等待通知的 Channel。

func (kv *KVServer) processMsg() {
	for {
		applyMsg := <-kv.applyCh
		if applyMsg.SnapshotValid {
			kv.readKVState(applyMsg.Snapshot)
			continue
		}
		Msg := applyMsg.Command.(Op)
		DPrintf("[KVServer-%d] Received Msg from channel. Msg=%v", kv.me, applyMsg)

		kv.mu.Lock()
		ck := kv.GetCk(Msg.ClerkId)
		// not now process this log
		if Msg.SeqId > ck.seqId {
			DPrintf("[KVServer-%d] Ignore Msg %v, Msg.Index > ck.index=%d", kv.me, applyMsg, ck.seqId)
			kv.mu.Unlock()
			continue
		}

		// check need snapshot or not
		_, isLeader := kv.rf.GetState()
		if kv.needSnapshot() {
			DPrintf("[KVServer-%d] size=%d, maxsize=%d, DoSnapshot %v", kv.me, kv.persister.RaftStateSize(), kv.maxraftstate, applyMsg)
			kv.saveKVState(applyMsg.CommandIndex - 1)
		}

		// check need notify or not
		needNotify := ck.msgUniqueId == applyMsg.CommandIndex
		//DPrintf("[KVServer-%d] msg=%v, isleader=%v, ck=%v", kv.me, Msg, ck)
		if Msg.Server == kv.me && isLeader && needNotify {
			// notify channel and reset timestamp
			ck.msgUniqueId = 0
			DPrintf("[KVServer-%d] Process Msg %v finish, ready send to ck.Ch, SeqId=%d isLeader=%v", kv.me, applyMsg, ck.seqId, isLeader)
			kv.NotifyApplyMsgByCh(ck.GetCh(Msg.Command), Msg)
			DPrintf("[KVServer-%d] Process Msg %v Send to Rpc handler finish SeqId=%d isLeader=%v", kv.me, applyMsg, ck.seqId, isLeader)
		}

		if Msg.SeqId < ck.seqId {
			DPrintf("[KVServer-%d] Ignore Msg %v,  Msg.SeqId < ck.seqId", kv.me, applyMsg)
			kv.mu.Unlock()
			continue
		}

		switch Msg.Command {
		case "Put":
			kv.dataSource[Msg.Key] = Msg.Value
			DPrintf("[KVServer-%d] Excute CkId=%d Put Msg=%v, kvdata=%v", kv.me, Msg.ClerkId, applyMsg, kv.dataSource)
		case "Append":
			DPrintf("[KVServer-%d] Excute CkId=%d Append Msg=%v kvdata=%v", kv.me, Msg.ClerkId, applyMsg, kv.dataSource)
			kv.dataSource[Msg.Key] += Msg.Value
		case "Get":
			DPrintf("[KVServer-%d] Excute CkId=%d Get Msg=%v kvdata=%v", kv.me, Msg.ClerkId, applyMsg, kv.dataSource)
		}
		ck.seqId = Msg.SeqId + 1
		kv.mu.Unlock()
	}
}

1.3 注意點

  1. 對比 2021 的 6.824,這裏主要新增了一個 TestSpeed3A 的 Test Case,該 Case 在最開始的情況下很難 Pass,因爲他要求 30ms 完成一個 Op,而 Raft 的心跳是 100ms 一次,如果直接去調整 Raft 心跳時長感覺也不太合適,最開始的時候,是直接在 Start 的時候直接發起心跳,但是這樣在後續多客戶端同步的時候,會有很多 重複 Commit,因此做了一次調整,直接 Reset Raft 層的心跳爲 10ms,這樣如果有多客戶端同時請求,可以合批到一次心跳。其次調整了 ApplyChannel 的大小爲 1000,測試的時候發現有很多 Goroutine 卡在 Msg->ApplyChannel 上,因此通過調整該 Channel 的緩衝大小,提高消費速率。
  2. 在測試最開始的時候,一直以爲 Raft 層不會重複 Commit 同一條 Log,後面調試打印日誌時才發現有很多重複的 Log,重複的 Log 會影響消費效率,主要原因如下:
  1. 同一個 Req 重發 + leader 重新選舉導致有重複 op
  2. 原來發心跳後,每同步成功一次(AppendEntries)就會檢查能不能提交,假如有 5 個 server 都同步成功,那麼 3,4,5 都會提交一次,raft 沒有禁止重複提交
  3. Start 後會馬上執行發心跳(爲了 TestSpeed),有可能出現處理速度過快,兩次 start 的 log 發了 2 次心跳,然後第一次還沒提交(rf.lastApplied 未設置),後者也同步成功了,然後兩次心跳都會執行一次提交,導致重複提交,因此後面調整成 Reset 心跳時長爲 10ms。

1.4 測試結果

➜  kvraft git:(main) go test -race -run 3A && go test -race -run 3B
Test: one client (3A) ...
labgob warning: Decoding into a non-default variable/field Err may not work
  ... Passed --  15.2  5  2904  572
Test: ops complete fast enough (3A) ...
  ... Passed --  30.8  3  3283    0
Test: many clients (3A) ...
  ... Passed --  15.7  5  2843  850
Test: unreliable net, many clients (3A) ...
  ... Passed --  17.3  5  3128  576
Test: concurrent append to same key, unreliable (3A) ...
  ... Passed --   1.6  3   152   52
Test: progress in majority (3A) ...
  ... Passed --   0.8  5    76    2
Test: no progress in minority (3A) ...
  ... Passed --   1.0  5    98    3
Test: completion after heal (3A) ...
  ... Passed --   1.2  5    66    3
Test: partitions, one client (3A) ...
  ... Passed --  22.9  5  2625  321
Test: partitions, many clients (3A) ...
  ... Passed --  23.3  5  4342  691
Test: restarts, one client (3A) ...
  ... Passed --  19.7  5  3229  564
Test: restarts, many clients (3A) ...
  ... Passed --  21.3  5  4701  815
Test: unreliable net, restarts, many clients (3A) ...
  ... Passed --  21.1  5  3670  611
Test: restarts, partitions, many clients (3A) ...
  ... Passed --  27.2  5  3856  601
Test: unreliable net, restarts, partitions, many clients (3A) ...
  ... Passed --  29.1  5  3427  384
Test: unreliable net, restarts, partitions, random keys, many clients (3A) ...
  ... Passed --  33.4  7  7834  498
PASS
ok      6.824/kvraft    283.329s

2. Part B: Key/value service with snapshots (hard)

該 Part 主要在前者的基礎上,實現 Snapshot 的功能,通過檢查 maxraftstate 檢查是否需要 Snapshot,其實感覺該 Part 相對反而更簡單一些。

2.1 分析

主要需要注意如下的點:

  1. Snapshot 的 Test case 主要檢查 Raft 層的 Log 是否超標了,因此 DoSnapshot 的操作放在了 processMsg 接口中判斷,主要做兩個事情,一個是檢查 ApplyMsg 是否爲 SnapshotMsg,另一個是判斷當前 persister.RaftStateSize 是否超標了(大於 maxraftstate
  2. 當從 ApplyChannel 中 Read 出來的 Msg 爲 Snapshot 時,就直接採用 Snapshot 中的數據覆蓋原生的數據即可
  3. 需要考慮將一些狀態持久化,比如 Clerk 的 SeqId,防止重複執行。

2.2 實現

2.2.1 保存狀態

這裏主要需要注意持久化當前 KVServer 的狀態,主要爲 DB 數據和 Client 的 SeqId

func (kv *KVServer) saveKVState(index int) {
    w := new(bytes.Buffer)
    e := labgob.NewEncoder(w)
    cks := make(map[int64]int)
    for ckId, ck := range kv.messageMap {
        cks[ckId] = ck.seqId
    }
    e.Encode(cks)
    e.Encode(kv.dataSource)
    kv.rf.Snapshot(index, w.Bytes())
    DPrintf("[KVServer-%d] Size=%d", kv.me, kv.persister.RaftStateSize())
}

2.2.2 檢查是否需要 Snapshot

這裏 /4 主要還是看到了 test case 中計算 size 時也做了除法,因此模擬了下。

func (kv *KVServer) needSnapshot() bool {
    return kv.persister.RaftStateSize()/4 >= kv.maxraftstate && kv.maxraftstate != -1
}

2.2.3 讀取快照

讀取快照的時機只有兩處:

  • processMsg 讀到了 Snapshot Msg
  • 啓動初始化 KVServer
func (kv *KVServer) readKVState(data []byte) {
    if data == nil || len(data) < 1 { // bootstrap without any state?
        return
    }

    DPrintf("[KVServer-%d] read size=%d", kv.me, len(data))
    r := bytes.NewBuffer(data)
    d := labgob.NewDecoder(r)
    cks := make(map[int64]int)
    dataSource := make(map[string]string)
    //var commitIndex int
    if d.Decode(&cks) != nil ||
    d.Decode(&dataSource) != nil {
            DPrintf("[readKVState] decode failed ...")
        } else {
            for ckId, seqId := range cks {
            kv.mu.Lock()
            ck := kv.GetCk(ckId)
            ck.seqId = seqId
            kv.mu.Unlock()
        }
            kv.mu.Lock()
            kv.dataSource = dataSource
            DPrintf("[KVServer-%d] readKVState messageMap=%v dataSource=%v", kv.me, kv.messageMap, kv.dataSource)
            kv.mu.Unlock()
        }
}

2.3 注意點

  1. 注意 KVServer 的 Lock 儘量不要將 Raft 層的一些有鎖的接口(比如 GetState)給包裹起來,否則可能會出現死鎖(其實這點在 3A 中也需要注意)

2.4 測試結果

Test: InstallSnapshot RPC (3B) ...
labgob warning: Decoding into a non-default variable/field Err may not work
  ... Passed --   3.1  3   255   63
Test: snapshot size is reasonable (3B) ...
  ... Passed --  13.4  3  2411  800
Test: ops complete fast enough (3B) ...
  ... Passed --  17.0  3  3262    0
Test: restarts, snapshots, one client (3B) ...
  ... Passed --  19.2  5  4655  788
Test: restarts, snapshots, many clients (3B) ...
  ... Passed --  20.7  5  9280 4560
Test: unreliable net, snapshots, many clients (3B) ...
  ... Passed --  17.6  5  3376  651
Test: unreliable net, restarts, snapshots, many clients (3B) ...
  ... Passed --  21.5  5  3945  697
Test: unreliable net, restarts, partitions, snapshots, many clients (3B) ...
  ... Passed --  28.3  5  3213  387
Test: unreliable net, restarts, partitions, snapshots, random keys, many clients (3B) ...
  ... Passed --  31.4  7  7083  878
PASS
ok      6.824/kvraft    173.344s

3. 小結

對比 lab2,這裏新增了一些調試的方式:

  1. 新增了一個 healthCheck 的 Goroutine,當目標數據超過一定時間不更新時,直接 panic 檢查所有 goroutine 的堆棧,需要在 go test 的最前面加上 GOTRACEBACK=1
  2. 後面 go test 加上 test.timeout 30s ,如果在指定時間內沒有 pass,則會退出 test 並打印堆棧

上述的方式對於查詢死鎖等問題非常有效。

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