服務發現原理分析與原始碼解讀

2022-07-26 12:02:35

在微服務架構中,有許多繞不開的技術話題。比如服務發現、負載均衡、指標監控、鏈路追蹤,以及服務治理相關的超時控制、熔斷、降級、限流等,還有RPC框架。這些都是微服務架構的基礎,只有打牢這些基礎,才敢說對微服務是有了一點理解,出門也好意思和別人打招呼了,被人提問的時候也能侃侃而談了,線上出了問題往往也能尋根溯源內心不慌了,旁邊的女同事小芳看著你的時候也是滿眼的小可愛了。

在《微服務實踐》公眾號,之前寫了《go-zero微服務實戰系列》的系列文章,這個系列的文章更多的是偏向業務功能和高並行下的服務優化等。本人水平有限,難免有寫的不足的地方,但也依然得到了大家的支援與鼓勵,倍感榮幸,所以決定趁熱打鐵,乘勝追擊,繼續給大家輸出乾貨。

《徹底搞懂系列》會基於 go-zero v1.3.5grpc-go v1.47.0 和大家一起學習微服務架構的方方面面,主要形式是理論+原始碼+案例,如果時間允許也可能會加上配套視訊。

本篇文章作為該系列的第一篇,會先介紹相對比較簡單的服務發現相關內容。

擼袖子開搞,奧利給!!!

服務發現

為什麼在微服務架構中,需要引入服務發現呢?本質上,服務發現的目的是解耦程式對服務具體位置的依賴,對於微服務架構來說,服務發現不是可選的,而是必須的。因為在生產環境中服務提供方都是以叢集的方式對外提供服務,叢集中服務的IP隨時都可能發生變化,比如服務重啟,釋出,擴縮容等,因此我們需要用一本「通訊錄」及時獲取到對應的服務節點,這個獲取的過程其實就是「服務發現」。

要理解服務發現,需要知道服務發現解決了如下三個問題:

  • 服務的註冊(Service Registration)

    當服務啟動的時候,應該通過某種形式(比如呼叫API、產生上線事件訊息、在Etcd中記錄、存資料庫等等)把自己(服務)的資訊通知給服務註冊中心,這個過程一般是由微服務架構來完成,業務程式碼無感知。

  • 服務的維護(Service Maintaining)

    儘管在微服務架構中通常都提供下線機制,但並沒有辦法保證每次服務都能優雅下線(Graceful Shutdown),而不是由於宕機、斷網等原因突然失聯,所以,在微服務架構中就必須要儘可能的保證維護的服務列表的正確性,以避免存取不可用服務節點的尷尬。

  • 服務的發現(Service Discovery)

    這裡所說的發現是狹義的,它特指消費者從微服務架構(服務發現模組)中,把一個服務標識(一般是服務名)轉換為服務實際位置(一般是ip地址)的過程。這個過程(可能是呼叫API,監聽Etcd,查詢資料庫等)業務程式碼無感知。

服務發現有兩種模式,分別是伺服器端服務發現和使用者端服務發現,下面分別進行介紹。

伺服器端服務發現

對於伺服器端服務發現來說,服務呼叫方無需關注服務發現的具體細節,只需要知道服務的DNS域名即可,支援不同語言的接入,對基礎設施來說,需要專門支援負載均衡器,對於請求鏈路來說多了一次網路跳轉,可能會有效能損耗。也可以把咱們比較熟悉的 nginx 反向代理理解為伺服器端服務發現。

使用者端服務發現

對於使用者端服務發現來說,由於使用者端和伺服器端採用了直連的方式,比伺服器端服務發現少了一次網路跳轉,對於服務呼叫方來說需要內建負載均衡器,不同的語言需要各自實現。

對於微服務架構來說,我們期望的是去中心化依賴,中心化的依賴會讓架構變得複雜,當出現問題的時候也會讓整個排查鏈路變得繁瑣,所以在 go-zero 中採用的是使用者端服務發現的模式。

