Categories
程式開發

gRPC長連接在微服務業務系統中的實踐


長連接和短連接哪個更好, 一直是被人反复討論且樂此不疲的話題。有人追求短連接的簡單可靠, 有人卻對長連接的低延時趨之若鶩。那麼長連接到底好在哪裡, 它是否是解決性能問題的銀彈? 本文就從gRPC 長連接的視角, 為你揭開這層面紗。

1 什麼是長連接

HTTP 長連接, 又稱為HTTP persistent connection, 也稱作HTTP keep-alive 或HTTP connection reuse, 是指在一條TCP 連接上發起多個HTTP 請求/ 應答的一種交互模式。

那麼什麼是長連接, 什麼是短連接? 他們和TCP 有什麼關係呢?

為了理解這個概念, 我們來看下圖中TCP 連接的三種行為。

gRPC長連接在微服務業務系統中的實踐 1

圖一展示了client 和server 端基於TCP 協議的一次交互過程, 分為三個階段: 三次握手, 數據交換和四次揮手。這個過程比較簡單, 但是實際應用中存在一個問題。假如server 處理請求過程非常耗時, 或者不幸突然宕機, 此時client 會陷入無限等待的狀態。為了解決這個問題, TCP 在具體的實現中加入了keepalive。

圖二展示了keepalive 的工作機制。當該機制開啟之後, 系統會為每一個連接設置一個定時器, 不斷地發送ACK 包, 用來探測目標主機是否存活, 當對方主機宕機或者網絡中斷時, 便能及時的得到反饋並釋放資源。

在圖一和圖二中可以看到, 雖然連接的持續時間不同, 但他們的行為類似, 都是完成了一次數據交互後便斷開了連接, 如果有更多的請求要發送, 就需要重新建立連接。這種行為模式被稱為短連接。

那有沒有可能在完成數據交互後不斷開連接, 而是複用它繼續下一次請求呢?

圖三展示了這種交互的過程。在client 和server 端完成了一次數據交換後, client 通過keepalive 機制保持該連接, 後面的請求會直接復用該連接, 我們稱這種模式為長連接。

理解了上面的過程, 我們便可以得出下面的結論:

  1. TCP 連接本身並沒有長短的區分, 長或短只是在描述我們使用它的方式
  2. 長/ 短是指多次數據交換能否復用同一個連接, 而不是指連接的持續時間
  3. TCP 的keepalive 僅起到保活探測的作用, 和連接的長短並沒有因果關係

需要注意的是, 在HTTP/1.x 協議中也有Keep-Alive 的概念。如下圖, 通過在報文頭部中設置connection: Keep-Alive 字段來告知對方自己支持並期望使用長連接通信, 這和TCP keepalive 保活探測的作用是完全不同的。

gRPC長連接在微服務業務系統中的實踐 2

2 長連接的優勢

相比於短連接,長連接具有:

  1. 較低的延時。由於跳過了三次握手的過程,長連接比短連接有更低的延遲。
  2. 較低的帶寬佔用。由於不用為每個請求建立和關閉連接,長連接交換效率更高,網絡帶寬佔用更少。
  3. 較少的系統資源佔用。 server 為了維持連接,會為每個連接創建socket,分配文件句柄, 在內存中分配讀寫buffer,設置定時器進行keepalive。因此更少的連接數也意味著更少的資源佔用。

另外, gRPC 使用HTTP/2.0 作為傳輸協議, 從該協議的設計來講, 長連接也是更推薦的使用方式, 原因如下:

1. HTTP/2.0 的多路復用, 使得連接的複用效率得到了質的提升

HTTP/1.0 開始支持長連接, 如下圖1, 請求會在client 排隊(request queuing), 當響應返回之後再發送下一個請求。而這個過程中, 任何一個請求處理過慢都會阻塞整個流程, 這個問題被稱為線頭阻塞問題, 即Head-of-line blocking。

HTTP/1.1 做出了改進, 允許client 可以連續發送多個請求, 但server 的響應必須按照請求發送的順序依次返回, 稱為Pipelining (server 端響應排隊), 如下圖2。這在一定程度上提高了復用效率, 但並沒能解決線頭阻塞的問題。

HTTP/2.0 引入了分幀分流的機制, 實現了多路復用(亂序發送亂序接受), 徹底的解決了線頭阻塞, 極大提高了連接復用的效率。如下圖3。

