超时与重试浅析

Posted ksfzhaohui

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了超时与重试浅析相关的知识,希望对你有一定的参考价值。

前言

超时可以说是除了空指针我们最熟悉的异常了,从系统的接入层,到服务层,再到数据库层等等都能看到超时的身影;超时很多情况下同时伴随着重试,因为某些情况下比如网络抖动问题等,重试是可以成功的;当然重试往往也会指定重试次数上限,因为如果程序确实存在问题,重试多少次都无济于事,那其实也是对资源的浪费。

为什么要设置超时

对于开发人员来说我们平时最常见的就是设置超时时间,比如数据库超时设置、缓存超时设置、中间件客户端超时设置、HttpClient超时设置、可能还有业务超时;为什么要设置超时时间,因为如果不设置超时时间,可能因为某个请求无法即时响应导致整个链路处于长时间等待状态,这种请求如果过多,直接导致整个系统瘫痪,抛出超时异常其实也是及时止损;纵观各种超时时间设置,可以看到其实大多数都是围绕网络超时,而网络超时不得不提Socket超时设置。

Socket超时

Socket是作为网络通信最基础的类,要进行通信基本分为两步:

  • 建立连接:在进行读写消息之前必须首先建立连接;连接阶段会有连接超时设置ConnectTimeout;
  • 读写操作:读写也就是双方正式交换数据,此阶段会有读写超时设置ReadTimeOut;

连接超时

Socket提供的connect方法提供了连接超时设置:

public void connect(SocketAddress endpoint) throws IOException
public void connect(SocketAddress endpoint, int timeout) throws IOException

不设置timeout默认是0,理论上应该是没有时间限制,经测试默认还是有一个时间限制大概21秒左右;

在建立连接的时候可能会抛出多种异常,常见的比如:

  • ProtocolException:基础协议中存在错误,例如TCP错误;

    java.net.ProtocolException: Protocol error
  • ConnectException:远程拒绝连接(例如,没有进程正在侦听远程地址/端口);

    java.net.ConnectException: Connection refused
  • SocketTimeoutException:套接字读取(read)或接受(accept)发生超时;

    java.net.SocketTimeoutException: connect timed out
    java.net.SocketTimeoutException: Read timed out
  • UnknownHostException:指示无法确定主机的IP地址;

    java.net.UnknownHostException: localhost1
  • NoRouteToHostException:连接到远程地址和端口时出错。通常,由于防火墙的介入,或者中间路由器关闭,无法访问远程主机;

    java.net.NoRouteToHostException: Host unreachable
    java.net.NoRouteToHostException: Address not available
  • SocketException:创建或访问套接字时出错;

    java.net.SocketException: Socket closed
    java.net.SocketException: connect failed

这里我们重点要讨论的是SocketTimeoutException,同时Connection refused也经常出现,这里做一个简单的对比

Connect timed out

本地可以直接使用一个不存在的ip尝试连接:

SocketAddress endpoint = new InetSocketAddress("111.1.1.1", 8080);
socket.connect(endpoint, 2000);

尝试连接报如下错误:

java.net.SocketTimeoutException: connect timed out
    at java.net.DualStackPlainSocketImpl.waitForConnect(Native Method)
    at java.net.DualStackPlainSocketImpl.socketConnect(DualStackPlainSocketImpl.java:85)
    at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:350)
    at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:206)
    at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:188)
    at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:172)
    at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392)
    at java.net.Socket.connect(Socket.java:589)

Connection refused

本地测试可以使用127.x.x.x来进行模拟,尝试连接报如下错误:

java.net.ConnectException: Connection refused: connect
    at java.net.DualStackPlainSocketImpl.waitForConnect(Native Method)
    at java.net.DualStackPlainSocketImpl.socketConnect(DualStackPlainSocketImpl.java:85)
    at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:350)
    at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:206)
    at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:188)
    at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:172)
    at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392)
    at java.net.Socket.connect(Socket.java:589)

对比

  • Connection refused:表示从本地客户端到目标IP地址的路由是正常的,但是该目标端口没有进程在监听,然后服务端拒绝掉了连接;127开头用作本地环回测试(loopback test)本主机的进程之间的通信,所以数据报不会发送给网络,路由都是正常的;
  • Connect timed out:超时的可能性比较多常见的如服务器无法ping通、防火墙丢弃了请求报文、网络间隙性问题等;