gRPC的服務發現

gRPC提供了自定義Resolver的能力來實現服務發現,通過 Register方法來進行註冊自定義的Resolver,自定義的Resolver需要實現Builder介面,定義如下:

grpc-go/resolver/resolver.go:261

type Builder interface {
    Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)
    Scheme() string
}

先說下 Scheme() 方法的作用,該方法返回一個stirng。註冊的 Resolver 會被儲存在一個全域性的變數m中,m是一個map,這個map的key即為 Scheme() 方法返回的字串。也就是多個Resolver是通過Scheme來進行區分的,所以我們定義 Resolver 的時候 Scheme 不要重複,否則 Resolver 就會被覆蓋。

grpc-go/resolver/resolver.go:49

func Register(b Builder) {
    m[b.Scheme()] = b
}

再來看下Build方法,Build方法有三個引數,還有Resolver返回值,乍一看不知道這些引數是幹嘛的,遇到這種情況該怎麼辦呢?其實也很簡單,去原始碼裡看一下Build方法在哪裡被呼叫的,就知道傳入的引數是哪裡來的,是什麼含義了。

使用gRPC進行服務呼叫前,需要先建立一個 ClientConn 物件,最終發起呼叫的時候,其實是呼叫了 ClientConnInvoke 方法,可以看下如下程式碼,其中 ClientConn 是通過呼叫 NewGreeterClient 傳入的,NewGreeterClientprotoc 自動生成的程式碼,並賦值給 cc 屬性,範例程式碼中建立 ClientConn 呼叫的是 Dial 方法,底層也會呼叫 DialContext

grpc-go/clientconn.go:104

func Dial(target string, opts ...DialOption) (*ClientConn, error) {
    return DialContext(context.Background(), target, opts...)
}

建立 ClientConn 物件,並傳遞給自動生成的 greeterClient

grpc-go/examples/helloworld/greeter_client/main.go:42

func main() {
    flag.Parse()

    // Set up a connection to the server.
    conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }
    defer conn.Close()

    c := pb.NewGreeterClient(conn)
    // Contact the server and print out its response.
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()
    r, err := c.SayHello(ctx, &pb.HelloRequest{Name: *name})
    if err != nil {
        log.Fatalf("could not greet: %v", err)
    }
    log.Printf("Greeting: %s", r.GetMessage())
}

最終通過 Invoke 方法真正發起呼叫請求。

grpc-go/examples/helloworld/helloworld/helloworld_grpc.pb.go:39

