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

Kevin Wan發表於2020-12-07

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

基本原理

原理流程圖如下:

yuanli

從圖中可以看出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

因此可以看出自定義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

dialcontext

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

ccresolverwrapper

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

http2

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

Balancer模組

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

balancer

在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進行處理

client_call

總結

本文主要分析了gRPC的resolver模組和balancer模組,詳細介紹瞭如何自定義resolver和balancer,以及通過分析go-zero中對resolver和balancer的實現瞭解了自定義resolver和balancer的過程,同時還分析可客戶端建立的流程和呼叫的流程。希望本文能給大家帶來一些幫助

專案地址

github.com/tal-tech/go-zero

如果覺得文章不錯,歡迎 github 點個 star ?

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章