读写超时

Socket可以设置SoTimeout表示读写的超时时间,如果不设置默认为0,表示没有时间限制;可以简单做一个模拟,模拟服务器端业务处理延迟10秒,而客户端设置的读写超时时间为2秒:

Socket socket = new Socket();
SocketAddress endpoint = new InetSocketAddress("127.0.0.1", 8189);
socket.connect(endpoint, 2000);//设置连接超时为2秒
socket.setSoTimeout(1000);//设置读写超时为1秒

InputStream inStream = socket.getInputStream();
inStream.read();//读取操作

因为服务器端做了延迟处理,所以超过客户端设置的读写超时时间,直接报如下错误:

java.net.SocketTimeoutException: Read timed out
    at java.net.SocketInputStream.socketRead0(Native Method)
    at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
    at java.net.SocketInputStream.read(SocketInputStream.java:171)
    at java.net.SocketInputStream.read(SocketInputStream.java:141)
    at java.net.SocketInputStream.read(SocketInputStream.java:224)

NIO超时

以上是基于传统Socket的超时配置,NIO提供的SocketChannel也同样存在超时的情况;NIO模式提供了阻塞模式和非阻塞模式,阻塞模式和传统的Socket是一样的,而且存在对应关系;而非阻塞模式并没有提供超时时间的设置;

阻塞模式

SocketChannel client = SocketChannel.open();
//阻塞模式
client.configureBlocking(true);
InetSocketAddress endpoint = new InetSocketAddress("128.5.50.12", 8888);
client.socket().connect(endpoint, 1000);

以上阻塞模式下可以通过client.socket()可以获取到SocketChannel对应的Socket,设置连接超时时间,报如下错误:

java.net.SocketTimeoutException
    at sun.nio.ch.SocketAdaptor.connect(SocketAdaptor.java:118)

非阻塞模式

SocketChannel client = SocketChannel.open();
// 非阻塞模式
client.configureBlocking(false);
// select注册
Selector selector = Selector.open();
client.register(selector, SelectionKey.OP_CONNECT);
InetSocketAddress endpoint = new InetSocketAddress("127.0.0.1", 8888);
client.connect(endpoint);

同样模拟以上两种情况,报如下错误:

//连接超时异常
java.net.ConnectException: Connection timed out: no further information
    at sun.nio.ch.SocketChannelImpl.checkConnect(Native Method)
    at sun.nio.ch.SocketChannelImpl.finishConnect(SocketChannelImpl.java:717)

//连接拒绝异常
java.net.ConnectException: Connection refused: no further information
    at sun.nio.ch.SocketChannelImpl.checkConnect(Native Method)
    at sun.nio.ch.SocketChannelImpl.finishConnect(SocketChannelImpl.java:717)    

常见超时

了解了Socket超时,那么了解其他因为网络而引发的超时就简单多了,常见的网络读写超时设置包括:数据库客户端超时、缓存客户端超时、RPC客户端超时、HttpClient超时,网关层超时;以上几种情况其实都是以客户端的角度来进行的超时时间设置,像Web容器在服务器端也做了超时处理;当然除了网络相关的超时可能也有一些业务超时的情况,下面分别介绍;

网络超时

这里重点看一下客户端相关的超时设置,服务端重点看一下Web容器;

数据库客户端超时

mysql为例,最简单的超时时间设置只需要在url后面添加即可:

jdbc:mysql://localhost:3306/ds0?connectTimeout=2000&socketTimeout=200

connectTimeout:连接超时时间;

socketTimeout:读写超时时间;

除了数据库驱动本身提供的超时时间配置,我们一般都直接使用ORM框架,比如Mybatis等,这些框架本身也会提供相应的超时时间:

 <setting name="defaultStatementTimeout" value="25"/>

defaultStatementTimeout:设置超时时间,它决定数据库驱动等待数据库响应的秒数。

缓存客户端超时

Redis为例,使用Jedis为例,在创建连接的时候同样可以配置超时时间:

public Jedis(final String host, final int port, final int timeout)

这里只配置了一个超时时间,但其实连接和读写超时共用一个值,可以查看Connection源码:

