Golang 實現 Redis(7): Redis 叢集與一致性 Hash

發表於2020-11-25

本文是使用 golang 實現 redis 系列的第七篇, 將介紹如何將單點的快取伺服器擴充套件為分散式快取。godis 叢集的原始碼在Github:Godis/cluster

單臺伺服器的CPU和記憶體等資源總是有限的,隨著資料量和訪問量的增加單臺伺服器很容易遇到瓶頸。利用多臺機器建立分散式系統,分工處理是提高系統容量和吞吐量的常用方法。

使用更多機器來提高系統容量的方式稱為系統橫向擴容。與之相對的,提高單臺機器效能被稱為縱向擴容。由於無法在單臺機器上無限提高硬體配置且硬體價格與效能的關係並非線性的,所以建立分散式系統進行橫向擴容是更為經濟實用的選擇。

我們採用一致性 hash 演算法 key 分散到不同的伺服器,客戶端可以連線到服務叢集中任意一個節點。當節點需要訪問的資料不在自己本地時,需要通過一致性 hash 演算法計算出資料所在的節點並將指令轉發給它。

與分散式系統理論中的分割槽容錯性不同,我們僅將資料存在一個節點沒有儲存副本。這種設計提高了系統吞吐量和容量,但是並沒有提高系統可用性,當有一個節點崩潰時它儲存的資料將無法訪問。

生產環境實用的 redis 叢集通常也採取類似的分片儲存策略,併為每個節點配置從節點作為熱備節點,並使用 sentinel 機制監控 master 節點狀態。在 master 節點崩潰後,sentinel 將備份節點提升為 master 節點以保證可用性。

一致性 hash 演算法

為什麼需要一致性 hash

在採用分片方式建立分散式快取時,我們面臨的第一個問題是如何決定儲存資料的節點。最自然的方式是參考 hash 表的做法,假設叢集中存在 n 個節點,我們用 node = hashCode(key) % n 來決定所屬的節點。

普通 hash 演算法解決了如何選擇節點的問題,但在分散式系統中經常出現增加節點或某個節點當機的情況。若節點數 n 發生變化, 大多數 key 根據 node = hashCode(key) % n 計算出的節點都會改變。這意味著若要在 n 變化後維持系統正常運轉,需要將大多數資料在節點間進行重新分佈。這個操作會消耗大量的時間和頻寬等資源,這在生產環境下是不可接受的。

演算法原理

一致性 hash 演算法的目的是在節點數量 n 變化時, 使盡可能少的 key 需要進行節點間重新分佈。一致性 hash 演算法將資料 key 和伺服器地址 addr 雜湊到 2^32 的空間中。

我們將 2^32 個整數首尾相連形成一個環,首先計算伺服器地址 addr 的 hash 值放置在環上。然後計算 key 的 hash 值放置在環上,順時針查詢,將資料放在找到的的第一個節點上。

key1, key2 和 key5 在 node2 上,key 3 在 node4 上,key4 在 node6 上

在增加或刪除節點時只有該節點附近的資料需要重新分佈,從而解決了上述問題。

新增 node8 後,key 5 從 node2 轉移到 node8。其它 key 不變

如果伺服器節點較少則比較容易出現資料分佈不均勻的問題,一般來說環上的節點越多資料分佈越均勻。我們不需要真的增加一臺伺服器,只需要將實際的伺服器節點對映為幾個虛擬節點放在環上即可。

Golang 實現一致性 Hash

我們使用 Golang 實現一致性 hash 演算法, 原始碼在 Github: HDT3213/Godis, 大約 80 行程式碼。

type HashFunc func(data []byte) uint32

type Map struct {
    hashFunc HashFunc
    replicas int
    keys     []int // sorted
    hashMap  map[int]string
}

