基於hashicorp/raft的分散式一致性實戰教學

qcloud發表於2019-03-01

本文由雲+社群發表

作者:Super

導語:hashicorp/raft是raft演算法的一種比較流行的golang實現,基於它能夠比較方便的構建具有強一致性的分散式系統。本文通過實現一個簡單的分散式快取系統來介紹使用hashicorp/raft來構建分散式應用程式的方法。

1. 背景

​ 對於後臺開發來說,隨著業務的發展,由於訪問量增大的壓力和資料容災的需要,一定會需要使用分散式的系統,而分散式勢必會引入一致性的問題。

​ 一般把一致性分為三種型別:弱一致性、最終一致性、強一致性。這三種模型的一致性強度逐漸遞增,實現代價也越來越大。通常弱一致性和最終一致性可以非同步冗餘,強一致性則是同步冗餘,而同步也就意味著影響效能。

​ 對常見的網際網路業務來說,使用弱一致性或者最終一致性即可。而使用強一致性一方面會影響系統的效能,另一方面實現也比較困難。常見的一致性協議如zab、raft、paxos,如果由業務純自己來實現的話代價較大,而且很可能會因為考慮不周而引入其他問題。

​ 對於一些需要強一致性,而又希望花費較小代價的業務來說,使用開源的一致性協議實現元件會是個不錯的選擇。hashicorp/raft是raft協議的一種golang實現,由hashicorp公司實現並開源,已經在consul等軟體中使用。它封裝了raft協議的leader選舉、log同步等底層實現,基於它能夠相對比較容易的構建強一致性的分散式系統,下面以實現一個簡單的分散式快取服務(取名叫stcache)來演示hashicorp/raft的具體使用,完整程式碼可以在github上下載。

2. raft簡介

​ 首先還是簡單介紹下raft協議。這裡不詳細介紹raft協議,只是為了方便理解後面的hashicorp/raft的使用步驟而簡單列舉出raft的一點原理。具體的raft協議可以參考raft的官網,如果已經瞭解raft協議可以直接跳過這一節。

​ raft是一種相對易於理解的一致性的協議。它屬於leader-follower型的協議,有且只有一個leader,所有的事務請求都由leader處理,leader徵求follower的意見,在叢集內部達成一致,決定是否執行事務。當leader出現故障,叢集中的follower會通過投票的方式選出一個新的leader,維持叢集執行。

​ raft的理論基礎是Replicated State Machine,Replicated State Machine需要滿足如下的條件:一個server可以有多個state,多個server從同一個start狀態出發,都執行相同的command序列,最終到達的stare是一樣的。如上圖,一般使用replicated log來記錄command序列,client的請求被leader轉化成log entry,然後通過一致性模組把log同步到各個server,讓各個server的log一致。每個server都有state Machine,從start出發,執行完這些log中的command後,server處於相同的state。所以raft協議的關鍵就是保證各個server的log一致,然後每個server通過執行相同的log來達到一致的狀態,理解這點有助於掌握後面對hashicorp/raft的具體使用。

img

3. hashicorp/raft使用

3.1 單機版

​ 首先我們建立一個單機版本的stcache,它是一個簡單的快取伺服器,在服務內部用一個map來儲存資料,只提供簡單的get和set操作。

type cacheManager struct {
        data map[string]string
        sync.RWMutex
}

​ 然後stcache開啟一個http服務,提供兩個api,第一個是set介面,用於設定資料到快取,成功時返回ok,失敗返回錯誤資訊:

img

​ 第二個是get介面,根據key查詢具體的value:

img

​ 下面我們在單機版stcache的基礎上逐步擴充,讓它成為一個具有強一致性的分散式系統。

3.2 建立節點

