位元組跳動開源 Go HTTP 框架 Hertz 設計實踐

前言

Hertz 是位元組跳動服務框架團隊研發的超大規模的企業級微服務 HTTP 框架,具有高易用性、易擴充套件、低時延等特點。在經過了位元組跳動內部一年多的使用和迭代,如今已在 CloudWeGo 正式開源。目前,Hertz 已經成為了位元組跳動內部最大的 HTTP 框架,線上接入的服務數量超過 1 萬,峰值 QPS 超過 4 千萬。除了各個業務線的同學使用外,也服務於內部很多基礎元件,如:函式計算平臺 FaaS、壓測平臺、各類閘道器、Service Mesh 控制面等,均收到不錯的使用反饋。在如此大規模的場景下,Hertz 擁有極強的穩定性和效能,在內部實踐中某些典型服務,如框架佔比較高的服務、閘道器等服務,遷移 Hertz 後相比 Gin 框架,資源使用顯著減少,CPU 使用率隨流量大小降低 30%-60%,時延也有明顯降低。

Hertz 堅持

內外維護一套程式碼,

為開源使用提供了強有力的保障。透過開源, Hertz 也將豐富雲原生的 Golang 中介軟體體系,完善 CloudWeGo 生態矩陣,為更多開發者和企業搭建雲原生化的大規模分散式系統,提供一種現代的、資源高效的的技術方案。

本文將重點關注 Hertz 的架構設計與功能特性。

專案緣起

最初,位元組跳動內部的 HTTP 框架是對 Gin 框架的封裝,具備不錯的易用性、生態完善等優點。隨著內部業務的不斷髮展,高效能、多場景的需求日漸強烈。而 Gin 是對 Golang 原生 net/http 進行的二次開發,在按需擴充套件和效能最佳化上受到很大侷限。因此,為了滿足業務需求,更好的服務各大業務線,2020 年初,位元組跳動服務框架團隊經過內部使用場景和外部主流開源 HTTP 框架 Fasthttp、Gin、Echo 的調研後,開始基於自研網路庫 Netpoll 開發內部框架 Hertz,讓 Hertz 在面對企業級需求時,有更好的效能及穩定性表現,也能夠滿足業務發展和應對不斷演進的技術需求。

架構設計

Hertz 設計之初調研了大量業界優秀的 HTTP 框架,同時參考了近年來內部實踐中積累的經驗。為了保證框架整體上滿足:1。 極致效能最佳化的可能性;2。 面對未來不可控需求的擴充套件能力, Hertz 採用了 4 層分層設計,保證各個層級功能內聚,同時透過層級之間的介面達到靈活擴充套件的目標。整體架構圖如圖 1 所示。

位元組跳動開源 Go HTTP 框架 Hertz 設計實踐

圖 1:Hertz 架構圖

Hertz 從上到下分為:應用層、路由層、協議層和傳輸層,每一層各司其職,同時公共能力被統一抽象到公共層(common),做到跨層級複用。另外,同主庫一同釋出的還有作為子模組的 Hz 腳手架,它能夠協助使用者快速搭建出專案核心骨架以及提供實用的構建工具鏈。

應用層

應用層是和使用者直接互動的一層,提供豐富易用的 API,主要包括 Server、Client 和一些其他通用抽象。Server 提供了註冊 HandlerFunc、Binding、Rendering 等能力;Client 提供了呼叫下游和服務發現等能力;以及抽象一個 HTTP 請求所必須涉及到的請求(Request)、響應(Response)、上下文(RequestContext)、中介軟體(Middleware)等等。Hertz 的 Server 和 Client 都能夠提供中介軟體這樣的擴充套件能力。

