超时与重试机制(2)—《亿级流量》

张开涛

2017-07-03 发表于 2017-07-03 09:45 高并发

             

数据库客户端超时

在使用数据库客户端时,我们会使用数据库连接池,数据库连接池可以进行如下超时设置。

<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>
  • 网络连接/读超时:使用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单位是秒,根据业务配置。如果数据库连接池配置了,则此处可以不用配置。

如果想只设置某个Statement的超时时间,则可以考虑:

如上配置其实最终会调用Statement.setQueryTimeout方法设置Statement超时时间。

  • 事务超时是总Statement超时设置,比如我们使用Spring管理事务的话,可以使用如下方式配置全局默认的事务级别的超时时间。
    <bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <propertyname="dataSource" ref="dataSource" />
    <propertyname="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实现。

public voidsetTimeoutInSeconds(int seconds) {
    setTimeoutInMillis(seconds* 1000);
}

public voidsetTimeoutInMillis(long millis) {
    this.deadline = newDate(System.currentTimeMillis() + millis);  
}

大家可以看到,此处会设置一个deadline时间,用来判断事务超时时间,那什么时候调用呢?首先检查该类中的代码,会发现。

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);
    }
}

我们发现调用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中,执行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>

我们曾经就遇到过因为不设置mongodb客户端timeout而导致服务响应慢的情况。

对于Redis,我们使用的是Jedis客户端,可以通过如下配置分配等待获取连接池连接的超时时间和网络连接/读超时时间。

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超时,则默认会使用该超时。

业务超时

任务型:比如,订单超时未支付取消,超时活动自动关闭等,这属于任务型超时,可以通过Worker定期扫描数据库修改状态即可。还有如有时候需要调用的远程服务超时了(比如,用户注册成功后,需要给用户发放优惠券),可以考虑使用队列或者暂时记录到本地稍后重试。

服务调用型:比如,某个服务的全局超时时间为500ms,但我们有多处服务调用,每处的服务调用超时时间可能不一样,此时,可以简单地使用Future来解决问题,通过如Future.get(3000,TimeUnit.MILLISECONDS)来设置超时。

前端Ajax超时

我们使用jQuery来进行Ajax请求,可以在请求时带上timeout参数设置超时时间。

$.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时超时是起作用了,但是,发出去的请求是没有取消的(请求还处于执行状态)。

如还有一种办法来进行超时重试,通过setTimeout进行超时重试,比如,京东首页的某个异步接口,其中一个域名(A机房)超时了,想超时后通过另一个域名(B机房)重新获取数据,代码如下所示。

var id = setTimeout(retryCallback, 5000);
$.ajax({
   dataType: 'jsonp',
    success:function() {
       clearTimeout(id);
        ...
    }
});

除了客户端设置超时外,服务端也一定要配置合理的超时时间。

总结

本文主要介绍了如何在Web应用访问的整个链路上进行超时时间设置。通过配置合理的超时时间,防止出现某服务的依赖服务超时时间太长而响应慢,以致自己响应慢甚至崩溃。

客户端和服务端都应该设置超时时间,而且客户端根据场景可以设置比服务端更长的超时时间。如果存在多级依赖关系,如A调用B,B调用C,则超时设置应该是A>B>C,否则可能会一直重试,引起DDoS攻击效果。不过最终如何选择还是要看场景,有时候客户端设置的就是要比服务端的超时时间短,通过在服务端实施限流/降级等手段防止DDoS攻击。

超时之后应该有相应的策略来处理,常见的策略有重试(等一会儿再试、尝试其他分组服务、尝试其他机房服务,重试算法可考虑使用如指数退避算法)、摘掉不存活节点(负载均衡/分布式缓存场景下)、托底(返回历史数据/静态数据/缓存数据)、等待页或者错误页。

对于非幂等写服务应避免重试,或者可以考虑提前生成唯一流水号来保证写服务操作通过判断流水号来实现幂等操作。

在进行数据库/缓存服务器操作时,记得经常检查慢查询,慢查询通常是引起服务出问题的罪魁祸首。也要考虑在超时严重时,直接将该服务降级,待该服务修复后再取消降级。

对于有负载均衡的中间件请考虑配置心跳/存活检查,而不是惰性检查。

超时重试必然导致请求响应时间增加,最坏情况下的响应时间=重试次数×单次超时时间,这很可能严重影响到用户体验,导致用户会不断刷新页面来重复请求,最后导致服务接收的请求太多而挂掉,因此除了控制单次超时时间,也要控制好用户能忍受的最坏超时时间。

超时时间太短会导致服务调用成功率降低,超时时间太长又导致本应成功的调用却失败了,这也要根据实际场景来选择最适合当前业务的,甚至是程序动态自动计算超时时间。比如商品详情页的库存状态服务,可以设置较短的超时时间,当超时时降级返回有货,而结算页服务就需要设置稍微长一些的超时时间保证确实有货。

在实际开发中,不要轻视超时时间,很多重大事故都是因为超时时间不合理导致的,设置超时时间一定是只有好处没有坏处的,请立即Review你的代码吧。

读者评论

相关专题

相关博文

  • 解读MySQL性能调优“金字塔”

    解读MySQL性能调优“金字塔”

    管理员账号 2019-10-22

    计算机是一种实验的科学 性能优化是实战的艺术 蒸汽机的改进不是一蹴而就的,MySQL性能的改进也是贯穿整个MySQL发展史的。MySQL之父Monty在1981年写了MySQL的第一行代码以后,在开源的帮助下MySQL成长为目...

    管理员账号 2019-10-22
    499 0 0 0
  • Binlog中的时间戳

    Binlog中的时间戳

    管理员账号 2017-06-13

    小编说:本文从一个典型的案例入手来讲述Binlog中时间戳的原理和实践,通过本文你可以了解时间戳在Binlog中的作用及产生方法,以便在出现一些这方面怪异的问题时,做到心中有数,胸有成竹。本文选自《MySQL运维内参》 背景 众所周...

    管理员账号 2017-06-13
    3325 0 0 0
  • 聊聊Neo4j图数据库的那些明显优势

    聊聊Neo4j图数据库的那些明显优势

    管理员账号 2017-06-12

    小编说:Neo4j是一个NoSQL的图数据库管理系统,像其他NoSQL数据库一样具有高效的查询性能。同时,Neo4j还具有完全事务管理特性,完全支持ACID事务管理。Neo4j与其他数据库相比,具有哪些明显的优势呢?本文选自《Neo4...

    管理员账号 2017-06-12
    3619 0 0 0