前言
超时可以说是除了空指针我们最熟悉的异常了,从系统的接入层,到服务层,再到数据库层等等都能看到超时的身影;超时很多情况下同时伴随着重试,因为某些情况下比如网络抖动问题等,重试是可以成功的;当然重试往往也会指定重试次数上限,因为如果程序确实存在问题,重试多少次都无济于事,那其实也是对资源的浪费。
为什么要设置超时
对于开发人员来说我们平时最常见的就是设置超时时间,比如数据库超时设置、缓存超时设置、中间件客户端超时设置、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源码、架构、算法和面试。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。