張開濤:超時與重試機制(1)
在實際開發(fā)過程中,筆者見過太多故障是因為超時沒有設置或者設置的不對而造成的。而這些故障都是因為沒有意識到超時設置的重要性而造成的。如果應用不設置超時,則可能會導致請求響應慢,慢請求累積導致連鎖反應,甚至應用雪崩。而有些中間件或者框架在超時后會進行重試(如設置超時重試兩次),讀服務天然適合重試,但寫服務大多不能重試(如寫訂單,如果寫服務是冪等,則重試是允許的),重試次數(shù)太多會導致多倍請求流量,即模擬了DDoS攻擊,后果可能是災難,因此,務必設置合理的重試機制,并且應該和熔斷、快速失敗機制配合。在進行代碼Review時,一定記得Review超時與重試機制。
本文主要從Web應用/服務化應用的角度出發(fā)介紹如何設置超時與重試(系統(tǒng)層面的超時設置本文沒有涉及),而Web應用需要在如下鏈條中設置超時與重試機制。
從上圖來看,在整個鏈條中的每一個點都要考慮設置超時與重試機制。而其中最重要的超時設置是網(wǎng)絡連接/讀/寫的超時時間設置。
本文將按照如下分類進行超時與重試機制的講解。
- 代理層超時與重試:如Haproxy、Nginx、Twemproxy,這些組件實現(xiàn)代理功能,如Haproxy和Nginx可以實現(xiàn)請求的負載均衡。而Twemproxy可以實現(xiàn)Redis的分片代理。需要設置代理與后端真實服務器之間的網(wǎng)絡連接/讀/寫超時時間。
- Web容器超時:如Tomcat、Jetty等,提供HTTP服務運行環(huán)境的。需要設置客戶端與容器之間的網(wǎng)絡連接/讀/寫超時時間,和在此容器中默認Socket網(wǎng)絡連接/讀/寫超時時間。
- 中間件客戶端超時與重試:如JSF(京東SOA框架)、Dubbo、JMQ(京東消息中間件)、CXF、Httpclient等,需要設置客戶的網(wǎng)絡連接/讀/寫超時時間與失敗重試機制。
- 數(shù)據(jù)庫客戶端超時:如Mysql、Oracle,需要分別設置JDBC Connection、Statement的網(wǎng)絡連接/讀/寫超時時間。事務超時時間,獲取連接池連接等待時間。
- NoSQL客戶端超時:如Mongo、Redis,需要設置其網(wǎng)絡連接/讀/寫超時時間,獲取連接池連接等待時間。
- 業(yè)務超時:如訂單取消任務、超時活動關閉。還有如通過Future#get(timeout,unit)限制某個接口的超時時間。
- 前端Ajax超時:瀏覽器通過Ajax訪問時的網(wǎng)絡連接/讀/寫超時時間。
從如上分類可以看出,其中最重要的超時設置是網(wǎng)絡相關的超時設置。
一、代理層超時與重試
對于代理層我們以Nginx和Twemproxy案例來講解。首先,看下Nginx的相關超時設置。
1. Nginx
Nginx主要有四類超時設置:客戶端超時設置、DNS解析超時設置、代理超時設置,如果使用ngx_lua,則還有l(wèi)ua相關的超時設置。
(1) 客戶端超時設置
對于客戶端超時主要設置有讀取請求頭超時時間、讀取請求體超時時間、發(fā)送響應超時時間、長連接超時時間。通過客戶端超時設置避免客戶端惡意或者網(wǎng)絡狀況不佳造成連接長期占用,影響服務端的可處理的能力。
- client_header_timeout time:設置讀取客戶端請求頭超時時間,默認為60s,如果在此超時時間內(nèi)客戶端沒有發(fā)送完請求頭,則響應408(RequestTime-out)狀態(tài)碼給客戶端。
- client_body_timeout time:設置讀取客戶端內(nèi)容體超時時間,默認為60s,此超時時間指的是兩次成功讀操作間隔時間,而不是發(fā)送整個請求體的超時時間,如果在此超時時間內(nèi)客戶端沒有發(fā)送任何請求體,則響應408(RequestTime-out)狀態(tài)碼給客戶端。
- send_timeout time:設置發(fā)送響應到客戶端的超時時間,默認為60s,此超時時間指的也是兩次成功寫操作間隔時間,而不是發(fā)送整個響應的超時時間。如果在此超時時間內(nèi)客戶端沒有接收任何響應,則Nginx關閉此連接。
- keepalive_timeout timeout [header_timeout]:設置HTTP長連接超時時間,其中,第一個參數(shù)timeout是告訴Nginx長連接超時時間是多少,默認為75s。第二個參數(shù)header_timeout是用于設置響應頭“Keep-Alive: timeout=time”,即告知客戶端長連接超時時間。兩個參數(shù)可以不一樣,“Keep-Alive:timeout=time”響應頭可以在Mozilla和Konqueror系列瀏覽器起作用,而MSIE長連接默認大約為60s,而不會使用“Keep-Alive: timeout=time”。如Httpclient框架會使用“Keep-Alive: timeout=time”響應頭的超時(如果不設置默認,則認為是永久)。如果timeout設置為0,則表示禁用長連接。
此參數(shù)要配合keepalive_disable 和keepalive_requests一起使用。keepalive_disable 表示禁用哪些瀏覽器的長連接,默認值為msie6,即禁用一些老版本的MSIE的長連接支持。keepalive_requests參數(shù)作用是一個客戶端可以通過此長連接的請求次數(shù),默認為100。
首先,瀏覽器在請求時會通過如下請求頭告知服務器是否支持長連接。
http/1.0默認是關閉長連接的,需要添加HTTP請求頭“Connection:Keep-Alive”才能啟用。而http/1.1默認啟用長連接,需要添加HTTP請求頭“Connection: close”才關閉。
接著,如果Nginx設置keepalive_timeout 5s,則瀏覽器會收到如下響應頭。
下圖是wireshark抓包,可以看到后兩次請求沒有三次握手。
如果Nginx設置keepalive_timeout 10s 10s,則瀏覽器會收到如下響應頭。
服務器端會在10s后發(fā)送FIN主動關閉連接。
如果Nginx設置keepalive_timeout為75s 30s。
如下是Chrome瀏覽器的Wireshark抓包,在45秒時,Chrome發(fā)送了TCPKeep-Alive來?;頣CP連接,在第57秒時,瀏覽器又發(fā)出了一次請求。而132秒時,Nginx發(fā)出了FIN來關閉連接(75秒連接沒活躍了)。
如下是IE瀏覽器抓包數(shù)據(jù),在請求后第65秒左右時,瀏覽器重置了連接。
可以看出不同瀏覽器超時處理方式不一樣,而HTTP響應頭“Keep-Alive: timeout=30”對Chrome和IE都沒有起作用。
接著,如果keepalive_timeout 0,則瀏覽器會收到如下響應頭。
對于客戶端超時設置,要根據(jù)實際場景來決定,如果是短連接服務,則可以考慮設置的短一些,如果是文件上傳,則需要考慮設置的時間長一些。另外,筆者見過很多人長連接并沒有配置正確,建議配置完成后通過抓包查看長連接是否起作用了。keepalive_timeout和keepalive_requests是控制長連接的兩個維度,只要其中一個到達設置的閾值連接就會被關閉。
(2) DNS解析超時設置
resolver_timeout 30s:設置DNS解析超時時間,默認為30s。其配合resolver address ... [valid=time]進行DNS域名解析。當在Nginx中使用域名時,就需要考慮設置這兩個參數(shù)。在社區(qū)版Nginx中采用如下配置。
- upstream backend {
- server c0.3.cn;
- server c1.3.cn;
- }
如上兩個域名會在Nginx解析配置文件的階段被解析成IP地址并記錄到upstream上,當這兩個域名對應的IP地址發(fā)生變化時,該upstream不會更新。Nginx商業(yè)版是支持動態(tài)更新的。
一種簡單辦法是使用如下方式,每次都會動態(tài)解析域名,這種情況在多域名情況下比較麻煩,實現(xiàn)就不優(yōu)雅了。
- location /test {
- proxy_pass http://c0.3.cn;
- }
如果使用Openresty,則可以通過Lua庫lua-resty-dns進行DNS解析。
- localresolver = require "resty.dns.resolver"
- local r, err = resolver:new{
- nameservers = {"8.8.8.8",{"8.8.4.4", 53} },
- retrans = 5, -- 5 retransmissions on receive timeout
- timeout = 2000, -- 2 sec
- }
當使用Nginx 1.5.8、1.7.4及遇到
- could not be resolved(110:Operation timed out);
或者
- wrong ident 37278 response for ***.jd.local, expected 33517
- unexpected response for ***.jd.local
可能是遇到了如下BUG(http://nginx.org/en/CHANGES-1.6、http://nginx.org/ en/CHANGES-1.8)。
- Bugfix: requests might hang if resolver was usedand a timeout
- occurred during a DNS request.
請考慮升級到Nginx 1.6.2、1.7.5或者在Nginx本機部署dnsmasq提升DNS解析性能。
(3) 代理超時設置
Nginx配置如下所示。
- upstream backend_server {
- server 192.168.61.1:9080 max_fails=2 fail_timeout=10s weight=1;
- server 192.168.61.1:9090 max_fails=2 fail_timeout=10s weight=1;
- }
- server {
- ……
- location /test {
- proxy_connect_timeout 5s;
- proxy_read_timeout 5s;
- proxy_send_timeout 5s;
- proxy_next_upstream error timeout;
- proxy_next_upstream_timeout 0;
- proxy_next_upstream_tries 0;
- proxy_pass http://backend_server;
- add_header upstream_addr $upstream_addr;
- }
- }
backend_server定義了兩個上游服務器192.168.61.1:9080(返回hello)和192.168.61.1:9090(返回hello2)。
如上指令主要有三組配置:網(wǎng)絡連接/讀/寫超時設置、失敗重試機制設置、upstream存活超時設置。
網(wǎng)絡連接/讀/寫超時設置。
- proxy_connect_timeout time:與后端/上游服務器建立連接的超時時間,默認為60s,此時間不超過75s。
- proxy_read_timeout time:設置從后端/上游服務器讀取響應的超時時間,默認為60s,此超時時間指的是兩次成功讀操作間隔時間,而不是讀取整個響應體的超時時間,如果在此超時時間內(nèi)上游服務器沒有發(fā)送任何響應,則Nginx關閉此連接。
- proxy_send_timeout time:設置往后端/上游服務器發(fā)送請求的超時時間,默認為60s,此超時時間指的是兩次成功寫操作間隔時間,而不是發(fā)送整個請求的超時時間,如果在此超時時間內(nèi)上游服務器沒有接收任何響應,則Nginx關閉此連接。
對于內(nèi)網(wǎng)高并發(fā)服務,請根據(jù)需要調(diào)整這幾個參數(shù),比如內(nèi)網(wǎng)服務TP999為1s,可以將連接超時設置為100~500毫秒,而讀超時可以為1.5~3秒左右。
失敗重試機制設置。
- proxy_next_upstream error | timeout | invalid_header | http_500 | http_502 | http_503 | http_504 |http_403 | http_404 | non_idempotent | off ...:
配置什么情況下需要請求下一臺上游服務器進行重試。默認為“errortimeout”。error表示與上游服務器建立連接、寫請求或者讀響應頭出錯。timeout表示與上游服務器建立連接、寫請求或者讀響應頭超時。invalid_header表示上游服務器返回空的或錯誤的響應頭。http_XXX表示上游服務器返回特定的狀態(tài)碼。non_idempotent表示RFC-2616定義的非冪等HTTP方法(POST、LOCK、PATCH),也可以在失敗后重試下一臺上游服務器(即默認冪等方法GET、HEAD、PUT、DELETE、OPTIONS、TRACE才可以重試)。off表示禁用重試。
重試不能無限制進行,因此,需要如下兩個指令控制重試次數(shù)和重試超時時間。
- proxy_next_upstream_tries number:設置重試次數(shù),默認0表示不限制,注意此重試次數(shù)指的是所有請求次數(shù)(包括第一次和之后的重試次數(shù)之和)。
- proxy_next_upstream_timeout time:設置重試最大超時時間,默認0表示不限制。
即在proxy_next_upstream_timeout時間內(nèi)允許proxy_next_upstream_tries次重試。如果超過了其中一個設置,則Nginx也會結束重試并返回客戶端響應(可能是錯誤碼)。
如下配置表示當error/timeout時重試upstream中的下一臺上游服務器,如果重試的總時間超出了6s或者重試了1次,則表示重試失敗(因為之前已經(jīng)請求一次了,所以還能重試一次),Nginx結束重試并返回客戶端響應。
- proxy_next_upstream error timeout;
- proxy_next_upstream_timeout 6s;
- proxy_next_upstream_tries 2;
(4) upstream存活超時設置
max_fails和fail_timeout:配置什么時候Nginx將上游服務器認定為不可用/不存活。當上游服務器在fail_timeout時間內(nèi)失敗了max_fails次,則認為該上游服務器不可用/不存活。并在接下來的fail_timeout時間內(nèi)從upstream摘掉該節(jié)點(即請求不會轉發(fā)到該上游服務器)。
什么情況下被認定為失敗呢?其由 proxy_next_upstream定義,不過,不管 proxy_next_upstream如何配置,error, timeout and invalid_header 都將被認為是失敗。
如server 192.168.61.1:9090max_fails=2 fail_timeout=10s;表示在10s內(nèi)如果失敗了2次,則在接下來的10s內(nèi)認定該節(jié)點不可用/不存活。這種存活檢測機制是只有當訪問該上游服務器時,采取惰性檢查,可以使用ngx_http_upstream_check_module配置主動檢查。
max_fails設置為0表示不檢查服務器是否可用(即認為一直可用),如果upstream中僅剩一臺上游服務器時,則該服務器是不會被摘除的,將從不被認為不可用。
(5) ngx_lua超時設置
當我們使用ngx_lua時,也請考慮設置如下網(wǎng)絡連接/讀/寫超時。
- lua_socket_connect_timeout 100ms;
- lua_socket_send_timeout 200ms;
- lua_socket_read_timeout 500ms;
在使用lua時,我們會按照如下策略進行重試。
- if (status == 502 or status == 503 or status ==504) and request_time < 200 then
- resp =capture(proxy_uri)
- status =resp.status
- body =resp.body
- request_timerequest_time = request_time + tonumber(var.request_time) * 1000
- end
即如果狀態(tài)碼是500/502/503/504時,并且該次請求耗時在200毫秒以內(nèi),則我們進行一次重試。
2. Twemproxy
Twemproxy是Twitter開源的Redis和Memcache代理中間件,其目的是減少與后端緩存服務器的連接數(shù)。
- timeout:表示與后端服務器建立連接、接收響應的超時時間,默認永不超時。
- server_retry_timeout和server_failure_limit:當開啟auto_eject_hosts,即當后端服務器不可用時自動摘除這些節(jié)點并在一定時間后進行重試。server_failure_limit設置連續(xù)失敗多少次后將節(jié)點臨時摘除,server_retry_timeout設置摘除節(jié)點后等待多久進行重試,從而保證不永久性的將節(jié)點摘除。
二、Web容器超時
筆者生產(chǎn)環(huán)境用的Java Web容器是Tomcat,本部分將以Tomcat8.5作為例子進行講解。
- connectionTimeout:配置與客戶端建立連接超時時間,從接收到連接后在配置的時間內(nèi)還沒有接收到客戶端請求行時,將被認定為連接超時,默認為60000(60s)。
- socket.soTimeout:從客戶端讀取請求數(shù)據(jù)的超時時間,默認同connectionTimeout,NIO and NIO2 支持該配置。
- asyncTimeout:Servlet 3異步請求的超時時間,默認為30000(30s)。
- disableUploadTimeout 和connectionUploadTimeout:當配置disableUploadTimeout為false時(默認為true,和connectionTimeout一樣),文件上傳將使用connectionUploadTimeout作為超時時間。
- keepAliveTimeout和maxKeepAliveRequests:和Nginx配置類似。keepAliveTimeout默認為connectionTimeout,配置-1表示永不超時。maxKeepAliveRequests默認為100。
三、中間件客戶端超時與重試
JSF是京東自研的SOA框架,主要有三個組件:注冊中心、服務提供端、服務消費端。
- 首先是服務提供端/消費端與注冊中心之間的進行服務注冊/發(fā)現(xiàn)時可以配置timeout(調(diào)用注冊中心超時時間,默認為5s)和connectTimeout(連接注冊中心的超時時間,默認為20s)。
- 服務提供端可以配置timeout(服務端調(diào)用超時,默認為5s)。
- 服務消費端可以配置timeout(調(diào)用端調(diào)用超時時間,默認為5s),connectTimeout(建立連接超時時間,默認為5s),disconnectTimeout(斷開連接/等待結果超時時間,默認為10s),reconnect(調(diào)用端重連死亡服務端的間隔,配置小于0表示不重連,默認為10s),heartbeat(調(diào)用端往服務端發(fā)心跳包間隔,配置小于0代表不發(fā)送,默認為30s),retries(失敗后重試次數(shù),默認0不重試)。
Dubbo也有類似的配置,在此就不闡述了。
JMQ是京東消息中間件,主要有四個組件:注冊中心、Broker(JMQ的服務端實例,生產(chǎn)和消費消息都跟它交互)、生產(chǎn)者、消費者。
- 首先是生產(chǎn)者/消費者與Broker進行發(fā)送/接收消息時可以配置connectionTimeout(連接超時)、sendTimeout(發(fā)送超時)、soTimeout(讀超時)。
- 生產(chǎn)者可以配置retryTimes(發(fā)送失敗后的重試次數(shù),默認為2次)。
- 消費者可以配置pullTimeout(長輪詢超時時間,即拉取消息超時時間)、maxRetrys(最大重試次數(shù),對于消費者要允許無限制重試,即一直拉取消息)、retryDelay(重試延遲,通過exponential配置延遲增加倍數(shù)一直增加到maxRetryDelay)、maxRetryDelay(最大重試延遲)。消費者還需要配置應答超時時間(服務端需要等待客戶端返回應答才能移除消息,如果沒有應答返回,則會等待應答超時,在這段時間內(nèi)鎖定的消息不能被消費,必須等待超時后才能被消費)。
對于消息中間件我們實際應用中關注超時配置會少一些,因為生產(chǎn)者默認配置了重試次數(shù),可能會存在重復消息,消費者需要進行去重處理。
CXF可以通過如下方式配置CXF客戶端連接超時、等待響應超時和長連接。
- HTTPClientPolicy httpClientPolicy = new HTTPClientPolicy();
- httpClientPolicy.setConnectionTimeout(30000);//默認為30s
- httpClientPolicy.setReceiveTimeout(60000); //默認為60s
- httpClientPolicy.setConnection(ConnectionType.KEEP_ALIVE);//默認為Keep- Alive
- ((HTTPConduit)client.getConduit()).setClient(httpClientPolicy);
Httpclient 4.2.x可以通過如下代碼配置網(wǎng)絡連接、等待數(shù)據(jù)超時時間。
- HttpParams params = new BasicHttpParams();
- //設置連接超時時間
- Integer CONNECTION_TIMEOUT = 2 * 1000; //設置請求超時2秒鐘
- Integer SO_TIMEOUT = 2 * 1000; //設置等待數(shù)據(jù)超時時間2秒鐘
- Long CONN_MANAGER_TIMEOUT = 1L * 1000; //定義了當從ClientConnectionManager中檢索ManagedClientConnection實例時使用的毫秒級的超時時間
- params.setIntParameter(CoreConnectionPNames.CONNECTION_TIMEOUT,CONNECTION_TIMEOUT);
- params.setIntParameter(CoreConnectionPNames.SO_TIMEOUT,SO_TIMEOUT);
- //在提交請求之前,測試連接是否可用
- params.setBooleanParameter(CoreConnectionPNames.STALE_CONNECTION_CHECK,true);
- //這個參數(shù)期望得到一個java.lang.Long類型的值。如果這個參數(shù)沒有被設置,則連接請求就不會超時(無限大的超時時間)
- params.setLongParameter(ClientPNames.CONN_MANAGER_TIMEOUT,CONN_MANAGER_TIMEOUT);
- PoolingClientConnectionManager conMgr = new PoolingClientConnectionManager();
- conMgr.setMaxTotal(200);//設置最大連接數(shù)
- //是路由的默認最大連接(該值默認為2),限制數(shù)量實際使用DefaultMaxPerRoute并非MaxTotal
- //設置過小,無法支持大并發(fā)(ConnectionPoolTimeoutException: Timeout waiting for connection frompool),路由是對maxTotal的細分
- conMgr.setDefaultMaxPerRoute(conMgr.getMaxTotal());//(目前只有一個路由,因此讓他等于最大值)
- //設置訪問協(xié)議
- conMgr.getSchemeRegistry().register(new Scheme("http",80, PlainSocketFactory. getSocketFactory()));
- conMgr.getSchemeRegistry().register(new Scheme("https",443, SSLSocketFactory. getSocketFactory()));
- httpClient = newDefaultHttpClient(conMgr, params);
- httpClient.setHttpRequestRetryHandler(newDefaultHttpRequestRetryHandler(0, false));
因為我們使用http connection連接池,所以需要配置CONN_MANAGER_TIMEOUT,表示從連接池獲取http connection的超時時間。
此處還通過httpClient.setHttpRequestRetryHandler(newDefaultHttpRequestRetry Handler(0, false))配置了請求重試策略(默認重試3次)。當執(zhí)行請求時遇到異常時會調(diào)用retryRequest來判斷是否進行重試,而retryRequest在以下情況不會進行重試:達到重試次數(shù)、服務器不可達、連接被拒絕、連接終止、請求已發(fā)送。而冪等HTTP方法的請求、requestSentRetryEnabled=true且請求還未成功發(fā)送時可以重試。
如果響應是503錯誤狀態(tài)碼時,如上重試機制是不可用的,則可以考慮使用AutoRetryHttpClient客戶端,其可以配置ServiceUnavailableRetryStrategy,默認實現(xiàn)為DefaultServiceUnavailableRetryStrategy,可以配置重試次數(shù)maxRetries和重試間隔retryInterval。每次重試之前都會等待retryInterval毫秒時間。
假設我們服務有多個機房提供,其中一個機房服務出現(xiàn)問題時應該自動切到另一個機房,可以考慮使用如下方法。
- public static String get(List<String> apis, Object[] args, String encoding,Header[] headers, Integer timeout) throws Exception {
- Stringresponse = null;
- for(String api : apis) {
- String uri =UriComponentsBuilder.fromHttpUrl(api).buildAndExpand(args). toUriString();
- response = HttpClientUtils.getDataFromUri(uri, encoding, headers,timeout);
- //如果失敗了,重試一次
- if(Objects.equal(response, HTTP_ERROR)){
- continue;
- }
- //如果域名解析失敗重試
- if(Objects.equal(response,HTTP_UNKNOWN_HOST_ERROR)) {
- response = HTTP_ERROR; //掉用方根據(jù)這個判斷是否有問題
- continue;
- }
- if(Objects.equal(response,HTTP_SOCKET_TIMEOUT_ERROR)) {
- response = HTTP_ERROR; //調(diào)用方根據(jù)這個判斷是否有問題
- continue;
- }
- return response;
- }
- return response;
- }
參數(shù)傳入不同機房的API即可,當其中一個不可用自動重試另一個機房的API。
【本文是51CTO專欄作者“張開濤”的原創(chuàng)文章,作者微信公眾號:開濤的博客( kaitao-1234567)】