微服務註冊中心分佈式集羣設計原理與 Golang 實現

內容提要

服務註冊發現作爲微服務的基礎組件,它的穩定性和可用性備受考驗。在之前的文章中,我們介紹了服務註冊中心的基本原理和實現,具體參閱:

服務註冊中心設計原理與 Golang 實現

今天我們來討論實現註冊中心集羣版,本文主要內容包括:

集羣待解決問題

要實現註冊中心從單機版到分佈式集羣,有幾個關鍵問題要解決:

  1. 集羣成員間的關係與成員發現問題

  2. 集羣成員間數據複製與一致性問題

  3. 數據副本機制和數據分區策略

針對上述問題會有不同解決方案,而不同方案會對集羣的可用性、容錯能力和數據一致性造成不同結果,著名的 CAP 理論就是對分佈式問題的最好詮釋。架構就是在不同的方案和結果中進行的折中,沒有最好的方案,只有適合場景的最佳實踐,權衡取捨也是架構之魅力所在。

節點關係與成員發現

架構模型

集羣中節點關係可以分爲兩種:平等公平關係和非公平關係。

P2P (pear to pear)點對點架構就是平等公平關係,這種關係中各節點沒有領導分工,大家分攤工作,共同努力完成目標。

與之對立的非公平關係,我們熟知的 Master/Slave 主從架構(主備架構),由於主從這個名字帶有歧視色彩,最新的叫法是 Leader/Follower 領導者跟隨者架構,在這個架構中節點的地位是不一樣的,會有不同的角色分工。

技術選型

針對註冊中心場景選擇哪種架構呢?可以從以下幾點分析。

1. 讀寫性能

點對點架構每個節點都可以承擔讀和寫,讀寫性能最佳;

主從架構一般是做讀寫分離,寫主讀從(當然也有同步寫,後面會分析到),相對來說寫性能有限,但可以通過多個從來提升讀性能。

註冊中心場景一般讀多寫少,這點上倒也沒有絕對的優劣。

2. 可用性

點對點架構中某節點掛了,讀寫不受影響,但可能會丟數據造成數據不一致,數據一致性會差一些;

主從架構中主掛了會影響寫,比如 MySQL 的 MHA,Redis 的 Sentinel 都是用來監控並實現切主,來保障高可用。而像 Zookeeper 支持半數以內的節點掛掉,超過半數就要觸發重新選主了,此時不能寫入。相比於點對點架構,整體可用性會差一點。

CAP 理論告訴我們,分佈式系統在一致性(Consistency)、可用性(Availability) 和分區容錯性 (Partition tolerance)三者只能選其二。在集羣正常情況下,一致性和可用性都沒問題(也就是 CA,網上大多數文章說 CA 模型不存在,其實說法並不準確,在正常情況下,一致性和可用性還是可以同時保障的)。但當集羣出現異常,分區容錯性必須保障(想想爲什麼?),那麼一致性和可用性就要二選一,選 AP 還是 CP?

(CAP 理論 圖片來自網絡)

註冊中心場景中,服務尋址都要依賴註冊中心,可用性顯得更加重要,而短暫不一致可忽略,畢竟服務上下線變動並不頻繁,就算偶爾沒拿到最新服務實例也不影響其他服務。著名的註冊中心 Euraka 就使用了 AP 模型,並闡明 Zookeeper 這種基於 CP 模型的註冊中心不可取,可參閱文章:

Why You Shouldn’t Use ZooKeeper for Service Discovery

3. 架構實現

點對點架構實現相對更簡單,不用考慮選主或主從切換的問題,節點狀態也只要考慮上線狀態和下線狀態即可;

而領導者協調者架構在實現實現選主時要應對複雜的一致性協同算法,維護更復雜的狀態機。

綜合分析,註冊中心技術選型使用點對點架構更合適,我們會以此架構展開討論。

集羣架構設計

我們來看點對點集羣架構圖:

(註冊中心集羣點對點架構圖)

