HTTP呼叫超時咋辦?重複請求又如何?
1 超時,無法避免的痛
HTTP呼叫即通過HTTP協議執行一次網路請求。既然是網路請求,就有超時的可能性(可能你的網路卡,也可能伺服器所處網路卡),因此在開發中需要注意:
- 框架設定的預設超時時間是否合理
- 過短,請求還未處理完成,你就急不可待了!
- 過長,請求早已超出正常響應時間而掛了
- 考慮網路不穩定性,超時後可以通過定時任務請求重試
注意考慮服務端介面冪等性設計,即是否允許重試 - 考慮框架是否會像瀏覽器那樣限制併發連線數,以免在高併發下,HTTP呼叫的併發數成為瓶頸
1.1 HTTP呼叫框架技術選型
- Spring Cloud全家桶
使用Feign進行宣告式的服務呼叫。 - 只使用Spring Boot
HTTP客戶端Apache HttpClient進行服務呼叫。
1.2 連線超時配置 && 讀取超時引數
雖然應用層是HTTP協議,但網路層始終是TCP/IP協議。TCP/IP是面向連線的協議,在傳輸資料之前需要建立連線。所以網路框架都會提供如下超時引數:
- 連線超時引數ConnectTimeout
可自定義配置的建立連線最長等待時間 - 讀取超時引數ReadTimeout
控制從Socket上讀取資料的最長等待時間。
1.3 常見踩坑點
連線超時配置過長
比如60s。TCP三次握手正常建立連線所需時間很短,在ms級最多到s級,不可能需要十幾、幾十秒,多半是網路或防火牆配置問題。這時如果幾秒還連不上,那麼可能永遠也連不上。所以設定特別長的連線超時無意義,1~5秒即可。
如果是純內網呼叫,還可以設更短,在下游服務無法連線時,快速失敗
無腦排查連線超時問題
服務一般會有多個節點,若別的客戶端通過負載均衡連線服務端,那麼客戶端和服務端會直接建立連線,此時出現連線超時大概率是服務端問題
而若服務端通過Nginx反向代理來負載均衡,客戶端連線的其實是Nginx,而非服務端,此時出現連線超時應排查Nginx
讀取超時引數和讀取超時“坑點”
只要讀取超時,服務端程式的正常執行就一定中斷了?
案例
client介面內部通過HttpClient
呼叫服務端介面server,客戶端讀取超時2秒,服務端介面執行耗時5秒。
呼叫client介面後,檢視日誌:
- 客戶端2s後出現
SocketTimeoutException
,即讀取超時 - 服務端卻泰然地在3s後執行完成
Tomcat Web伺服器是把服務端請求提交到執行緒池處理,只要服務端收到請求,網路層面的超時和斷開便不會影響服務端的執行。因此,出現讀取超時不能隨意假設服務端的處理情況,需要根據業務狀態考慮如何進行後續處理。
讀取超時只是Socket網路層面概念,是資料傳輸的最長耗時,故將其配置很短
比如100ms。
發生讀取超時,網路層面無法區分如下原因:
- 服務端沒有把資料返回給客戶端
- 資料在網路上耗時較久或丟包
但TCP是連線建立完成後才傳輸資料,對於網路情況不是特差的服務呼叫,可認為:
- 連線超時
網路問題或服務不線上 - 讀取超時
服務處理超時。讀取超時意味著向Socket寫入資料後,我們等到Socket返回資料的超時時間,其中包含的時間或者說絕大部分時間,是服務端處理業務邏輯的時間
超時時間越長,任務介面成功率越高,便將讀取超時引數配置過長
HTTP請求一般需要獲得結果,屬同步呼叫。
若超時時間很長,在等待 Server 返回資料同時,Client 執行緒(通常為 Tomcat 執行緒)也在等待,當下遊服務出現大量超時,程式可能也會受到拖累建立大量執行緒,最終崩潰。
- 對定時任務或非同步任務,讀取超時配置較長問題不大
- 但面向使用者響應的請求或是微服務平臺的同步介面呼叫,併發量一般較大,應該設定一個較短的讀取超時時間,以防止被下游服務拖慢,通常不會設定讀取超時超過30s。
評論可能會有人問了,若把讀取超時設為2s,而服務端介面需3s,不就永遠拿不到執行結果?
的確,因此設定讀取超時要結合實際情況:
- 過長可能會讓下游抖動影響到自己
- 過短又可能影響成功率。甚至,有些時候我們還要根據下游服務的SLA,為不同的服務端介面設定不同的客戶端讀取超時。
1.4 最佳實踐
連線超時代表建立TCP連線的時間,讀取超時代表了等待遠端返回資料的時間,也包括遠端程式處理的時間。在解決連線超時問題時,我們要搞清楚連的是誰;在遇到讀取超時問題的時候,我們要綜合考慮下游服務的服務標準和自己的服務標準,設定合適的讀取超時時間。此外,在使用諸如Spring Cloud Feign等框架時務必確認,連線和讀取超時引數的配置是否正確生效。
2 Feign&&Ribbon
2.1 如何配置超時
為Feign配置超時引數的難點在於,Feign自身有兩個超時引數,它使用的負載均衡元件Ribbon本身還有相關配置。這些配置的優先順序是啥呢?
2.2 案例
-
測試服務端超時,假設服務端介面,只休眠10min
-
Feign呼叫該介面:
-
通過Feign Client進行介面呼叫
在配置檔案僅指定服務端地址的情況下:
clientsdk.ribbon.listOfServers=localhost:45678
得到如下輸出:
[21:46:24.222] [http-nio-45678-exec-4] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController:26 ] -
執行耗時:222ms 錯誤:Connect to localhost:45679 [localhost/127.0.0.1, localhost/0:0:0:0:0:0:0:1]
failed: Connection refused (Connection refused) executing
POST http://clientsdk/feignandribbon/server
Feign預設讀取超時是1秒,如此短的讀取超時算是“坑”。
- 分析原始碼
自定義配置Feign客戶端的兩個全域性超時時間
可以設定如下引數:
feign.client.config.default.readTimeout=3000
feign.client.config.default.connectTimeout=3000
修改配置後重試,得到如下日誌:
[http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] - 執行耗時:3006ms 錯誤:Read timed out executing POST http://clientsdk/feignandribbon/server
3秒讀取超時生效。
注意:這裡有一個大坑,如果希望只修改讀取超時,可能會只配置這麼一行:
feign.client.config.default.readTimeout=3000
測試會發現,這樣配置無法生效。
要配置Feign讀取超時,必須同時配置連線超時
檢視FeignClientFactoryBean
原始碼
- 只有同時設定
ConnectTimeout
、ReadTimeout
,Request.Options才會被覆蓋
想針對單獨的Feign Client設定超時時間,可以把default替換為Client的name:
feign.client.config.default.readTimeout=3000
feign.client.config.default.connectTimeout=3000
feign.client.config.clientsdk.readTimeout=2000
feign.client.config.clientsdk.connectTimeout=2000
單獨的超時可覆蓋全域性超時
[http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] -
執行耗時:2006ms 錯誤:Read timed out executing
POST http://clientsdk/feignandribbon/server
除了可以配置Feign,也可配置Ribbon元件的引數以修改兩個超時時間
引數首字母要大寫,和Feign的配置不同。
ribbon.ReadTimeout=4000
ribbon.ConnectTimeout=4000
可以通過日誌證明引數生效:
[http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] -
執行耗時:4003ms 錯誤:Read timed out executing
POST http://clientsdk/feignandribbon/server
同時配置Feign和Ribbon的引數
誰會生效?
clientsdk.ribbon.listOfServers=localhost:45678
feign.client.config.default.readTimeout=3000
feign.client.config.default.connectTimeout=3000
ribbon.ReadTimeout=4000
ribbon.ConnectTimeout=4000
最終生效的是Feign的超時:
[http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController :26 ] -
執行耗時:3006ms 錯誤:Read timed out executing
POST http://clientsdk/feignandribbon/server
同時配置Feign和Ribbon的超時,以Feign為準
在LoadBalancerFeignClient
原始碼
如果Request.Options
不是預設值,就會建立一個FeignOptionsClientConfig
代替原來Ribbon的DefaultClientConfigImpl
,導致Ribbon的配置被Feign覆蓋:
但若這麼配置,最終生效的還是Ribbon的超時(4秒),難點Ribbon又反覆蓋了Feign?不,這還是因為坑點二,單獨配置Feign的讀取超時無法生效:
clientsdk.ribbon.listOfServers=localhost:45678
feign.client.config.default.readTimeout=3000
feign.client.config.clientsdk.readTimeout=2000
ribbon.ReadTimeout=4000
3 Ribbon自動重試請求
一些HTTP客戶端往往會內建一些重試策略,其初衷是好的,畢竟因為網路問題導致丟包雖然頻繁但持續時間短,往往重試就能成功,
但要留心這是否符合我們期望。
3.1 案例
簡訊重複傳送的問題,但簡訊服務的呼叫方使用者服務,反覆確認程式碼裡沒有重試邏輯。
那問題究竟出在哪裡?
- Get請求的傳送簡訊介面,休眠2s以模擬耗時:
配置一個Feign供客戶端呼叫:
Feign內部有一個Ribbon元件負責客戶端負載均衡,通過配置檔案設定其呼叫的服務端為兩個節點:
SmsClient.ribbon.listOfServers=localhost:45679,localhost:45678
- 客戶端介面,通過Feign呼叫服務端
在45678和45679兩個埠上分別啟動服務端,然後訪問45678的客戶端介面進行測試。因為客戶端和服務端控制器在一個應用中,所以45678同時扮演了客戶端和服務端的角色。
在45678日誌中可以看到,29秒時客戶端收到請求開始呼叫服務端介面發簡訊,同時服務端收到了請求,2秒後(注意對比第一條日誌和第三條日誌)客戶端輸出了讀取超時的錯誤資訊:
[http-nio-45678-exec-4] [INFO ] [c.d.RibbonRetryIssueClientController:23 ] - client is called
[http-nio-45678-exec-5] [INFO ] [c.d.RibbonRetryIssueServerController:16 ] - http://localhost:45678/ribbonretryissueserver/sms is called, 13600000000=>a2aa1b32-a044-40e9-8950-7f0189582418
[http-nio-45678-exec-4] [ERROR] [c.d.RibbonRetryIssueClientController:27 ] - send sms failed : Read timed out executing GET http://SmsClient/ribbonretryissueserver/sms?mobile=13600000000&message=a2aa1b32-a044-40e9-8950-7f0189582418
而在另一個服務端45679的日誌中還可以看到一條請求,客戶端介面呼叫後的1秒:
[http-nio-45679-exec-2] [INFO ] [c.d.RibbonRetryIssueServerController:16 ] - http://localhost:45679/ribbonretryissueserver/sms is called, 13600000000=>a2aa1b32-a044-40e9-8950-7f0189582418
客戶端介面被呼叫的日誌只輸出了一次,而服務端的日誌輸出了兩次。雖然Feign的預設讀取超時時間是1秒,但客戶端2秒後才出現超時錯誤。
說明客戶端自作主張進行了一次重試,導致簡訊重複傳送。
3.2 原始碼揭祕
檢視Ribbon原始碼,MaxAutoRetriesNextServer引數預設為1,也就是Get請求在某個服務端節點出現問題(比如讀取超時)時,Ribbon會自動重試一次:
解決方案
- 把發簡訊介面從Get改為Post
API設計規範:有狀態的API介面不應定義為Get。根據HTTP協議規範,Get請求適用於資料查詢,Post才是把資料提交到服務端用於修改或新增。選擇Get還是Post的依據,應該是API行為,而非引數大小。
常見誤區:Get請求的引數包含在Url QueryString中,會受瀏覽器長度限制,所以一些開發會選擇使用JSON以Post提交大引數,使用Get提交小引數。
- 將
MaxAutoRetriesNextServer
引數配為0,禁用服務呼叫失敗後在下一個服務端節點的自動重試。在配置檔案中新增一行即可:
ribbon.MaxAutoRetriesNextServer=0
問責
至此,問題出在使用者服務還是簡訊服務?
也許雙方都有問題吧。
- Get請求應該是無狀態或者冪等的,簡訊介面可以設計為支援冪等呼叫
- 使用者服務的開發同學,如果對Ribbon的重試機制有所瞭解的話,或許就能在排查問題上少走彎路
最佳實踐
對於重試,因為HTTP協議認為Get請求是資料查詢操作,是無狀態的,又考慮到網路出現丟包是比較常見的事情,有些HTTP客戶端或代理伺服器會自動重試Get/Head請求。如果你的介面設計不支援冪等,需要關閉自動重試。但,更好的解決方案是,遵從HTTP協議的建議來使用合適的HTTP方法。
4 併發限制爬蟲抓取
HTTP請求呼叫還有一個常見的問題:併發數的限制,導致程式處理效能無法提升。
4.1 案例
某爬蟲專案,整體爬取資料效率很低,增加執行緒池數量也無謂,只能堆機器。
現在模擬該場景,探究問題本質。
假設要爬取的服務端是這樣的一個簡單實現,休眠1s返回數字1:
爬蟲需多次呼叫該介面抓取資料,為確保執行緒池不是併發瓶頸,使用了一個無執行緒上限的newCachedThreadPool
,然後使用HttpClient
執行HTTP請求,把請求任務迴圈提交到執行緒池處理,最後等待所有任務執行完成後輸出執行耗時:
- 使用預設的
PoolingHttpClientConnectionManager
構造的CloseableHttpClient
,測試一下爬取10次的耗時:
雖然一個請求需要1s執行完成,但執行緒池可擴張使用任意數量執行緒。
按道理,10個請求併發處理的時間基本相當於1個請求的處理時間,即1s,但日誌中顯示實際耗時5秒:
4.2 原始碼解析
PoolingHttpClientConnectionManager
原始碼有兩個重要引數:
- defaultMaxPerRoute=2,即同一主機/域名的最大併發請求數為2。我們的爬蟲需要10個併發,顯然是預設值太小限制了爬蟲的效率。
- maxTotal=20,即所有主機整體最大併發為20,這也是HttpClient整體的併發度。我們請求數是10最大併發是10,20不會成為瓶頸。舉一個例子,使用同一個HttpClient訪問10個域名,defaultMaxPerRoute設定為10,為確保每一個域名都能達到10併發,需要把maxTotal設定為100。
HttpClient
是常用的HTTP客戶端,那為什麼預設值限制得這麼小?
很多早期的瀏覽器也限制了同一個域名兩個併發請求。對於同一個域名併發連線的限制,其實是HTTP 1.1協議要求的,這裡有這麼一段話:
Clients that use persistent connections SHOULD limit the number of simultaneous connections that they maintain to a given server. A single-user client SHOULD NOT maintain more than 2 connections with any server or proxy. A proxy SHOULD use up to 2*N connections to another server or proxy, where N is the number of simultaneously active users. These guidelines are intended to improve HTTP response times and avoid congestion.
HTTP 1.1協議是20年前制定的,現在HTTP伺服器的能力強很多了,所以有些新的瀏覽器沒有完全遵從2併發這個限制,放開併發數到了8甚至更大。
如果需要通過HTTP客戶端發起大量併發請求,不管使用什麼客戶端,請務必確認客戶端的實現預設的併發度是否滿足需求。
嘗試宣告一個新的HttpClient放開相關限制,設定maxPerRoute為50、maxTotal為100,然後修改一下剛才的wrong方法,使用新的客戶端進行測試:
輸出如下,10次請求在1秒左右執行完成。可以看到,因為放開了一個Host 2個併發的預設限制,爬蟲效率得到了大幅提升:
4.3 最佳實踐
若你的客戶端有比較大的請求呼叫併發,比如做爬蟲,或是扮演類似代理的角色,又或者是程式本身併發較高,如此小的預設值很容易成為吞吐量的瓶頸,需要及時調整。
參考
- 《Java 業務開發常見錯誤》
相關文章
- Angular 如何通過 HTTP Interceptor 實現 HTTP 請求的超時監控AngularHTTP
- 技術分享:如何避免ajax重複請求?
- 大請求、請求超時問題
- 中止請求和超時 跨域的HTTP請求 認證方式 JSONP跨域HTTPJSON
- 網站提示408 請求超時怎麼辦網站
- 日常Bug排查-Nginx重複請求?Nginx
- 如何用istio實現請求超時管理
- 學習AJAX必知必會(3)~自動重啟工具nodemon、快取問題、請求超時和網路異常、取消重複請求快取
- Go如何響應http請求?GoHTTP
- vue帶參請求,登入時效(防止重複登陸)Vue
- 使用Python請求http/https時設定失敗重試次數PythonHTTP
- 前端進階(2)使用fetch/axios時, 如何取消http請求前端iOSHTTP
- 如何使POST請求具有冪等性防止重複提交 - mscharhag
- HTTP協議如何發起請求HTTP協議
- 重複的ajax請求讓人很受傷
- 併發請求的重複插入問題
- go http請求GoHTTP
- http請求概述HTTP
- Jsoup http請求JSHTTP
- open feign 呼叫超時與重試
- axios請求超時解決方案iOS
- crmeb系統請求介面超時,超過十秒就請求失敗
- 瀏覽器如何將你的http請求轉為https請求?瀏覽器HTTP
- Vue路由切換 & Axios介面取消重複請求Vue路由iOS
- 合併HTTP請求vs並行HTTP請求,到底誰更快?HTTP並行
- 合併HTTP請求 vs 並行HTTP請求,到底誰更快?HTTP並行
- axios請求超時,設定重新請求的完美解決方法iOS
- es請求方式呼叫
- HTTP請求報文HTTP
- Cookie 與 HTTP請求CookieHTTP
- python做http請求PythonHTTP
- php用curl封裝一個http請求類(鏈式呼叫)PHP封裝HTTP
- ajax呼叫WebMethed返回處理請求時出錯Web
- [譯]axios 是如何封裝 HTTP 請求的iOS封裝HTTP
- Laravel 底層是如何處理HTTP請求LaravelHTTP
- YApi 新版如何檢視 http 請求資料APIHTTP
- HttpSender OkHttp+RxJava超好用、功能超級強大的Http請求框架HTTPRxJava框架
- java傳送http請求JavaHTTP