應用層中一個非常重要的抽象就是對 Server HandlerFunc 的抽象。早期,Hertz 路由的處理函式 (HandlerFunc)中並沒有接收標準的 context。Context,我們在大量的實踐過程中發現,業務方通常需要一個標準的上下文在 RPC Client 或者日誌、Tracing 等元件間傳遞,但由於請求上下文(RequestContext)生命週期侷限於一次 HTTP 請求之內,而以上提到的場景往往存在非同步的傳遞和處理,導致如果直接傳遞請求上下文,會導致出現一些資料不一致的問題。為此我們做了諸多嘗試,但是因為核心原因在於請求上下文(RequestContext)的生命週期無法優雅的按需延長,最終在各種設計權衡下,我們在路由的處理函式簽名中增加一個標準的上下文入參,透過分離出生命週期長短各異的兩個上下文的方式,從根本上解決各種因為上下文生命週期不一致導致的異常問題,即:

type HandlerFunc func(c context。Context, ctx *app。RequestContext)

路由層

路由層負責根據 URI 匹配對應的處理函式。

起初,Hertz 的路由基於 httprouter 開發,但隨著使用的使用者越來越多,httprouter 漸漸不能夠滿足需求,主要體現在 httprouter 不能夠同時註冊靜態路由和引數路由,即

/a/b

/:c/d

這兩個路由不能夠同時註冊;甚至有一些更特殊的需求,如

/a/b

/:c/b

,當匹配

/a/b

路由時,兩個路由都能夠匹配上。

Hertz 為滿足這些需求重新構造了路由樹,使用者在註冊路由時擁有很高的自由度:支援靜態路由、引數路由的註冊;支援按優先順序匹配,如上述例子會優先匹配靜態路由

/a/b

;支援路由回溯,如註冊

/a/b

/:c/d

,當匹配

/a/d

時仍然能夠匹配上;支援尾斜線重定向,如註冊

/a/b

,當匹配

/a/b/

時能夠重定向到

/a/b

上。Hertz 提供了豐富的路由能力來滿足使用者的需求,更多的功能可以參考 Hertz 配置文件。

協議層

協議層負責不同協議的實現和擴充套件。

Hertz 支援協議的擴充套件,使用者只需要實現下面的介面便可以按照自己的需求在引擎(Engine) 上擴充套件協議,同時也支援透過 ALPN 協議協商的方式註冊。Hertz 首批只開源了 HTTP1 實現,未來會陸續開源 HTTP2、QUIC 等實現。協議層擴充套件提供的靈活性甚至可以超越 HTTP 協議的範疇,使用者完全可以按需註冊任意符合自身需求的協議層實現,並且加入到 Hertz 的引擎中來,同時,也能夠無縫享受到傳輸層帶來的極致效能。

type ServerFactory interface { New(core Core) (server protocol。Server, err error)}type Server interface { Serve(c context。Context, conn network。Conn) error}

傳輸層

傳輸層負責底層的網路庫的抽象和實現。

Hertz 支援底層網路庫的擴充套件。Hertz 原生完美適配 Netpoll,在時延方面有很多深度的最佳化,非常適合時延敏感的業務接入。Netpoll 對 TLS 能力的支援有待完善,而 TLS 能力又是 HTTP 框架必備能力,為此 Hertz 底層同時支援基於 Golang 標準網路庫的實現適配,支援網路庫的一鍵切換,使用者可根據自己的需求選擇合適的網路庫進行替換。如果使用者有更加高效的網路庫或其他網路庫需求,也完全可以根據需求自行擴充套件。

Hz 腳手架

與 Hertz 一併開源的還有一個易用的命令列工具 Hz,使用者只需提供一個 IDL,根據定義好的介面資訊,Hz 便可以一鍵生成專案腳手架,讓 Hertz 達到開箱即用的狀態;Hz 也支援基於 IDL 的更新能力,能夠基於 IDL 變動智慧地更新專案程式碼。目前 Hz 支援了 Thrift 和 Protobuf 兩種 IDL 定義。命令列工具內建豐富的選項,可以根據自己的需求使用。同時它底層依賴 Protobuf 官方的編譯器和自研的 Thriftgo 的編譯器,兩者都支援自定義的生成程式碼外掛。如果預設模板不能夠滿足需求,完全能夠按需定義。

