張開濤:超時與重試機制(2)
數(shù)據(jù)庫客戶端超時
在使用數(shù)據(jù)庫客戶端時,我們會使用數(shù)據(jù)庫連接池,數(shù)據(jù)庫連接池可以進行如下超時設置。
- <bean id="dataSource" class="org.apache.commons.dbcp2.BasicDataSource" destroy-method="close">
- <!--Statement默認超時時間 -->
- <property name="defaultQueryTimeout" value="3"/>
- <!-- 另外可以通過如下配置來配置socket連接/讀超時:-->
- <property name="connectionProperties"
- value="connectTimeout=2000; socketTimeout=2000 "/>
- <!--這個是等待獲取連接池連接時間,也不要太大,比如設置在500毫秒-->
- <property name="maxWaitMillis" value="500"/>
- </bean>
● 網(wǎng)絡連接/讀超時:使用connectionProperties配置Mysql超時時間,如果是Oracle則可以通過如下配置。
- <property name="connectionProperties"
- value="oracle.net.CONNECT_TIMEOUT=2000;oracle.jdbc.ReadTimeout=2000"/>
● 默認Statement超時時間,通過defaultQueryTimeout配置,單位是秒。
● 從連接池獲取連接的等待時間,通過maxWaitMillis配置。
● Statement超時,如果使用ibatis,則可以通過如下方式配置Statement超時。
因此我們只需要如下配置。
- <settings cacheModelsEnabled="false"enhancementEnabled="true"
- lazyLoadingEnabled="false"errorTracingEnabled="true" maxRequests="32"
- defaultStatementTimeout="2"/>
defaultStatementTimeout單位是秒,根據(jù)業(yè)務配置。如果數(shù)據(jù)庫連接池配置了,則此處可以不用配置。
如果想只設置某個Statement的超時時間,則可以考慮:
- <insert……timeout="2">
如上配置其實最終會調用Statement.setQueryTimeout方法設置Statement超時時間。
● 事務超時是總Statement超時設置,比如我們使用Spring管理事務的話,可以使用如下方式配置全局默認的事務級別的超時時間。
- <bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
- <propertynamepropertyname="dataSource" ref="dataSource" />
- <propertynamepropertyname="defaultTimeout" value="3"/>
- </bean>
這里我們分析下為什么說事務超時是Statement超時的總和,此處我們分析spring的DataSourceTransactionManager,首先開啟事務時會調用其doBegin方法。
- //先獲取@Transactional定義的timeout,如果沒有,則使用defaultTimeout
- int timeout =determineTimeout(definition);
- if (timeout !=TransactionDefinition.TIMEOUT_DEFAULT) {
- txObject.getConnectionHolder().setTimeoutInSeconds(timeout);
- }
其中determineTimeout用來獲取我們設置的事務超時時間,然后設置到ConnectionHolder對象上(其是ResourceHolder子類),接著看ResourceHolderSupport的setTimeoutInSeconds實現(xiàn)。
- public voidsetTimeoutInSeconds(int seconds) {
- setTimeoutInMillis(seconds* 1000);
- }
- public voidsetTimeoutInMillis(long millis) {
- this.deadline = newDate(System.currentTimeMillis() + millis);
- }
大家可以看到,此處會設置一個deadline時間,用來判斷事務超時時間,那什么時候調用呢?首先檢查該類中的代碼,會發(fā)現(xiàn)。
- public int getTimeToLiveInSeconds() {
- double diff = ((double) getTimeToLiveInMillis()) /1000;
- int secs = (int) Math.ceil(diff);
- checkTransactionTimeout(secs <= 0);
- return secs;
- }
- public long getTimeToLiveInMillis() throwsTransactionTimedOutException{
- if (this.deadline == null) {
- throw new IllegalStateException("No timeoutspecified for this resource holder");
- }
- long timeToLive = this.deadline.getTime() -System.currentTimeMillis();
- checkTransactionTimeout(timeToLive <= 0);
- return timeToLive;
- }
- private void checkTransactionTimeout(booleandeadlineReached) throws TransactionTimedOutException {
- if (deadlineReached) {
- setRollbackOnly();
- throw newTransactionTimedOutException("Transaction timed out: deadline was " +this.deadline);
- }
- }
我們發(fā)現(xiàn)調用getTimeToLiveInSeconds和getTimeToLiveInMillis會檢查是否超時,如果超時了,則標記事務需回滾,并拋出TransactionTimedOutException異常進行回滾。
DataSourceUtils.applyTransactionTimeout會調用DataSourceUtils. applyTimeout, DataSourceUtils.applyTimeout代碼如下。
- public static void applyTimeout(Statement stmt,DataSource dataSource, int timeout) throws SQLException {
- ConnectionHolder holder = (ConnectionHolder)TransactionSynchronizationManager.getResource(dataSource);
- if (holder != null && holder.hasTimeout()){
- // 計算剩余的事務超時時間覆蓋Statement超時
- stmt.setQueryTimeout(holder.getTimeToLiveInSeconds());
- } else if (timeout > 0) {
- //如果沒有配置事務超時,則使用Statement超時
- stmt.setQueryTimeout(timeout);
- }
- }
在stmt.setQueryTimeout(holder.getTimeToLiveInSeconds())時會調用getTimeToLiveIn Seconds(),這會檢查事務是否超時。在JdbcTemplate中,執(zhí)行SQL之前,會調用其applyStatementSettings方法,其將調用DataSourceUtils.applyTimeout(stmt,getDataSource(), getQueryTimeout())設置超時時間。
此處有一個問題,如果設置了事務超時,Statement級別的就不起作用了,整體會使用事務超時覆蓋Statement超時。
NoSQL客戶端超時
對于MongoDB,我們使用的是spring-data-mongodb客戶端,可以通過如下配置設置相關的超時時間。
- <mongo:mongo id="tryMongo"replica-set="${try.mongo.hostAndPorts}">
- <mongo:options
- connections-per-host="${mongo.connectionsPerHost}"
- threads-allowed-to-block-for-connection-multiplier="${mongo.threadsAllowedToBlockForConnectionMultiplier}"
- max-wait-time="${mongo.maxWaitTime}"
- connect-timeout="${mongo.connectTimeout}"
- socket-timeout="${mongo.socketTimeout}"
- socket-keep-alive="${mongo.socketKeepAlive}"
- auto-connect-retry="${mongo.autoConnectRetry}" />
- </mongo:mongo>
我們曾經(jīng)就遇到過因為不設置mongodb客戶端timeout而導致服務響應慢的情況。
對于Redis,我們使用的是Jedis客戶端,可以通過如下配置分配等待獲取連接池連接的超時時間和網(wǎng)絡連接/讀超時時間。
- PoolJedisConnectionFactory connectionFactory = new PoolJedisConnectionFactory();
- connectionFactory.setMaxWaitMillis(maxWaitMillis);
- connectionFactory.setTimeout(timeoutInMillis);
Jedis在建立Socket時通過如下代碼設置超時。
- this.socket.connect(new InetSocketAddress(this.host, this.port),this. timeout);
- this.socket.setSoTimeout(this.timeout);
可以在JVM啟動時通過添加-Dsun.net.client.defaultConnectTimeout=60000-Dsun.net.client.defaultReadTimeout=60000來配置默認全局的Socket連接/讀超時。即如Httpclient、JDBC等,如果沒有配置socket超時,則默認會使用該超時。
業(yè)務超時
任務型:比如,訂單超時未支付取消,超時活動自動關閉等,這屬于任務型超時,可以通過Worker定期掃描數(shù)據(jù)庫修改狀態(tài)即可。還有如有時候需要調用的遠程服務超時了(比如,用戶注冊成功后,需要給用戶發(fā)放優(yōu)惠券),可以考慮使用隊列或者暫時記錄到本地稍后重試。
服務調用型:比如,某個服務的全局超時時間為500ms,但我們有多處服務調用,每處的服務調用超時時間可能不一樣,此時,可以簡單地使用Future來解決問題,通過如Future.get(3000,TimeUnit.MILLISECONDS)來設置超時。
前端Ajax超時
我們使用jQuery來進行Ajax請求,可以在請求時帶上timeout參數(shù)設置超時時間。
- $.ajax({
- url:"http://ins.jd.com:9090/test",
- dataType:"jsonp",
- jsonp:"test",
- jsonpCallback:"test",
- timeout:2000,
- success:function(result,status,xhr) {
- //success
- },
- error: function(result,status,xhr){
- if(status== 'timeout') {
- //timeout
- }
- }
- });
當進行跨域JSONP請求時,使用jQuery 1.4.x版本時,IE9、Chrome 52、Firefox 49測試 JSONP時,請求在超時后不能被取消,即使客戶端超時了,該腳本也將一直運行;使用jQuery1.5.2時超時是起作用了,但是,發(fā)出去的請求是沒有取消的(請求還處于執(zhí)行狀態(tài))。
如還有一種辦法來進行超時重試,通過setTimeout進行超時重試,比如,京東首頁的某個異步接口,其中一個域名(A機房)超時了,想超時后通過另一個域名(B機房)重新獲取數(shù)據(jù),代碼如下所示。
- var id = setTimeout(retryCallback, 5000);
- $.ajax({
- dataType: 'jsonp',
- success:function() {
- clearTimeout(id);
- ...
- }
- });
除了客戶端設置超時外,服務端也一定要配置合理的超時時間。
總結
本文主要介紹了如何在Web應用訪問的整個鏈路上進行超時時間設置。通過配置合理的超時時間,防止出現(xiàn)某服務的依賴服務超時時間太長而響應慢,以致自己響應慢甚至崩潰。
客戶端和服務端都應該設置超時時間,而且客戶端根據(jù)場景可以設置比服務端更長的超時時間。如果存在多級依賴關系,如A調用B,B調用C,則超時設置應該是A>B>C,否則可能會一直重試,引起DDoS攻擊效果。不過最終如何選擇還是要看場景,有時候客戶端設置的就是要比服務端的超時時間短,通過在服務端實施限流/降級等手段防止DDoS攻擊。
超時之后應該有相應的策略來處理,常見的策略有重試(等一會兒再試、嘗試其他分組服務、嘗試其他機房服務,重試算法可考慮使用如指數(shù)退避算法)、摘掉不存活節(jié)點(負載均衡/分布式緩存場景下)、托底(返回歷史數(shù)據(jù)/靜態(tài)數(shù)據(jù)/緩存數(shù)據(jù))、等待頁或者錯誤頁。
對于非冪等寫服務應避免重試,或者可以考慮提前生成唯一流水號來保證寫服務操作通過判斷流水號來實現(xiàn)冪等操作。
在進行數(shù)據(jù)庫/緩存服務器操作時,記得經(jīng)常檢查慢查詢,慢查詢通常是引起服務出問題的罪魁禍首。也要考慮在超時嚴重時,直接將該服務降級,待該服務修復后再取消降級。
對于有負載均衡的中間件請考慮配置心跳/存活檢查,而不是惰性檢查。
超時重試必然導致請求響應時間增加,最壞情況下的響應時間=重試次數(shù)×單次超時時間,這很可能嚴重影響到用戶體驗,導致用戶會不斷刷新頁面來重復請求,最后導致服務接收的請求太多而掛掉,因此除了控制單次超時時間,也要控制好用戶能忍受的最壞超時時間。
超時時間太短會導致服務調用成功率降低,超時時間太長又導致本應成功的調用卻失敗了,這也要根據(jù)實際場景來選擇最適合當前業(yè)務的,甚至是程序動態(tài)自動計算超時時間。比如商品詳情頁的庫存狀態(tài)服務,可以設置較短的超時時間,當超時時降級返回有貨,而結算頁服務就需要設置稍微長一些的超時時間保證確實有貨。
在實際開發(fā)中,不要輕視超時時間,很多重大事故都是因為超時時間不合理導致的,設置超時時間一定是只有好處沒有壞處的,請立即Review你的代碼吧。
【本文是51CTO專欄作者“張開濤”的原創(chuàng)文章,作者微信公眾號:開濤的博客( kaitao-1234567)】