淺談gRPC

簡述

gRPC 是一個高性能、開源和通用的 RPC 框架,面向移動和 HTTP/2 設計。目前提供 C、Java 和 Go 語言版本,分別是:grpc, grpc-java, grpc-go. 其中 C 版本支持 C, C++, Node.js, Python, Ruby, Objective-C, PHP 和 C# 支持。

gRPC 基於 HTTP/2 標準設計,帶來諸如雙向流、流控、頭部壓縮、單 TCP 連接上的多複用請求等特性。這些特性使得其在移動設備上表現更好,更省電和節省空間佔用。

調用模型

在這裏插入圖片描述

  1. 客戶端(gRPC Stub)調用 A 方法,發起 RPC 調用。
  2. 對請求信息使用 Protobuf 進行對象序列化壓縮(IDL)。
  3. 服務端(gRPC Server)接收到請求後,解碼請求體,進行業務邏輯處理並返回。
  4. 對響應結果使用 Protobuf 進行對象序列化壓縮(IDL)。
  5. 客戶端接受到服務端響應,解碼請求體。回調被調用的 A 方法,喚醒正在等待響應(阻塞)的客戶端調用並返回響應結果。

調用方式

一、Unary RPC:一元 RPC

在這裏插入圖片描述
Server

type SearchService struct{}

func (s *SearchService) Search(ctx context.Context, r *pb.SearchRequest) (*pb.SearchResponse, error) {
    return &pb.SearchResponse{Response: r.GetRequest() + " Server"}, nil
}

const PORT = "9001"

func main() {
    server := grpc.NewServer()
    pb.RegisterSearchServiceServer(server, &SearchService{})

    lis, err := net.Listen("tcp", ":"+PORT)
    ...

    server.Serve(lis)
}
  • 創建 gRPC Server 對象,你可以理解爲它是 Server 端的抽象對象。
  • 將 SearchService(其包含需要被調用的服務端接口)註冊到 gRPC Server。 的內部註冊中心。這樣可以在接受到請求時,通過內部的 “服務發現”,發現該服務端接口並轉接進行邏輯處理。
  • 創建 Listen,監聽 TCP 端口。
  • gRPC Server 開始 lis.Accept,直到 Stop 或 GracefulStop。

Client

func main() {
    conn, err := grpc.Dial(":"+PORT, grpc.WithInsecure())
    ...
    defer conn.Close()

    client := pb.NewSearchServiceClient(conn)
    resp, err := client.Search(context.Background(), &pb.SearchRequest{
        Request: "gRPC",
    })
    ...
}
  • 創建與給定目標(服務端)的連接句柄。
  • 創建 SearchService 的客戶端對象。
  • 發送 RPC 請求,等待同步響應,得到回調後返回響應結果。

二、Server-side streaming RPC:服務端流式 RPC

在這裏插入圖片描述
Server

func (s *StreamService) List(r *pb.StreamRequest, stream pb.StreamService_ListServer) error {
    for n := 0; n <= 6; n++ {
        stream.Send(&pb.StreamResponse{
            Pt: &pb.StreamPoint{
                ...
            },
        })
    }

    return nil
}

Client

func printLists(client pb.StreamServiceClient, r *pb.StreamRequest) error {
    stream, err := client.List(context.Background(), r)
    ...
    
    for {
        resp, err := stream.Recv()
        if err == io.EOF {
            break
        }
        ...
    }

    return nil
}

三、Client-side streaming RPC:客戶端流式 RPC

在這裏插入圖片描述
Server

func (s *StreamService) Record(stream pb.StreamService_RecordServer) error {
    for {
        r, err := stream.Recv()
        if err == io.EOF {
            return stream.SendAndClose(&pb.StreamResponse{Pt: &pb.StreamPoint{...}})
        }
        ...

    }

    return nil
}

Client

func printRecord(client pb.StreamServiceClient, r *pb.StreamRequest) error {
    stream, err := client.Record(context.Background())
    ...
    
    for n := 0; n < 6; n++ {
        stream.Send(r)
    }

    resp, err := stream.CloseAndRecv()
    ...

    return nil
}

四、Bidirectional streaming RPC:雙向流式 RPC

在這裏插入圖片描述
Server

