引子
如何判断Socket已经关闭曾提到,经历了服务被拖死的教训后,我们在服务端开启了读超时500ms自动关闭连接的动作。注:服务端使用的是Thrift协议,线程模型使用的是TThreadpool。上线后,使用了连接池的上游调用方向我们报异常,说请求总是返回错误,表现的情形为Socket的InputStream的read方法返回-1。具体原因已经在上文分析过。
不巧的是,我们的API gateway这个接入层,也使用了连接池。同样,连接池里并不知道下游已经关闭了连接。拿到一个已经关闭了的连接去发送消息,然后等待返回,结果就是达到设定的最大超时时间后报错。
上文已经提到,如果想知道socket是否还有效,唯一的办法就是发送一个消息,收到ack就是有效,如果收到了rst则为无效,如果什么都收不到,连续重传了多次之后也会认为连接已无效,关闭连接。
其他的检验连接是否有效的方式,都是徒劳,read返回-1可以知道已经关闭?但是要是一个没关闭的socket,调用read(),就一直阻塞着么...
TCP KeepAlive
一开始想tcp层面,是否有这种"保活"的东西,既然超时要关闭,能不能就不让它关闭呢,正好TCP有一个东西叫保活定时器。
其实对于keep alive有个误解,名字叫保活,其实是一个探活的功能。
实际执行的是发送一个比当前序号小1的报文到对端,如果正常对端会返回一个ACK告知期望的id,收到则可以知道socket连接现在还好着呢,如果不正常就知道已经不正常了。
发送的报文里面是没有数据报文的,据说BSD4.3以前的有些操作系统在保活的包中有大小为1的报文字段,但是序号也是比正常应该发送的小1。
保活定时器的超时时间在系统中配置,默认不小于2个小时,也就是不能对单个socket进行配置,只能系统统一设置。
从另一方面来说,Socket的SO_TIMEOUT也已经脱离了TCP层面,即使可以在java层面控制定时发送报文,也不会触发Socket的read方法进入非阻塞状态,达到续命的效果,所以没啥用,还是得从应用层入手。
IdleStateHandler
既然是netty的东西,自然就想到了IdleStateHandler,而且本身我们连接池就给channel加上了IdleStateHandler,其中writeIdleTime、readIdleTime、allIdleTime都设置的是5s,是否直接把时间改为500ms就万事大吉?
现在来看一下IdleStateHandler的代码
EventExecutor loop = ctx.executor();
lastReadTime = lastWriteTime = System.nanoTime();
if (readerIdleTimeNanos > 0) {
readerIdleTimeout = loop.schedule(
new ReaderIdleTimeoutTask(ctx),
readerIdleTimeNanos, TimeUnit.NANOSECONDS);
}
private final class ReaderIdleTimeoutTask implements Runnable {
private final ChannelHandlerContext ctx;
ReaderIdleTimeoutTask(ChannelHandlerContext ctx) {
this.ctx = ctx;
}
@Override
public void run() {
if (!ctx.channel().isOpen()) {
return;
}
long nextDelay = readerIdleTimeNanos;
if (!reading) { //在read开始的时候会置为true,read结束后,更新lastReadTime并置为false
nextDelay -= System.nanoTime() - lastReadTime;
}
if (nextDelay <= 0) {
// Reader is idle - set a new timeout and notify the callback.
readerIdleTimeout =
ctx.executor().schedule(this, readerIdleTimeNanos, TimeUnit.NANOSECONDS);
try {
IdleStateEvent event = newIdleStateEvent(IdleState.READER_IDLE, firstReaderIdleEvent);
if (firstReaderIdleEvent) {
firstReaderIdleEvent = false;
}
channelIdle(ctx, event);
} catch (Throwable t) {
ctx.fireExceptionCaught(t);
}
} else {
// Read occurred before the timeout - set a new timeout with shorter delay.
readerIdleTimeout = ctx.executor().schedule(this, nextDelay, TimeUnit.NANOSECONDS);
}
}
从代码中可以看到,IdleStateHandler的逻辑是通过一个Scheduler定时调度去检查最后一次读写时间与当前时间的差是否已经超过了设定的超时值。显然这与我们的需求不和,因为我们下游系统中接口比较多,有很多耗时超过500ms的接口,正在等待处理结果的时候channel就被关闭了,这样就仅能支持耗时小于500ms(忽略网络耗时),这显然是不可接受的。
而对于服务端来说,读超时是指Server端调用client对端的socket的read方法后500ms内读不到内容关闭。举个例子,如果一个方法在服务端的处理事件是3s,这个channel在等待服务端回复消息的那段时间其实是可用的,对于服务端来说,真正的超时是在他调用了read方法的时候再开始计算的。
而IdleStateHandler的逻辑是使用channel所带的executor去scheduler执行这个检查操作,他不关心channel目前正处于何种状态,只是简单地隔一段时间就去看此channel是否发生过read和write的动作,如果没有,则将channel关闭。所以这两种不同的超时逻辑,导致了使用IdleStateHandler进行“探活”检查无效。
发送消息探活
api gateway只是一个简单的路由转发接入层,下面连着的是多个thrift服务,发一个消息探活浪费网络带宽不说,而且还要让消息不能影响业务,针对不不同的服务发送的内容也不同,也是一种不现实的方法。
ChannelHealthChecker
换一种思路,让探活的操作回归HealthChecker的本意,在我们使用的普通SimpleChannelPool,使用的默认探活是ChannelHealthChecker.ACTIVE,仅仅是判断channel是否是active的,可以认为这个探活基本没啥作用,就是自己主动关闭的才是非active。
我们的使用逻辑是从pool中拿出channel后发消息到服务端,接受完消息后将channel放回到pool中,其中拿出之后和放回之前都会进行healthcheck。
在从channelPool里面拿出channel来时判断这一步,因为拿出来之后肯定第一件事就是进行write操作,而且对于此channel来说,如果可以从pool里面拿出来,则表明服务端与此channel对应的socket肯定处于一个read阻塞中,只需要将当前时间与此channel的最后一次read时间相比较,计算差值即可。
一开始想到拿最后的read时间通过idleStateHandler里的参数来实现,因为想当然地认为简单,然而,他的字段是package访问权限的,如果想读取就只能通过自己实现一个idleStateHandler来实现,复制一个,简单地添加getter方法。
但是此做法比较low,一个channelpool的逻辑,依赖于channel具体添加了那个ChannelHandler了,而且,仅仅是想要一个lastReadTime,去自己复制粘贴一个IdleStateHandler也是比较丑陋的做法。后来发现,通过将lastReadTime保存在Channel的attribute中是比较靠谱的做法。
这样判断的时候就从channel的attribute中获取lastReadTime,相差超过400ms就认为channel已关闭。预留了100ms的网络时延。
注意Channel的attribute和ChannelHandlerContext的attribute不一样,内容是独立的,每一个ChannelHandlerContext的内容都是独立的,不与上下游的ChannelHandler共享。
疑点
1、idleStateHandler的lastReadTime不准确,偏差10ms左右
2、为什么使用一个半关闭的socket发送消息,channel在收到一个RST后,还是一直阻塞在等待中,不抛出异常。
后记
直接捕捉Channel InActive和Channel Unregistered事件就可以,要啥自行车
疑点一,SingleEventLoop线程调度的all tasks会有偏差。疑点二,理解错误,已经出现INACTIVE事件了,没处理而已。