gRPC長連接在微服務業務系統中的實踐 3

2. HTTP/2.0 的單個連接維持的成本更高

除了分幀分流之外, HTTP/2.0 還加入了諸如流控制和服務端推送等特性, 這也使得協議變得複雜, 連接的建立和維護成本升高。

下圖展示了HTTP/1.1 一次短連接交互的過程。可以看到, 握手和揮手之間, 只發生了兩次數據交換, 一次請求①和一次響應②。

gRPC長連接在微服務業務系統中的實踐 4

下圖展示了HTTP/2.0 一次短連接交互過程, 握手和揮手之間, 發生了多達11 次的數據交換。除了client 端請求(header 和body 分成了兩個數據幀, 於第⑤⑥步分開傳輸)和server 端響應(⑨) 之外, 還夾雜著一些諸如協議確認(①) , 連接配置(②③④) , 流管理(⑦⑩) 和探測保活(⑧⑪) 的過程。

gRPC長連接在微服務業務系統中的實踐 5

很明顯可以看出, HTTP/2.0 的連接更重, 維護成本更高, 使得複用帶來的收益更高。

3 長連接不是銀彈

雖然長連接有很多優勢, 但並不是所有的場景都適用。在使用長連接之前, 至少有以下兩個點需要考慮。

1. client 和server 的數量

長連接模式下, server 要和每一個client 都保持連接。如果client 數量遠遠超過server 數量, 與每個client 都維持一個長連接, 對server 來說會是一個極大的負擔。好在這種場景中, 連接的利用率和復用率往往不高,使用簡單且易於管理的短連接是更好的選擇。即使用長連接, 也必須設置一個合理的超時機制, 如在空閒時間過長時斷開連接, 釋放server 資源。

2. 負載均衡機制

現代後端服務端架構中, 為了實現高可用和可伸縮, 一般都會引入單獨的模塊來提供負載均衡的功能, 稱為負載均衡器。根據工作在OSI 不同的層級, 不同的負載均衡器會提供不同的轉發功能。接下來就最常見的L4 (工作在TCP 層)和L7 (工作在應用層, 如HTTP) 兩種負載均衡器來分析。

L4 負載均衡器: 原理是將收到的TCP 報文, 以一定的規則轉發給後端的某一個server。這個轉發規則其實是到某個server 地址的映射。由於它只轉發, 而不會進行報文解析, 因此這種場景下client 會和server 端握手後直接建立連接, 並且所有的數據報文都只會轉發給同一個server。如下圖所示, L4 會將10.0.0.1:3001 的流量全部轉發給11.0.0.2:3110。

gRPC長連接在微服務業務系統中的實踐 6

在短連接模式下, 由於連接會不斷的建立和關閉, 同一個client 的流量會被分發到不同的server。

在長連接模式下, 由於連接一旦建立便不會斷開, 就會導致流量會被分發到同一個server。在client 與server 數量差距不大甚至client 少於server 的情況下, 就會導致流量分發不均。如下圖中, 第三個server 會一直處於空閒的狀態。

gRPC長連接在微服務業務系統中的實踐 7

為了避免這種場景中負載均衡失效的情況, L7 負載均衡器便成了一個更好的選擇。

L7 負載均衡器: 相比L4 只能基於連接進行負載均衡, L7 可以進行HTTP 協議的解析. 當client 發送請求時, client 會先和L7 握手, L7 再和後端的一個或幾個server 握手,並根據不同的策略將請求分發給這些server,實現基於請求的負載均衡. 如下圖所示,10.0.0.1 通過長連接發出的多個請求會根據url, cookies 或header 被L7 分發到後端不同的server 。

gRPC長連接在微服務業務系統中的實踐 8

因此,必須要意識到,雖然長連接可以帶來性能的提升,但如果忽略了使用場景或是選擇了錯誤的負載均衡器,結果很可能會適得其反。實踐中一定要結合實際情況, 避免因錯誤的使用導致性能下降或者負載均衡失效的情況發生。

4 Biz-UI 團隊長連接實踐

連接的管理

Biz-UI 的業務系統採用Kubernetes + Istio 架構來作為生產平台。 Kubernetes 負責服務的部署、升級和管理等較基礎的功能。 Istio 負責上層的服務治理, 包括流量管理, 熔斷, 限流降級和調用鏈治理等。在這之上,業務系統服務之間則使用gRPC 進行遠程調用。