func (s *StreamService) Route(stream pb.StreamService_RouteServer) error {
    for {
        stream.Send(&pb.StreamResponse{...})
        r, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        ...
    }

    return nil
}

Client

func printRoute(client pb.StreamServiceClient, r *pb.StreamRequest) error {
    stream, err := client.Route(context.Background())
    ...

    for n := 0; n <= 6; n++ {
        stream.Send(r)
        resp, err := stream.Recv()
        if err == io.EOF {
            break
        }
        ...
    }

    stream.CloseSend()

    return nil
}

客戶端與服務端是如何交互的

在開始分析之前,我們要先 gRPC 的調用有一個初始印象。那麼最簡單的就是對 Client 端調用 Server 端進行抓包去剖析,看看整個過程中它都做了些什麼事。如下圖:
在這裏插入圖片描述

  • Magic
  • SETTINGS
  • HEADERS
  • DATA
  • SETTINGS
  • WINDOW_UPDATE
  • PING
  • HEADERS
  • DATA
  • HEADERS
  • WINDOW_UPDATE
  • PING

我們略加整理髮現共有十二個行爲,是比較重要的。在開始分析之前,建議你自己先想一下,它們的作用都是什麼?大膽猜測一下,帶着疑問去學習效果更佳。

行爲分析

Magic

在這裏插入圖片描述
Magic 幀的主要作用是建立 HTTP/2 請求的前言。在 HTTP/2 中,要求兩端都要發送一個連接前言,作爲對所使用協議的最終確認,並確定 HTTP/2 連接的初始設置,客戶端和服務端各自發送不同的連接前言。

而上圖中的 Magic 幀是客戶端的前言之一,內容爲 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n,以確定啓用 HTTP/2 連接。

SETTINGS

在這裏插入圖片描述
在這裏插入圖片描述
SETTINGS 幀的主要作用是設置這一個連接的參數,作用域是整個連接而並非單一的流。

而上圖的 SETTINGS 幀都是空 SETTINGS 幀,圖一是客戶端連接的前言(Magic 和 SETTINGS 幀分別組成連接前言)。圖二是服務端的。另外我們從圖中可以看到多個 SETTINGS 幀,這是爲什麼呢?是因爲發送完連接前言後,客戶端和服務端還需要有一步互動確認的動作。對應的就是帶有 ACK 標識 SETTINGS 幀。

HEADERS

在這裏插入圖片描述
HEADERS 幀的主要作用是存儲和傳播 HTTP 的標頭信息。我們關注到 HEADERS 裏有一些眼熟的信息,分別如下:

  • method:POST
  • scheme:http
  • path:/proto.SearchService/Search
  • authority::10001
  • content-type:application/grpc
  • user-agent:grpc-go/1.20.0-dev

你會發現這些東西非常眼熟,其實都是 gRPC 的基礎屬性,實際上遠遠不止這些,只是設置了多少展示多少。例如像平時常見的 grpc-timeoutgrpc-encoding 也是在這裏設置的。

DATA

在這裏插入圖片描述
DATA 幀的主要作用是裝填主體信息,是數據幀。而在上圖中,可以很明顯看到我們的請求參數 gRPC 存儲在裏面。只需要瞭解到這一點就可以了。

HEADERS, DATA, HEADERS

在這裏插入圖片描述
在上圖中 HEADERS 幀比較簡單,就是告訴我們 HTTP 響應狀態和響應的內容格式。
在這裏插入圖片描述
在上圖中 DATA 幀主要承載了響應結果的數據集,圖中的 gRPC Server 就是我們 RPC 方法的響應結果。
在這裏插入圖片描述
在上圖中 HEADERS 幀主要承載了 gRPC 狀態 和 gRPC 狀態消息,圖中的 grpc-statusgrpc-message 就是我們的 gRPC 調用狀態的結果。

其它步驟

WINDOW_UPDATE

主要作用是管理和流的窗口控制。通常情況下打開一個連接後,服務器和客戶端會立即交換 SETTINGS 幀來確定流控制窗口的大小。默認情況下,該大小設置爲約 65 KB,但可通過發出一個 WINDOW_UPDATE 幀爲流控制設置不同的大小。
在這裏插入圖片描述

PING/PONG

