基於 gRPC 的服務註冊與發現和負載均衡的原理與實戰

zhoushuguang發表於2020-12-03

gRPC是一個現代的、高效能、開源的和語言無關的通用 RPC 框架,基於 HTTP2 協議設計,序列化使用 PB(Protocol Buffer),PB 是一種語言無關的高效能序列化框架,基於 HTTP2+PB 保證了的高效能。go-zero是一個開源的微服務框架,支援 http 和 rpc 協議,其中 rpc 底層依賴 gRPC,本文會結合 gRPC 和 go-zero 原始碼從實戰的角度和大家一起分析下服務註冊與發現和負載均衡的實現原理

基本原理

原理流程圖如下:

從圖中可以看出 go-zero 實現了 gRPC 的 resolver 和 balancer 介面,然後通過 gprc.Register 方法註冊到 gRPC 中,resolver 模組提供了服務註冊的功能,balancer 模組提供了負載均衡的功能。當 client 發起服務呼叫的時候會根據 resolver 註冊進來的服務列表,使用註冊進來的 balancer 選擇一個服務發起請求,如果沒有進行註冊 gRPC 會使用預設的 resolver 和 balancer。服務地址的變更會同步到 etcd 中,go-zero 監聽 etcd 的變化通過 resolver 更新服務列表

Resolver 模組

通過 resolver.Register 方法可以註冊自定義的 Resolver,Register 方法定義如下,其中 Builder 為 interface 型別,因此自定義 resolver 需要實現該介面,Builder 定義如下

// Register 註冊自定義resolver
func Register(b Builder) {
    m[b.Scheme()] = b
}

// Builder 定義resolver builder
type Builder interface {
    Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)
    Scheme() string
}

Build 方法的第一個引數 target 的型別為Target定義如下,建立 ClientConn 呼叫 grpc.DialContext 的第二個引數 target 經過解析後需要符合這個結構定義,target 定義格式為: scheme://authority/endpoint_name

type Target struct {
    Scheme    string // 表示要使用的名稱系統
    Authority string // 表示一些特定於方案的引導資訊
    Endpoint  string // 指出一個具體的名字
}

Build 方法返回的 Resolver 也是一個介面型別。定義如下

type Resolver interface {
    ResolveNow(ResolveNowOptions)
    Close()
}

流程圖下圖

因此可以看出自定義 Resolver 需要實現如下步驟:

  • 定義 target
  • 實現 resolver.Builder
  • 實現 resolver.Resolver
  • 呼叫 resolver.Register 註冊自定義的 Resolver,其中 name 為 target 中的 scheme
  • 實現服務發現邏輯 (etcd、consul、zookeeper)
  • 通過 resolver.ClientConn 實現服務地址的更新

go-zero 中 target 的定義如下,預設的名字為discov

// BuildDiscovTarget 構建target
func BuildDiscovTarget(endpoints []string, key string) string {
    return fmt.Sprintf("%s://%s/%s", resolver.DiscovScheme,
        strings.Join(endpoints, resolver.EndpointSep), key)
}

// RegisterResolver 註冊自定義的Resolver
func RegisterResolver() {
    resolver.Register(&dirBuilder)
    resolver.Register(&disBuilder)
}

Build 方法的實現如下

func (d *discovBuilder) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (
    resolver.Resolver, error) {
    hosts := strings.FieldsFunc(target.Authority, func(r rune) bool {
        return r == EndpointSepChar
    })
  // 獲取服務列表
    sub, err := discov.NewSubscriber(hosts, target.Endpoint)
    if err != nil {
        return nil, err
    }

    update := func() {
        var addrs []resolver.Address
        for _, val := range subset(sub.Values(), subsetSize) {
            addrs = append(addrs, resolver.Address{
                Addr: val,
            })
        }
    // 呼叫UpdateState方法更新
        cc.UpdateState(resolver.State{
            Addresses: addrs,
        })
    }

  // 新增監聽,當服務地址發生變化會觸發更新
    sub.AddListener(update)
  // 更新服務列表
    update()

    return &nopResolver{cc: cc}, nil
}

那麼註冊進來的 resolver 在哪裡用到的呢?當建立客戶端的時候呼叫 DialContext 方法建立 ClientConn 的時候回進行如下操作

  • 攔截器處理
  • 各種配置項處理
  • 解析 target
  • 獲取 resolver
  • 建立 ccResolverWrapper

