MIT 6.824分布式 LAB2B:Raft

Lab 2B难度为hard,但是有了之前2A的经历,于我而言,2B的难度倒是远低于2A的难度。2B需要实现的功能就只是log replication。

在写2B的过程中,我还找到了之前2A写的一些bug,说实话,多线程的程序,有些bug真的很难发现,你会发现突然某次运行就有一个raft节点发生了死锁,没有任何响应了,大概率是因为锁设置的太多了,导致某处出现了死锁,同时此写这个lab对于我debug能力的提升也挺大。

完成过程中,又学到了很多写多线程的注意点。

注意:代码中的注释都是我用中式英语写的,大家可能会看不懂。还有就是之前Lab 2A中的部分代码还是有问题的,在此次实验中发现并修改了。

介绍

Lab 2B就是让我们实现log replication,这块功能是和投票、心跳机制紧密关联的,因此我们在实现这部分功能的时候肯定也会适当调整这些代码。

go test -race -run 2B
#这个就是用来进行Lab 2B的测试指令

注意事项

由于实验说明除了在2A处说了注意事项,就没说了,因此这里的注意事项,是我过程中踩的坑。

1、如果进入临界区之前需要同时获取多个锁,那么确保整个代码的其他部分用到这些锁的获取顺序是一致的,不然可能会出现死锁。

举例:需要查看并修改的变量涉及两个互斥锁,mutex1和mutex2。应该确保都是一直的获取顺序,例如先获取mutex1再获取mutex2

A:
mutex1.Lock()
defer mutex1.Unlock()
mutex2.Lock()
defer mutex2.Unlock()
Code...
​
​
​
​
B:
mutex2.Lock()
defer mutex2.Unlock()
mutex1.Lock()
defer mutex1.Unlock()
Code...

如果,goroutine A 先获取mutex1,goroutine B先获取了mutex2,然后就发生了死锁。

一般来说,这个地方知识大部分人都应该知道,但是有时候急了,进行复制粘贴进行代码复用的时候,一不注意就会导致上面的发生。

2、每个goroutine尽量只使用当前协程中的局部变量,若使用rf.nextIndex这种多协程共享的变量,那么该共享变量如果在协程外被改变了,多个协程之间的不确定性地执行,给这个变量带来不具有确定性的值,这大概率会带来bug。因此,goroutine如果需要使用共享变量,应该用一个局部变量将这个时刻的共享变量保存下来使用。

3、在进行RPC调用的时候,不要复用reply变量,每次进行RPC调用时都应该用新创建的reply变量来接受RPC的调用结果,不然在本实验中,你也将接受到一个警告---labgob warning: Decoding into a non-default variable/field %v may not work。

4、每个goroutine需要注意自身是否已经过时,例如:该协程是raft1作为leader发起的,但是过了一会儿raft1就已经不是Leader了,那么此时goroutine就已经过时了,不应该将完成的结果提交上去,而是选择结束例程。亦或是,一个最新的协程先提交了结果,将matchIndex[id]的数值更新为了n,此时过时的协程提交结果,想要将matchIndex[id]的数值更新为n-1就是不合理的。

5、在本实验中,我们使用sendAppendEntreis函数向followers进行log replication时,调用sendAppendEntreis函数可能会失败,因此需要设定失败重调的机制,确保能够成功向followers发出log。

6、在本实验中,可能followers收到的ae包,里面的Log为index:a-b,但是自身的已有的log的index:c

c>b,那么这种情况有以下几种可能:(1)这个ae包由于网络延迟而比后发的ae包都迟了,导致这个ae包已经过时了;(2)由于leader同时收到大量command,由于大量的Log replication协程是并发进行,可能负责index比较小的那个协程执行分配到的cpu资源很少,导致一直没执行,从而导致发出的ae包时,这个ae包已经过时了;(3)这个follower本身log中包含大量uncommit的无效log,因此需要把那些无效Log记录给删掉。

本次实验内容

1、继续完善Raft结构体内的信息,并设计Log的结构体。

2、实现Start()函数

3、继续完善AppendEntries()函数

4、继续完善heartBeats()函数

5、继续完善ticker()函数

6、继续完善Make()函数

代码阶段

继续完善Raft结构体,设计Log的结构体