func New(replicas int, fn HashFunc) *Map {
    m := &Map{
        replicas: replicas, // 每個物理節點會產生 replicas 個虛擬節點
        hashFunc: fn,
        hashMap:  make(map[int]string), // 虛擬節點 hash 值到物理節點地址的對映
    }
    if m.hashFunc == nil {
        m.hashFunc = crc32.ChecksumIEEE
    }
    return m
}

func (m *Map) IsEmpty() bool {
    return len(m.keys) == 0
}

接下來實現新增物理節點的 Add 方法:

func (m *Map) Add(keys ...string) {
    for _, key := range keys {
        if key == "" {
            continue
        }
        for i := 0; i < m.replicas; i++ {
            // 使用 i + key 作為一個虛擬節點,計算虛擬節點的 hash 值
            hash := int(m.hashFunc([]byte(strconv.Itoa(i) + key))) 
            // 將虛擬節點新增到環上
            m.keys = append(m.keys, hash) 
            // 註冊虛擬節點到物理節點的對映
            m.hashMap[hash] = key
        }
    }
    sort.Ints(m.keys)
}

接下來實現查詢演算法:

func (m *Map) Get(key string) string {
    if m.IsEmpty() {
        return ""
    }

    // 支援根據 key 的 hashtag 來確定分佈 
    partitionKey := getPartitionKey(key)
    hash := int(m.hashFunc([]byte(partitionKey)))

    // sort.Search 會使用二分查詢法搜尋 keys 中滿足 m.keys[i] >= hash 的最小 i 值
    idx := sort.Search(len(m.keys), func(i int) bool { return m.keys[i] >= hash })

    // 若 key 的 hash 值大於最後一個虛擬節點的 hash 值,則 sort.Search 找不到目標
    // 這種情況下選擇第一個虛擬節點
    if idx == len(m.keys) {
        idx = 0
    }

    // 將虛擬節點對映為實際地址
    return m.hashMap[m.keys[idx]]
}

實現叢集

實現了一致性 hash 演算法後我們可以著手實現叢集模式了,Godis 叢集的程式碼在 Github:Godis/cluster

叢集最核心的邏輯是找到 key 所在節點並將指令轉發過去:

// 叢集模式下,除了 MSet、DEL 等特殊指令外,其它指令會交由 defaultFunc 處理
func defaultFunc(cluster *Cluster, c redis.Connection, args [][]byte) redis.Reply {
    key := string(args[1])
    peer := cluster.peerPicker.Get(key) // 通過一致性 hash 找到節點
    return cluster.Relay(peer, c, args)
}

func (cluster *Cluster) Relay(peer string, c redis.Connection, args [][]byte) redis.Reply {
    if peer == cluster.self { // 若資料在本地則直接呼叫資料庫引擎
        // to self db
        return cluster.db.Exec(c, args)
    } else {
        // 從連線池取一個與目標節點的連線
        // 連線池使用 github.com/jolestar/go-commons-pool/v2 實現
        peerClient, err := cluster.getPeerClient(peer) 
        if err != nil {
            return reply.MakeErrReply(err.Error())
        }
        defer func() {
            _ = cluster.returnPeerClient(peer, peerClient) // 處理完成後將連線放回連線池
        }()
        // 將指令傳送到目標節點
        return peerClient.Send(args) 
    }
}

func (cluster *Cluster) getPeerClient(peer string) (*client.Client, error) {
    connectionFactory, ok := cluster.peerConnection[peer]
    if !ok {
        return nil, errors.New("connection factory not found")
    }
    raw, err := connectionFactory.BorrowObject(context.Background())
    if err != nil {
        return nil, err
    }
    conn, ok := raw.(*client.Client)
    if !ok {
        return nil, errors.New("connection factory make wrong type")
    }
    return conn, nil
}

func (cluster *Cluster) returnPeerClient(peer string, peerClient *client.Client) error {
    connectionFactory, ok := cluster.peerConnection[peer]
    if !ok {
        return errors.New("connection factory not found")
    }
    return connectionFactory.ReturnObject(context.Background(), peerClient)
}

相關文章