主要作用是判斷當前連接是否仍然可用,也常用於計算往返時間。其實也就是 PING/PONG,大家對此應該很熟。

小結

在這裏插入圖片描述

  • 在建立連接之前,客戶端/服務端都會發送連接前言(Magic+SETTINGS),確立協議和配置項。
  • 在傳輸數據時,是會涉及滑動窗口(WINDOW_UPDATE)等流控策略的。
  • 傳播 gRPC 附加信息時,是基於 HEADERS 幀進行傳播和設置;而具體的請求/響應數據是存儲的 DATA 幀中的。
  • 請求/響應結果會分爲 HTTP 和 gRPC 狀態響應兩種類型。
  • 客戶端發起 PING,服務端就會迴應 PONG,反之亦可。

淺談理解

服務端

在這裏插入圖片描述
爲什麼四行代碼,就能夠起一個 gRPC Server,內部做了什麼邏輯。你有想過嗎?接下來我們一步步剖析,看看裏面到底是何方神聖。

一、初始化

// grpc.NewServer()
func NewServer(opt ...ServerOption) *Server {
	opts := defaultServerOptions
	for _, o := range opt {
		o(&opts)
	}
	s := &Server{
		lis:    make(map[net.Listener]bool),
		opts:   opts,
		conns:  make(map[io.Closer]bool),
		m:      make(map[string]*service),
		quit:   make(chan struct{}),
		done:   make(chan struct{}),
		czData: new(channelzData),
	}
	s.cv = sync.NewCond(&s.mu)
	...

	return s
}

這塊比較簡單,主要是實例 grpc.Server 並進行初始化動作。涉及如下:

  • lis:監聽地址列表。
  • opts:服務選項,這塊包含 Credentials、Interceptor 以及一些基礎配置。
  • conns:客戶端連接句柄列表。
  • m:服務信息映射。
  • quit:退出信號。
  • done:完成信號。
  • czData:用於存儲 ClientConn,addrConn 和 Server 的channelz 相關數據。
  • cv:當優雅退出時,會等待這個信號量,直到所有 RPC 請求都處理並斷開纔會繼續處理。

二、註冊

pb.RegisterSearchServiceServer(server, &SearchService{})

步驟一:Service API interface

// search.pb.go
type SearchServiceServer interface {
	Search(context.Context, *SearchRequest) (*SearchResponse, error)
}

func RegisterSearchServiceServer(s *grpc.Server, srv SearchServiceServer) {
	s.RegisterService(&_SearchService_serviceDesc, srv)
}

還記得我們平時編寫的 Protobuf 嗎?在生成出來的 .pb.go 文件中,會定義出 Service APIs interface 的具體實現約束。而我們在 gRPC Server 進行註冊時,會傳入應用 Service 的功能接口實現,此時生成的 RegisterServer 方法就會保證兩者之間的一致性。

步驟二:Service API IDL

你想亂傳糊弄一下?不可能的,請乖乖定義與 Protobuf 一致的接口方法。但是那個 &_SearchService_serviceDesc 又有什麼作用呢?代碼如下:

// search.pb.go
var _SearchService_serviceDesc = grpc.ServiceDesc{
	ServiceName: "proto.SearchService",
	HandlerType: (*SearchServiceServer)(nil),
	Methods: []grpc.MethodDesc{
		{
			MethodName: "Search",
			Handler:    _SearchService_Search_Handler,
		},
	},
	Streams:  []grpc.StreamDesc{},
	Metadata: "search.proto",
}

這看上去像服務的描述代碼,用來向內部表述 “我” 都有什麼。涉及如下:

  • ServiceName:服務名稱
  • HandlerType:服務接口,用於檢查用戶提供的實現是否滿足接口要求
  • Methods:一元方法集,注意結構內的 Handler 方法,其對應最終的 RPC 處理方法,在執行 RPC 方法的階段會使用。
  • Streams:流式方法集
  • Metadata:元數據,是一個描述數據屬性的東西。在這裏主要是描述 SearchServiceServer 服務

步驟三:Register Service