func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) {
    out := new(HelloReply)
    err := c.cc.Invoke(ctx, "/helloworld.Greeter/SayHello", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

在瞭解了使用者端呼叫發起的流程後,我們重點看下 ClientConn 方法,該方法巨長,只看我們關注的Resolver部分。ClientConn 第二個引數 Target 的語法可以參考 https://github.com/grpc/grpc/blob/master/doc/naming.md ,採用了URI的格式,其中第一部分表示Resolver的名稱,即自定義Builder方法Scheme的返回值。格式如下:

dns:[//authority/]host[:port] -- DNS(預設)

繼續往下看,通過呼叫 parseTargetAndFindResolver 方法來獲取Resolver

grpc-go/clientconn.go:251

resolverBuilder, err := cc.parseTargetAndFindResolver()

parseTargetAndFindResolver 方法中,主要就是把 target 中的resolver name解析出來,然後根據resolver name去上面我們提到的儲存Resolver的全域性變數m中去找對應的Resolver。

grpc-go/clientconn.go:1574

func (cc *ClientConn) parseTargetAndFindResolver() (resolver.Builder, error) {
    // 非關鍵程式碼省略 ...
  
    var rb resolver.Builder
    parsedTarget, err := parseTarget(cc.target)
  
    // 非關鍵程式碼省略 ...
  
    rb = cc.getResolver(parsedTarget.Scheme)
    if rb == nil {
        return nil, fmt.Errorf("could not get resolver for default scheme: %q", parsedTarget.Scheme)
    }
    cc.parsedTarget = parsedTarget
    return rb, nil
}

接著往下看,找到我們自己註冊的Resolver之後,又呼叫了 newCCResolverWrapper 方法,把我們自己的Resolver也傳了進去

grpc-go/clientconn.go:292

rWrapper, err := newCCResolverWrapper(cc, resolverBuilder)

進入到 newCCResolverWrapper 方法中,在這個方法中終於找到了我們自定義的 BuilderBuild 方法在哪裡被呼叫了,在 grpc-go/resolver_conn_wrapper.go:72 呼叫了我們自定義的Build方法,其中第一引數target傳入的為cc.parseTarget,cc為 newCCResolverWrapper 第一個引數,即 ClientConn 物件。cc.parseTarget是在上面提到的獲取自定義Resolver方法 parseTargetAndFindResolver 中最後賦值的,其中Scheme、Authority、Endpoint分別對應Target語法中定義的三部分,這幾個屬性即將被廢棄,只保留URL屬性,定義如下:

grpc-go/resolver/resolver.go:245

type Target struct {
    // Deprecated: use URL.Scheme instead.
    Scheme string
    // Deprecated: use URL.Host instead.
    Authority string
    // Deprecated: use URL.Path or URL.Opaque instead. The latter is set when
    // the former is empty.
    Endpoint string
    // URL contains the parsed dial target with an optional default scheme added
    // to it if the original dial target contained no scheme or contained an
    // unregistered scheme. Any query params specified in the original dial
    // target can be accessed from here.
    URL url.URL
}

URL的Scheme對應Target的Scheme,URL的Host對應Target的Authority,URL的Path對應Target的Endpoint

/usr/local/go/src/net/url/url.go:358

type URL struct {
    Scheme      string
    Opaque      string    // encoded opaque data
    User        *Userinfo // username and password information
    Host        string    // host or host:port
    Path        string    // path (relative paths may omit leading slash)
    RawPath     string    // encoded path hint (see EscapedPath method)
    ForceQuery  bool      // append a query ('?') even if RawQuery is empty
    RawQuery    string    // encoded query values, without '?'
    Fragment    string    // fragment for references, without '#'
    RawFragment string    // encoded fragment hint (see EscapedFragment method)
}

繼續看傳入自定義Build方法的第二個引數cc,這個cc引數是一個介面 ClientConn,不要和我們之前講的建立使用者端呼叫用的 ClientConn混淆,這個 ClientConn定義如下:

grpc-go/resolver/resolver.go:203

type ClientConn interface {
    UpdateState(State) error
    ReportError(error)
    NewAddress(addresses []Address)
    NewServiceConfig(serviceConfig string)
    ParseServiceConfig(serviceConfigJSON string) *serviceconfig.ParseResult
}

ccResolverWrapper 實現了這個介面,並作為自定義Build方法的第二個引數傳入

grpc-go/resolver_conn_wrapper.go:36

type ccResolverWrapper struct {
    cc         *ClientConn
    resolverMu sync.Mutex
    resolver   resolver.Resolver
    done       *grpcsync.Event
    curState   resolver.State

    incomingMu sync.Mutex // Synchronizes all the incoming calls.
}

自定義Build方法的第三個引數為一些設定項,newCCResolverWrapper實現如下:

grpc-go/resolver_conn_wrapper.go:48

func newCCResolverWrapper(cc *ClientConn, rb resolver.Builder) (*ccResolverWrapper, error) {
    ccr := &ccResolverWrapper{
        cc:   cc,
        done: grpcsync.NewEvent(),
    }

    var credsClone credentials.TransportCredentials
    if creds := cc.dopts.copts.TransportCredentials; creds != nil {
        credsClone = creds.Clone()
    }
    rbo := resolver.BuildOptions{
        DisableServiceConfig: cc.dopts.disableServiceConfig,
        DialCreds:            credsClone,
        CredsBundle:          cc.dopts.copts.CredsBundle,
        Dialer:               cc.dopts.copts.Dialer,
    }

    var err error
    ccr.resolverMu.Lock()
    defer ccr.resolverMu.Unlock()
    ccr.resolver, err = rb.Build(cc.parsedTarget, ccr, rbo)
    if err != nil {
        return nil, err
    }
    return ccr, nil
}

好了,到這裡我們已經知道了自定Resolver的Build方法在哪裡被呼叫,以及傳入的引數的由來以及含義,如果你是第一次看gRPC原始碼的話可能現在已經有點懵了,可以多讀幾遍,為大家提供了時序圖配合程式碼閱讀效果更佳:

go-zero中如何實現的服務發現

通過對gRPC服務發現相關內容的學習,我們大概已經知道了服務發現是怎麼回事了,有了理論,接下來我們就一起看下go-zero是如何基於gRPC做服務發現的。

通過上面的時序圖可以看到第一步是需要自定義Resolver,第二步註冊自定義的Resolver。

go-zero的服務發現是在使用者端實現的。在建立zRPC使用者端的時候,通過init方法進行了自定義Resolver的註冊。

go-zero/zrpc/internal/client.go:23

func init() {
    resolver.Register()
}

在go-zero中預設註冊了四個自定義的Resolver。

go-zero/zrpc/resolver/internal/resolver.go:35

func RegisterResolver() {
    resolver.Register(&directResolverBuilder)
    resolver.Register(&discovResolverBuilder)
    resolver.Register(&etcdResolverBuilder)
    resolver.Register(&k8sResolverBuilder)
}

通過 goctl 自動生成的rpc程式碼預設使用的是etcd作為服務註冊與發現元件的,因此我們重點來看下go-zero是如何基於etcd實現服務註冊與發現的。

etcdBuilder返回的Scheme值為etcd

go-zero/zrpc/resolver/internal/etcdbuilder.go:7

func (b *etcdBuilder) Scheme() string {
    return EtcdScheme
}

go-zero/zrpc/resolver/internal/resolver.go:15

EtcdScheme = "etcd"

還記得我們上面講過的嗎?在時序圖的第五步和第六步,會通過scheme去全域性的m中尋找自定義的Resolver,而scheme是從DialContext第二個引數target中解析出來的,那我們看下go-zero呼叫DialContext的時候,傳入的target值是什麼。target是通過 BuildTarget 方法獲取來的,定義如下:

go-zero/zrpc/config.go:72

func (cc RpcClientConf) BuildTarget() (string, error) {
    if len(cc.Endpoints) > 0 {
        return resolver.BuildDirectTarget(cc.Endpoints), nil
    } else if len(cc.Target) > 0 {
        return cc.Target, nil
    }

    if err := cc.Etcd.Validate(); err != nil {
        return "", err
    }

    if cc.Etcd.HasAccount() {
        discov.RegisterAccount(cc.Etcd.Hosts, cc.Etcd.User, cc.Etcd.Pass)
    }
    if cc.Etcd.HasTLS() {
        if err := discov.RegisterTLS(cc.Etcd.Hosts, cc.Etcd.CertFile, cc.Etcd.CertKeyFile,
            cc.Etcd.CACertFile, cc.Etcd.InsecureSkipVerify); err != nil {
            return "", err
        }
    }

    return resolver.BuildDiscovTarget(cc.Etcd.Hosts, cc.Etcd.Key), nil
}

最終生成target結果的方法如下,也就是對於etcd來說,最終生成的target格式為:

etcd://127.0.0.1:2379/product.rpc

go-zero/zrpc/resolver/target.go:17

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

似乎有點不對勁,scheme不應該是etcd麼?為什麼是discov?其實是因為etcd和discov共用了一套Resolver邏輯,也就是gRPC通過scheme找到已經註冊的discov Resolver,該Resolver對應的Build方法同樣適用於etcd,discov可以認為是對服務發現的一個抽象,etcdResolver的定義如下:

go-zero/zrpc/resolver/internal/etcdbuilder.go:3

type etcdBuilder struct {
    discovBuilder
}

服務註冊

在詳細看基於etcd的自定義Resolver邏輯之前,我們先來看下go-zero的服務註冊,即如何把服務資訊註冊到etcd中的,我們以 lebron/apps/product/rpc 這個服務為例進行說明。

在product-rpc的組態檔中設定了Etcd,包括etcd的地址和服務對應的key,如下:

lebron/apps/product/rpc/etc/product.yaml:4

ListenOn: 127.0.0.1:9002

Etcd:
  Hosts:
  - 127.0.0.1:2379
  Key: product.rpc

呼叫zrpc.MustNewServer建立gRPC server,接著會呼叫 NewRpcPubServer 方法,定義如下:

go-zero/zrpc/internal/rpcpubserver.go:17

func NewRpcPubServer(etcd discov.EtcdConf, listenOn string, opts ...ServerOption) (Server, error) {
    registerEtcd := func() error {
        pubListenOn := figureOutListenOn(listenOn)
        var pubOpts []discov.PubOption
        if etcd.HasAccount() {
            pubOpts = append(pubOpts, discov.WithPubEtcdAccount(etcd.User, etcd.Pass))
        }
        if etcd.HasTLS() {
            pubOpts = append(pubOpts, discov.WithPubEtcdTLS(etcd.CertFile, etcd.CertKeyFile,
                etcd.CACertFile, etcd.InsecureSkipVerify))
        }
        pubClient := discov.NewPublisher(etcd.Hosts, etcd.Key, pubListenOn, pubOpts...)
        return pubClient.KeepAlive()
    }
    server := keepAliveServer{
        registerEtcd: registerEtcd,
        Server:       NewRpcServer(listenOn, opts...),
    }

    return server, nil
}

在啟動Server的時候,呼叫Start方法,在Start方法中會呼叫registerEtcd進行真正的服務註冊

go-zero/zrpc/internal/rpcpubserver.go:44

func (s keepAliveServer) Start(fn RegisterFn) error {
    if err := s.registerEtcd(); err != nil {
        return err
    }

    return s.Server.Start(fn)
}

在KeepAlive方法中,首先建立etcd連線,然後呼叫register方法進行服務註冊,在register首先建立租約,租約預設時間為10秒鐘,最後通過Put方法進行註冊。

go-zero/core/discov/publisher.go:125

func (p *Publisher) register(client internal.EtcdClient) (clientv3.LeaseID, error) {
    resp, err := client.Grant(client.Ctx(), TimeToLive)
    if err != nil {
        return clientv3.NoLease, err
    }

    lease := resp.ID
    if p.id > 0 {
        p.fullKey = makeEtcdKey(p.key, p.id)
    } else {
        p.fullKey = makeEtcdKey(p.key, int64(lease))
    }
    _, err = client.Put(client.Ctx(), p.fullKey, p.value, clientv3.WithLease(lease))

    return lease, err
}

key的規則定義如下,其中key為在組態檔中設定的Key,這裡為product.rpc,id為租約id。value為服務的地址。

go-zero/core/discov/clients.go:39

func makeEtcdKey(key string, id int64) string {
    return fmt.Sprintf("%s%c%d", key, internal.Delimiter, id)
}

在瞭解了服務註冊的流程後,我們啟動product-rpc服務,然後通過如下命令檢視服務註冊的地址:

$ etcdctl get product.rpc --prefix
product.rpc/7587864068988009477
127.0.0.1:9002

KeepAlive 方法中,服務註冊完後,最後會呼叫 keepAliveAsync 進行租約的續期,以保證服務一直是存活的狀態,如果服務異常退出了,那麼也就無法進行續期,服務發現也就能自動識別到該服務異常下線了。

服務發現

現在已經把服務註冊到etcd中了,繼續來看如何發現這些服務地址。我們回到 etcdBuilder 的Build方法的實現。

還記得第一個引數target是什麼嗎?如果不記得了可以往上翻再複習一下,首先從target中解析出etcd的地址,和服務對應的key。然後建立etcd連線,接著執行update方法,在update方法中,通過呼叫cc.UpdateState方法進行服務狀態的更新。

go-zero/zrpc/resolver/internal/discovbuilder.go:14

func (b *discovBuilder) Build(target resolver.Target, cc resolver.ClientConn, _ resolver.BuildOptions) (
    resolver.Resolver, error) {
    hosts := strings.FieldsFunc(targets.GetAuthority(target), func(r rune) bool {
        return r == EndpointSepChar
    })
    sub, err := discov.NewSubscriber(hosts, targets.GetEndpoints(target))
    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,
            })
        }
        if err := cc.UpdateState(resolver.State{
            Addresses: addrs,
        }); err != nil {
            logx.Error(err)
        }
    }
    sub.AddListener(update)
    update()

    return &nopResolver{cc: cc}, nil
}