建立 clientConn 的時候回根據 target 解析出 scheme,然後根據 scheme 去找已註冊對應的 resolver,如果沒有找到則使用預設的 resolver

ccResolverWrapper 的流程如下圖,在這裡 resolver 會和 balancer 會進行關聯,balancer 的處理方式和 resolver 類似也是通過 wrapper 進行了一次封裝

緊著著會根據獲取到的地址建立 htt2 的連結

到此 ClientConn 建立過程基本結束,我們再一起梳理一下整個過程,首先獲取 resolver,其中 ccResolverWrapper 實現了 resovler.ClientConn 介面,通過 Resolver 的 UpdateState 方法觸發獲取 Balancer,獲取 Balancer,其中 ccBalancerWrapper 實現了 balancer.ClientConn 介面,通過 Balnacer 的 UpdateClientConnState 方法觸發建立連線 (SubConn),最後建立 HTTP2 Client

Balancer 模組

balancer 模組用來在客戶端發起請求時進行負載均衡,如果沒有註冊自定義的 balancer 的話 gRPC 會採用預設的負載均衡演算法,流程圖如下

在 go-zero 中自定義的 balancer 主要實現瞭如下步驟:

  • 實現 PickerBuilder,Build 方法返回 balancer.Picker
  • 實現 balancer.Picker,Pick 方法實現負載均衡演算法邏輯
  • 呼叫 balancer.Registet 註冊自定義 Balancer
  • 使用 baseBuilder 註冊,框架已提供了 baseBuilder 和 baseBalancer 實現了 Builer 和 Balancer

Build 方法的實現如下

func (b *p2cPickerBuilder) Build(readySCs map[resolver.Address]balancer.SubConn) balancer.Picker {
    if len(readySCs) == 0 {
        return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
    }

    var conns []*subConn
    for addr, conn := range readySCs {
        conns = append(conns, &subConn{
            addr:    addr,
            conn:    conn,
            success: initSuccess,
        })
    }

    return &p2cPicker{
        conns: conns,
        r:     rand.New(rand.NewSource(time.Now().UnixNano())),
        stamp: syncx.NewAtomicDuration(),
    }
}

go-zero 中預設實現了 p2c 負載均衡演算法,該演算法的優勢是能彈性的處理各個節點的請求,Pick 的實現如下

func (p *p2cPicker) Pick(ctx context.Context, info balancer.PickInfo) (
    conn balancer.SubConn, done func(balancer.DoneInfo), err error) {
    p.lock.Lock()
    defer p.lock.Unlock()

    var chosen *subConn
    switch len(p.conns) {
    case 0:
        return nil, nil, balancer.ErrNoSubConnAvailable // 沒有可用連結
    case 1:
        chosen = p.choose(p.conns[0], nil) // 只有一個連結
    case 2:
        chosen = p.choose(p.conns[0], p.conns[1])
    default: // 選擇一個健康的節點
        var node1, node2 *subConn
        for i := 0; i < pickTimes; i++ {
            a := p.r.Intn(len(p.conns))
            b := p.r.Intn(len(p.conns) - 1)
            if b >= a {
                b++
            }
            node1 = p.conns[a]
            node2 = p.conns[b]
            if node1.healthy() && node2.healthy() {
                break
            }
        }

        chosen = p.choose(node1, node2)
    }

    atomic.AddInt64(&chosen.inflight, 1)
    atomic.AddInt64(&chosen.requests, 1)
    return chosen.conn, p.buildDoneFunc(chosen), nil
}

客戶端發起呼叫的流程如下,會呼叫 pick 方法獲取一個 transport 進行處理

總結

本文主要分析了 gRPC 的 resolver 模組和 balancer 模組,詳細介紹瞭如何自定義 resolver 和 balancer,以及通過分析 go-zero 中對 resolver 和 balancer 的實現瞭解了自定義 resolver 和 balancer 的過程,同時還分析可客戶端建立的流程和呼叫的流程。寫作不易,如果覺得文章對你有幫助的話,有勞 star?

專案地址

https://github.com/tal-tech/go-zero

框架地址

https://github.com/tal-tech/go-zero/tree/master/zrpc

文件地址

https://www.yuque.com/tal-tech/go-zero/rhakzy

微信交流群

更多原創文章乾貨分享,請關注公眾號
  • 基於 gRPC 的服務註冊與發現和負載均衡的原理與實戰
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章