func (s *Server) register(sd *ServiceDesc, ss interface{}) {
    ...
	srv := &service{
		server: ss,
		md:     make(map[string]*MethodDesc),
		sd:     make(map[string]*StreamDesc),
		mdata:  sd.Metadata,
	}
	for i := range sd.Methods {
		d := &sd.Methods[i]
		srv.md[d.MethodName] = d
	}
	for i := range sd.Streams {
		...
	}
	s.m[sd.ServiceName] = srv
}

在最後一步中,我們會將先前的服務接口信息、服務描述信息給註冊到內部 service 去,以便於後續實際調用的使用。涉及如下:

  • server:服務的接口信息
  • md:一元服務的 RPC 方法集
  • sd:流式服務的 RPC 方法集
  • mdata:metadata,元數據

小結

在這一章節中,主要介紹的是 gRPC Server 在啓動前的整理和註冊行爲,看上去很簡單,但其實一切都是爲了後續的實際運行的預先準備。因此我們整理一下思路,將其串聯起來看看,如下:
在這裏插入圖片描述

三、監聽

接下來到了整個流程中,最重要也是大家最關注的監聽/處理階段,核心代碼如下:

func (s *Server) Serve(lis net.Listener) error {
	...
	var tempDelay time.Duration 
	for {
		rawConn, err := lis.Accept()
		if err != nil {
			if ne, ok := err.(interface {
				Temporary() bool
			}); ok && ne.Temporary() {
				if tempDelay == 0 {
					tempDelay = 5 * time.Millisecond
				} else {
					tempDelay *= 2
				}
				if max := 1 * time.Second; tempDelay > max {
					tempDelay = max
				}
				...
				timer := time.NewTimer(tempDelay)
				select {
				case <-timer.C:
				case <-s.quit:
					timer.Stop()
					return nil
				}
				continue
			}
			...
			return err
		}
		tempDelay = 0

		s.serveWG.Add(1)
		go func() {
			s.handleRawConn(rawConn)
			s.serveWG.Done()
		}()
	}
}

Serve 會根據外部傳入的 Listener 不同而調用不同的監聽模式,這也是 net.Listener 的魅力,靈活性和擴展性會比較高。而在 gRPC Server 中最常用的就是 TCPConn,基於 TCP Listener 去做。接下來我們一起看看具體的處理邏輯,如下:
在這裏插入圖片描述

  • 循環處理連接,通過 lis.Accept 取出連接,如果隊列中沒有需處理的連接時,會形成阻塞等待。
  • lis.Accept 失敗,則觸發休眠機制,若爲第一次失敗那麼休眠 5ms,否則翻倍,再次失敗則不斷翻倍直至上限休眠時間 1s,而休眠完畢後就會嘗試去取下一個 “它”。
  • lis.Accept 成功,則重置休眠的時間計數和啓動一個新的 goroutine 調用 handleRawConn 方法去執行/處理新的請求,也就是大家很喜歡說的 “每一個請求都是不同的 goroutine 在處理”。
  • 在循環過程中,包含了 “退出” 服務的場景,主要是硬關閉和優雅重啓服務兩種情況。

客戶端

在這裏插入圖片描述

一、創建撥號連接

// grpc.Dial(":"+PORT, grpc.WithInsecure())
func DialContext(ctx context.Context, target string, opts ...DialOption) (conn *ClientConn, err error) {
	cc := &ClientConn{
		target:            target,
		csMgr:             &connectivityStateManager{},
		conns:             make(map[*addrConn]struct{}),
		dopts:             defaultDialOptions(),
		blockingpicker:    newPickerWrapper(),
		czData:            new(channelzData),
		firstResolveEvent: grpcsync.NewEvent(),
	}
	...
	chainUnaryClientInterceptors(cc)
	chainStreamClientInterceptors(cc)

	...
}

grpc.Dial 方法實際上是對於 grpc.DialContext 的封裝,區別在於 ctx 是直接傳入 context.Background。其主要功能是創建與給定目標的客戶端連接,其承擔了以下職責:

  • 初始化 ClientConn
  • 初始化(基於進程 LB)負載均衡配置
  • 初始化 channelz
  • 初始化重試規則和客戶端一元/流式攔截器
  • 初始化協議棧上的基礎信息
  • 相關 context 的超時控制
  • 初始化並解析地址信息
  • 創建與服務端之間的連接

連沒連