如果忘記了Build方法第二個引數cc的話,可以往上翻翻再複習一下,cc.UpdateState方法定義如下,最終會呼叫 ClientConnupdateResolverState 方法:

grpc-go/resolver_conn_wrapper.go:94

func (ccr *ccResolverWrapper) UpdateState(s resolver.State) error {
    ccr.incomingMu.Lock()
    defer ccr.incomingMu.Unlock()
    if ccr.done.HasFired() {
        return nil
    }
    ccr.addChannelzTraceEvent(s)
    ccr.curState = s
    if err := ccr.cc.updateResolverState(ccr.curState, nil); err == balancer.ErrBadResolverState {
        return balancer.ErrBadResolverState
    }
    return nil
}

繼續看 Build 方法,update方法會被新增到事件監聽中,當有PUT和DELETE事件觸發,都會呼叫update方法進行服務狀態的更新,事件監聽是通過etcd的Watch機制實現,程式碼如下:

go-zero/core/discov/internal/registry.go:295

func (c *cluster) watchStream(cli EtcdClient, key string) bool {
    rch := cli.Watch(clientv3.WithRequireLeader(c.context(cli)), makeKeyPrefix(key), clientv3.WithPrefix())
    for {
        select {
        case wresp, ok := <-rch:
            if !ok {
                logx.Error("etcd monitor chan has been closed")
                return false
            }
            if wresp.Canceled {
                logx.Errorf("etcd monitor chan has been canceled, error: %v", wresp.Err())
                return false
            }
            if wresp.Err() != nil {
                logx.Error(fmt.Sprintf("etcd monitor chan error: %v", wresp.Err()))
                return false
            }

            c.handleWatchEvents(key, wresp.Events)
        case <-c.done:
            return true
        }
    }
}

