成員變更在一致性協議里稍復雜一些,由於不同的成員不可能在同一時刻從舊成員組切換至新成員組,所以可能出現兩個不相交的majority,從而導致同一個term出現兩個leader,進而導致同一個index的日志不一致,違反一致性協議。下圖是個例子:
raft作者提出了一種比較簡單的方法,一次只增加或減少一個成員,這樣能夠保證任何時刻,都不可能出現兩個不相交的majority,所以,可以從舊成員組直接切到新成員組。如下圖:
切換的時機是把成員變更日志寫盤的時候,不管是否commit。這個切換時機帶來的問題是如果這條成員變更日志最終沒有commit,在發生leader切換的時候,成員組就需要回滾到舊的成員組。
etcd raft為了實現簡單,將切換成員組的實機選在apply成員變更日志的時候。
下面看看etcd raft library如何實現的:
應用調用
func (n *node) ProposeConfChange(ctx context.Context, cc pb.ConfChange) error {
data, err := cc.Marshal()
if err != nil {
return err
}
return n.Step(ctx, pb.Message{Type: pb.MsgProp, Entries: []pb.Entry{{Type: pb.EntryConfChange, Data: data}}})
}
可以看出,ConfChange是和普通的log entry一樣封裝在MsgProp消息中,進入propc,
跑raft算法的goroutine從propc中拿到消息后,會做如下判斷:
for i, e := range m.Entries {
if e.Type == pb.EntryConfChange {
if r.pendingConf {
r.logger.Infof("propose conf %s ignored since pending unapplied configuration", e.String())
m.Entries[i] = pb.Entry{Type: pb.EntryNormal}
}
r.pendingConf = true
}
}
檢查已經有成員變更正在做,就忽略新的成員變更。然后將pendingConf置為true,意味着目前有成員變更正在做了,從這里可以看出,多個成員變更不能同時進行。follower接收端的處理和普通log entry一樣。
如果成員變更日志達成了一致,則會被封裝在Ready中,應用拿到后,做如下處理:
if entry.Type == raftpb.EntryConfChange {
var cc raftpb.ConfChange
cc.Unmarshal(entry.Data)
s.Node.ApplyConfChange(cc)
}
ApplyConfChange:
func (n *node) ApplyConfChange(cc pb.ConfChange) *pb.ConfState {
var cs pb.ConfState
select {
case n.confc <- cc:
case <-n.done:
}
select {
case cs = <-n.confstatec:
case <-n.done:
}
return &cs
}
講ConfChange放入confc,然后阻塞在confstatec上,跑raft協議的goroutine從confc中拿出ConfChange,做相應的增加/刪除節點操作,然后將成員組放入confstatec。
switch cc.Type {
case pb.ConfChangeAddNode:
r.addNode(cc.NodeID)
case pb.ConfChangeRemoveNode:
// block incoming proposal when local node is
// removed
if cc.NodeID == r.id {
propc = nil
}
r.removeNode(cc.NodeID)
case pb.ConfChangeUpdateNode:
r.resetPendingConf()
default:
panic("unexpected conf type")
}
select {
case n.confstatec <- pb.ConfState{Nodes: r.nodes()}:
case <-n.done:
}
增加/刪除節點操作都只是更新prs,map的每個元素保存一個peer的狀態,其中最重要的狀態莫過於
Match, Next uint64
看過raft小論文的人一看變量名就很明確意義,Match代表最大的已經落盤的log index,Next代表下一條需要發給這個peer的log index。然后將pendingConf置為false,代表成員變更結束。
重啟如何恢復成員組:
hs, cs, err := c.Storage.InitialState()
Storage接口中:
// InitialState returns the saved HardState and ConfState information.
InitialState() (pb.HardState, pb.ConfState, error)
Storage是個接口,其中InitialState()用於恢復成員組,需要應用自己實現,通常將ConfState記在最后一次Snapshot的Metadata中:
message SnapshotMetadata {
optional ConfState conf_state = 1 [(gogoproto.nullable) = false];
optional uint64 index = 2 [(gogoproto.nullable) = false];
optional uint64 term = 3 [(gogoproto.nullable) = false];
}
ConfState:
message ConfState {
repeated uint64 nodes = 1;
}
拿到ConfState后就可以初始化上面提到的prs,snapshot后續的已經commit的log entry一樣,通過Ready封裝,應用進行apply,如果其中有ConfChange,則調用
s.Node.ApplyConfChange(cc)