Golang負載均衡器Balancer的原始碼解讀

騎牛上青山發表於2023-04-15

Balancer是一個由Golang開發的反向代理7層負載均衡,是一個適合初學者學習的Golang專案,今天我們就來看看這個專案是如何實現的。

前言

在開始瞭解具體的專案前需要了解一些基礎的概念。

反向代理

反向代理指的是當使用者訪問介面或者是伺服器資源時並不直接訪問具體伺服器,而是透過訪問代理伺服器,然後代理伺服器根據具體的使用者請求去具體的內網中的伺服器獲取所需的資料。

反向代理在網際網路中被大量應用,通常反向代理由web伺服器來實現,例如nginxopenresty等。

負載均衡

單點應用會面臨可用性和效能的問題,因此往往需要進行多臺服務部署。此時為了使得流量均勻分佈到不同的伺服器上,需要啟用負載均衡機制。

一般來說負載均衡的能力是反向代理伺服器自帶的能力,負載均衡會有不少的演算法,輪詢加權等等,這個後續會介紹。

程式碼實現

Balancer作為一個反向代理的負載均衡器,其包含了不同負載均衡演算法實現,以及一些心跳保持,健康檢查的基礎能力。

專案結構

Balancer的專案結構極為簡單,主要就是伺服器的基本代理實現proxy和負載均衡演算法實現balancer

伺服器基本結構

Balancer基於"net/http/httputil"包來實現自身基礎能力,透過其ReverseProxy來實現自身的反向代理能力。

服務所需配置樣例如下:

schema: http
port: 8089
ssl_certificate:
ssl_certificate_key:
tcp_health_check: true
health_check_interval: 3 
max_allowed: 100
location:
  - pattern: /
    proxy_pass:
    - "http://192.168.1.1"
    - "http://192.168.1.2:1015"
    - "https://192.168.1.2"
    - "http://my-server.com"
    balance_mode: round-robin 

伺服器啟動的入口在main中,啟動方法很簡單:

func main() {
    config, err := ReadConfig("config.yaml")
    if err != nil {
        log.Fatalf("read config error: %s", err)
    }

    err = config.Validation()
    if err != nil {
        log.Fatalf("verify config error: %s", err)
    }

    router := mux.NewRouter()
    for _, l := range config.Location {
        httpProxy, err := proxy.NewHTTPProxy(l.ProxyPass, l.BalanceMode)
        if err != nil {
            log.Fatalf("create proxy error: %s", err)
        }
        // start health check
        if config.HealthCheck {
            httpProxy.HealthCheck(config.HealthCheckInterval)
        }
        router.Handle(l.Pattern, httpProxy)
    }
    if config.MaxAllowed > 0 {
        router.Use(maxAllowedMiddleware(config.MaxAllowed))
    }
    svr := http.Server{
        Addr:    ":" + strconv.Itoa(config.Port),
        Handler: router,
    }

    // print config detail
    config.Print()

    // listen and serve
    if config.Schema == "http" {
        err := svr.ListenAndServe()
        if err != nil {
            log.Fatalf("listen and serve error: %s", err)
        }
    } else if config.Schema == "https" {
        err := svr.ListenAndServeTLS(config.SSLCertificate, config.SSLCertificateKey)
        if err != nil {
            log.Fatalf("listen and serve error: %s", err)
        }
    }
}

在上述的啟動方法中做了如下幾件事:

  1. 獲取到伺服器的配置並進行解析
  2. 使用配置中配置的反向代理地址和負載均衡演算法來初始化伺服器並開啟健康檢查
  3. 啟動服務

上述流程很簡單,其重點在於伺服器的構建:

type HTTPProxy struct {
    hostMap map[string]*httputil.ReverseProxy
    lb      balancer.Balancer

    sync.RWMutex // protect alive
    alive        map[string]bool
}

上面是Balancer伺服器的基本結構體,其中包含了負載均衡器lbhostMap用來記錄主機和反向代理之間的對映關係,alive用來記錄反向代理伺服器的健康狀態。

func NewHTTPProxy(targetHosts []string, algorithm string) (
    *HTTPProxy, error) {

    hosts := make([]string, 0)
    hostMap := make(map[string]*httputil.ReverseProxy)
    alive := make(map[string]bool)
    for _, targetHost := range targetHosts {
        url, err := url.Parse(targetHost)
        if err != nil {
            return nil, err
        }
        proxy := httputil.NewSingleHostReverseProxy(url)

        originDirector := proxy.Director
        proxy.Director = func(req *http.Request) {
            originDirector(req)
            req.Header.Set(XProxy, ReverseProxy)
            req.Header.Set(XRealIP, GetIP(req))
        }

        host := GetHost(url)
        alive[host] = true // initial mark alive
        hostMap[host] = proxy
        hosts = append(hosts, host)
    }

    lb, err := balancer.Build(algorithm, hosts)
    if err != nil {
        return nil, err
    }

    return &HTTPProxy{
        hostMap: hostMap,
        lb:      lb,
        alive:   alive,
    }, nil
}