當有事件觸發的時候,會呼叫事件處理常式 handleWatchEvents ,最終會呼叫 Build 方法中定義的update進行服務狀態的更新:

go-zero/core/discov/internal/registry.go:172

func (c *cluster) handleWhandleWatchEventsatchEvents(key string, events []*clientv3.Event) {
    c.lock.Lock()
    listeners := append([]UpdateListener(nil), c.listeners[key]...)
    c.lock.Unlock()

    for _, ev := range events {
        switch ev.Type {
        case clientv3.EventTypePut:
            c.lock.Lock()
            if vals, ok := c.values[key]; ok {
                vals[string(ev.Kv.Key)] = string(ev.Kv.Value)
            } else {
                c.values[key] = map[string]string{string(ev.Kv.Key): string(ev.Kv.Value)}
            }
            c.lock.Unlock()
            for _, l := range listeners {
                l.OnAdd(KV{
                    Key: string(ev.Kv.Key),
                    Val: string(ev.Kv.Value),
                })
            }
        case clientv3.EventTypeDelete:
            c.lock.Lock()
            if vals, ok := c.values[key]; ok {
                delete(vals, string(ev.Kv.Key))
            }
            c.lock.Unlock()
            for _, l := range listeners {
                l.OnDelete(KV{
                    Key: string(ev.Kv.Key),
                    Val: string(ev.Kv.Value),
                })
            }
        default:
            logx.Errorf("Unknown event type: %v", ev.Type)
        }
    }
}