每個節點 Node 互相獨立,並通過數據複製同步數據,每個節點都可接受服務註冊、續約和發現操作。針對註冊中心各節點相互發現問題,既然註冊中心本身就是解決服務註冊發現的,那麼使用自己來管理自己不就好了?所以可以將節點作爲服務實例,實現自發現。

(註冊中心集羣節點自發)

代碼實現

下面我們通過具體代碼來展開講解實現原理。首先我們定義節點的概念和結構體,一個節點就是一個獨立的註冊中心服務,集羣由多個節點組成。結構體 Node 存儲節點地址和節點狀態,節點狀態有兩種:上線狀態(可對外提供服務),下線狀態(不對外服務)。

type Node struct {
    config      *configs.Config
    addr        string
    status      int 
}
func NewNode(config *configs.GlobalConfig, addr string) *Node {
    return &Node{
        addr:        addr,
        status:      configs.NodeStatusDown, //初始化設爲下線狀態
    }   
}

(代碼 model/node.go)

結構體 Nodes 用於存放所有節點列表和當前節點地址,方便節點初始化和節點感知。

type Nodes struct {
    nodes    []*Node
    selfAddr string
}
//初始化默認從配置文件中加載節點信息
func NewNodes(c *configs.GlobalConfig) *Nodes {
    nodes := make([]*Node, 0, len(c.Nodes))
    for _, addr := range c.Nodes {
        n := NewNode(c, addr)
        nodes = append(nodes, n)
    }
    return &Nodes{
        nodes:    nodes,
        selfAddr: c.HttpServer,
    }   
}

(代碼 model/nodes.go)

最後將 Nodes 維護到 Discovery 結構體中,當服務啓動首次加載全局 Discovery 時,開始創建並維護 Nodes 列表。

type Discovery struct {
    config    *configs.GlobalConfig
    protected bool
    Registry  *Registry
+   Nodes     atomic.Value
}
func NewDiscovery(config *configs.GlobalConfig) *Discovery {
//...
+ dis.Nodes.Store(NewNodes(config))
}

(代碼 model/discovery.go)

註冊中心節點實現自發現,節點之間可以感知到狀態變化,註冊中心集羣當做服務 Application,將 AppId  統一命名爲 Kavin.discovery (寫到配置文件 configs.DiscoveryAppId),每個節點對應服務實例 Instance。對這塊概念還不清楚,可以先參閱上一篇文章:

服務註冊中心設計原理與 Golang 實現

在啓動服務初始化 Discovery 時,將自己註冊到註冊中心。

func (dis *Discovery) regSelf() {
    now := time.Now().UnixNano()
    instance := &Instance{
        Env:             dis.config.Env,
        Hostname:        dis.config.Hostname,
        AppId:           configs.DiscoveryAppId, //Kavin.discovery
        Addrs:           []string{"http://" + dis.config.HttpServer},
        Status:          configs.NodeStatusUp,
        RegTimestamp:    now,
        UpTimestamp:     now,
        LatestTimestamp: now,
        RenewTimestamp:  now,
        DirtyTimestamp:  now,
    }
    dis.Registry.Register(instance, now)
    //註冊後同步到其他集羣,下面部分會展開講解
    dis.Nodes.Load().(*Nodes).Replicate(configs.Register, instance)
}

(代碼 model/discovery.go)

註冊成功後同步給集羣其他節點,數據複製後面會具體講解。註冊成功後還要實現節點的定期續約,每 30s 發送一次續約請求,如果續約返回了 NotFound 未找到實例,做了一次重新註冊的操作,保障了系統的健壯性。

func (dis *Discovery) renewTask(instance *Instance) {
    now := time.Now().UnixNano()
    ticker := time.NewTicker(configs.RenewInterval) //30 second
    defer ticker.Stop()
    for {
        select {
        case <-ticker.C:            
            _, err := dis.Registry.Renew(instance.Env, instance.AppId, instance.Hostname)
            if err == errcode.NotFound {
                dis.Registry.Register(instance, now)
                dis.Nodes.Load().(*Nodes).Replicate(configs.Register, instance)
            } else {
                dis.Nodes.Load().(*Nodes).Replicate(configs.Renew, instance)
            } 
        }   
    }   
}