Istio 功能的實現依賴於其使用sidecar (默認為Envoy)控制Pod 的入站出站流量, 從來進行劫持和代理轉發。

下圖展示了Istio 中兩個service 流量的轉發過程。

gRPC長連接在微服務業務系統中的實踐 9

藍色部分是Kubernetes 的一些基本組件, 如集群元數據存儲中心etcd, 提供元數據查詢和管理服務的api-server, 服務註冊中心coreDNS, 負責流量轉發的kube-proxy 和iptables。

黃色的部分是Istio 引入的Pilot 和Envoy 組件。 Pilot 通過list/watch api-server 來為Envoy 提供服務發現功能。 Envoy 則負責接管Pod 的出站和入站流量, 從而實現連接管理, 熔斷限流等功能。和nginx 類似, Envoy 也是工作在第七層。

綠色部分錶示提供業務功能的兩種服務, 訂單服務(Order) 和用戶數據服務(User)。

Order 調用User 服務的過程為:

  1. Order 通過coreDNS 解析到User 服務對應的ClusterIP。
  2. 當Order 向該ClusterIP 發送請求時, 實際上是同Envoy 代理建立連接。
  3. Envoy 根據Pilot 的路由規則, 從ClusterIP 對應的多個User Pod IP 中選擇一個, 並同該Pod 的Envoy 代理建立連接。
  4. 最後, User 的Envoy 代理再與User 建立連接, 並進行請求轉發。

在這個過程中, 總共有三個連接被建立:

  • 第一個連接是Order -> Order Envoy, 是由Order 建立並控制。
  • 第二和第三個連接是Order Envoy -> User Envoy -> User, 由Envoy 發起和建立, 不受Order 控制。默認是工作在長連接模式, 並通過連接池進行維護。

具體實踐中, Envoy 會選擇建立多個連接的方式來提高可用性。如下面的圖示中:

gRPC長連接在微服務業務系統中的實踐 10

綠色的連接表示由Envoy 管理的連接。可以看到, Order Envoy 會選擇多個上游User Envoy,並分別與每一個建立兩個長連接。同時,每個User Envoy 也會與User 建立四條長連接。這個行為是Envoy 的行為,不受Order 連接(藍色的部分) 的影響。

藍色的連接表示由Order 管理的連接。可以看到,無論是建立N 個短連接(圖左上方)還是一個長連接(圖右上方),Order 發出的多個請求都會經過兩層長連接分發到不同的User 實例上,從而實現基於請求的負載均衡。

值得注意的是, Order service 中代碼的實現決定了藍色的連接為長連接或短連接, 且不會影響綠色的部分。

長連接的實現

我們以下面的proto 文件為例來講述基於Go 語言的實現。

syntax = "proto3";
 package test;

 message HelloRequest {
   string message = 1;
 }

 message HelloResponse {
   string response = 1;
 }

 service TestService {
   rpc SayHello (HelloRequest) returns (HelloResponse) {
   }
 }

proto 生成對應的client 代碼如下:

type TestServiceClient interface {
  SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption)
(*HelloResponse, error)
}
type testServiceClient struct {
  cc *grpc.ClientConn
}

func NewTestServiceClient(cc *grpc.ClientConn) TestServiceClient {
  return &testServiceClient{cc}
}

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

我們可以看到, testServiceClient (以下簡稱client)中有一個成員變量grpc.ClientConn (以下簡稱con),它代表了一條gRPC 連接,用來承擔底層發送請求和接受響應的功能。 client 和con 是一對一綁定的,為了連接復用,我們可以把其中任何一個提取成共享變量,將其改寫成單例模式。

假如將con 提取成共享變量,那麼每次復用的時候,還需為其新建一個client 對象,因此我們可以直接將client 提取成共享變量。

首先我們定義兩個包級別共享變量,

// 实现了 TestServiceClient 接口, 作为共享连接的载体
var internalTestServiceClientInstance proto.TestServiceClient

 // 互斥锁, 用来对 internalTestServiceClientInstance 提供并发访问保护
 var internalTestServiceClientMutex sync.Mutex

然後我們構建一個client 的代理,對外暴露方法調用,對內提供