之前聽到有的人說調用 grpc.Dial 後客戶端就已經與服務端建立起了連接,但這對不對呢?我們先鳥瞰全貌,看看正在跑的 goroutine。如下:
在這裏插入圖片描述
我們可以有幾個核心方法一直在等待/處理信號,通過分析底層源碼可得知。涉及如下:

func (ac *addrConn) connect()
func (ac *addrConn) resetTransport()
func (ac *addrConn) createTransport(addr resolver.Address, copts transport.ConnectOptions, connectDeadline time.Time)
func (ac *addrConn) getReadyTransport()

在這裏主要分析 goroutine 提示的 resetTransport 方法,看看都做了啥。核心代碼如下:

func (ac *addrConn) resetTransport() {
	for i := 0; ; i++ {
		if ac.state == connectivity.Shutdown {
			return
		}
		...
		connectDeadline := time.Now().Add(dialDuration)
		ac.updateConnectivityState(connectivity.Connecting)
		newTr, addr, reconnect, err := ac.tryAllAddrs(addrs, connectDeadline)
		if err != nil {
			if ac.state == connectivity.Shutdown {
				return
			}
			ac.updateConnectivityState(connectivity.TransientFailure)
			timer := time.NewTimer(backoffFor)
			select {
			case <-timer.C:
				...
			}
			continue
		}

		if ac.state == connectivity.Shutdown {
			newTr.Close()
			return
		}
		...
		if !healthcheckManagingState {
			ac.updateConnectivityState(connectivity.Ready)
		}
		...

		if ac.state == connectivity.Shutdown {
			return
		}
		ac.updateConnectivityState(connectivity.TransientFailure)
	}
}

在該方法中會不斷地去嘗試創建連接,若成功則結束。否則不斷地根據 Backoff 算法的重試機制去嘗試創建連接,直到成功爲止。從結論上來講,單純調用 DialContext 是異步建立連接的,也就是並不是馬上生效,處於 Connecting 狀態,而正式下要到達 Ready 狀態纔可用。

真的連了嗎

在這裏插入圖片描述
在抓包工具上提示一個包都沒有,那麼這算真正連接了嗎?我認爲這是一個表述問題,我們應該儘可能的嚴謹。如果你真的想通過 DialContext 方法就打通與服務端的連接,則需要調用 WithBlock 方法,雖然會導致阻塞等待,但最終連接會到達 Ready 狀態(握手成功)。如下圖:
在這裏插入圖片描述

二、實例化 Service API

type SearchServiceClient interface {
	Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error)
}

type searchServiceClient struct {
	cc *grpc.ClientConn
}

func NewSearchServiceClient(cc *grpc.ClientConn) SearchServiceClient {
	return &searchServiceClient{cc}
}

這塊就是實例 Service API interface,比較簡單。

三、調用

// search.pb.go
func (c *searchServiceClient) Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error) {
	out := new(SearchResponse)
	err := c.cc.Invoke(ctx, "/proto.SearchService/Search", in, out, opts...)
	if err != nil {
		return nil, err
	}
	return out, nil
}

proto 生成的 RPC 方法更像是一個包裝盒,把需要的東西放進去,而實際上調用的還是 grpc.invoke 方法。如下:

func invoke(ctx context.Context, method string, req, reply interface{}, cc *ClientConn, opts ...CallOption) error {
	cs, err := newClientStream(ctx, unaryStreamDesc, cc, method, opts...)
	if err != nil {
		return err
	}
	if err := cs.SendMsg(req); err != nil {
		return err
	}
	return cs.RecvMsg(reply)
}

通過概覽,可以關注到三塊調用。如下:

  • newClientStream:獲取傳輸層 Trasport 並組合封裝到 ClientStream 中返回,在這塊會涉及負載均衡、超時控制、 Encoding、 Stream 的動作,與服務端基本一致的行爲。
  • cs.SendMsg:發送 RPC 請求出去,但其並不承擔等待響應的功能。
  • cs.RecvMsg:阻塞等待接受到的 RPC 方法響應結果。

連接

// clientconn.go
func (cc *ClientConn) getTransport(ctx context.Context, failfast bool, method string) (transport.ClientTransport, func(balancer.DoneInfo), error) {
	t, done, err := cc.blockingpicker.pick(ctx, failfast, balancer.PickOptions{
		FullMethodName: method,
	})
	if err != nil {
		return nil, nil, toRPCErr(err)
	}
	return t, done, nil
}