// NewRaft is used to construct a new Raft node. It takes a configuration, as well
// as implementations of various interfaces that are required. If we have any
// old state, such as snapshots, logs, peers, etc, all those will be restored
// when creating the Raft node.
func NewRaft(conf *Config,
    fsm FSM,
    logs LogStore,
    stable StableStore,
    snaps SnapshotStore,
    trans Transport) (*Raft, error) {

​ hashicorp/raft庫提供NewRaft方法來建立一個raft節點,這也是使用這個庫的最重要的一個api。NewRaft需要呼叫層提供6個引數,分別是:

  • Config: 節點配置
  • FSM: finite state machine,有限狀態機
  • LogStore: 用來儲存raft的日誌
  • StableStore: 穩定儲存,用來儲存raft叢集的節點資訊等
  • SnapshotStore: 快照儲存,用來儲存節點的快照資訊
  • Transport: raft節點內部的通訊通道

​ 下面從這些引數入手看應用程式需要做哪些工作。

3.3 Config

​ config是節點的配置資訊,我們直接使用raft預設的配置,然後用監聽的地址來作為節點的id。config裡面還有一些可配置的項,後面我們用到的時候再說。

    raftConfig := raft.DefaultConfig()
    raftConfig.LocalID = raft.ServerID(opts.raftTCPAddress)
    raftConfig.Logger = log.New(os.Stderr, "raft: ", log.Ldate|log.Ltime)

3.4 LogStore 和 StableStore

​ LogStore、StableStore分別用來儲存raft log、節點狀態資訊,hashicorp提供了一個raft-boltdb來實現底層儲存,它是一個嵌入式的資料庫,能夠持久化儲存資料,我們直接用它來實現LogStore和StableStore.

   logStore, err := raftboltdb.NewBoltStore(filepath.Join(opts.dataDir, 
                                            "raft-log.bolt"))                            
   stableStore, err := raftboltdb.NewBoltStore(filepath.Join(opts.dataDir, 
                                               "raft-stable.bolt"))                      

3.5 SnapshotStore

​ SnapshotStore用來儲存快照資訊,對於stcache來說,就是儲存當前的所有的kv資料,hashicorp內部提供3中快照儲存方式,分別是:

  • DiscardSnapshotStore: 不儲存,忽略快照,相當於/dev/null,一般用於測試
  • FileSnapshotStore: 檔案持久化儲存
  • InmemSnapshotStore: 記憶體儲存,不持久化,重啟程式會丟失

​ 這裡我們使用檔案持久化儲存。snapshotStore只是提供了一個快照儲存的介質,還需要應用程式提供快照生成的方式,後面我們再具體說。

    snapshotStore, err := raft.NewFileSnapshotStore(opts.dataDir, 1, os.Stderr)

3.6 Transport

​ Transport是raft叢集內部節點之間的通訊渠道,節點之間需要通過這個通道來進行日誌同步、leader選舉等。hashicorp/raft內部提供了兩種方式來實現,一種是通過TCPTransport,基於tcp,可以跨機器跨網路通訊;另一種是InmemTransport,不走網路,在記憶體裡面通過channel來通訊。顯然一般情況下都使用TCPTransport即可,在stcache裡也採用tcp的方式。

func newRaftTransport(opts *options) (*raft.NetworkTransport, error) {
    address, err := net.ResolveTCPAddr("tcp", opts.raftTCPAddress)
    if err != nil {
        return nil, err
    }
    transport, err := raft.NewTCPTransport(address.String(), address, 3, 10*time.Second, os.Stderr)
    if err != nil {
        return nil, err
    }
    return transport, nil
}

3.7 FSM

​ 最後再看FSM,它是一個interface,需要應用程式來實現3個funcition。

/*FSM provides an interface that can be implemented by
clients to make use of the replicated log.*/
type FSM interface {
    /* Apply log is invoked once a log entry is committed.
    It returns a value which will be made available in the
    ApplyFuture returned by Raft.Apply method if that
    method was called on the same Raft node as the FSM.*/
    Apply(*Log) interface{}
    // Snapshot is used to support log compaction. This call should
    // return an FSMSnapshot which can be used to save a point-in-time
    // snapshot of the FSM. Apply and Snapshot are not called in multiple
    // threads, but Apply will be called concurrently with Persist. This means
    // the FSM should be implemented in a fashion that allows for concurrent
    // updates while a snapshot is happening.
    Snapshot() (FSMSnapshot, error)
    // Restore is used to restore an FSM from a snapshot. It is not called
    // concurrently with any other command. The FSM must discard all previous
    // state.
    Restore(io.ReadCloser) error
}

​ 第一個是Apply,當raft內部commit了一個log entry後,會記錄在上面說過的logStore裡面,被commit的log entry需要被執行,就stcache來說,執行log entry就是把資料寫入快取,即執行set操作。我們改造doSet方法, 這裡不再直接寫快取,而是呼叫raft的Apply方式,為這次set操作生成一個log entry,這裡面會根據raft的內部協議,在各個節點之間進行通訊協作,確保最後這條log 會在整個叢集的節點裡面提交或者失敗。

// doSet saves data to cache, only raft master node provides this api
func (h *httpServer) doSet(w http.ResponseWriter, r *http.Request) {
    // ... get params from request url

    event := logEntryData{Key: key, Value: value}
    eventBytes, err := json.Marshal(event)
    if err != nil {
        h.log.Printf("json.Marshal failed, err:%v", err)
        fmt.Fprint(w, "internal error\n")
        return
    }

    applyFuture := h.ctx.st.raft.raft.Apply(eventBytes, 5*time.Second)
    if err := applyFuture.Error(); err != nil {
        h.log.Printf("raft.Apply failed:%v", err)
        fmt.Fprint(w, "internal error\n")
        return
    }

    fmt.Fprintf(w, "ok\n")
}

​ 對follower節點來說,leader會通知它來commit log entry,被commit的log entry需要呼叫應用層提供的Apply方法來執行日誌,這裡就是從logEntry拿到具體的資料,然後寫入快取裡面即可。

// Apply applies a Raft log entry to the key-value store.
func (f *FSM) Apply(logEntry *raft.Log) interface{} {
        e := logEntryData{}
        if err := json.Unmarshal(logEntry.Data, &e); err != nil {
                panic("Failed unmarshaling Raft log entry.")
        }
        ret := f.ctx.st.cm.Set(e.Key, e.Value)
        return ret
} 

3.7.1 snapshot

​ FSM需要提供的另外兩個方法是Snapshot()和Restore(),分別用於生成一個快照結構和根據快照恢復資料。首先我們需要定義快照,hashicorp/raft內部定義了快照的interface,需要實現兩個func,Persist用來生成快照資料,一般只需要實現它即可;Release則是快照處理完成後的回撥,不需要的話可以實現為空函式。

// FSMSnapshot is returned by an FSM in response to a Snapshot
// It must be safe to invoke FSMSnapshot methods with concurrent
// calls to Apply.
type FSMSnapshot interface {
    // Persist should dump all necessary state to the WriteCloser 'sink',
    // and call sink.Close() when finished or call sink.Cancel() on error.
    Persist(sink SnapshotSink) error
    // Release is invoked when we are finished with the snapshot.
    Release()
}

​ 我們定義一個簡單的snapshot結構,在Persist裡面,自己把快取裡面的資料用json格式化的方式來生成快照,sink.Write就是把快照寫入snapStore,我們剛才定義的是FileSnapshotStore,所以會把資料寫入檔案。

type snapshot struct {
        cm *cacheManager
}
// Persist saves the FSM snapshot out to the given sink.
func (s *snapshot) Persist(sink raft.SnapshotSink) error {
        snapshotBytes, err := s.cm.Marshal()
        if err != nil {
                sink.Cancel()
                return err
        }
        if _, err := sink.Write(snapshotBytes); err != nil {
                sink.Cancel()
                return err
        }
        if err := sink.Close(); err != nil {
                sink.Cancel()
                return err
        }
        return nil
}
func (f *snapshot) Release() {}

3.7.2 snapshot儲存與恢復

​ 而快照生成和儲存的觸發條件除了應用程式主動觸發外,還可以在Config裡面設定SnapshotInterval和SnapshotThreshold,前者指每間隔多久生成一次快照,後者指每commit多少log entry後生成一次快照。需要兩個條件同時滿足才會生成和儲存一次快照,預設config裡面配置的條件比較高,我們可以自己修改配置,比如在stcache裡面配置SnapshotInterval為20s,SnapshotThreshold為2,表示當滿足距離上次快照儲存超過20s,且log增加2條的時候,儲存一個新的快照。

    raftConfig := raft.DefaultConfig()
    raftConfig.LocalID = raft.ServerID(opts.raftTCPAddress)
    raftConfig.Logger = log.New(os.Stderr, "raft: ", log.Ldate|log.Ltime)
    raftConfig.SnapshotInterval = 20 * time.Second
    raftConfig.SnapshotThreshold = 2

​ 服務重啟的時候,會先讀取本地的快照來恢復資料,在FSM裡面定義的Restore函式會被呼叫,這裡我們就簡單的對資料解析json反序列化然後寫入記憶體即可。至此,我們已經能夠正常的儲存快照,也能在重啟的時候從檔案恢復快照資料。

// Restore stores the key-value store to a previous state.
func (f *FSM) Restore(serialized io.ReadCloser) error {
        return f.ctx.st.cm.UnMarshal(serialized)
}

// UnMarshal deserializes cache data
func (c *cacheManager) UnMarshal(serialized io.ReadCloser) error {
        var newData map[string]string
        if err := json.NewDecoder(serialized).Decode(&newData); err != nil {
                return err
        }
        c.Lock()
        defer c.Unlock()
        c.data = newData
        return nil
}

3.8 叢集建立

​ 叢集最開始的時候只有一個節點,我們讓第一個節點通過bootstrap的方式啟動,它啟動後成為leader。

    if opts.bootstrap {
        configuration := raft.Configuration{
            Servers: []raft.Server{
                {
                    ID:      raftConfig.LocalID,
                    Address: transport.LocalAddr(),
                },
            },
        }
        raftNode.BootstrapCluster(configuration)
    }

​ 後續的節點啟動的時候需要加入叢集,啟動的時候指定第一個節點的地址,併傳送請求加入叢集,這裡我們定義成直接通過http請求。

// joinRaftCluster joins a node to raft cluster
func joinRaftCluster(opts *options) error {
    url := fmt.Sprintf("http://%s/join?peerAddress=%s", 
                       opts.joinAddress, 
                       opts.raftTCPAddress)
    resp, err := http.Get(url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return err
    }
    if string(body) != "ok" {
        return errors.New(fmt.Sprintf("Error joining cluster: %s", body))
    }
    return nil
}

​ 先啟動的節點收到請求後,獲取對方的地址(指raft叢集內部通訊的tcp地址),然後呼叫AddVoter把這個節點加入到叢集即可。申請加入的節點會進入follower狀態,這以後叢集節點之間就可以正常通訊,leader也會把資料同步給follower。

// doJoin handles joining cluster request
func (h *httpServer) doJoin(w http.ResponseWriter, r *http.Request) {
    vars := r.URL.Query()

    peerAddress := vars.Get("peerAddress")
    if peerAddress == "" {
        h.log.Println("invalid PeerAddress")
        fmt.Fprint(w, "invalid peerAddress\n")
        return
    }
    addPeerFuture := h.ctx.st.raft.raft.AddVoter(raft.ServerID(peerAddress), 
                                                 raft.ServerAddress(peerAddress), 
                                                 0, 0)
    if err := addPeerFuture.Error(); err != nil {
        h.log.Printf("Error joining peer to raft, peeraddress:%s, err:%v, code:%d", peerAddress, err, http.StatusInternalServerError)
        fmt.Fprint(w, "internal error\n")
        return
    }
    fmt.Fprint(w, "ok")
}

3.9 故障切換

​ 當叢集的leader故障後,叢集的其他節點能夠感知到,並申請成為leader,在各個follower中進行投票,最後選取出一個新的leader。leader選舉是屬於raft協議的內容,不需要應用程式操心,但是對有些場景而言,應用程式需要感知leader狀態,比如對stcache而言,理論上只有leader才能處理set請求來寫資料,follower應該只能處理get請求查詢資料。為了模擬說明這個情況,我們在stcache裡面我們設定一個寫標誌位,當本節點是leader的時候標識位置true,可以處理set請求,否則標識位為false,不能處理set請求。

// doSet saves data to cache, only raft master node provides this api
func (h *httpServer) doSet(w http.ResponseWriter, r *http.Request) {
        if !h.checkWritePermission() {
                fmt.Fprint(w, "write method not allowed\n")
                return
        }
        // ... set data  
}

​ 當故障切換的時候,follower變成了leader,應用程式如何感知到呢? 在raft結構裡面提供有一個eaderCh,它是bool型別的channel,不帶快取,當本節點的leader狀態有變化的時候,會往這個channel裡面寫資料,但是由於不帶緩衝且寫資料的協程不會阻塞在這裡,有可能會寫入失敗,沒有及時變更狀態,所以使用leaderCh的可靠性不能保證。好在raft Config裡面提供了另一個channel NotifyCh,它是帶快取的,當leader狀態變化時會往這個chan寫資料,寫入的變更訊息能夠快取在channel裡面,應用程式能夠通過它獲取到最新的狀態變化。

​ 我們首先在初始化config時候建立一個帶快取的chan,把它賦值給config裡面的NotifyCh,然後在節點啟動後監聽這個chan,當本節點的leader狀態變化時(變成leader或者從leader變成follower),就能夠從這個chan裡面讀取到bool值,並調整我們先前設定的寫標誌位,控制是否能否處理set操作。

func newRaftNode(opts *options, ctx *stCachedContext) (*raftNodeInfo, error) {
    raftConfig := raft.DefaultConfig()
    raftConfig.LocalID = raft.ServerID(opts.raftTCPAddress)
    raftConfig.Logger = log.New(os.Stderr, "raft: ", log.Ldate|log.Ltime)
    raftConfig.SnapshotInterval = 20 * time.Second
    raftConfig.SnapshotThreshold = 2
    leaderNotifyCh := make(chan bool, 1)
    raftConfig.NotifyCh = leaderNotifyCh
    // ... 
}

4. 成果演示

​ 做完上面的工作後,我們來測試下效果,我們同一臺機器上啟動3個節點來構成一個叢集,第一個節點用bootstrapt的方式啟動,成為leader

img

​ 第二個節點和第三個節點啟動時指定加入叢集,成為follower

img

img

​ 現在叢集中有3個節點,leader監聽127.0.01:6000對外提供set和get介面,兩個follower分別監聽127.0.0.1:6001和127.0.0.1:6002,對外提供get介面。

4.1 叢集資料同步

​ 通過呼叫leader的set介面寫入一個資料,key是ping,value是pong

img

​ 這時候能在兩個follower上看見apply的日誌,follower節點寫入了log,並收到leader的通知提交資料。

img

​ 通過查詢介面,也能從follower裡面查詢到剛才寫入的資料,證明資料同步沒有問題。

img

​ 有一點需要說明的事,我們這裡從follower是可能讀不到最新資料的。由於leader對set操作返回的時候,follower可能還沒有apply資料,所以從follower的get查詢可能返回舊資料或者空資料。如果要保證能從follower查詢到的一定是最新的資料還需要很多額外的工作,即做到linearizable read,有興趣可以看這篇測試文章,這裡不再展開。

4.2 快照儲存與恢復

​ 我們再通過set介面寫入兩個資料,能看見節點開始儲存快照

img

​ 在指定的目錄下面,能看見快照的具體資訊,有兩個檔案,meta.json儲存了版本號、log序號、叢集節點地址等叢集資訊;state.bin裡面是快照資料,這裡就是我們剛剛寫入的資料被json序列化後的字串。

img

​ 現在把節點都停止,然後重新啟動leader,記憶體的資料都丟失,它會從儲存的快照檔案裡面恢復資料。重啟follower也一樣會從自己儲存的快照裡面載入資料。

img

4.3 leader切換

​ 把leader和follower都重啟恢復,現在leader監聽127.0.01:6000,只有它能執行set操作,follower只能執行get操作

img

​ 我們停掉leader節點,兩個follower會開始選舉,這裡node2贏得了選舉,進入leader狀態,並且它開始開啟set操作

img

​ 我們再請求node2監聽的127.0.0.1:6001,發現已經可以正常寫入資料了,leader切換順利完成。

img

​ 我們再重啟原來的leader節點,它會變成follower,並從新的leader(也就是node2)這裡同步它所缺失的資料。

5. 總結

​ 上面所建立的stcache只是一個簡單的示例程式,真正要做到線上上使用還有很多問題需要考慮,目前基於hashicorp/raft比較成熟的開源軟體有consul,如果有興趣可以通過它做進一步研究。

​ 總的來說,hashicorp/raft封裝了raft的內部協議,提供簡潔明瞭的使用方法,基於它能夠很快速地構建出具有強一致性的應用程式。

此文已由騰訊雲+社群在各渠道釋出

獲取更多新鮮技術乾貨,可以關注我們騰訊雲技術社群-雲加社群官方號及知乎機構號

相關文章