第一次會呼叫 load 方法,獲取key對應的服務列表,通過etcd字首匹配的方式獲取,獲取方式如下:

func (c *cluster) load(cli EtcdClient, key string) {
    var resp *clientv3.GetResponse
    for {
        var err error
        ctx, cancel := context.WithTimeout(c.context(cli), RequestTimeout)
        resp, err = cli.Get(ctx, makeKeyPrefix(key), clientv3.WithPrefix())
        cancel()
        if err == nil {
            break
        }

        logx.Error(err)
        time.Sleep(coolDownInterval)
    }

    var kvs []KV
    for _, ev := range resp.Kvs {
        kvs = append(kvs, KV{
            Key: string(ev.Key),
            Val: string(ev.Value),
        })
    }

    c.handleChanges(key, kvs)
}

獲取的服務地址列表,通過map儲存在本地,當有事件觸發的時候通過操作map進行服務列表的更新,這裡有個隱藏的設計考慮是當 etcd 連不上或者出現故障時,記憶體裡的服務地址列表不會被更新,保障了當 etcd 有問題時,服務發現依然可以工作,保障服務繼續正常執行。邏輯相對比較直觀,這裡就不再贅述,程式碼邏輯在 go-zero/core/discov/subscriber.go:76 ,下面是go-zero服務發現的時序圖

結束語

到這裡服務發現相關的內容已經講完了,內容還是有點多的,特別是程式碼部分需要反覆仔細閱讀才能加深理解。

我們一起來簡單回顧下本篇的內容:

  • 首先介紹了服務發現的概念,以及服務發現需要解決哪些問題
  • 服務發現的兩種模式,分別是伺服器端發現模式和使用者端發現模式
  • 接著一起學習了gRPC提供的註冊Resolver的能力,通過註冊Resolver來實現自定義的服務發現功能,以及gRPC內部是如何尋找到自定義的Resolver和觸發呼叫自定義Resolver的邏輯
  • 最後學習了go-zero中服務發現的實現原理,
    • 先是介紹了go-zero的服務註冊流程,演示了最終註冊的效果
    • 接著從自定義Resolver的Build方法出發,瞭解到先是通過字首匹配的方式獲取對應的服務列表存在本地,然後呼叫UpdateState方法更新服務狀態
    • 通過Watch的方式監聽服務狀態的變化,監聽到變化後會觸發呼叫update方法更新原生的服務列表和呼叫UpdateState更新服務的狀態。

服務發現是理解微服務架構的基礎,希望大家能仔細的閱讀本文,如果有疑問可以隨時找我討論,在社群群中可以搜尋dawn_zhou找到我。

通過服務發現獲取到服務列表後,接著就會通過Invoke方法進行服務呼叫,在服務呼叫的時候就涉及到負載均衡,通過負載均衡選擇一個合適的節點發起請求。負載均衡是下一篇文章要講的內容,敬請期待。

希望本篇文章對你有所幫助,你的點贊是作者持續輸出的最大動力。

專案地址

https://github.com/zeromicro/go-zero

歡迎使用 go-zerostar 支援我們!

微信交流群

關注『微服務實踐』公眾號並點選 交流群 獲取社群群二維條碼。