MIT 6.824 Lab3 RaftKV

Raft 博士论文的翻译

实验内容

在lab2的Raft函数库之上,搭建一个能够容错的key/value存储服务,需要提供强一致性保证。

强一致性的解释如下:对于单个请求,整个服务需要表现得像个单机服务,并且对状态机的修改基于之前所有的请求。对于并发的请求,返回的值和最终的状态必须相同,就好像所有请求都是串行的一样。即使有些请求发生在了同一时间,那么也应当一个一个响应。此外,在一个请求被执行之前,这之前的请求都必须已经被完成(在技术上我们也叫着线性化(linearizability))。

kv服务支持三种操作:Put, Append, Get。通过在内存维护一个简单的键/值对数据库,键和值都是字符串;

整体架构

image-20211123154210343

image-20211204184921567

Part A - 不需要日志压缩的key/value服务

Clerk客户端实现

客户端实现比较简单

需要解决的问题:客户端发送请求,服务端同步成功并且提交,接着apply后返回给客户端执行结果,但返回客户端时候rpc丢失,客户端只能进行重试直到明确地写入成功或失败为止,但该操作可能已经在服务端应用过了,违背了线性一致性。

clientId和commandId来唯一的标识一个客户端,从而保证线性一致性。RAFT原文介绍需要保证日志仅被执行一次,即它可以被 commit 多次,但一定只能 apply 一次。

可以看一下:

链接:https://www.zhihu.com/question/278551592/answer/400962941

在Raft论文的6.3节,这个问题有详细讨论。

用普通后台术语就是幂等。Raft作者把这归为实现linearizable semantics所需要处理的一部分。Raft论文里,也给出了具体的通用解决办法。基本思路是:

  • 每个要做proposal的client需要一个唯一的identifier,它的每个不同proposal需要有一个顺序递增的序列号,client id和这个序列号由此可以唯一确定一个不同的proposal,从而使得各个raft节点可以记录保存各proposal应用以后的结果。
  • 当一个proposal超时,client不提高proposal的序列号,使用原proposal序列号重试。
  • 当一个proposal被成功提交并应用且被成功回复给client以后,client顺序提高proposal的序列号,并记录下收到的成功回复的proposal的序列号。raft节点收到一个proposal请求以后,得到请求中夹带的这个最大成功回复的proposal的序列号,它和它之前所有的应用结果都可以删去。proposal序列号和client id可用于判断这个proposal是否应用过,如果已经应用过,则不再再次应用,直接返回已保存的结果。等于是每个不同的proposal可以被commit多次,在log中出现多次,但永远只会被apply一次。
  • 系统维护一定数量允许的client数量,比如可以用LRU策略淘汰。请求过来了,而client已经被LRU淘汰掉了,则让client直接fail掉。
  • 这些已经注册的client信息,包括和这些client配套的上述proposal结果、各序列号等等,需要在raft组内一致的维护。也就是说,上述各raft端数据结构和它们的操作实际是state machine的一部分。在做snapshotting的时候,它们同样需要被保存与恢复。

可能感觉让这样重试的request被commit多次有奇怪。其实不奇怪,实际操作中,它们对状态机而言是个NO-OP。原文中的原话也清楚列明这些log entry会在raft log中重复出现,由状态机来负责过滤掉,状态机能看到接触到的自然是commit以后的log entry。

The Raft log provides a serial order in which commands are applied on every server. Commands take effect instantaneously and exactly once according to their first appearance in the Raft log, since any subsequent appearances are filtered out by the state machines as described above.

不是所有的应用都需要这样的功能。最直接的例子就是membership change本身。membership change的时候,比如有一个node的id是XYZ,因为超时你试图去再次提交一个membership remove操作,再次去删除这个id为XYZ的节点,它并不带来实际损害(很多raft库不允许一个已经被删除的节点再次以相同node id加入回来)。

还需要注意的是:请求服务端超时或请求的服务端不为主节点时,能尝试连接其他服务端。

参考清华大佬代码结构实现,重构了PutAndAppend函数

type Clerk struct {
	servers []*labrpc.ClientEnd
	// You will have to modify this struct.
	leaderId int64
	clientId int64
	commandId int64

}