未來,我們將繼續迭代 Hz,持續整合各種常用的中介軟體,提供更高層面的模組化構建能力。給 Hertz 的使用者提供按需調整的能力,透過靈活的自定義配置打造一套滿足自身開發需求的腳手架。

Common 元件

Common 元件主要存放一些公共的能力,比如錯誤處理、單元測試能力、可觀測性相關能力(Log、Trace、Metrics 等)。對於服務可觀測性的能力,Hertz 提供了預設的實現,使用者可以按需裝配;如果使用者有特殊的需求,也可以透過 Hertz 提供的介面注入。比如對於 Trace 能力,Hertz 提供了預設的實現,也提供了將 Hertz 和 Kitex 串起來的 Example。如果想注入自己的實現,也可以實現下面的介面:

// Tracer is executed at the start and finish of an HTTP。type Tracer interface { Start(ctx context。Context, c *app。RequestContext) context。Context Finish(ctx context。Context, c *app。RequestContext)}

功能特性

中介軟體

Hertz 除了提供 Server 的中介軟體能力,還提供了 Client 中介軟體能力。使用者可以使用中介軟體能力將通用邏輯(如:日誌記錄、效能統計、異常處理、鑑權邏輯等等)和業務邏輯區分開,讓使用者更加專注於業務程式碼。Server 和 Client 中介軟體使用方式相同,使用

Use

方法註冊中介軟體,中介軟體執行順序和註冊順序相同,同時支援預處理和後處理邏輯。

Server 和 Client 的中介軟體實現方式並不相同。對於 Server 來說,我們希望減少棧的深度,同時也希望中介軟體能夠預設的執行下一個,使用者需要手動終止中介軟體的執行。因此,我們將 Server 的中介軟體分成了兩種型別,即不在同一個函式呼叫棧(該中介軟體呼叫完後返回,由上一個中介軟體呼叫下一個中介軟體,如圖 2 中 B 和 C)和在同一個函式呼叫棧的中介軟體(該中介軟體呼叫完後由該中介軟體繼續呼叫下一個中介軟體,如圖 2 中 C 和 Business Handler)。

位元組跳動開源 Go HTTP 框架 Hertz 設計實踐

圖 2: 中介軟體鏈路

其核心是需要一個地方存下當前的呼叫位置 index,並始終保持其遞增。恰好 RequestContext 就是一個儲存 index 合適的位置。但是對於 Client,由於沒有合適的地方儲存 index,我們只能退而求其次,拋棄 index 的實現,將所有的中介軟體構造在同一呼叫鏈上,需要使用者手動呼叫下一個中介軟體。

流式處理

Hertz 提供 Server 和 Client 的流式處理能力。HTTP 的檔案場景是十分常見的場景,除了 Server 側的上傳場景之外,Client 的下載場景也十分常見。為此,Hertz 支援了 Server 和 Client 的流式處理。在內部閘道器場景中,從 Gin 遷移到 Hertz 後,cpu 使用量隨流量大小不同可節省 30%-60% 不等,服務壓力越大,收益越大。Hertz 開啟流式功能的方式也很容易,只需要在 Server 上或 Client 上新增一個配置即可,可參考 CloudWeGo 官網 Hertz 文件的流式處理部分。

由於 Netpoll 採用 LT 的觸發模式,由網路庫主動將將資料從 TCP 緩衝區讀到使用者態,並存儲到 buffer 中,否則 epoll 事件會持續觸發。因此 Server 在超大請求的場景下,由於 Netpoll 持續將資料讀到使用者態記憶體中,可能會有 OOM 的風險。HTTP 檔案上傳場景就是一個典型的場景,但 HTTP 上傳服務又是很常見的場景,因此我們支援標準網路庫 go net,並針對 Hertz 做了特殊最佳化,暴露出

Read()

介面,防止 OOM 發生。

對於 Client,情況並不相同。流式場景下會將連線封裝成

Reader