首先,实验说明中可以知道raft节点对于每一个commit的log都需要把该log放到applyCh管道中,而这个管道在raft节点创建的时候作为Make函数中的参数给予的,因此仅需在结构体中添加一个变量来保存管道即可。

log结构体的设计,里面肯定要能保存Command,同时还需要保存这个log对应的index和term。同时Command的数据类型,可以看到ApplyMsg结构体中指定Command为Interface{},我们也就和它一样即可。

代码如下所示:

type LogEntry struct {
    Index   int
    Term    int
    Command interface{}
}
​
type Raft struct {
    mu        sync.Mutex          // Lock to protect shared access to this peer's state
    peers     []*labrpc.ClientEnd // RPC end points of all peers
    persister *Persister          // Object to hold this peer's persisted state
    me        int                 // this peer's index into peers[]
    dead      int32               // set by Kill()
​
    // Your data here (2A, 2B, 2C).
    // Look at the paper's Figure 2 for a description of what
    // state a Raft server must maintain.
    peerNum int
​
    // persistent state
    currentTerm int
    voteFor     int
    log         []LogEntry
​
    // volatile state
    commitIndex int
    lastApplied int
    // lastHeardTime time.Time
    state        string
    lastLogIndex int
    lastLogTerm  int
​
    // send each commited command to applyCh
    applyCh chan ApplyMsg
​
    // Candidate synchronize election with condition variable
    mesMutex    sync.Mutex // used to lock variable opSelect
    messageCond *sync.Cond
​
    // opSelect == 1 -> start election, opSelect == -1 -> stay still, opSelect == 2 -> be a leader, opSelect == 3 -> election timeout
    opSelect int
​
    // special state for leader
    nextIndex  []int
    matchIndex []int
}

实现Start()函数

Start()作为一个接口提供给外界,将指令发送给raft节点,请求其进行执行。下面讲解这个函数的执行流程:

  1. 函数收到一个command,首先判断自身是否为Leader,若不是则返回,若是继续。

  2. leader收到一个command后,需要将该命令保存到本地的log中,并更新matchIndex、nextIndex、lastLogIndex、lastLogTerm等变量。

  3. 调用多个goroutine来并发地给其余follower发起Log replication请求。在每个goroutine中,注意查看注意事项中的第二条。例如:本实验中,负责log replication的goroutine中,将rf.nextIndex[id]这个共享变量的值赋给了局部变量nextIndex;想要获取发起goroutine时rf.currentTerm的值,就去查看args.Term值即可。

  4. 负责id=n号follower节点的log replication的goroutine中,首先需要检查该n号raft节点的nextIndex,将nextIndex和index进行比较,index为最新来的Log的索引值,如果两者存在差值表明,n号Raft节点和leader之间差了不止index号这么一个Log值,我们需要将nextIndex+1~index之间的Log也需要打包到log数组中一块发送给follower。后续,leader将nextIndex指定的Index号的log包也添加到Log数组中。

  5. 这里leader将nextIndex位置的log继续打包到log数组中,然后通过sendAppendEntries RPC调用将log数组发送给Follower。该RPC调用返回值为true则表明Log同步成功,后续更新matchIndex[id]即可,更新的时候,注意查看注意事项中的第四条,协程想要matchIndex[id]更新为n,但是发现matchIndex[id]的数值大于n,这则表明该协程已经过时了,则不更新matchIndex[id]的值了。

若该RPC调用返回值为false,那么就要分情况来进行处理了:

reply.Term > args.Term:则表明该goroutine已经过时了,需要停止该goroutine并检查raft节点是否及时更新了Term值,若没有则将rf.currentTerm更新为reply.Term。

reply.Term <= args.Term:则表明,follower发现preLogTerm和preLogIndex并不匹配,需要leader发送更早的log。这种情况下,leader可以逐步一个个将前面的log加进去和follower进行确认是否匹配,但是一个个加log并询问follower的效率太低了,应该利用reply中的follower的term值,来加速定位,因为Follower中的log的term值一定都不会大于follower的term值,所以那些term> follower.term的log必然是follower缺失的。后续再尝试一个个加更早的log。直到follower返回true,表明找到了两者log相同的地方了,并将后续的log都同步上了。

  1. leader完成了给一个follower的同步后,进行matchIndex检查是否又有新的Log已经同步到大多数的节点上,这个检查是通过收集各个节点的matchIndex,对matchIndex的出现次数进行一个计数形成一个key-val的数组,key为matchIndex,value为处于这个matchIndex的节点数量,这个数组按照key从高到低的排序,然后遍历数组进行累加value的值,直到value累加的值刚好超过raft节点的半数,则这个key值即为此时的半数节点都已经同步了的最新的log的index值。

  2. 将这个index值和commitIndex进行比较,若大于则表明有新的log同步到半数节点上了,可以进行提交,更新commitIndex值,并将提交的指令发送到applyCh管道中以及更新lastApplied变量的值。

