再談 APISIX 高性能實踐

  • 2019 年 11 月 1 日
  • 筆記

2019 年 8 月 31 日,OpenResty 社區聯合又拍雲,舉辦 OpenResty × Open Talk 全國巡迴沙龍·成都站,APISIX 主要作者王院生在活動上做了《APISIX 高性能實踐》的分享。

OpenResty × Open Talk 全國巡迴沙龍是由 OpenResty 社區、又拍雲發起,邀請業內資深的 OpenResty 技術專家,分享 OpenResty 實戰經驗,增進 OpenResty 使用者的交流與學習,推動 OpenResty 開源項目的發展。

王院生,APISIX 項目發起人和主要作者,OpenResty 社區、OpenResty 軟件基金會發起人,《OpenResty 最佳實踐》主要作者。

以下是分享全文:

首先做下自我介紹,我大學畢業後在傳統金融行業工作九年,2014 年加入奇虎 360,期間撰寫了《OpenResty 最佳實踐》。我個人比較喜歡研究技術和開源,可能是受老羅影響,喜歡嘗試理想化的事情。今年 3 月份與志同道合的夥伴一起創辦了深圳支流科技公司,這是一家以開源方式創業的科技公司,在國內屈指可數,APISIX 是我們目前的主要項目。

APISIX 是微服務 API 網關產品,今年 7 月份我在上海做過一次關於「 APISIX 高性能實踐」的分享,這次的內容是在上次分享的基礎上,並會將最近的新積累分享給大家。

什麼是 API 網關

API 網關的地位越來越重要,它是所有流量的出入口,從圖中可以看到請求方可能來自於瀏覽器、loT 設備以及移動設備等,API 網關作為中間管控層需要做安全控制、流量以及日誌記錄等。越來越多的企業採用了微服務的方式,以此完成內部解耦、靈活部署、彈性伸縮等技術特性從而滿足業務需求。微服務的數量和複雜度也都隨之水漲船高,通過 API 網關來完成統一的流量管理調度就非常必要,並對 API 網關提出了更高要求。

APISIX 概述

上圖是 APISIX 的基本構架,由於要支持集群和高可用,所以在任何一個節點都需要包含 adminAPI 或 APISIX 內核,使用時可以只啟用其中一部分或都啟用。admin API 主要用於接收管理員的提交信息,通過 json schema 完成參數的校驗,防止非法參數落到存儲的配置中心。APISIX 內部部分處理外部請求,根據請求特徵,匹配到具體路由規則,執行插件,然後把流量轉發到指定上游服務。

APISIX 每個月會發佈一個版本,在 0.7 版本支持了路由插件化,很自豪地說這是目前唯一允許自定義路由的 API 網關實現。除了之前已有的 r3 路由,APISIX 新增了專門高性能的前綴匹配 radixtree,radixtree 是由 Redix 的作者開源出來的。radixtree 代碼的匹配效率是 r3 的 10 倍甚至更高,一些生產用戶升級 radixtree 後 CPU 使用率確實下降明顯。

上圖顯示的是兩個月前 APISIX 已有的功能。

最近的兩個月,APISIX 增加了以上新功能,每個月大概都會有 5、6 個大的新特性,如果我只準備 APISIX 里的一些新特性與大家分享,各位受益可能會比較小,所以今天我給大家分享一些通用的 OpenResty 編程技巧。

APISIX 主打的是高性能,我們與 OpenResty 對比性能,這樣更能突出 APISIX 性能的極致。首先用 APISIX 完整服務來壓測,對比一個沒有任何功能的空 OpenResty 服務,發現 APISIX 在加載了所有功能的情況下只下降了 15% 的性能。換言之,你如果能接受 15% 的性能下降,就可以直接享受上圖的所有功能。

OpenResty 優化技巧

路由:radixtree vs r3

既然已經有了 r3 ,為什麼我們還要繼續用 resty-radixtree 實現新的路由呢?

先介紹 r3 的問題:r3 的學習複雜度比較高(正則本身就有學習難度),並且不支持通過迭代器的方式迭代匹配結果,效率相比前綴樹實現低不少。相反這些問題在 resty-radixtree 上都有完美解決方案,性能、穩定性自然也就提升很多。目前的 resty-radixtree 是基於 antirez/rax 實現的,也是 Redis 的作者寫的,站在巨人肩膀可以讓我們少走不少彎路。

從數據結構上看,前綴樹理論上是比哈希算法更快,原因是哈希算法的真正複雜度是O(K),K 是指查詢的 Key 的長度,Key 越長哈希算法把字符串變成整數就越複雜,而前綴樹是層層遞進,最壞的複雜度就是 O(K),因此前綴樹的最壞效率與哈希算法是一樣的。

當然這只是原理上的,經過專門測試發現 Lua table 的哈希查找速度秒殺前綴樹,這是因為在編譯 LuaJIT 的時候,它使用了 CPU 指令集來計算哈希值,這樣可以完美的做到 O(1),所以 LuaJIT table 的哈希是效率是最高的,其次才是前綴樹。

在 LuaJIT 世界匹配效率最高,永遠都是先優先使用 Lua table 的哈希匹配。我們最終也沒直接使用前綴樹(trietree),因為它比較消耗內存,而是採用了基數樹(radixtree),在性能相差不多的情況下,內存佔用更小。

OpenResty VS Golang、HTTP VS gRPC

2015 年我沒有選擇 Golang 而選擇 OpenResty,原因是我認為 OpenResty 可以思考地更深入,而 Golang 只能站在應用層去解決問題,出於這個原因我選擇了 OpenResty。

APISIX 支持了這樣一個場景:HTTP(s) -> APISIX -> gRPC server,把 REST API 轉成 gRPC 請求。完成該功能後,需要做些壓力測試驗證效果。為了方便對比,用 Golang 的方式也寫了一個協議轉換網關。測試發現 APISIX 的版本比 Golang 的版本性能略還好一點,我的電腦上都是單核 1 萬左右的 QPS。本以為在 gRPC 領域 Golang 的性能應當是最好的,沒想到 APISIX 有機會略勝一籌。

我們之前粗淺地認為 HTTP 的性能一定沒有 gRPC 的性能好,現在看有點武斷。gRPC 的很多優勢是 HTTP 不具備的,比如它的體積更小且內置 schema 檢查等。但如果你的請求體比較小,在 HTTP 上使用 json 加 json schema,它們倆的性能幾乎相同,尤其是在內網環境下相差還是非常小的。如果請求體比較大編碼複雜,那麼 gRPC 會有明顯優勢。

ngx.var 的加速

對獲取 Nginx 變量的加速,最簡單的就是用 iresty/lua-var-nginx-module 倉庫,把它作為一個 lua module 編譯到 OpenResty 項目里。當我們提取對應的 ngx.var 的時,使用庫里提供的方法來獲取,可以讓 APISIX 整體有 5% 的性能提升,單純某個變量性能對比,至少有 10 倍差別。當然也可以把這個模塊編譯成動態庫,然後用動態方式加載,這樣就不用重新編譯 OpenResty。

APISIX 網關會從 ngx.var 里獲取大量變量信息,比如 host 地址等變量更是可能會被反覆獲取,每次都與 Nginx 交互效率會比較低。因此我們在 APISIX/core 里加了一層 ctx 緩存,也就是第一次與 Nginx 交互獲取變量,後面將直接使用緩存。

題外篇:再次推薦大家多參考借鑒 APISIX/core 中的代碼,這些代碼是通用的,對大多數項目都應該有借鑒意義。

fail to json encode

當我們用 json 的方式去 encode 一個 table時,可能會失敗。失敗原因有以下幾種:比如 table 中包含 cdata 或者 userdata 無法 encode ,又或者包含 function 等,但實際上我們做 encode 並不是想要一個可以完美支持序列化/反序列化的結果,有時候只是為了調試。

所以我在 APISIX 的 core/json_encode 增加了一個布爾參數,表示是否進行強制轉碼,這樣當遇到不能轉碼時就把強制它變成一個字符串。此外 table 套 table 是一個常見的情況,即有一個 table A,在 A 的 table 裏面的內部又引用了A 自身,形成了一個循環嵌套。這個問題的解決比較簡單,在發生嵌套時,到達某一個位置點後就不要再往裡嵌了。這兩個場景下允許強制 table encode 對我們開發調試非常有用。

在調試時,如果需要打一下 table 結果,當日誌級別不夠時,不應該觸發無意義的 jsonencode 行為,這時候推薦使用 delay_encode 來調試日誌,只有當日誌真正需要寫到磁盤上時,才會觸發 json encode,避免那些不需要 encode 。這個問題在APISIX 裏面效果非常好,終於不需要注釋代碼就可以完成不同級別日誌的測試,有點 C 語言中宏定義的味道,對性能和易用是個極好的平衡。

靜態代碼檢測工具

目前 APISIX 進行 CI 回歸,都會運行代碼檢查工具進行檢查:Lua -check 和 lj-releng。對當前代碼目錄的內容做靜態檢測,比如有沒有加全局的變量,代碼行的長度是不是超了等。

rapid json 的生命周期

調試過程中發現的一個特別有意思的關於 rapid json 聲明周期的 bug。關於這個周期的原因可以看一下上圖的最後一行,我們真正使用的是 validator,而且只調用了validator 的一個驗證,它是從上邊的 create-validator 得來的。這裡值得注意的是,為什麼用一個數組緩存住另一個叫 sd 的對象呢?

因為 validator 是個 cdata ,內部有對 sd 對象的指針引用依賴,他們兩個也就必須要有相同的聲明周期,不能有某一項提前釋放的情況。如果我們需要讓兩個對象有相同的生命周期,那麼把它們放到同一個 table 中是最簡單的方法。

第三方庫使用 pcre

如果你選擇效率最高的 C 庫,而這個 C 庫里還引用了 pcre 這個庫,那就需要考慮到一個問題,這個對象的跨請求就會有非常大的風險,為此必須要單獨給這個庫創建獨立的內存池,決不能使用當前請求的內存池,因為當前請求很快就被釋放。

怎麼解決這個問題呢?如果現在 OpenResty 有相關的 API,那麼直接去申請內存池是最好的,但是可惜 OpenResty 並不具備。看看 Ningx 源碼,可以看到創建 Nginx 的內存池函數定義是 ngx_create_pool(size_tsize, void *log) ,只要能獲取到全局日誌句柄即可。

我們選擇從全局的 ngx_cycle 獲取 log 對象,這裡我定義了一個虛假的 fake_ngx_cycle 結構體,這個結構體和 Nginx 的 ngx_cycle 構體的前三項是一樣的,但是截掉了後面的部分,然後我們做內存拷貝,從而得到了 log 對象指針位置。

開啟 prometheus 插件

我當時在研究 Kong 的 prometheus 插件時粗略看了一下它的代碼,發現他的實現邏輯是有問題的,會非常影響性能。所以沒有直接使用 Kong 的方式,在 APISIX 中開啟這個 插件,性能只會下降5% 左右。這個插件我們比 Kong 高近 10 倍性能。我也和 Kong 的技術負責人聊過這裡,後續會把 APISIX 的一些做法貢獻給 Kong,相互學習一起成長。

以上是我今天的全部分享,謝謝大家!

演講PPT下載及視頻觀看:

Apache APISIX 微服務網關極致性能架構解析

本文由博客一文多發平台 OpenWrite 發佈!