【分布式系统设计简卷(2)】KV Raft

前言

  • 本篇是关于 2022-MIT 6.828 的关于 Fault-tolerant Key/Value Service 的实验记录;
  • 如果发现内容上的纰漏,请不要吝啬您的键盘。

一点点的背景知识

In this lab you will build a fault-tolerant key/value storage service using your Raft library from lab 2. Your key/value service will be a replicated state machine, consisting of several key/value servers that use Raft for replication. Your key/value service should continue to process client requests as long as a majority of the servers are alive and can communicate, in spite of other failures or network partitions. After Lab3, you will have implemented all parts (Clerk, Service, and Raft) shown in the diagram of Raft interactions.

The service supports three operations: Put(key, value), Append(key, arg), and Get(key). It maintains a simple database of key/value pairs. Keys and values are strings. Each client talks to the service through a Clerk with Put/Append/Get methods. A Clerk manages RPC interactions with the servers.

Your service must provide strong consistency to application calls to the Clerk Get/Put/Append methods. Here's what we mean by strong consistency. If called one at a time, the Get/Put/Append methods should act as if the system had only one copy of its state, and each call should observe the modifications to the state implied by the preceding sequence of calls. For concurrent calls, the return values and final state must be the same as if the operations had executed one at a time in some order. Calls are concurrent if they overlap in time, for example if client X calls Clerk.Put(), then client Y calls Clerk.Append(), and then client X's call returns. Furthermore, a call must observe the effects of all calls that have completed before the call starts (so we are technically asking for linearizability). Strong consistency is convenient for applications because it means that, informally, all clients see the same state and they all see the latest state.


Lab 3 是要实现通过 Clerk 对象去代理用户 Client 来完成与 KV Server 的交互的三个接口,分别是 Put(), Append()Get()。而这些接口的 KV Server 端的实现就是要依赖我们先前在 Lab 2 完成的 Raft 库提供的 Replication 来实现 fault-tolerance 的。而 Client 和 KV Server 的接口通信是利用两种 RPC Request 来实现的,分别是 PutAppend()Get()。接口 Put()Append() 将会复用同一个 PutAppend() RPC。

最后说一下我自己对线性一致性的理解。比如 KV Server 的状态机现在有 (K: x, V: 0)。此时有客户端 C1 发送 Put(x, 7),客户端 C2 发送 Get(x), Put(x, 42)。线性一致性就认为这三个请求分别都是在某个瞬间完成的,当然实际不可能瞬间完成,只是从结果上看像是瞬间完成的而已。不同客户端之间由于是完全并行的,请求的瞬间可以随意,但同一个客户端的请求是串行的,前一个请求瞬间必须在后一个请求瞬间之前。如果系统未满足线性一致性,客户端 C2 可能会拿到 (K: x, V: 7),并且状态机是 (K: x,V: 7) 的结果。在线性一致性的情况下,这个结果无论怎么排列组合这三个请求都是无法出现的(注:Get(x) 一定会在 Put(x, 42) 之前完成)。这个结果只能是 KV Server 1 响应了 Put(x, 7) 提交并应用到状态机之后在返回给客户端之前挂掉了,之后 RPC 超时之后客户端 C1 向 KV Server 2 重试发送该请求,但这个请求是在 KV Server 2 处理完 Put(x, 42) 之后响应的,因此这个错误的结果就产生了。

了解完了基本情况之后,如果你是刚刚完成了 Lab 2,就可以趁热看测试代码和实现部分开始上手写了。Lab 3 的难度相对于 Lab 2 要简单很多。

实验伪代码

  1. 自己的实现没有用到 Raft.Start() 返回的 Index,因为将客户端请求和 ApplyCh 出来的 Command 对应起来的是 ClientID 和 SequenceNumber。这是为了保证线性一致性需要去除重复响应的请求,而 (ClientID, SequenceNumber) 二元组能唯一确定一个请求。ClientID 由客户端自己抛一个 64 位的随机数决定,这个实现已经足够好来通过测试了,因为两个 ClientID 撞车的概率为 1/10^64 基本可以认为不可能,但真实的生产环境肯定不会用这种毫无健壮性的实现。SequenceNumber 是同一个 Client 的请求的序列号,当前请求成功响应后 Client 的序列号自增一,否则还是用原来的序列号发送同一个请求。
  2. 同一个 Client 同一时间只会发起一个请求,不同请求间是严格串行执行的,如果这个请求失败 Client 还会不断重试,所以 KV Server 端用一个从 ClientID 映射到“请求结果”的 HashMap 来进行结果的返回和查重。
  3. 模仿 Lab 2 测试中 config.goappliersnap() go routine 去接收来自 applyCh 的 ApplyMsg,并应用到状态机,最后写到对应 ClientID 的请求结果中去。
  4. 伪代码将省略获取和释放锁的时机。