NewHTTPProxy適用於構建伺服器反向代理的。他接收目標host的陣列和負載均衡演算法,之後將資料進行整合以此構建完整的hostMapalive中的map資料。在上述的處理過程中反向代理額外新增了兩個請求頭將之傳遞到下游。

負載均衡演算法

Balancer整個專案的核心是他實現的多種不同負載均衡演算法,包括:bounded,random,consistent-hash,ip-hash,p2c,least-load,round-robin

我們先來看下負載均衡器是如何設計的:
首先對負載均衡器進行抽象,抽象出Balancer介面:

type Balancer interface {
    Add(string)
    Remove(string)
    Balance(string) (string, error)
    Inc(string)
    Done(string)
}

其中AddRemove用於增刪負載均衡叢集中的具體機器,IncDone則用於控制請求數目(如果有配置最大請求數),Balance用於選擇最後負載均衡演算法計算出的目標伺服器。

並且其提供了各個演算法的map對映以及建立的工廠方法:

type Factory func([]string) Balancer

var factories = make(map[string]Factory)

func Build(algorithm string, hosts []string) (Balancer, error) {
    factory, ok := factories[algorithm]
    if !ok {
        return nil, AlgorithmNotSupportedError
    }
    return factory(hosts), nil
}

由於大部分演算法的AddRemove邏輯相同,因此構建了BaseBalancer將這部分程式碼抽象出來:

type BaseBalancer struct {
    sync.RWMutex
    hosts []string
}

// Add new host to the balancer
func (b *BaseBalancer) Add(host string) {
    b.Lock()
    defer b.Unlock()
    for _, h := range b.hosts {
        if h == host {
            return
        }
    }
    b.hosts = append(b.hosts, host)
}

// Remove new host from the balancer
func (b *BaseBalancer) Remove(host string) {
    b.Lock()
    defer b.Unlock()
    for i, h := range b.hosts {
        if h == host {
            b.hosts = append(b.hosts[:i], b.hosts[i+1:]...)
            return
        }
    }
}

// Balance selects a suitable host according
func (b *BaseBalancer) Balance(key string) (string, error) {
    return "", nil
}

// Inc .
func (b *BaseBalancer) Inc(_ string) {}

// Done .
func (b *BaseBalancer) Done(_ string) {}

大體邏輯是透過陣列儲存host資訊,AddRemove方法就是在陣列中進行增刪具體的host資訊。

round robin

round robin一般稱之為輪詢,是最經典,用途最廣泛的負載均衡演算法之一。

type RoundRobin struct {
    BaseBalancer
    i uint64
}

func (r *RoundRobin) Balance(_ string) (string, error) {
    r.RLock()
    defer r.RUnlock()
    if len(r.hosts) == 0 {
        return "", NoHostError
    }
    host := r.hosts[r.i%uint64(len(r.hosts))]
    r.i++
    return host, nil
}

它的程式碼實現原理是在RoundRobin的結構體中定義好一個uint值,每次請求時使用這個值和伺服器資料進行取模操作以決定最後的目標伺服器,並且在請求之後對於這個值進行累加,以確保下次請求使用不同伺服器。

ip hash

ip hash是在伺服器場合上使用較多的一種負載均衡策略,其策略宗旨是相同使用者ip的請求總是會落在相同的伺服器上。

func (r *IPHash) Balance(key string) (string, error) {
    r.RLock()
    defer r.RUnlock()
    if len(r.hosts) == 0 {
        return "", NoHostError
    }
    value := crc32.ChecksumIEEE([]byte(key)) % uint32(len(r.hosts))
    return r.hosts[value], nil
}

程式碼中的實現是將使用者ip利用ChecksumIEEE轉換成uint之後和伺服器數目進行取模操作。

random

顧名思義random是隨機選取演算法。

程式碼很簡單:

func (r *Random) Balance(_ string) (string, error) {
    r.RLock()
    defer r.RUnlock()
    if len(r.hosts) == 0 {
        return "", NoHostError
    }
    return r.hosts[r.rnd.Intn(len(r.hosts))], nil
}

consistent hash

consistent hash即為一致性雜湊,如果不知道什麼是一致性hash可以去網上找資料,或者參考我之前的文章分散式系統中的雜湊演算法

func (c *Consistent) Add(host string) {
    c.ch.Add(host)
}

func (c *Consistent) Remove(host string) {
    c.ch.Remove(host)
}

func (c *Consistent) Balance(key string) (string, error) {
    if len(c.ch.Hosts()) == 0 {
        return "", NoHostError
    }
    return c.ch.Get(key)
}

程式碼中的一致性雜湊並未自己實現,藉助的是開源庫"github.com/lafikl/consistent"

bounded

bounded指的是Consistent Hashing with Bounded Loads,即有界負載的一致性雜湊。此處就不多贅述,大家可以參考此篇文章。後續有人有興趣的話我可能單獨開文講一講。

實現方式和普通一致性雜湊一樣,都是藉助的開源庫:

func (b *Bounded) Add(host string) {
    b.ch.Add(host)
}

func (b *Bounded) Remove(host string) {
    b.ch.Remove(host)
}