func nrand() int64 {
	max := big.NewInt(int64(1) << 62)
	bigx, _ := rand.Int(rand.Reader, max)
	x := bigx.Int64()
	return x
}

func MakeClerk(servers []*labrpc.ClientEnd) *Clerk {
	ck := new(Clerk)
	ck.servers = servers
	// You'll have to add code here.
	ck.leaderId = 0
	ck.clientId = nrand()
	ck.commandId = 0
	return ck
}

//
// fetch the current value for a key.
// returns "" if the key does not exist.
// keeps trying forever in the face of all other errors.
//
// you can send an RPC with code like this:
// ok := ck.servers[i].Call("KVServer.Get", &args, &reply)
//
// the types of args and reply (including whether they are pointers)
// must match the declared types of the RPC handler function's
// arguments. and reply must be passed as a pointer.
//
func (ck *Clerk) Get(key string) string {
	// You will have to modify this function.
	return ck.Command(&CommandArgs{Key : key, Op: OpGet})
}

func (ck *Clerk) Put(key string, value string) {
	ck.Command(&CommandArgs{Key : key, Value : value, Op: OpPut})
	//ck.PutAppend(key, value, "Put")
}
func (ck *Clerk) Append(key string, value string) {
	ck.Command(&CommandArgs{Key : key, Value : value, Op: OpAppend})
	//ck.PutAppend(key, value, "Append")
}

//
// shared by Get, Put and Append.
//
// you can send an RPC with code like this:
// ok := ck.servers[i].Call("KVServer.Command", &args, &reply)
//
// the types of args and reply (including whether they are pointers)
// must match the declared types of the RPC handler function's
// arguments. and reply must be passed as a pointer.
//
//func (ck *Clerk) PutAppend(key string, value string, op string) {
//	// You will have to modify this function.
//}
func (ck *Clerk) Command(args *CommandArgs) string {
	args.CommandId, args.ClientId = ck.commandId, ck.clientId
	DPrintf("command is %v", args)
	for{
		var reply CommandReply
		if !ck.servers[ck.leaderId].Call("KVServer.Command", args, &reply) || reply.Err == ErrTimeout || reply.Err == ErrWrongLeader {
			ck.leaderId = (ck.leaderId + 1) % int64(len(ck.servers))
			continue
		}
		ck.commandId++
		return reply.Value
	}
}

const Debug = false

func DPrintf(format string, a ...interface{}) (n int, err error) {
	if Debug {
		log.Printf(format, a...)
	}
	return
}
type OperationContext struct {
	MaxAppliedCommandId int64
	LastReply       *CommandReply
}
type Err uint8
const (
	OK Err = iota
	ErrNoKey
	ErrWrongLeader
	ErrTimeout
)
func (err Err) String() string {
	switch err {
	case OK:
		return "Ok"
	case ErrNoKey:
		return "ErrNoKey"
	case ErrWrongLeader:
		return "ErrWrongLeader"
	case ErrTimeout:
		return "ErrTimeout"
	}
	// 手动触发宕机
	panic(fmt.Sprintf("unexpected Err %d", err))
}

type OperationOp uint8
const (
	OpGet OperationOp = iota
	OpPut
	OpAppend
)
func (op OperationOp) String() string {
	switch op {
	case OpPut:
		return "OpPut"
	case OpAppend:
		return "OpAppend"
	case OpGet:
		return "OpGet"
	}
	panic(fmt.Sprintf("unexpected OperationOp %d", op))
}

type Command struct {
	*CommandArgs
}

type CommandArgs struct{
	Key string
	Value string
	Op OperationOp
	ClientId int64
	CommandId int64
}

func (request CommandArgs) String() string {
	return fmt.Sprintf("{Key:%v,Value:%v,Op:%v,ClientId:%v,CommandId:%v}", request.Key, request.Value, request.Op, request.ClientId, request.CommandId)
}

type CommandReply struct{
	Err Err
	Value string
}

func (response CommandReply) String() string {
	return fmt.Sprintf("{Err:%v,Value:%v}", response.Err, response.Value)
}

Server服务器实现

一图胜千言

image-20211204184921567