newClientStream 方法中,我們通過 getTransport 方法獲取了 Transport 層中抽象出來的 ClientTransportServerTransport,實際上就是獲取一個連接給後續 RPC 調用傳輸使用。

四、關閉連接

// conn.Close()
func (cc *ClientConn) Close() error {
	defer cc.cancel()
    ...
	cc.csMgr.updateState(connectivity.Shutdown)
    ...
	cc.blockingpicker.close()
	if rWrapper != nil {
		rWrapper.close()
	}
	if bWrapper != nil {
		bWrapper.close()
	}

	for ac := range conns {
		ac.tearDown(ErrClientConnClosing)
	}
	if channelz.IsOn() {
		...
		channelz.AddTraceEvent(cc.channelzID, ted)
		channelz.RemoveEntry(cc.channelzID)
	}
	return nil
}

該方法會取消 ClientConn 上下文,同時關閉所有底層傳輸。涉及如下:

  • Context Cancel
  • 清空並關閉客戶端連接
  • 清空並關閉解析器連接
  • 清空並關閉負載均衡連接
  • 添加跟蹤引用
  • 移除當前通道信息

Q&A

1. gRPC Metadata 是通過什麼傳輸?

在這裏插入圖片描述

2. 調用 grpc.Dial 會真正的去連接服務端嗎?

會,但是是異步連接的,連接狀態爲正在連接。但如果你設置了 grpc.WithBlock 選項,就會阻塞等待(等待握手成功)。另外你需要注意,當未設置 grpc.WithBlock 時,ctx 超時控制對其無任何效果。

3. 調用 ClientConn 不 Close 會導致泄露嗎?

會,除非你的客戶端不是常駐進程,那麼在應用結束時會被動地回收資源。但如果是常駐進程,你又真的忘記執行 Close 語句,會造成的泄露。如下圖:

3.1. 客戶端

在這裏插入圖片描述

3.2. 服務端

在這裏插入圖片描述

3.3. TCP

在這裏插入圖片描述

4. 不控制超時調用的話,會出現什麼問題?

短時間內不會出現問題,但是會不斷積蓄泄露,積蓄到最後當然就是服務無法提供響應了。如下圖:
在這裏插入圖片描述

5. 爲什麼默認的攔截器不可以傳多個?

func chainUnaryClientInterceptors(cc *ClientConn) {
	interceptors := cc.dopts.chainUnaryInts
	if cc.dopts.unaryInt != nil {
		interceptors = append([]UnaryClientInterceptor{cc.dopts.unaryInt}, interceptors...)
	}
	var chainedInt UnaryClientInterceptor
	if len(interceptors) == 0 {
		chainedInt = nil
	} else if len(interceptors) == 1 {
		chainedInt = interceptors[0]
	} else {
		chainedInt = func(ctx context.Context, method string, req, reply interface{}, cc *ClientConn, invoker UnaryInvoker, opts ...CallOption) error {
			return interceptors[0](ctx, method, req, reply, cc, getChainUnaryInvoker(interceptors, 0, invoker), opts...)
		}
	}
	cc.dopts.unaryInt = chainedInt
}

當存在多個攔截器時,取的就是第一個攔截器。因此結論是允許傳多個,但並沒有用。

6. 真的需要用到多個攔截器的話,怎麼辦?

可以使用 go-grpc-middleware 提供的 grpc.UnaryInterceptorgrpc.StreamInterceptor 鏈式方法,方便快捷省心。

單單會用還不行,我們再深剖一下,看看它是怎麼實現的。核心代碼如下:

func ChainUnaryClient(interceptors ...grpc.UnaryClientInterceptor) grpc.UnaryClientInterceptor {
	n := len(interceptors)
	if n > 1 {
		lastI := n - 1
		return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
			var (
				chainHandler grpc.UnaryInvoker
				curI         int
			)

			chainHandler = func(currentCtx context.Context, currentMethod string, currentReq, currentRepl interface{}, currentConn *grpc.ClientConn, currentOpts ...grpc.CallOption) error {
				if curI == lastI {
					return invoker(currentCtx, currentMethod, currentReq, currentRepl, currentConn, currentOpts...)
				}
				curI++
				err := interceptors[curI](currentCtx, currentMethod, currentReq, currentRepl, currentConn, chainHandler, currentOpts...)
				curI--
				return err
			}

			return interceptors[0](ctx, method, req, reply, cc, chainHandler, opts...)
		}
	}
    ...
}