func (b *Bounded) Balance(key string) (string, error) {
    if len(b.ch.Hosts()) == 0 {
        return "", NoHostError
    }
    return b.ch.GetLeast(key)
}

func (b *Bounded) Inc(host string) {
    b.ch.Inc(host)
}

func (b *Bounded) Done(host string) {
    b.ch.Done(host)
}

least load

least load是最小負載演算法,使用此演算法,請求會選擇叢集中負載最小的機器。

func (h *host) Tag() interface{} { return h.name }

func (h *host) Key() float64 { return float64(h.load) }

type LeastLoad struct {
    sync.RWMutex
    heap *fibHeap.FibHeap
}

func NewLeastLoad(hosts []string) Balancer {
    ll := &LeastLoad{heap: fibHeap.NewFibHeap()}
    for _, h := range hosts {
        ll.Add(h)
    }
    return ll
}

func (l *LeastLoad) Add(hostName string) {
    l.Lock()
    defer l.Unlock()
    if ok := l.heap.GetValue(hostName); ok != nil {
        return
    }
    _ = l.heap.InsertValue(&host{hostName, 0})
}

func (l *LeastLoad) Remove(hostName string) {
    l.Lock()
    defer l.Unlock()
    if ok := l.heap.GetValue(hostName); ok == nil {
        return
    }
    _ = l.heap.Delete(hostName)
}

func (l *LeastLoad) Balance(_ string) (string, error) {
    l.RLock()
    defer l.RUnlock()
    if l.heap.Num() == 0 {
        return "", NoHostError
    }
    return l.heap.MinimumValue().Tag().(string), nil
}

func (l *LeastLoad) Inc(hostName string) {
    l.Lock()
    defer l.Unlock()
    if ok := l.heap.GetValue(hostName); ok == nil {
        return
    }
    h := l.heap.GetValue(hostName)
    h.(*host).load++
    _ = l.heap.IncreaseKeyValue(h)
}

func (l *LeastLoad) Done(hostName string) {
    l.Lock()
    defer l.Unlock()
    if ok := l.heap.GetValue(hostName); ok == nil {
        return
    }
    h := l.heap.GetValue(hostName)
    h.(*host).load--
    _ = l.heap.DecreaseKeyValue(h)
}

程式碼中比較簡單的使用了當前正在處理的請求數目來作為伺服器的負載水平,並且藉助了開源庫"github.com/starwander/GoFibonacciHeap"來維護叢集中伺服器的負載值。

p2c

p2c全稱 Power of Two Random Choices,一般翻譯為兩次隨機選擇演算法,出自論文[The Power of Two Random Choices: A Survey of
Techniques and Results](http://www.eecs.harvard.edu/~michaelm/postscripts/handbook200...)

大題的思路是從伺服器列表中進行兩次隨機選擇獲取兩個節點,然後進行比較選出最終的目標伺服器節點。

const Salt = "%#!"

type host struct {
    name string
    load uint64
}

type P2C struct {
    sync.RWMutex
    hosts   []*host
    rnd     *rand.Rand
    loadMap map[string]*host
}

func (p *P2C) Balance(key string) (string, error) {
    p.RLock()
    defer p.RUnlock()

    if len(p.hosts) == 0 {
        return "", NoHostError
    }

    n1, n2 := p.hash(key)
    host := n2
    if p.loadMap[n1].load <= p.loadMap[n2].load {
        host = n1
    }
    return host, nil
}

func (p *P2C) hash(key string) (string, string) {
    var n1, n2 string
    if len(key) > 0 {
        saltKey := key + Salt
        n1 = p.hosts[crc32.ChecksumIEEE([]byte(key))%uint32(len(p.hosts))].name
        n2 = p.hosts[crc32.ChecksumIEEE([]byte(saltKey))%uint32(len(p.hosts))].name
        return n1, n2
    }
    n1 = p.hosts[p.rnd.Intn(len(p.hosts))].name
    n2 = p.hosts[p.rnd.Intn(len(p.hosts))].name
    return n1, n2
}

func (p *P2C) Inc(host string) {
    p.Lock()
    defer p.Unlock()

    h, ok := p.loadMap[host]

    if !ok {
        return
    }
    h.load++
}

func (p *P2C) Done(host string) {
    p.Lock()
    defer p.Unlock()

    h, ok := p.loadMap[host]

    if !ok {
        return
    }

    if h.load > 0 {
        h.load--
    }
}

程式碼的實現思路是這樣的,根據使用者的ip作為key來進行hash操作,如果ip為空隨機選取兩個伺服器,如果不為空,則分別使用ip和ip加鹽後ChecksumIEEE計算出的值來選取伺服器,選出兩個伺服器後比較兩者的負載狀況,選擇負載更小的那個。負載的計算方式和least load保持一致。

總結

我們可以看到Balancer實際上完成了簡單的方向代理能力以及實現了多種的負載均衡演算法,不僅可以作為伺服器單獨執行,還可以作為sdk提供負載均衡演算法的使用。其程式碼實現較為簡單,但是十分清晰,非常適合剛接觸golang的開發者。

相關文章