internalTestServiceClientInstance 的封装。然后按照如下的方式实现 SayHello
type internalTestServiceClient struct {
  dialOptions []grpc.DialOption
}
func (i *internalTestServiceClient) SayHello(ctx context.Context, req
*proto.HelloRequest, opts ...grpc.CallOption) (*proto.HelloResponse, error) {
  // 通过配置⽂件来控制是否启⽤⻓连接
  useLongConnection := grpcClient.UseLongConnection() && len(i.dialOptions) ==
0
  // 如果启⽤了⻓连接, 且 client 已被初始化, 直接进⾏⽅法调⽤
  if useLongConnection && internalTestServiceClientInstance != nil {
    return internalTestServiceClientInstance.SayHello(ctx, req, opts...)
  }

  // 当启⽤了⻓连接, 但 client 还未被初始化时, 进⾏初始化
  c, conn, err := getTestServiceClient(i.dialOptions...)
  if err != nil {
    return nil, err
  }

  if useLongConnection {
    internalTestServiceClientMutex.Lock()
    defer internalTestServiceClientMutex.Unlock()

    // DCL 双重检查, 确保实例只会被初始化⼀次
    if internalTestServiceClientInstance == nil {
      internalTestServiceClientInstance = c
      log.Info("long connection established for internalTestServiceClient")
    } else {
      // 当未通过双重检查时, 关闭当前连接, 避免连接泄露
      defer grpcClient.CloseCon(conn)
      log.Info("long connection for internalTestServiceClient has been
established, going to close current connection")
    }
  } else {
    // 当⻓连接未开启, 则在⽅法调⽤后关闭连接
    defer grpcClient.CloseCon(conn)
  }

  return c.SayHello(ctx, req, opts...)
}

這裡需要注意的幾個點:

  • client 的共享而不是con 層的共享
  • 懶加載
  • DCL 雙檢查避免連接洩露
  • 當使用自定義的dialOptions 時, 切換到短連接模式

性能測試

我們在Istio 平台下, 對同一個接口在長連接和短連接兩種模式下的響應時間和吞吐量進行了壓力測試。

首先是對響應時間的測試, 結果如下圖所示。

gRPC長連接在微服務業務系統中的實踐 11

對短連接來說, 當並發數<350 的, 響應時間呈線性增長, 當並發數超過350 時, 響應時間陡增, 很快達到了10s 並引發了超時。

對長連接來說, 當並發數<500 時, 響應時間雖然也呈線性增長, 但比短連接要小。當並發數超過500 時, 響應時間陡增並很快超時。

接下來是吞吐量的測試, 結果如下圖所示。

gRPC長連接在微服務業務系統中的實踐 12

對短連接來說, 當並發數<350 時, 吞吐量基本維持在290, 超過350 便開始驟減。

對長連接來說, 當並發數<500 時, 吞吐量基本維持在325, 超過500 便開始驟減。

從測試結果來看, 長連接和短連接都存在明顯的性能拐點(長連接為500, 短連接為350), 在到達拐點之前, 性能變化較為平穩,一旦超過便急劇下降。但無論是從響應時間,QPS, 或是拐點值大小來看, 長連接都明顯要優於短連接。

5 總結

本文深入解釋了長連接和短連接概念, 並闡述了長連接的優勢及使用時應考慮的問題。結合Biz-UI 的業務系統, 分析了Istio 平台中gRPC 連接的管理方式和長連接基於Go 語言的實現, 並通過性能測試展示了長連接帶來的響應時間和吞吐量上的提升, 為gRPC 框架中使用長連接提供了有力的理論依據和數據支持。

希望此文會對你有所幫助!

參考鏈接:

【1】[HTTP/2.0 – RFC7540]

https:// httpwg.org/specs/rfc7540.html

【2】[TCP keepalive]

https://www.freesoft.org/CIE/RFC/1122/71.htm

【3】[HTTP Keep-Alive]

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Keep-Alive

【4】[gRPC]

https://grpc.io/

【5】[Istio]

https://istio.io/

【6】[Kubernetes]

https://kubernetes.io/

【7】[Envoy Doc]

https://www.envoyproxy.io/docs/envoy/latest/

【8】[NGINX Layer 7 Load Balancing]

https://www.nginx.com/resources/glossary/layer-7-load-balancing/

作者簡介:

張琦,FreeWheel Biz-UI 團隊高級研發工程師, 熱衷於新技術的研究與分享,擅長發現與解決後端開發痛點,目前致力於Go,容器化和無服務化相關的實踐。