注意:leader每次更新commitIndex并提交执行新的log的时候,并不会立刻通知节点去提交,为了减轻网络流量压力,这个通知可以让周期的heartbeat去顺带通知。这里heartbeat通知follower更新commitIndex的细节将放在heartbeat函数那儿讲解。

代码如下:

//
// the service using Raft (e.g. a k/v server) wants to start
// agreement on the next command to be appended to Raft's log. if this
// server isn't the leader, returns false. otherwise start the
// agreement and return immediately. there is no guarantee that this
// command will ever be committed to the Raft log, since the leader
// may fail or lose an election. even if the Raft instance has been killed,
// this function should return gracefully.
//
// the first return value is the index that the command will appear at
// if it's ever committed. the second return value is the current
// term. the third return value is true if this server believes it is
// the leader.
//
func (rf *Raft) Start(command interface{}) (int, int, bool) {
    index := -1
    term := -1
    isLeader := true
    // Your code here (2B).
    _, isLeader = rf.GetState()
    if !isLeader {
        return index, term, isLeader
    }
    rf.mu.Lock()
    defer rf.mu.Unlock()
    rf.lastLogTerm = rf.currentTerm
    rf.lastLogIndex = rf.nextIndex[rf.me]
    index = rf.nextIndex[rf.me]
    term = rf.lastLogTerm
    var peerNum = rf.peerNum
    var entry = LogEntry{Index: index, Term: term, Command: command}
    rf.log = append(rf.log, entry)
    fmt.Printf("%v              leader%d receive a command, index:%d term:%d\n", time.Now(), rf.me, index, term)
    rf.matchIndex[rf.me] = index
    rf.nextIndex[rf.me] = index + 1
​
    for i := 0; i < peerNum; i++ {
        if i == rf.me {
            continue
        }
        go func(id int, nextIndex int) {
            var args = &AppendEntriesArgs{}
            rf.mu.Lock()
            args.Entries = make([]LogEntry, 0)
            if nextIndex < index {
                for j := nextIndex + 1; j <= index; j++ {
                    args.Entries = append(args.Entries, rf.log[j])
                }
            }
            args.Term = rf.currentTerm
            args.LeaderId = rf.me
            // we update the nextIndex array at first time, even if the follower hasn't received the msg.
            if index+1 > rf.nextIndex[id] {
                rf.nextIndex[id] = index + 1
            }
            rf.mu.Unlock()
​
            for {
                var reply = &AppendEntriesReply{}
                rf.mu.Lock()
                args.PrevLogIndex = rf.log[nextIndex-1].Index
                args.PrevLogTerm = rf.log[nextIndex-1].Term
                args.Entries = rf.log[nextIndex : index+1]
                // args.Entries = append([]LogEntry{rf.log[nextIndex]}, args.Entries...)
                rf.mu.Unlock()
                for {
                    // if sendAE failed, retry util success
                    if rf.sendAppendEntries(id, args, reply) {
                        break
                    }
                }
                rf.mu.Lock()
                if reply.Term > args.Term {
                    if reply.Term > rf.currentTerm {
                        rf.currentTerm = reply.Term
                        rf.state = "follower"
                        rf.voteFor = -1
                        fmt.Printf("%v raft%d find a higher term, turn back to follower, term:%d\n", time.Now(), rf.me, rf.currentTerm)
                        rf.mu.Unlock()
                        break
                    }
                    fmt.Printf("%v goroutine (term:%d, raft%d send log to raft%d) is out of date. Stop the goroutine.\n", time.Now(), args.Term, rf.me, id)
                    rf.mu.Unlock()
                    break
                }
                if !reply.Success {
                    if rf.log[nextIndex-1].Term > reply.Term {
                        for rf.log[nextIndex-1].Term > reply.Term {
                            nextIndex--
                        }
                    } else {
                        nextIndex--
                    }
                    // nextIndex--
                    if nextIndex == 0 {
                        fmt.Printf("Error:leader%d send log to raft%d, length:%d \n", rf.me, id, len(args.Entries))
                        rf.mu.Unlock()
                        break
                    }
                    rf.mu.Unlock()
                } else {
                    fmt.Printf("%v              leader%d send log from %d to %d to raft%d\n", time.Now(), rf.me, nextIndex, index, id)
                    if rf.matchIndex[id] < index {
                        rf.matchIndex[id] = index
                    }
                    // we need to check if most of the raft nodes have reach a agreement.
                    var mp = make(map[int]int)
                    for _, val := range rf.matchIndex {
                        mp[val]++
                    }
                    var tempArray = make([]num2num, 0)
                    for k, v := range mp {
                        tempArray = append(tempArray, num2num{key: k, val: v})
                    }
                    sort.Slice(tempArray, func(i, j int) bool {
                        return tempArray[i].key > tempArray[j].key
                    })
                    var voteAddNum = 0
                    for j := 0; j < len(tempArray); j++ {
                        if tempArray[j].val+voteAddNum >= (rf.peerNum/2)+1 {
                            if rf.commitIndex < tempArray[j].key {
                                fmt.Printf("%v              %d nodes have received %d mes, leader%d update commitIndex from %d to %d\n", time.Now(), tempArray[j].val+voteAddNum, tempArray[j].key, rf.me, rf.commitIndex, tempArray[j].key)
                                rf.commitIndex = tempArray[j].key
                                for rf.lastApplied < rf.commitIndex {
                                    rf.lastApplied++
                                    var applyMsg = ApplyMsg{}
                                    applyMsg.Command = rf.log[rf.lastApplied].Command
                                    applyMsg.CommandIndex = rf.log[rf.lastApplied].Index
                                    applyMsg.CommandValid = true
                                    rf.applyCh <- applyMsg
                                    fmt.Printf("%v              leader%d insert the msg%d into applyCh\n", time.Now(), rf.me, rf.lastApplied)
                                }
                                break
                            }
                        }
                        voteAddNum += tempArray[j].val
                    }
​
                    rf.mu.Unlock()
                    break
                }
​
                time.Sleep(10 * time.Millisecond)
            }
        }(i, rf.nextIndex[i])
    }
    return index, term, isLeader
}