server结构体与初始化代码实现:

  1. 一个存储kv的map,即状态机,但这里实现一个基于内存版本KV即可的,但实际生产环境下必然不可能把数据全部存在内存当中,系统往往采用的是 LSM 的架构,例如 RocksDB 等,抽象成KVStateMachine 的接口。
  2. 一个能记录某一个客户端最后一次操作序号和应用结果的map
  3. 一个能记录每个raft同步操作结果的map
type KVServer struct {
	mu      sync.RWMutex
	me      int
	rf      *raft.Raft
	applyCh chan raft.ApplyMsg
	dead    int32 // set by Kill()

	maxraftstate int // snapshot if log grows this big

	// Your definitions here.
	lastApplied    int                        //
	stateMachine   KVStateMachine             // 服务器数据存储(key,value)
	lastOperations map[int64]OperationContext // 客户端id最后的命令id和回复内容 (clientId,{最后的commdId,最后的LastReply})
	notifyChans    map[int]chan *CommandReply // Leader回复给客户端的响应(日志Index, CommandReply)
}
//
// servers[] contains the ports of the set of
// servers that will cooperate via Raft to
// form the fault-tolerant key/value service.
// me is the index of the current server in servers[].
// the k/v server should store snapshots through the underlying Raft
// implementation, which should call persister.SaveStateAndSnapshot() to
// atomically save the Raft state along with the snapshot.
// the k/v server should snapshot when Raft's saved state exceeds maxraftstate bytes,
// in order to allow Raft to garbage-collect its log. if maxraftstate is -1,
// you don't need to snapshot.
// StartKVServer() must return quickly, so it should start goroutines
// for any long-running work.
//
func StartKVServer(servers []*labrpc.ClientEnd, me int, persister *raft.Persister, maxraftstate int) *KVServer {
	// call labgob.Register on structures you want
	// Go's RPC library to marshall/unmarshall.

	labgob.Register(Command{})

	kv := new(KVServer)
	kv.me = me
	kv.maxraftstate = maxraftstate

	// You may need initialization code here.
	kv.applyCh = make(chan raft.ApplyMsg)
	kv.rf = raft.Make(servers, me, persister, kv.applyCh)
	kv.dead = 0
	kv.lastApplied = 0
	kv.stateMachine = NewMemoryKV()
	kv.lastOperations = make(map[int64]OperationContext)
	kv.notifyChans = make(map[int]chan *CommandReply)

	// You may need initialization code here.
	go kv.applier()

	DPrintf("{Node %v} has started", kv.rf.Me())
	return kv
}

状态机抽象类:

type KVStateMachine interface {
	Get(key string) (string, Err)
	Put(key, value string) Err
	Append(key, value string) Err
}

type MemoryKV struct {
	KV map[string]string
}

func NewMemoryKV() *MemoryKV {
	return &MemoryKV{make(map[string]string)}
}

func (memoryKV *MemoryKV) Get(key string) (string, Err) {
	if value, ok := memoryKV.KV[key]; ok {
		return value, OK
	}
	return "", ErrNoKey
}

func (memoryKV *MemoryKV) Put(key, value string) Err {
	memoryKV.KV[key] = value
	return OK
}

func (memoryKV *MemoryKV) Append(key, value string) Err {
	memoryKV.KV[key] += value
	return OK
}

kv.applier协程:单独开一个go routine来远程监视apply channel,一旦底层的Raft commit一个到apply channel,状态机就立马执行且通过commandIndex通知到该客户端的NotifyChan,Command函数取消阻塞返回给客户端。

kv.applier协程主要实现要点:

  1. raft同步完成后,也需要判断请求是否为重复请求。因为同一请求可能由于重试会被同步多次。
  2. 当要通过channel返回操作结果时,需判断当前节点为主才返回操作结果,否则返回WrongLeader。
  3. 为了保证强一致性,仅对当前 term 日志的 notifyChan 进行通知,让之前 term 的客户端协程都超时重试。避免leader 降级为 follower 后又迅速重新当选了 leader,而此时依然有客户端协程未超时在阻塞等待,那么此时 apply 日志后,根据 index 获得 channel 并向其中 push 执行结果就可能出错,因为可能并不对应。
  4. 在目前的实现中,读(Get)请求也会生成一条 raft 日志去同步,最简单粗暴的方式保证线性一致性,即LogRead方法。但是,这样子实现的读性能会相当的差,实际生产级别的 raft 读请求实现一般都采用了 Read Index 或者 Lease Read 的方式,具体原理可以参考此博客,具体实现可以参照 SOFAJRaft 的实现博客