(代碼 model/discovery.go)

節點如果要進行下線操作,會先進行節點註銷操作,在項目 main() 中增加註銷自己的代碼,實現比較簡單,可直接參考代碼:Discovery.CancelSelf(),代碼可通過本公衆號 “技術歲月” 發送 “註冊發現” 獲取。

func main() {
    //graceful restart
    quit := make(chan os.Signal)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGQUIT)
    <-quit
    log.Println("shutdown discovery server...")
    //cancel
++  global.Discovery.CancelSelf()
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
}

(代碼 model/discovery.go)

節點的狀態變更感知,用於維護集羣節點的上下線,從節點註冊表中拉取 AppId 爲 Kavin.discovery 的數據,然後通過該數據中的實例信息來維護節點列表。

func (dis *Discovery) nodesPerception() {
    var lastTimestamp int64
    ticker := time.NewTicker(configs.NodePerceptionInterval)
    defer ticker.Stop()
    for {
        select {
        case <-ticker.C:
            fetchData, err := dis.Registry.Fetch(dis.config.Env, configs.DiscoveryAppId, configs.NodeStatusUp, lastTimest
amp)
            if err != nil || fetchData == nil {
                continue
            }   
            var nodes []string
            for _, instance := range fetchData.Instances {
                for _, addr := range instance.Addrs {
                    u, err := url.Parse(addr)
                    if err == nil {
                        nodes = append(nodes, u.Host)
                    }   
                }   
            } 
            lastTimestamp = fetchData.LatestTimestamp
            config := new(configs.GlobalConfig)
            *config = *dis.config
            config.Nodes = nodes
            ns := NewNodes(config)
            ns.SetUp()
            dis.Nodes.Store(ns)
        }
    }
}

(代碼 model/discovery.go)

數據副本與數據一致性

數據模型一般會有副本和分區兩種形式,分區我們等會討論,先說說副本機制。

所謂副本機制 Replication,是指分佈式系統在各節點上保存相同的數據拷貝,來達到備份的目的。

副本提供了幾個好處:數據冗餘;可伸縮性;改善數據局部性。在點對點架構中,每個節點都是一個獨立的數據副本,這樣某個節點出事不會影響別人,還可通過擴充節點提升可用性,抗住更大併發。

多副本最大的困擾,就是數據的一致性了,上面我們分析了 CAP,明確了使用 AP 模型,成員間數據雖然不能做到強一致性,但怎麼保障最終一致性呢?這裏考慮如下幾點:

節點啓動時註冊表初始化

節點首次啓動時,其註冊表是空的,那麼就要想辦法從其他節點同步數據。其邏輯就是遍歷所有節點,獲取註冊表數據,依次註冊到本地。這裏注意只有當所有數據同步完畢後,該註冊中心纔可對外提供服務,切換爲上線狀態。

func (dis *Discovery) initSync() {
    nodes := dis.Nodes.Load().(*Nodes)
    for _, node := range nodes.AllNodes() {
        if node.addr == nodes.selfAddr {
            continue
        }
        uri := fmt.Sprintf("http://%s%s", node.addr, configs.FetchAllURL)
        resp, err := httputil.HttpPost(uri, nil)
        if err != nil {
            log.Println(err)
            continue
        }
        var res struct {
            Code    int                    `json:"code"`
            Message string                 `json:"message"`
            Data    map[string][]*Instance `json:"data"`
        }
        err = json.Unmarshal([]byte(resp)&res)
        if err != nil {
            log.Printf("get from %v error : %v", uri, err)
            continue
        }
        if res.Code != configs.StatusOK {
            log.Printf("get from %v error : %v", uri, res.Message)
            continue
        }
        dis.protected = false
        for _, v := range res.Data {
            for _, instance := range v {
                dis.Registry.Register(instance, instance.LatestTimestamp)
            }
        }
    }
    nodes.SetUp()
}