继续完善AppendEntries()函数

由于实验2B中,AE RPC调用不再仅仅是heartbeat了,因此,我在这里将log replication和heartbeat这两者进行了区分处理,heartbeat调用AppendEntries时,传来的参数中是没有log数组的,而如果是进行log replication那么必然会发送来Log数组,因此我将此作为区分点。

以下是这个函数的处理流程:

Term的比较以及对应的处理,在lab2A中已经讲述,此处不赘述。

若args.Entries数组为nil,那么表明为heartbeat调用的;反之,表明为log replication调用的。

heartbeat调用的AppendEntreis时:

  1. 如果args.LeaderCommit > rf.commitIndex,则更新rf.commitIndex = args.LeaderComiit,并进入步骤2。反之就返回true即可。

  2. 更新了rf.commitIndex表明,有新的log可以提交并执行了,随后把那些log发送到applyCh管道中,并更新lastApplied变量的值。

Log replication调用的AppendEntreis时:

log同步应该在双方Log一致的index点开始,因此这里着重点在于回馈给leader一致的index在哪儿。

当然里面还有很多特殊情况需要考虑。

  1. follower需要检查发送来的ae包是否满足本节点的缺失情况,也就是检查args.PreLogIndex和rf.lastLogIndex。

  2. 如果args.PreLogIndex > rf.lastLogIndex表明肯定不满足本节点的缺失情况,则返回false;反之进入步骤3

  3. 来到这儿表明args.PreLogIndex <= rf.lastLogIndex,这依旧不能确定这个ae包是满足本节点的缺失情况的,可能本节点存在部分无效的Log,举例来说:一个leader加了好多log,但是断开了连接这些新加的log都没有来得及发送给任何一个节点,后续重连回来就成为follower了,那么这些独自拥有的的log就是无效Log。

  4. raft算法的特点之一是,如果两个Log的index和term都是一样的,那么这两个log必然是一致的。因此Follower仅需判断args.PrevLogTerm == rf.log[args.PreLogIndex]是否成立,如果成立表明,在args.PrevLogIndex这里以及之前的Log都是同步的了。否则,就是args.PreLogIndex < rf.lastLogIndex,但是这个索引值为args.PreLogIndex处的log,两个节点的log依旧不一致,需要返回false,让leader继续往前找到一致的index。