func (kv *KVServer) applier() {
	for kv.killed() == false {
		select {
		case message := <-kv.applyCh:
			if message.CommandValid {
				kv.mu.Lock()
				if message.CommandIndex <= kv.lastApplied {
					kv.mu.Unlock()
					continue
				}
				kv.lastApplied = message.CommandIndex

				var reply *CommandReply
				command := message.Command.(Command)
				if command.Op != OpGet && kv.isDuplicateRequest(command.ClientId, command.CommandId) {
					reply = kv.lastOperations[command.ClientId].LastReply
				} else {
					reply = kv.applyLogToStateMachine(command)
					if command.Op != OpGet {
						kv.lastOperations[command.ClientId] = OperationContext{command.CommandId, reply}
					}
				}
				if currentTerm, isLeader := kv.rf.GetState(); isLeader && message.CommandTerm == currentTerm {
					ch := kv.getNotifyChan(message.CommandIndex)
					ch <- reply
				}
				kv.mu.Unlock()
			} else {
				panic(fmt.Sprintf("unexpected Message %v", message))
			}

		}
	}
}
func (kv *KVServer) getNotifyChan(index int) chan *CommandReply {
	if _, ok := kv.notifyChans[index]; !ok {
		kv.notifyChans[index] = make(chan *CommandReply, 1)
	}
	return kv.notifyChans[index]
}
//每个RPC都意味着客户端已经看到了它之前的RPC的回复。
//因此,我们只需要判断一个clientId的最新commandId是否满足条件
func (kv *KVServer) isDuplicateRequest(clientId int64, commandId int64) bool {
	operationContext, ok := kv.lastOperations[clientId]
	return ok && commandId <= operationContext.MaxAppliedCommandId
}

leader 比 follower 多出一个 notifyChan 环节,是因为 leader 需要处理 rpc 请求响应,而 follower 不用,一个很简单的流程其实就是 client -> kvservice -> Start() -> applyCh -> kvservice -> client,但是applyCh是逐个 commit 一个一个返回,所以需要明确返回的 commit 对应的是哪一个请求,即通过 commitIndex唯一确定一个请求,然后通知该请求执行流程可以返回了。

对于读请求,由于其不影响系统状态,所以直接去状态机执行即可,当然,其结果也不需要再记录到去重的数据结构中。

// 处理并返回客户端结果
func (kv *KVServer) Command(args *CommandArgs, reply *CommandReply) {
	defer DPrintf("{Node %v} processes CommandRequest %v with CommandResponse %v", kv.rf.Me(), args, reply)
	kv.mu.RLock()
	if args.Op != OpGet && kv.isDuplicateRequest(args.ClientId, args.CommandId) {
		lastReply := kv.lastOperations[args.ClientId].LastReply
		reply.Err, reply.Value = lastReply.Err, lastReply.Value
		kv.mu.RUnlock()
		return
	}
	kv.mu.RUnlock()
	index, _, isLeader := kv.rf.Start(Command{args})
	if !isLeader {
		reply.Err = ErrWrongLeader
		return
	}

	kv.mu.Lock()
	ch := kv.getNotifyChan(index)
	kv.mu.Unlock()
	select {
	case res := <-ch:
		reply.Err, reply.Value = res.Err, res.Value
	case <-time.After(ExecuteTimeout):
		reply.Err = ErrTimeout
	}
	go func() {
		kv.mu.Lock()
		delete(kv.notifyChans, index)
		kv.mu.Unlock()
	}()
}



func (kv *KVServer) applyLogToStateMachine(command Command) *CommandReply {
	var value string
	var err Err
	switch command.Op {
	case OpGet:
		value, err = kv.stateMachine.Get(command.Key)
	case OpPut:
		err = kv.stateMachine.Put(command.Key, command.Value)
	case OpAppend:
		err = kv.stateMachine.Append(command.Key, command.Value)
	}
	return &CommandReply{err, value}
}

测试

image-20211205152416859

Part B - 包含日志压缩的key/value服务

将服务端的一些重要变量编码后生成snapshot,然后通知raft进行日志压缩。