暴露給使用者,而 Client 有連線池管理,那這樣連線就多了一種狀態,何時關連線,何時複用連線成了一個問題。由於框架側並不知道該連線何時會用完,框架側複用該連線不現實,會導致串包問題。由於 GC 會關閉連線,因此我們起初設想流式場景下的連線交由使用者後,由 GC 負責關閉,這樣也不會導致資源洩漏。但是在測試後發現,由於 GC 存在一定時間間隔,另外 TCP 中主動關閉連線的一方需要等待 2RTT,在高併發場景下會導致 fd 被打滿的情況。最終我們提供了複用連線的介面,對於效能有場要求使用者,在使用完連線後可以將連線重新放入連線池中複用。

效能表現

Hertz 使用位元組跳動自研高效能網路庫 Netpoll,在提高網路庫效率方面有諸多實踐,參考已釋出文章位元組跳動在 Go 網路庫上的實踐。除此之外,Netpoll 還針對 HTTP 場景進行最佳化,透過減少複製和系統呼叫次數提高吞吐以及降低時延。為了衡量 Hertz 效能指標,我們選取了社群中有代表性的框架 Gin(net/http)和 Fasthttp 作為對比,如圖 3 所示。可以看到,Hertz 的極限吞吐、TP99 等指標均處於業界領先水平。未來,Hertz 還將繼續和 Netpoll 深度配合,探索 HTTP 框架效能的極限。

位元組跳動開源 Go HTTP 框架 Hertz 設計實踐

圖 3:Hertz 和其他框架效能對比

一個 Demo

下面簡單演示一下 Hertz 是如何開發一個服務的。

首先,定義 IDL,這裡使用 Thrift 作為 IDL 的定義(也支援使用 Protobuf 定義的 IDL),編寫一個名為 Demo 的 service。這個服務有一個 API: Hello,它的請求引數是一個 query,響應是一個包含一個 RespBody 欄位的 Json。

// idl/hello。thriftnamespace go hello。examplestruct HelloReq { 1: string Name (api。query=“name”);}struct HelloResp { 1: string RespBody;}service HelloService { HelloResp Hello(1: HelloReq request) (api。get=“/hello”);}

接下來我們使用 hz 生成程式碼,並整理和拉取依賴

$ hz new -idl idl/hello。thrift -mod Demo$ go mod tidy && go mod verify

填充業務邏輯,比如我們返回

hello, ${Name}

,那我們在

biz/handler/example/hello_service。go

中新增以下程式碼即可

// Hello 。// @router /hello [GET]func Hello(ctx context。Context, c *app。RequestContext) { var err error var req example。HelloReq err = c。BindAndValidate(&req) if err != nil { c。String(400, err。Error()) return } resp := new(example。HelloResp) resp。RespBody = “hello, ” + req。Name c。JSON(200, resp)}

編譯並執行專案

$ go build$ 。/Demo

到現在一個簡單的 Hertz 專案已經生成,下面我們來測試一下

$ curl http://localhost:8888/hello\?name\=Xiaoming// 如果看到以下返回說明服務已經正常啟動起來啦$ {“RespBody”:“hello, Xiaoming”}

(以上 demo 可以在 hertz-examples 中檢視) 之後就可以愉快的構建自己的專案了。

後記

希望以上的分享能夠讓大家對 Hertz 有一個整體上的認識。同時,我們也在不斷地迭代 Hertz、完善 CloudWeGo 整體生態。歡迎各位感興趣的同學們加入我們,共同建設 CloudWeGo。

參考資料

Hertz: https://github。com/cloudwego/hertz

Hertz Doc: https://www。cloudwego。io/zh/docs/hertz/

位元組跳動在 Go 網路庫上的實踐: https://www。cloudwego。io/zh/blog/2021/10/09/%E5%AD%97%E8%8A%82%E8%B7%B3%E5%8A%A8%E5%9C%A8-go-%E7%BD%91%E7%BB%9C%E5%BA%93%E4%B8%8A%E7%9A%84%E5%AE%9E%E8%B7%B5/