注意:follower发现发来的这个ae包中的log的PrevLogIndex和PrevLogTerm都是符合条件的,但是依旧不一定就要同步上来。我们需要注意部分ae是过时的,例如:1号ae包负责将follower同步到index=n处的Log;2号ae包负责而将follower同步到index=n+1处的log。但是1号ae比2号还迟,2号ae包已经将各个Follower的log同步到了index=n+1处,当过时的1号ae包被follower接受到时,follower依旧按照包中指示同步到index=n处,那么同步进度就倒退了,将会引发bug。因此follower还需要检测这个ae是否过时。

        5. 到这里,表明这个ae包是合法的,同步的开始Log处,双方也都是一致的,但是需要判断这个ae是否是过时的。follower仅需检测,发来的ae包中,args.Entreis中最新的log,本节点中有没有,若有的话,表明无需同步,这个包是过时的了。若没有则进入下一步进行同步。

        6. 更新本地的rf.log ,rf.lastLogIndex,rf.lastLogTerm等变量。

代码如下:

func (rf *Raft) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
    rf.mu.Lock()
    defer rf.mu.Unlock()
    reply.Term = rf.currentTerm
    if args.Term < rf.currentTerm {
        reply.Success = false
    } else {
        // fmt.Printf("raft%d receive ae from leader%d\n", rf.me, args.LeaderId)
        if args.Entries == nil {
            // if the args.Entries is empty, it means that the ae message is a heartbeat message.
            if args.LeaderCommit > rf.commitIndex {
                fmt.Printf("%v              raft%d update commitIndex from %d to %d\n", time.Now(), rf.me, rf.commitIndex, args.LeaderCommit)
                rf.commitIndex = args.LeaderCommit
                for rf.lastApplied < rf.commitIndex {
                    rf.lastApplied++
                    var applyMsg = ApplyMsg{}
                    applyMsg.Command = rf.log[rf.lastApplied].Command
                    applyMsg.CommandIndex = rf.log[rf.lastApplied].Index
                    applyMsg.CommandValid = true
                    rf.applyCh <- applyMsg
                    fmt.Printf("%v              raft%d insert the msg%d into applyCh\n", time.Now(), rf.me, rf.lastApplied)
                }
            }
            reply.Success = true
        } else {
            // if the args.Entries is not empty, it means that we should update our entries to be aligned with leader's.
            if args.PrevLogIndex <= rf.lastLogIndex {
                if args.PrevLogTerm == rf.log[args.PrevLogIndex].Term {
                    // Notice!!
                    // we need to consider a special situation: followers may receive a older log replication request, and followers should do nothing at that time
                    // so followers should ignore those out-of-date log replication requests or followers will choose to synchronized and delete lastest logs
                    var length = len(args.Entries)
                    var index = args.PrevLogIndex + length
                    reply.Success = true
                    if index < rf.lastLogIndex {
                        // check if the ae is out-of-date
                        if args.Entries[length-1].Term == rf.log[index].Term {
                            fmt.Printf("%v              raft%d receive a out-of-date ae and do nothing. prevLogIndex:%d, length:%d from leader%d\n", time.Now(), rf.me, args.PrevLogIndex, length, args.LeaderId)
                            return
                        }
                    }
                    fmt.Printf("%v              raft%d receive prevLogIndex:%d, length:%d from leader%d\n", time.Now(), rf.me, args.PrevLogIndex, length, args.LeaderId)
                    rf.log = rf.log[:args.PrevLogIndex+1]
                    rf.log = append(rf.log, args.Entries...)
                    // fmt.Printf("%v               raft%d log:%v\n", time.Now(), rf.me, rf.log)
                    var logLength = len(rf.log)
                    rf.lastLogIndex = rf.log[logLength-1].Index
                    rf.lastLogTerm = rf.log[logLength-1].Term
​
                }
            } else {
                reply.Success = false
            }
        }
​
        if rf.currentTerm < args.Term {
            fmt.Printf("%v raft%d update term from %d to %d\n", time.Now(), rf.me, rf.currentTerm, args.Term)
        }
        rf.currentTerm = args.Term
        rf.state = "follower"
        rf.changeOpSelect(-1)
        rf.messageCond.Broadcast()
    }
}

