張超:又拍雲系統開發高階工程師,負責又拍雲 CDN 平臺相關元件的更新及維護。Github ID: tokers,活躍於 OpenResty 社群和 Nginx 郵件列表等開源社群,專注於服務端技術的研究;曾為 ngx_lua 貢獻原始碼,在 Nginx、ngx_lua、CDN 效能優化、日誌優化方面有較為深入的研究。
子請求、父請求和主請求
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->postponed 和 r->parent這兩個指標,遍歷 r->postponed 來按序訪問當前請求的子請求(樹中同層的兄弟節點);遍歷 r->parent 訪問到父請求(樹中上一層的父節點)。
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 服務退出這麼慢?