(代碼 model/discovery.go)

這裏考慮到節點數據可能不一致,循環同步了所有節點數據來提高一致性,相應的會有網絡 io 開銷與浪費,在一致性和資源開銷上做了取捨選擇。

註冊表數據變更時同步

當實例向某節點發起註冊、續約、取消操作,該節點在完成本地註冊表數據更新後,還需要將其同步給其他節點。同步採用當前節點依次廣播的形式,擴散傳播(Gossip 算法)雖然能實現更快的擴散,但實現複雜且容易發生多輪同步的問題。

Gossip 過程是由種子節點發起,當一個種子節點有狀態需要更新到其他節點時,它會隨機的選擇周圍幾個節點散播消息,收到消息的節點也會重複該過程,直至最終網絡中所有的節點都收到消息。

(當前節點發起廣播同步數據)

關於數據更新還要多做一些說明,這裏我們是更新完當前節點,即代表寫入完成,此時可以通過該節點獲取最新數據,而同步其他節點並沒做檢查,也就是說其他節點在同步完成前,獲取的數據可能不一致。如果在同步前當前節點掛了,可能這次操作會丟失,我們並沒有採用同步寫模式,採用了弱一致性策略。

如果我們要強一致性怎麼做呢?在數據寫入當前節點並完成同步之前,所有節點數據不可讀或仍讀取之前版本數據(快照 / 多版本控制)。

在數據複製技術中,有同步複製、異步複製、半同步複製技術,對應的響應延遲時間(可用性)和一致性也會有差別。

來看具體代碼實現,以服務註冊爲例,在 RegisterHandler 中,增加數據同步的邏輯,將數據變動同步給其他節點。

func RegisterHandler(c *gin.Context) {
   //...
+   if req.Replication {
+       global.Discovery.Nodes.Load().(*model.Nodes).Replicate(c, configs.Register, instance)
+   } 
}

(代碼 api/handler/register.go)

在 Replicate 方法中,遍歷所有的節點,依次執行註冊操作。

func (nodes *Nodes) Replicate(c *gin.Context, action configs.Action, instance *Instance) error {
    if len(nodes.nodes) == 0 {
        return nil
    }
    for _, node := range nodes.nodes {
        if node.addr != nodes.selfAddr {
            go nodes.action(c, node, action, instance)
        }
    }
    return nil
}
func (nodes *Nodes) action(c *gin.Context, node *Node, action configs.Action, instance *Instance) {
    switch action {
    case configs.Register:
        go node.Register(c, instance)
    case configs.Renew:
        go node.Renew(c, instance)
    case configs.Cancel:
        go node.Cancel(c, instance)
    }
}

(代碼 model/nodes.go)

Nodes 通過調用 Node 裏方法實現節點操作邏輯。

func (node *Node) Register(c *gin.Context, instance *Instance) error {
    return node.call(c, node.registerURL, configs.Register, instance, nil)
}
func (node *Node) call(c *gin.Context, uri string, action configs.Action, instance *Instance, data interface{}) error {
    params := make(map[string]interface{})
    params["env"] = instance.Env
    params["appid"] = instance.AppId
    params["hostname"] = instance.Hostname
    params["replication"] = true //broadcast stop here
    switch action {
    case configs.Register:
        params["addrs"] = instance.Addrs
        params["status"] = instance.Status
        params["version"] = instance.Version
        params["reg_timestamp"] = strconv.FormatInt(instance.RegTimestamp, 10) 
        params["dirty_timestamp"] = strconv.FormatInt(instance.DirtyTimestamp, 10) 
        params["latest_timestamp"] = strconv.FormatInt(instance.LatestTimestamp, 10) 
    case configs.Renew:
        params["dirty_timestamp"] = strconv.FormatInt(instance.DirtyTimestamp, 10) 
    case configs.Cancel:
        params["latest_timestamp"] = strconv.FormatInt(instance.LatestTimestamp, 10) 
    }   
    resp, err := httputil.HttpPost(uri, params)
    if err != nil {
        return err 
    }   
    res := Response{}
    err = json.Unmarshal([]byte(resp)&res)
    if err != nil {
        return err 
    }   
    if res.Code != configs.StatusOK {
        json.Unmarshal([]byte(res.Data), data)
        return errcode.Conflict
    }   
    return nil 
}

