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

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

1 什麼是長連接

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

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

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

圖一 展示了 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 保活探測的作用是完全不同的。

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。

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

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

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

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

很明顯可以看出, 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。

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

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

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

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

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

4 Biz-UI 團隊長連接實踐

連接的管理

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

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

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

藍色部分是 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 會選擇建立多個連接的方式來提高可用性。如下面的圖示中:

綠色的連接表示由 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 平臺下, 對同一個接口在長連接和短連接兩種模式下的響應時間和吞吐量進行了壓力測試。

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

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

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

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

對短連接來說, 當併發數 <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/en-US/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,容器化和無服務化相關的實踐。

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