當攔截器數量大於 1 時,從 interceptors[1] 開始遞歸,每一個遞歸的攔截器 interceptors[i] 會不斷地執行,最後才真正的去執行 handler 方法。同時也經常有人會問攔截器的執行順序是什麼,通過這段代碼你得出結論了嗎?

7. 頻繁創建 ClientConn 有什麼問題?

這個問題我們可以反向驗證一下,假設不公用 ClientConn 看看會怎麼樣?如下:

func BenchmarkSearch(b *testing.B) {
	for i := 0; i < b.N; i++ {
		conn, err := GetClientConn()
		if err != nil {
			b.Errorf("GetClientConn err: %v", err)
		}
		_, err = Search(context.Background(), conn)
		if err != nil {
			b.Errorf("Search err: %v", err)
		}
	}
}

輸出結果:

    ... connection error: desc = "transport: Error while dialing dial tcp :10001: socket: too many open files"
    ... connection error: desc = "transport: Error while dialing dial tcp :10001: socket: too many open files"
    ... connection error: desc = "transport: Error while dialing dial tcp :10001: socket: too many open files"
    ... connection error: desc = "transport: Error while dialing dial tcp :10001: socket: too many open files"
FAIL
exit status 1

當你的應用場景是存在高頻次同時生成/調用 ClientConn 時,可能會導致系統的文件句柄佔用過多。這種情況下你可以變更應用程序生成/調用 ClientConn 的模式,又或是池化它,這塊可以參考 grpc-go-pool 項目。

8. 客戶端請求失敗後會默認重試嗎?

會不斷地進行重試,直到上下文取消。而重試時間方面採用 backoff 算法作爲的重連機制,默認的最大重試時間間隔是 120s。

9. 爲什麼要用 HTTP/2 作爲傳輸協議?

許多客戶端要通過 HTTP 代理來訪問網絡,gRPC 全部用 HTTP/2 實現,等到代理開始支持 HTTP/2 就能透明轉發 gRPC 的數據。不光如此,負責負載均衡、訪問控制等等的反向代理都能無縫兼容 gRPC,比起自己設計 wire protocol 的 Thrift,這樣做科學不少。@ctiller @滕亦飛

10. 在 Kubernetes 中 gRPC 負載均衡有問題?

gRPC 的 RPC 協議是基於 HTTP/2 標準實現的,HTTP/2 的一大特性就是不需要像 HTTP/1.1 一樣,每次發出請求都要重新建立一個新連接,而是會複用原有的連接。

所以這將導致 kube-proxy 只有在連接建立時纔會做負載均衡,而在這之後的每一次 RPC 請求都會利用原本的連接,那麼實際上後續的每一次的 RPC 請求都跑到了同一個地方。

注:使用 k8s service 做負載均衡的情況下

總結

  • gRPC 基於 HTTP/2 + Protobuf。
  • gRPC 有四種調用方式,分別是一元、服務端/客戶端流式、雙向流式。
  • gRPC 的附加信息都會體現在 HEADERS 幀,數據在 DATA 幀上。
  • Client 請求若使用 grpc.Dial 默認是異步建立連接,當時狀態爲 Connecting。
  • Client 請求若需要同步則調用 WithBlock(),完成狀態爲 Ready。
  • Server 監聽是循環等待連接,若沒有則休眠,最大休眠時間 1s;若接收到新請求則起一個新的 goroutine 去處理。
  • grpc.ClientConn 不關閉連接,會導致 goroutine 和 Memory 等泄露。
  • 任何內/外調用如果不加超時控制,會出現泄漏和客戶端不斷重試。
  • 特定場景下,如果不對 grpc.ClientConn 加以調控,會影響調用。
  • 攔截器如果不用 go-grpc-middleware 鏈式處理,會覆蓋。
  • 在選擇 gRPC 的負載均衡模式時,需要謹慎。

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章