结构体及常量定义

/* client.go */

type Clerk struct {
    servers []*labrpc.ClientEnd
    // You will have to modify this struct.
    serverHint  int
    clientID    int64
    sequenceNum int // log sequence number
}

/* server.go */

const (
    PUT    = 1
    APPEND = 2
    GET    = 3
    //CheckTime = 1 // best result
    CheckTime = 2 // better enough
    // CheckTime = 3 // a little bit slow
)


type Op struct {
    // Your definitions here.
    // Field names must start with capital letters,
    // otherwise RPC will break.
    T           int
    K           string
    V           string
    ClientID    int64
    SequenceNum int
}

type ApplyResult struct {
    E           Err
    V           string
    SequenceNum int
}

type KVServer struct {
    mu      sync.Mutex
    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.
    StateMachine map[string]string
    resultMap    map[int64]ApplyResult
    persister    *raft.Persister
}

// 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.
    ClientID    int64
    SequenceNum int
}

/* common.go */

type PutAppendReply struct {
    E Err
}

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

type GetReply struct {
    E     Err
    Value string
}
  • KV Server 端被 Client 请求拉起的 RPC 线程是通过类似在 Lab 2 的定时器一样去用半轮询的方式来查看请求消息有没有被处理好的,轮询间隔设为 2 毫秒已经足够好了,因为我的 Raft 库通常同步一个日志从 Start() 下传开始到 applyCh 出来平均 3 毫秒左右,性能也可以。当然你也可以使用 channel 来实现效果一样。

客户端

/* Client end pseudo code */

func (ck *Clerk) Get(key string) string {
    // You will have to modify this function.
    Create & config argument and reply

    Send 'Get' RPC request

    for reply.E != OK && reply.E != ErrNoKey {
        // try again
        Redirect to other server
        Send 'Get' RPC request
    }

    ck.sequenceNum += 1
    return reply.Value
}


func (ck *Clerk) PutAppend(key string, value string, op string) {
    // You will have to modify this function.
    Create & config argument and reply
    
    Send 'PutAppend' PRC request

    for reply.E != OK {
        // try again
        Redirect to other server
        Send 'PutAppend' RPC request
    }

    ck.sequenceNum += 1
}

服务端

/* Server end pseudo code */

func (kv *KVServer) applier() {
    for kv.killed() == false {
        m := <-kv.applyCh
        if m.SnapshotValid {
            Decode StateMachine & ResultMap from the Snapshot
        } else if m.CommandValid {
            kv.applyMsg(&m)
        } else {
            Do nothing
        }
    }
}

func (kv *KVServer) applyMsg(m *raft.ApplyMsg) {
    op := m.Command.(Op)

    if kv.resultMap[op.ClientID].SequenceNum == op.SequenceNum {
        return
    }

    Result := Apply op into StateMachine
    Record the Result into corresponding ResultMap
    
    if kv.maxraftstate != -1 && kv.maxraftstate <= kv.persister.RaftStateSize() {
        Encode StateMachine & ResultMap into Snapshot
        Call Raft Snapshot
    }
}

func (kv *KVServer) Get(args *GetArgs, reply *GetReply) {
    if args.SequenceNum != kv.resultMap[args.ClientID].SequenceNum {
        Create & config Op
        
        kv.rf.Start(op)

        _, term, isLeader := kv.rf.Start(op)
        if !isLeader {
            return
        }

        for args.SequenceNum != kv.resultMap[args.ClientID].SequenceNum {
            time.Sleep(CheckTime * time.Millisecond)

            currentTerm, _ := kv.rf.GetState()
            if currentTerm != term {
                return
            }
        }
    }
}

/* PutAppend 和 Get 几乎一致 */

后记

我自己 Lab 3 的测试卡住的原因全是因为 Raft 库里的问题,但让我惊讶的竟然这些问题并没有在 Lab 2 的测试中暴露出来,因为我当时是每个部分的重要的测试都测了 3000 遍以上。一个是 Snapshot 中的一个 typo,但就这个 typo 找了一下午,属于是一生之敌了……。另一个是 Leader 计算 CommitIndex 的代码 out of Log range 了。简单 Debug 了之后都没问题了。

【分布式系统设计简卷(2)】KV Raft_第1张图片

Lab 3 要求整个测试的 real time 不超过 400 秒,CPU time 不超过 700 秒,TestSnapshotSize 的 real time 不超过 20 秒。可以看到均达到了要求,并且最后一个测试跑了 3000 遍没有一个挂掉,Lab 3 结束,应该是目前来说最简单的实验吧(

内心 OS:

听群友说 Lab 4 会比 Lab 2 还难,我直接???

(咕,鲨了我罢)

你可能感兴趣的:(raft分布式系统学习笔记)