所需要持久化的状态由:

  • 状态机kv键值对stateMachine
  • 去重的 lastOperations 哈希表

有两种情况的持久化,分为主动和被动

  • 主动:这里我的实现是,从applyCh中每次拿到 msg 之后,如果是CommandValid,则说明raft 的状态增加了,主动检测一次是否达到了maxraftstate,是则主动调用Snapshot
  • 被动:从applyCh中拿到的是SnapshotValid,则证明是 leader 发来的安装快照信息,主动调用CondInstallSnapshot,并根据返回的 ok 确认是否安装该快照
func (kv *KVServer) applier() {
	for kv.killed() == false {
		select {
		case message := <-kv.applyCh:
			if message.CommandValid {
				kv.mu.Lock()
				if message.CommandIndex <= kv.lastApplied {
					kv.mu.Unlock()
					continue
				}
				kv.lastApplied = message.CommandIndex

				var reply *CommandReply
				command := message.Command.(Command)
				if command.Op != OpGet && kv.isDuplicateRequest(command.ClientId, command.CommandId) {
					reply = kv.lastOperations[command.ClientId].LastReply
				} else {
					reply = kv.applyLogToStateMachine(command)
					if command.Op != OpGet {
						kv.lastOperations[command.ClientId] = OperationContext{command.CommandId, reply}
					}
				}
				if currentTerm, isLeader := kv.rf.GetState(); isLeader && message.CommandTerm == currentTerm {
					ch := kv.getNotifyChan(message.CommandIndex)
					ch <- reply
				}
				needSnapshot := kv.needSnapshot()
				if needSnapshot {
					kv.takeSnapshot(message.CommandIndex)
				}
				kv.mu.Unlock()
			}else if message.SnapshotValid {
				kv.mu.Lock()
				if kv.rf.CondInstallSnapshot(message.SnapshotTerm, message.SnapshotIndex, message.Snapshot) {
					kv.restoreSnapshot(message.Snapshot)
					kv.lastApplied = message.SnapshotIndex
				}
				kv.mu.Unlock()
			} else {
				panic(fmt.Sprintf("unexpected Message %v", message))
			}

		}
	}
}
// 持久化
func (kv *KVServer) restoreSnapshot(snapshot []byte) {
	if snapshot == nil || len(snapshot) == 0 {
		return
	}
	r := bytes.NewBuffer(snapshot)
	d := labgob.NewDecoder(r)
	var stateMachine MemoryKV
	var lastOperations map[int64]OperationContext
	if d.Decode(&stateMachine) != nil ||
		d.Decode(&lastOperations) != nil {
	}
	kv.stateMachine, kv.lastOperations = &stateMachine, lastOperations
}
func (kv *KVServer) needSnapshot() bool {
	return kv.maxraftstate != -1 && kv.rf.GetRaftStateSize() >= kv.maxraftstate
}

func (kv *KVServer) takeSnapshot(index int) {
	w := new(bytes.Buffer)
	e := labgob.NewEncoder(w)
	e.Encode(kv.stateMachine)
	e.Encode(kv.lastOperations)
	kv.rf.Snapshot(index, w.Bytes())
}

func StartKVServer(servers []*labrpc.ClientEnd, me int, persister *raft.Persister, maxraftstate int) *KVServer {
	// call labgob.Register on structures you want
	// Go's RPC library to marshall/unmarshall.

	labgob.Register(Command{})

	kv := new(KVServer)
	kv.me = me
	kv.maxraftstate = maxraftstate

	// You may need initialization code here.
	kv.applyCh = make(chan raft.ApplyMsg)
	kv.rf = raft.Make(servers, me, persister, kv.applyCh)
	kv.dead = 0
	kv.lastApplied = 0
	kv.stateMachine = NewMemoryKV()
	kv.lastOperations = make(map[int64]OperationContext)
	kv.notifyChans = make(map[int]chan *CommandReply)

	// You may need initialization code here.
	kv.restoreSnapshot(persister.ReadSnapshot())
	go kv.applier()

	DPrintf("{Node %v} has started", kv.rf.Me())
	return kv
}

image-20211205154609831

posted @ 2021-11-23 22:30  pxlsdz  阅读(1646)  评论(0编辑  收藏  举报