继续完善heartBeats()函数

这里给heartBeats函数多加的一个功能就是通知follower更新commitIndex。

注意:我们需要考虑部分节点尚未同步log,若leader无条件地向那些follower通知自身的commitIndex更新了,要求它们也同步更新,将会导致混乱。因此leader应该需要根据follower的情况来进行选择性的通知。

Leader应该通知那些同步的进度超过commitIndex的节点,Leader处有commitIndex这个数组记录了各个follower的同步进度,因此可以利用这个数组来进行判断即可。

本实验中,若leader要通知节点进行更新commitIndex,仅需在发给follower的ae包中赋值args.LeaderCommit = rf.commitIndex,随后follower就能收到最新的commitIndex。

若leader发现节点的同步进度不够,并不通知节点进行更新commitIndex,那么在发给follower的ae包中不赋值args.LeaderCommit即可,这个参数的默认值为0,并不会对follower的commitIndex造成任何影响。

由于代码变动较小,仅放出更新的地方:

go func(index int) {
                rf.mu.Lock()
                var args AppendEntriesArgs
                args.LeaderId = rf.me
                args.Term = rf.currentTerm
                if rf.matchIndex[index] >= rf.commitIndex {
                    args.LeaderCommit = rf.commitIndex
                }
                rf.mu.Unlock()
                ...
            }(index)

继续完善ticker()函数

在本实验中,需要进行log replication,Leader需要使用nextIndex和matchIndex这两个变量,因此每个raft节点成为leader后都需要对这两个变量进行初始化。

按照paper中的说法,leader开始需要将matchIndex数组全部初始化为0,nextIndex数组的初始化则按照自身的nextIndex初始化。为什么这么初始化,去看paper即可,此处不赘述。

由于代码变动较小,仅放出更新的地方:

if rf.opSelect != -1 && rf.opSelect != 4 && term == rf.currentTerm && times == currentTermTimes {
                            // suceess means the node successfully becomes a leader
                            rf.opSelect = 2
                            rf.state = "leader"
                            var length = len(rf.log)
                            // reinitialize these two arrays after election
                            for i := 0; i < rf.peerNum; i++ {
                                rf.nextIndex[i] = rf.log[length-1].Index + 1
                                rf.matchIndex[i] = 0
                            }
                            fmt.Println("leader's nextIndex array:", rf.nextIndex)
                            fmt.Println("leader's matchIndex array:", rf.matchIndex)
                            rf.messageCond.Broadcast()
                        }

继续完善Make()函数

本实验中,在raft结构体中多了applyCh这个管道,以及需要使用nextIndex和matchIndex这两个切片,因此在raft节点初始化时,也需要对这些变量初始化。

注意:实验说明中提到,index应当从1开始,为了index的值和log数组的索引值对应起来,我将log数组的0号索引位用一个index和term均为0的log来占用了,这样的做了以后,index为1的log就存放在log[index]处。而非log[index-1]处。同时这个index和term均为0的log作为数组的头部,在log数组遍历时可以以此来判断是否到了数组的头部。

applyCh的值直接从Make函数中的参数直接拿来赋值即可。

nextIndex和matchIndex由于都是切片需要用make进行初始化,并且这些切片的长度应该和raft节点数一样。

由于代码变动较小,仅放出更新的地方:

rf.log = make([]LogEntry, 1)
rf.log[0].Index = 0
rf.log[0].Term = 0
rf.applyCh = applyCh
rf.nextIndex = make([]int, rf.peerNum)
rf.matchIndex = make([]int, rf.peerNum)

实验结果图

由于实验过程中插桩了大量调试用的fmt.Printf指令,我最后也懒得把那些指令给删除了,因此图片中就我截取了最后一行的成功输出。

 

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值