nginx — 子請求設計之道

nginx — 子請求設計之道

來自專欄 我眼裡的 nginx

(註:下文所介紹的一切關於子請求的講解,都局限在 nginx 的 HTTP 框架內。)

子請求、父請求和主請求

nginx 所處理的大部分請求,都是在接收到客戶端發來的 HTTP 請求報文後創建的,這些請求直接與客戶端打交道,稱之為主請求;與之相對的則是子請求,顧名思義,子請求是由另外的請求創建的,比如主請求(當然子請求本身也可以創建子請求),當一個請求創建一個子請求後,它就成了該子請求的父請求。從源碼層面來說,當前請求的主請求通過 r->main 指針獲取,父請求則通過 r->parent 指針獲取。

使用子請求機制的意義在於,它能夠分散原本集中在單個請求里的處理邏輯,簡化任務,大大降低請求的複雜度。例如當既需要訪問一個 MySQL 集群,又需要訪問一個 Redis 集群時,我們就可以分別創建一個子請求負責和 MySQL 的交互,另外一個負責和 Redis 的交互,簡化主請求的業務複雜度。而且創建子請求的過程不涉及任何的網路 I/O,僅僅是一些內存的分配,其代價非常可控,因此在筆者看來,子請求機制是 nginx 里最為巧妙的設計之一。

子請求創建與驅動

通常需要創建子請求時,模塊開發者們可以調用函數 ngx_http_subrequest 來實現,默認情況下,子請求會共享父請求的內存池,變數緩存,下游連接和 HTTP 請求頭等數據。當子請求創建完畢後,它會被掛到 r->main->posted_requests 鏈表上,這個鏈表用以保存需要延遲處理的請求(不局限於子請求)。因此子請求會在父請求本地調度完畢後得到運行的機會,這通常是子請求獲得首次運行機會的手段。

我們知道 nginx 針對一個 HTTP 請求,將其處理邏輯分別劃分到了 11 個不同的階段。當一個子請求被創建出來後,它首先運行的是 find config 階段,即尋找一個合適的 location,然後開始後續的邏輯處理。通常,如果一個子請求不涉及任何的網路 I/O 操作,或者定時器處理,一次調度即可完成當前的子請求;而如果子請求需要處理一些網路、定時器事件,那麼後續該子請求的調度,都會由這些事件來驅動,這使得它的調度和普通的主請求變得無差別。

既然除第一次外,子請求的驅動可能是由網路事件來驅動的,那麼子請求的調度就是亂序的了。假設當前主請求需要向後端請求一個大小 2MB 的資源,我們通過產生兩個子請求,分別獲取 0-1MB 和 1MB - 2MB 的部分,然後發往下游,因為網路的不確定性,很有可能後者(1MB - 2MB)先獲取到並往下游傳輸。那麼此時下游所得到的數據就成了臟數據了。

為了解決這個問題,nginx 為子請求機制引入了另外一個稱為 postpone_filter 的模塊。該模塊的目的在於,判斷當前準備發送數據的請求,是否是「活躍的」,如果當前請求不是「活躍」的,則它期望發送的數據會被暫時保存起來,直到某一刻它「活躍」了,才能將這些數據發往下游。

怎麼判斷一個請求是否是「活躍」的?我們需要先了解父、子請求之間的保存形式。對於當前請求,它的子請求以鏈表的方式被維護起來,而前面提到,子請求也可以創建子請求,因此這些請求間完整的保存形式可以理解成一顆分層樹,如下圖所示。

子請求分層樹模型

上圖中,每個紅圈表示一個請求,每一層的請求分別是上一層請求的子請求。從樹遍歷的角度講,在這樣一棵樹上,哪個節點應該最先被處理?結合子請求機制的實際意義來分析,子請求是為了分攤父請求的處理邏輯,降低業務複雜度。換而言之,父請求是依賴於子請求的。很大程度上父請求可能需要等到當前子請求運行完畢後根據子請求反饋的結果來做一些收尾工作。所以需要採用的是類似後序遍歷的規則。即上圖最右下角的請求是第一個「活躍」的請求。