(代碼 model/node.go)

這裏同步失敗並不影響正常響應,也就是說本地執行成功即會返回成功,那麼有可能會因爲同步失敗,造成節點間的數據不一致。分析有如下不一致的 case:

所以不處理,也可以經過一段時間後達成最終一致,但達成的最終一致的時間會久一些,所以也可以通過記錄失敗隊列,補發失敗請求來快速修復。

最終一致性保障

做了上述工作一般情況下,出現不一致的概率會非常低,但如果確實存在特殊 case,導致了各節點數據不一致,那麼就需要有一個反熵兜底的方案來實現最終一致性了。

可以考慮開啓定時任務,定期與其他節點進行數據比對,並根據 lastTimestamp 來決策哪條數據準確進行修復,如果數據不存在需要進行補錄。當所有節點都和其他節點進行比對並修復後,理論上數據可達成一致性(實際過程中可能又發生了變化),這個過程叫反熵。所以又回到一開始技術選型時,我們就選擇了較弱的一致性。

自測方案

我們準備 3 個配置,搭建 3 個節點,通過靜態配置集羣節點列表。

nodes: ["localhost:8881", "localhost:8882", "localhost:8883"]
http_server: "localhost:8881" //其他節點配置8882和8883
hostname: "sd1"  //其他節點配置sd2和sd3

(代碼 cmd/configs.yaml)

啓動節點 1,節點 1 先從配置中拿集羣列表,但節點感知後發現其他兩個節點不能訪問,節點 1 會變更爲單節點。

啓動節點 2,節點 2 先同步節點 1 的註冊表,並註冊自己同步給節點 1,節點 1 和 節點 2 組成雙節點集羣,數據互相複製並保持一致。啓動節點 3 過程也一樣。

kill 節點 3,會發現其在節點 1 和節點 2 已註銷了。

數據分區策略

副本解決了數據冗餘的事,但本質上還是單機存放了全量數據,當註冊表數據過多,單機出現瓶頸,數據分區就出現了。另外如果不同機房,滿足機房內服務調用內斂,提供就近訪問能力,也需要進行分區。

分區有不同的描述,如 Kafka 叫分區 Partitioning,而 MySQL 中叫分表,在 MongoDB、Elasticsearch 又叫分片 Shard,HBase、Tidb 中叫 Region,雖然實現原理可能不盡相同,但底層數據分區的思想卻是一致性的。

註冊中心可以實現劃分區域 zone 的機制,zone1 保存服務 A、B、C,zone2 保存服務 D、E、F,來實現數據分區治理。

(多註冊中心數據分區)

跨區域同步數據時,單向同步,並且只在該區域內部廣播,不能再次廣播到其他區域,每個區域各節點可使用負載均衡做反向代理。這裏的實現部分就不展開描述了,主要就是在註冊表中嵌套一層 zone 來隔離數據。

總結

本文實現了註冊中心的集羣版,在集羣實現過程中,先明確了點對點的平等架構方式,並通過複製技術實現各副本間一致性問題,也說明了在可用性和一致性問題上做的取捨。

註冊中心早期的 Zookeeper 使用 ZAB 協議(Paxos 算法)實現了線性一致性,到 etcd 的 Raft 算法,分佈式一致性算法是做分佈式集羣繞不開的話題,後續有機會會展開了講。

註冊中心功能上還有一些待完善的地方,比如多機房流量調度,與 Service Mesh 結合,還有客戶端方案等,歡迎大家持續關注公衆號 “技術歲月”,來獲取後續更新,本文項目參考 bilibili 的 discovery 開源項目。

文章完整代碼請關注公衆號  技術歲月,發送關鍵字 註冊發現獲取。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/xvI1hLqZ0ev1_l_vQMOsIg