public void connect() {
        if (!isConnected()) {
            try {
                socket = new Socket();
                socket.setReuseAddress(true);
                socket.setKeepAlive(true);
                socket.setTcpNoDelay(true);
                socket.setSoLinger(true, 0);
                //timeout连接超时设置
                socket.connect(new InetSocketAddress(host, port), timeout);
                //timeout读写超时设置
                socket.setSoTimeout(timeout);
                outputStream = new RedisOutputStream(socket.getOutputStream());
                inputStream = new RedisInputStream(socket.getInputStream());
            } catch (IOException ex) {
                throw new JedisConnectionException(ex);
            }
        }
    }

RPC客户端超时

Dubbo为例,可以直接在xml中配置超时时间:

<dubbo:consumer timeout="" >

默认时间为1000ms,Dubbo作为RPC框架,底层使用的是Netty等通信框架,但是Dubbo通过Future实现了自己的超时机制,可以直接查看DefaultFuture,部分代码如下所示:

 // 内部锁
 private final Lock lock = new ReentrantLock();
 private final Condition done = lock.newCondition();
 // 在指定时间内不能获取直接返回TimeoutException
 public Object get(int timeout) throws RemotingException {
        if (timeout <= 0) {
            timeout = Constants.DEFAULT_TIMEOUT;
        }
        if (!isDone()) {
            long start = System.currentTimeMillis();
            lock.lock();
            try {
                while (!isDone()) {
                    done.await(timeout, TimeUnit.MILLISECONDS);
                    if (isDone() || System.currentTimeMillis() - start > timeout) {
                        break;
                    }
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                lock.unlock();
            }
            if (!isDone()) {
                throw new TimeoutException(sent > 0, channel, getTimeoutMessage(false));
            }
        }
        return returnFromResponse();
    }

HttpClient超时

HttpClient可以说是我们最常使用的Http客户端了,可以通过RequestConfig来设置超时时间:

RequestConfig requestConfig = RequestConfig.custom().setSocketTimeout(2000).setConnectTimeout(1000)
                .setConnectionRequestTimeout(3000).build();

其中可以配置三个超时时间分别是:

  • socketTimeout:连接建立成功,读写超时时间;
  • connectTimeout:连接超时时间;
  • connectionRequestTimeout:从连接管理器请求连接时使用的超时;

网关层超时

以常见的nginx为例,作为代理转发,从下游Web服务器的角度来看,Nginx作为转发器其实就是客户端,同样需要配置连接、读写等超时时间:

server {
        listen 80;
        server_name localhost;
        location / {
           // 超时配置
           proxy_connect_timeout 2s;
           proxy_read_timeout 2s;
           proxy_send_timeout 2s;
           
           //重试机制
           proxy_next_upstream error timeout;
           proxy_next_upstream_tries 5;
           proxy_next_upstream_timeout 5;
        }
    }

相关超时时间配置:

  • proxy_connect_timeout:与后端服务器建立连接超时时间,默认60s;
  • proxy_read_timeout:从后端服务器读取响应的超时时间,默认60s;
  • proxy_send_timeout:往后端服务器发送请求的超时时间,默认60s;

Nginx作为代理服务器,同样提供了重试机制,对于上游服务器往往会配置多台来实现负载均衡,相关配置如下:

  • proxy_next_upstream:什么情况下需要请求下一台后端服务器进行重试,默认error timeout;
  • proxy_next_upstream_tries:重试次数,默认为0表示不限次数;
  • proxy_next_upstream_timeout:重试最大超时时间,默认为0表示不限次数;

服务端超时

以上几种情况我们都是站在客户端的角度,也是作为开发人员最常使用的超时配置,其实在服务器端也同样可以配置相应的超时时间,比如最常见的Web容器Tomcat、上面介绍的Nginx等,下面看一下Tomcat的相关超时配置:

<Connector connectionTimeout="20000" socket.soTimeout="20000" asyncTimeout="20000" disableUploadTimeout="20000" connectionUploadTimeout="20000" keepAliveTimeout="20000" />
  • connectionTimeout:连接器在接受连接后,指定时间内没有接收到请求URI行,则表示连接超时;
  • socket.soTimeout:从客户端读取请求数据的超时时间,默认同connectionTimeout;
  • asyncTimeout:异步请求的超时时间;
  • disableUploadTimeout和connectionUploadTimeout:文件上传使用的超时时间;
  • keepAliveTimeout:设置Http长连接超时时间;

更多配置:Tomcat8.5

业务超时

基本上我们用到的中间件都提供了超时设置,当然业务中某些情况也需要我们自己做超时处理,比如某个功能需要调用多个服务,每个服务都有自己的超时时间,但是此功能有个总的超时时间,这时候我们可以参考Dubbo使用Future来解决超时问题。

重试

重试往往伴随着超时一起出现,因为超时可能是因为某些特殊原因导致暂时性的请求失败,也就是说重试是有可能出现请求再次成功的;其实现在很多提供负载均衡的系统,不仅是在超时的时候重试,出现任何异常都会重试,比如类似Nginx的网关,RPC,MQ等;下面具体看看各种系统都是如何实现重试的;

RPC重试

RPC系统一般都会提供注册中心,服务提供方会提供多个节点,所以如果某个服务端节点异常,消费端会重新选择其他的节点;以Dubbo为例,提供了容错机制类FailoverClusterInvoker,默认会失败重试两次,具体重试是通过for循环来实现的:

 for (int i = 0; i < len; i++) {
     try{
         //负载均衡选择一个服务端
         Invoker<T> invoker = select(loadbalance, invocation, copyinvokers, invoked);
         //执行
         Result result = invoker.invoke(invocation);
     } catch (Throwable e) {
         //出现异常并不会退出
        le = new RpcException(e.getMessage(), e);
    }
 }

以上通过for循环捕获异常来实现重试是一种比较好的方式,比在catch子句中再实现重试更方便;

MQ重试

很多消息系统都提供了重试机制比如ActiveMQ、RocketMQ、Kafka等;

ActiveMQ中的ActiveMQMessageConsumer类的rollback提供了重试机制,最大的重发次数DEFAULT_MAXIMUM_REDELIVERIES=6

RocketMQ在消息量大,网络有波动的情况下,重试也是一个大概率事件;Producer中的setRetryTimesWhenSendFailed设置在同步方式下自动重试的次数,默认值为2;

网关重试

网关作为一个负载均衡器,其中一个核心功能就是重试机制,除了此机制外还有健康检测机制,事先把有问题的业务逻辑节点排除掉,这样也减少了重试的几率,重试本身也是很浪费时间的;Nginx相关重试的配置上节中已经介绍,这里不在重复;

HttpClient重试

HttpClient内部其实提供了重试机制,实现类RetryExec,默认重试次数为3次,代码部分如下:

for (int execCount = 1;; execCount++) {
     try {
        return this.requestExecutor.execute(route, request, context, execAware);
     } catch (final IOException ex) {
         // 重试异常检查
     }
}
  • 只有发生IOExecetion时才会发生重试;
  • InterruptedIOException、UnknownHostException、ConnectException、SSLException,发生这4种异常不重试;

可以发现SocketTimeoutException继承于InterruptedIOException,所以并不会重试;

定时器重试

之前有遇到过需要通知外部系统的情况,因为实时性没那么高,而且很多外部系统都不是那么稳定,不一定什么时候就进入维护中;采用数据库+定时器的方式来进行重试,每条通知记录会保存下一次重试的时间(重试时间采用递增的方式),定时器定期查找哪些下一次重试时间在当前时间内的,如果成功更新状态为成功,如果失败更新下一次重试时间,重试次数+1,当然也会设置最大重试值;

注意点

当然重试也需要注意是查询类的还是更新类的,如果是查询类的多次重试并不影响结果,如果是更新类的,需要做好幂等性。

总结

合理的设置超时与重试机制,是保证系统高可用的前提之一;太多故障因为不合理的设置超时时间导致的,所以我们在开发过程中一定要注意;另外一点就是可用多看看一些中间件的源码,很多解决方案都可用在这些中间件中找到答案,比如Dubbo中的超时重试机制,可用作为一个很好的参考。

感谢关注

可以关注微信公众号「回滚吧代码」,第一时间阅读,文章持续更新;专注Java源码、架构、算法和面试。

以上是关于超时与重试浅析的主要内容,如果未能解决你的问题,请参考以下文章

读书笔记:超时与重试机制

Dubbo -- dubbo高级特性(序列化 地址缓存 超时与重试机制 多版本:灰度发布 负载均衡 集群容错策略 服务降级)

使用 Polly 实现复杂策略(超时重试)

SpringCloud Alibaba系列Dubbo高级特性篇

TCP超时与重传机制

如何编写 Kotlin 口语代码同步重试直到超时或成功?