從源碼層面來說,這顆分層樹的保存用到了兩個數據結構,r->postponedr->parent這兩個指針,遍歷 r->postponed 來按序訪問當前請求的子請求(樹中同層的兄弟節點);遍歷 r->parent 訪問到父請求(樹中上一層的父節點)。

postpone_filter 模塊會判斷當前請求是否「活躍」,如果不「活躍」,則把將要發送的數據臨時攔截到它自己的 r->postponed鏈表上(所以這個鏈表上其實既有數據也有請求);如果是活躍的,則遍歷它的 r->postponed 鏈表,要麼把被臨時攔截下來的數據發送出去,要麼找到第一個子請求,將其標記為 「活躍」,然後返回。等到該子請求處理結束,重新將其父請求標記為「活躍」,這樣一來,當父請求再一次運行到 postpone_filter 模塊的時候,又可以遍歷 r->postponed 鏈表,循環往複直到所有請求或者數據處理完畢。感興趣的同學可以自行閱讀相關源碼(ngx_http_postpone_filter_module.c)。

使用了子請求機制的模塊

目前整個 nginx 生態圈,有很多使用子請求的例子,最著名的便是 ngx_lua 的子請求和 nginx 官方的 slice_filter 模塊了。

ngx_lua 提供給用戶的 API (ngx.location.capture)靈活性非常大。 包括針對是否共享變數也可自行選擇。特別地,ngx_lua 的子請求運行時,會阻塞父請求(掛起其對應的 Lua 協程)。直到子請求運行完畢,子請求的響應頭、響應體(所以如果響應體比較大,則會消耗很多內存)等信息都會返回給父請求。ngx_lua 的子請求是不經過 postpone_filter模塊的,它在一個較早的 filter 模塊(ngx_http_lua_capture_filter) 里就完成了對子請求響應體的攔截。

nginx 官方提供的 slice_filter模塊,可以將一個資源下載,拆分成若干個 HTTP Range 請求,這樣做最大的好處是分散熱點。這個模塊允許我們設置一個指令 slice_size,用以設置後續 Range 請求的區間大小。該模塊會陸續創建子請求(在前一個完成後),直到所需資源下載完畢。

另外, nginx/1.13.1 也引入了一個稱為 Background subrequests 的機制(用以更新緩存)。基於這個機制,nginx/1.13.4 引入了一個 mirror 模塊,通過創建子請求,可以讓用戶自定義一些後台任務。比如預熱一些資源,直接將它們放入 nginx 自身的 proxy_cache 緩存中。

陷阱與缺陷

前文說到,子請求創建出來時,復用了父請求的一些數據,這無形中引入了一些坑點。

比如變數緩存,如果在子請求中訪問並緩存了某個變數,當後續在父請求中使用時,我們就會得到之前的緩存數據,這可能造成工程師們花費大量的時間和精力去調試這個問題(關於更詳細的父、子請求變數陷阱的描述,可以閱讀 agentzh 的 Nginx 教程)。

另外筆者認為一個非常重大的缺陷是,子請求復用了父請求的內存池,以 slice_filter 模塊舉例,它將一個 HTTP 請求劃分成若干個的子請求,每個子請求向後端發起 HTTP Range 請求,在資源非常大 ,而配置的 slice_size 相對比較小的時候,會造成有大量的子請求的創建,整個資源下載過程可能會持續很長一段時間,這導致父請求的內存池在一段時間內沒有釋放,加之如果並發數比較大,可能會造成進程內存使用率變得很高,嚴重時可能會 OOM,影響到服務。因此在考慮使用的時候,需要權衡這些問題,有必要的話可能需要自行修改源碼,以滿足業務上的需要。

雖然一些缺點是在所難免的,但是子請求機制很大程度上簡化了請求的處理邏輯,它分而治之的處理思想非常值得我們去學習和借鑒,無論如何,子請求機制也將是後續進行系統設計時的一大參考範例。


推薦閱讀:

世界上有沒有鬼魂,如何科學地證明?
逆天!微軟的AI可以看圖說話了,快來玩!
世界最豪華的潛水遊艇啥樣?
現在(2017年)是買32g的iphone6sPlus好還是華為4+64的mate10?
鉚接(riveted)、螺栓連接(bolted)和焊接(welded)各有什麼優劣?

TAG:Nginx | 科技 | 設計 |