2019 年 7 月 6 日,OpenResty 社區聯合又拍雲,舉辦 OpenResty × Open Talk 全國巡回沙龍·上海站,OpenResty 軟件基金會聯合創始人王院生在活動上做了《APISIX 的高性能實踐》的分享。
OpenResty x Open Talk 全國巡回沙龍是由 OpenResty 社區、又拍雲發起,邀請業內資深的 OpenResty 技術專家,分享 OpenResty 實戰經驗,增進 OpenResty 使用者的交流與學習,推動 OpenResty 開源項目的發展。活動將陸續在深圳、北京、武漢、上海、成都、廣州、杭州等城市巡回舉辦。
王院生,OpenResty 社區、OpenResty 軟件基金會聯合創始人,《OpenResty 最佳實踐》主要作者,APISIX 項目發起人和主要作者。
以下是分享全文:
大家好,我是王院生,很高興來到上海。首先做下自我介紹,我於 2014 年加入奇虎 360,在那時認識了 OpenResty,此前我是一個純粹的 C/C++ 語言開發者。在 360 工作期間,利用工作閑暇時間寫了《OpenResty 最佳實踐》,希望能影響更多的人正確掌握 OpenResty 入門。2017 年我作為技術合伙人和春哥(章亦春,agentzh)一起創業。今年我個人的重心有所調整並在今年三月份離職,准備將更多精力投入到開源上,於是發起了 APISIX 這個項目,企業宗旨是依托開源社區,致力於微服務 API 相關技術的創新和實現。
什么是 API 網關
API 網關的地位越來越重要,它幾乎劫持了所有流量,內外之間完成了用戶的安全控制、審計,通過自定義插件的方式滿足企業自身特定需求,最常見的自由身份認證等。隨着服務在數量和復雜度上的不斷增長,更多的企業采用了微服務的方式,這時通過 API 網關來完成統一的流量管理和調度就非常有必要。
微服務網關和傳統意義上的 API 網關有一些不同,主要包括下面幾點:
- 動態更新:在微服務之前,服務不像現在這樣經常來回地變化。比如微服務需要做橫向擴充,或者故障恢復、熱備、切換等,IP 、節點等變動更加頻繁。舉例如微博上一旦出現了爆點事件,就急速擴充計算點,必須要非常快地擴充新機器來扛壓。波峰波谷變化明顯,分鍾級別的機器動態管理,已經越發是常態。
- 更低延遲:通常動態就意味着可能會做一些延遲(復雜度增加),在微服務里面,對於延遲要求比較高,尤其對於現在的用戶體驗,超過 1 秒以上的延遲是完全不可接受的。
- 用戶自定義插件:API 網關是給企業用戶使用的,它一定存在私有邏輯(比如特殊的認證授權等),所以微服務網關必須能夠支持企業用戶自定義插件。
- 更集中的管理 API:如前面所說 API 網關劫持了用戶的所有流量,所以用網關來做統一的 API 管理是非常必要的。在網關角度可以看到 API 是如何設計,是否存在延遲、安全問題,以及響應速度和健康信息等。
我們要做的微服務 API 網關產品,除了上面的基本要求,還有一些是我們區別於其他人的:
- 通過社區聚焦:通過開源方式聚焦有共同需求的人群,讓更多不同公司的人可以一起協作,共同打磨更好的產品,減少冗余開發。
- 簡潔的 core:產品的內核必須是非常簡潔的,如果內核復雜,會使得大家的上手成本高很多,望而卻步肯定不是我們期望的。
- 可擴展性、頂級性能、低延遲:這幾項都是要同時嚴格保障的,也是我們會花主要精力保證的。目前 APISIX 項目的性能比空跑 OpenResty 只低 15%,這點還是非常值得傲嬌的。
APISIX 高性能微服務網關
APISIX 架構與功能
上圖是 APISIX 的基本架構,羅列用到的幾個基本組件。其中包括 ETCD 可以完成配置存儲,由於 ETCD 可以走集群,所以我們可以借用它完成動態伸縮、高可用集群等。ETCD 數據支持通過 watch 的方式增量獲取,使得 APISIX 節點規則更新可以做到毫秒級,甚至更低。APISIX 自身是無服務狀態的,所以方便橫向擴充。
另一個組件是 JSON Schema,它是一個標准協議,主要用來驗證數據的有效性。JSON Schema 目前對外公開有四個不同版本,我們最終選用 RapidJSON,因為他對這四個版本都有相對完整的支持。
圖中的 Admin API 和 APISIX 可以放在一起,也可以分開。Admin API 接收用戶提交的請求,在請求參數保存到 ETCD 之前,會使用 JSON Schema 做一次完整校驗,有了校驗可以確定到 ETCD 里的都是有效數據。
上圖右側是接收外部用戶的真實流量,APISIX 從 ETCD 中訂閱所有配置規則,拿到配置規則后給到下面的路由引擎(libr3),目前默認使用的路由引擎是 libr3,我之前在武漢的分享中進行過詳細介紹(https://www.upyun.com/opentalk/428.html)。 libr3 是一個路由引擎實現,基於前綴樹,由於他還支持正則,所以效率非常高的,同時功能也很強大。
APISIX 的 v0.5 版本具備以下功能:
APISIX 的性能
通常來說,引入了前面提到的十幾項功能,會伴隨着性能的下降,那么究竟下降了多少呢?這里我做了一個性能的測試對比。如上圖,右側是我為了測試寫的一個虛假的服務,這個服務里面空空如也,只是把 ngx_lua 里的一些變量拿出來,然后傳給了什么都不做的 fake_fetch,后面的 http filter、log 階段等一樣,沒有任何計算量。
然后對 APISIX 和右邊的虛假服務分別跑壓力測定,對比結果發現 APISIX 的性能僅僅下降了 15%,也就是說在接受了 15% 的性能下降的同時,就可以享受前面提到的所有功能。
說一下具體數值,這里使用的是阿里雲的計算平台,單 worker 下可以跑到 23-24k QPS,4 worker 可以跑到 68k 的 QPS。
APISIX 目前的狀態
目前最新版本是 v0.5,架構是基於 ETCD+libr3+RapidJSON。這個版本加的最多的是代碼覆蓋率,v0.4 版本代碼覆蓋率不超過 5%,但最新版本中代碼覆蓋率達到 70%,這其中 95% 是核心代碼,周邊的代碼覆蓋率相對較低,主要是插件的相關測試有所欠缺。
原本計划在 0.5 版本上線管理界面功能,這樣可以降低入門門檻,但是遺憾的是目前還沒開發完成,這與我們自身專業有關系,不擅長做前端界面,需要借助前端的專家幫我們實現,我們計划會在 0.6 的版本上線(注:目前已經發布了 v0.6 版本:https://github.com/iresty/apisix/blob/master/CHANGELOG_CN.md#060)。
OpenResty 編程哲學與優化技巧
我從 2014 年開始做 OpenResty 開發,至今已經有六年了。在 OpenResty 的領域里,它的哲學是要學會大事化小,小事化了,因為 Nginx 的內存管理方式是把所有的請求內存默認放到一個內存池里,請求退出的時再把內存池銷毀。如果不能很快地一進一出,它就會不停申請,最后釋放時資源損耗很大,這是 Nginx 不擅長的。所以用 OpenResty 做長連接就需要非常小心,避免把內存池搞大。
此外,要盡可能少地創建臨時對象。這里所指的臨時對象有兩類,一類是 table 類,一類是字符串拼接,比如某兩個變量拼接產生新的字符串,這個看似在其他很多語言都沒有問題,但在 OpenResty 里需要盡量少做這種操作。Lua 語言雖然簡單,但也是門高級語言,攜帶了優良的 GC ,讓我們無需關心所有變量的生命周期,只負責申請就好了,但如果濫用臨時變量等,會讓 GC 比較忙碌,付出代價是整體運行效能不高。Lua 擅長動態和流程控制,如果遇到硬核的 CPU 運算任務,還是推薦交給 C/C++ 實現。
今天和大家分享優化技巧,主要還是如何寫好 Lua,畢竟他的受眾群體更多。在 APISIX 的 core 中,我們使用了一些比較特別的優化技巧,下面逐一給大家介紹。
技巧一:delay_json
先說一下場景:比如上面的這行日志調用,如果當前日志級別是 info ,我們期望會正常 json encode;而當是 error 級別,我們就不期望發生 json encode 操作,如果能自動跳過是最完美了。那我們如何近似的實現這個目的呢?
我們看一下 delay_encode 的實現源碼,首先用元方法重載了 tostring ,下面 delay_encode 只是對 delay_tab 的兩個對象 data 和 force 做了賦值,然后沒有做其他的事情,這與大家平時看到的 json encode 方法都不一樣。因為真正在寫日志時,如果給定的參數是 table,在 OpenResty 里會把他轉成 string 的,過程是檢查是否有 tostring 的元方法注冊,如果有就調這個方法把它轉換成字符串。有了上面的封裝,我們就在高性能和易用性上做了很好的平衡。
技巧二:HASH vs 前綴樹 vs 遍歷
- Lua table 的 HASH:性能最好的匹配方式,缺點是只能做全量匹配。
- 前綴樹:借助 libr3 完成前綴等高級匹配(支持正則)。
- 遍歷:永遠是最糟糕的。
在 APISIX 的世界里,我把 HASH 和前綴樹做了融合,如果你的請求和路由規則不包含高級規則匹配,會默認走 HASH 來保證效率;但如果有模糊匹配邏輯,則使用前綴樹。
技巧三:ngx.log 是 NYI
因為 ngx.log 是 NYI,所以我們要盡量減少下面這段代碼的觸發頻率:
return ngx_log(log_level,…)
要降到最低,需要判斷當前日志級別,如果當前的日志級別和你輸入的日志級別存在大小比值關系,發現不需要輸入就直接 return。避免出現日志處理完,傳到 Nginx 內核后再發現不需要寫日志,這樣就會浪費非常多的資源。
前面提到的壓力測試,都是把日志打到 error 級別,加了非常多的調試代碼並且保留不刪,這些測試代碼的存在完全不會影響性能結果。
技巧四:gc for cdata and table
場景:當某個 table 對象被系統回收時,希望觸發特定邏輯以釋放關聯資源。那么我們如何給 table 注冊 gc 呢?請參考下圖示例:
當我們無法控制 Lua table 的整個生命周期,可以用上圖的方法去注冊一個 GC,當 table 對象沒有任何引用時會觸發 GC,釋放關聯資源。
技巧五:如何保護常駐內存的 cdata 對象
我們在使用 r3 這個 C 庫時遇到這么一個問題:我們給 r3 添加很多路由規則,然后生成 r3 tree,如果規則沒有變化 r3 將被反復使用,由於 r3 內部沒有申請額外的內存存儲,只是引用指針地址。但外面傳入的 Lua 變量可能是臨時變量,引用計數為 0 后會被 Lua GC 自動回收。導致的現象是 r3 內部引用的原有內存地址內容突然發生變化,最后致使路由匹配失敗。
知道了問題原因,解決方法就比較簡單了,只需要避免變量 A 提前釋放,讓 Lua 里面變量 A 的生命周期和 r3 對象的生命周期保持一致即可。
技巧六:ngx.var.* 是比較慢的
大家知道 C 是不支持動態的,它是編譯性語言。ngx.var.* 的內部實現可以查看 Nginx 源代碼,或者通過火焰圖的方式可以看到他內部的實現方式。為了完成動態獲取變量,內部必須通過一次 hash 查找,到后用內部的規則把變量值讀出。
解決方案是用上圖這個庫(http://github.com/iresty/lua-var-nginx-module),非常簡單沒有技術含量的辦法。比如要獲取客戶端的 IP,在 C 里面直接把代碼摘出來,然后通過 Lua FFI 方式讀取變量的值,就是這么一段小代碼可以讓 APISIX 性能有 5% 提升。這么做缺點是必須要對 OpenResty 編譯時添加這個第三方模塊,上手成本略高。
技巧七:減少每請求的垃圾對象
我們要盡可能降低每請求產生的垃圾對象的數量,作為 OpenResty 開發者,如果把這句話理解透徹,基本上可以進階到前 50% 的行列。
減少不必要的字符串的拼接,並非意味着在需要做拼接字符串的時候不要拼接,而是需要在腦子里一直有這個意識,把無效的拼接降低下來,當這些小細節累積下來,性能提升就會非常大。
技巧八:重用 table
首先介紹下初級版的 table.clear。當需要使用一個臨時 table,大家習慣性的寫法是
local t ={}
我們來聊聊這么做的缺點,如果在開頭創建了一個臨時的 table t,當函數退出的時候,t 會被回收;下次再進來這個函數,又會產生一個臨時的 table t。在 Lua 世界,table 的產生和銷毀是非常耗資源的,因為 table 是一個復雜對象,它不像 number、字符串等簡單對象,申請和釋放可以用一個結構體搞定,它會讓你的 GC 一下子變得非常忙碌。
如果 worker 里只需要一個唯一實例 table 對象,那么就可以使用 table.clear 方式來反復使用這個臨時表,比如上圖的臨時表 local_plugins_hash。
重用 table :進階版 table.pool
有些 Lua table 的生命周期是每請求的,通常是請求進入申請對象,請求退出釋放對象,這時候使用 table.pool 會非常合適。tablepool 中文翻譯過來是表池,里面放的是可以重用的 table。官方文檔可以到 https://github.com/openresty/lua-tablepool#synopsis 查看,結合 APISIX 的業務使用代碼,更容易理解。
在 APISIX 中最集中使用的是兩個地方,除了上圖這里做回收,還有是申請的地方。在回收之后,這些 table 可以被其他請求所復用,由 tablepool 做統一控制,在 pool 里維持的對象可能就固定的幾十、幾百個,會反復使用,不存在銷毀的情況。這個技巧的正確使用,性能至少可以提升 20%,提升效果非常明顯。
技巧九:Irucache 的正確姿勢
簡單介紹下 Irucache,Irucache 可以完成在 worker 內的數據的緩存和復用,Irucache 有一個非常大的優勢是可以存儲任何對象。而共享內存則是完成不同 worker 之間的數據共享,但它只能存儲簡單對象,有些東西是不能跨 worker 共享,比如 function、cdata 對象等。
對 Irucache 進行二次封裝,封裝的內容主要包括:
- key 要盡量短、簡單:我們在寫 key 時最重要的是要簡單,key 最糟糕的設計是里面東西很長,但是有用信息不多。key 理論上大家都喜歡用字符串,但他可以是 table 等對象,key 盡量做到明確,只包含你感興趣的內容,能省略的盡量省略,降低拼接成本。
- version 可降低垃圾緩存:這點算是我在做 APISIX 的突破:提取出了 version, Irucache+ version 這套組合,可以極大地降低垃圾緩存。
- 重用 stale 狀態的緩存數據。
上圖是 lrucache 的封裝,從下往上看,key 是 /routes,它跟的版本號是 conf_version,global 函數里做的事情是根據 key+version 的方式,去查找有無陳舊數據的緩存,如果有就直接返回,如果沒有就調 creat_r3_router 完成創建,creat_r3_router 是負責創建一個新的對象,它只接受一個傳參 routes,這個傳參是由 routes.values 傳進去的。
這層封裝,把 Irucache new、數量等都隱藏起來,這樣很多東西我們看不到,當我們需要自定義的時候可能還是需要關心這些。APISIX 為了簡化插件開發者對各種東西的理解,所以必須要做一層封裝,簡化使用。
△ lrucache 最佳實踐⽤例
上圖是用 version 降低垃圾緩存、重用 stale 狀態的緩存數據,這 Irucache 的二次封裝的代碼。首先來看第二行,根據 key 去緩存里面取對象,然后把對象的 cache_ver 拿出來和當前傳入的 version 做比較,如果相同則判定這個緩存對象一定是可用的。
往下多了 stale_obj,stale_obj 在文檔里面說明的比較少,它只有在一種情況會發生:緩存對象在 Irucache 中已經被淘汰了,但是它只是到了淘汰的邊緣,還沒有完全被扔掉。上圖中通過陳舊數據的 cache_ver 與進來的 version 做比較,如果 version 一致那就是有效的。所以只要源頭的數據沒有變化,就可以再次使用。這樣我們就可以復用 stale_obj 從而避免再次創建新的對象。
到這里可以解釋一下前面提到的:version 可降低垃圾緩存。如果沒有 version,我們需要把 version 寫到 key 里面,每次 version 變化都會產生一個新的 key,那些被淘汰的舊數據會一直存在,沒辦法剔除掉。同時意味着 Irucache 里面的對象數會不停增加。而我們前面的方式是保證 key 如果是一個對象,只會有一個 table 與它對應,不會根據不同的 version 產生不同的對象緩存,進而降低緩存總數。
以上是我今天的全部分享,謝謝大家!
演講視頻及PPT下載傳送門: