張超:又拍雲系統開發高級工程師,負責又拍雲 CDN 平臺相關組件的更新及維護。Github ID: tokers,活躍於 OpenResty 社區和 Nginx 郵件列表等開源社區,專一於服務端技術的研究;曾爲 ngx_lua 貢獻源碼,在 Nginx、ngx_lua、CDN 性能優化、日誌優化方面有較爲深刻的研究。html
Nginx 所處理的大部分請求,都是在接收到客戶端發來的 HTTP 請求報文後建立的,這些請求直接與客戶端打交道,稱之爲主請求;與之相對的則是子請求,顧名思義,子請求是由另外的請求建立的,好比主請求(固然子請求自己也能夠建立子請求),當一個請求建立一個子請求後,它就成了該子請求的父請求。從源碼層面來講,當前請求的主請求經過 r->main 指針獲取,父請求則經過 r->parent 指針獲取。nginx
使用子請求機制的意義在於,它可以分散本來集中在單個請求裏的處理邏輯,簡化任務,大大下降請求的複雜度。例如當既須要訪問一個 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->postponed 和 r->parent這兩個指針,遍歷 r->postponed 來按序訪問當前請求的子請求(樹中同層的兄弟節點);遍歷 r->parent 訪問到父請求(樹中上一層的父節點)。post
postpone_filter 模塊會判斷當前請求是否「活躍」,若是不「活躍」,則把將要發送的數據臨時攔截到它本身的 r->postponed鏈表上(因此這個鏈表上其實既有數據也有請求);若是是活躍的,則遍歷它的 r->postponed 鏈表,要麼把被臨時攔截下來的數據發送出去,要麼找到第一個子請求,將其標記爲 「活躍」,而後返回。等到該子請求處理結束,從新將其父請求標記爲「活躍」,這樣一來,當父請求再一次運行到 postpone_filter 模塊的時候,又能夠遍歷 r->postponed 鏈表,循環往復直到全部請求或者數據處理完畢。感興趣的同窗能夠自行閱讀相關源碼(http://hg.nginx.org/nginx/file/tip/src/http/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 緩存中。
前文說到,子請求建立出來時,複用了父請求的一些數據,這無形中引入了一些坑點。
好比變量緩存,若是在子請求中訪問並緩存了某個變量,當後續在父請求中使用時,咱們就會獲得以前的緩存數據,這可能形成工程師們花費大量的時間和精力去調試這個問題。
另外筆者認爲一個很是重大的缺陷是,子請求複用了父請求的內存池,以 slice_filter 模塊舉例,它將一個 HTTP 請求劃分紅若干個的子請求,每一個子請求向後端發起 HTTP Range 請求,在資源很是大 ,而配置的 slice_size 相對比較小的時候,會形成有大量的子請求的建立,整個資源下載過程可能會持續很長一段時間,這致使父請求的內存池在一段時間內沒有釋放,加之若是併發數比較大,可能會形成進程內存使用率變得很高,嚴重時可能會 OOM,影響到服務。所以在考慮使用的時候,須要權衡這些問題,有必要的話可能須要自行修改源碼,以知足業務上的須要。
雖然一些缺點是在所不免的,可是子請求機制很大程度上簡化了請求的處理邏輯,它分而治之的處理思想很是值得咱們去學習和借鑑,不管如何,子請求機制也將是後續進行系統設計時的一大參考範例。
《我眼中的 Nginx》系列:
我眼中的 Nginx(一):Nginx 和位運算
我眼中的 Nginx(二):HTTP/2 dynamic table size update
我眼中的 Nginx(三):Nginx 變量和變量插值
我眼中的 Nginx(四):是什麼讓你的 Nginx 服務退出這麼慢?