在前一篇文章 《聊聊 TCP 长链接和心跳那些事》 中,咱们已经聊过了 TCP 中的 KeepAlive,以及在应用层设计心跳的意义,但却对长链接心跳的设计方案没有作详细地介绍。事实上,设计一个好的心跳机制并非一件容易的事,就我所熟知的几个 RPC 框架,它们的心跳机制能够说截然不同,这篇文章我将探讨一下 如何设计一个优雅的心跳机制,主要从 Dubbo 的现有方案以及一个改进方案来作分析。java
由于后续咱们将从源码层面来进行介绍,因此一些服务治理框架的细节还须要提早交代一下,方便你们理解。apache
高性能的 RPC 框架几乎都会选择使用 Netty 来做为通讯层的组件,非阻塞式通讯的高效不须要我作过多的介绍。但也因为非阻塞的特性,致使其发送数据和接收数据是一个异步的过程,因此当存在服务端异常、网络问题时,客户端接是接收不到响应的,那咱们如何判断一次 RPC 调用是失败的呢?bootstrap
误区一:Dubbo 调用不是默认同步的吗?安全
Dubbo 在通讯层是异步的,呈现给使用者同步的错觉是由于内部作了阻塞等待,实现了异步转同步。微信
误区二: Channel.writeAndFlush
会返回一个 channelFuture
,我只须要判断 channelFuture.isSuccess
就能够判断请求是否成功了。网络
注意,writeAndFlush 成功并不表明对端接受到了请求,返回值为 true 只能保证写入网络缓冲区成功,并不表明发送成功。框架
避开上述两个误区,咱们再来回到本小节的标题:客户端如何得知请求失败?正确的逻辑应当是以客户端接收到失败响应为判断依据。等等,前面不还在说在失败的场景中,服务端是不会返回响应的吗?没错,既然服务端不会返回,那就只能客户端本身造了。异步
一个常见的设计是:客户端发起一个 RPC 请求,会设置一个超时时间 client_timeout
,发起调用的同时,客户端会开启一个延迟 client_timeout
的定时器ide
Dubbo 中的超时断定逻辑:oop
public static DefaultFuture newFuture(Channel channel, Request request, int timeout) {
final DefaultFuture future = new DefaultFuture(channel, request, timeout);
// timeout check
timeoutCheck(future);
return future;
}
private static void timeoutCheck(DefaultFuture future) {
TimeoutCheckTask task = new TimeoutCheckTask(future);
TIME_OUT_TIMER.newTimeout(task, future.getTimeout(), TimeUnit.MILLISECONDS);
}
private static class TimeoutCheckTask implements TimerTask {
private DefaultFuture future;
TimeoutCheckTask(DefaultFuture future) {
this.future = future;
}
@Override
public void run(Timeout timeout) {
if (future == null || future.isDone()) {
return;
}
// create exception response.
Response timeoutResponse = new Response(future.getId());
// set timeout status.
timeoutResponse.setStatus(future.isSent() ? Response.SERVER_TIMEOUT : Response.CLIENT_TIMEOUT);
timeoutResponse.setErrorMessage(future.getTimeoutMessage(true));
// handle response.
DefaultFuture.received(future.getChannel(), timeoutResponse);
}
}
复制代码
主要逻辑涉及的类:DubboInvoker
,HeaderExchangeChannel
,DefaultFuture
,经过上述代码,咱们能够得知一个细节,不管是何种调用,都会通过这个定时器的检测,超时即调用失败,一次 RPC 调用的失败,必须以客户端收到失败响应为准。
网络通讯永远要考虑到最坏的状况,一次心跳失败,不能认定为链接不通,屡次心跳失败,才能采起相应的措施。
忙检测的对立面是空闲检测,咱们作心跳的初衷,是为了保证链接的可用性,以保证及时采起断连,重连等措施。若是一条通道上有频繁的 RPC 调用正在进行,咱们不该该为通道增长负担去发送心跳包。心跳扮演的角色应当是晴天收伞,雨天送伞。
本文的源码对应 Dubbo 2.7.x 版本,在 apache 孵化的该版本中,心跳机制获得了加强。
介绍完了一些基础的概念,咱们便来看看 Dubbo 是如何设计应用层心跳的。Dubbo 的心跳是双向心跳,客户端会给服务端发送心跳,反之,服务端也会向客户端发送心跳。
public class HeaderExchangeClient implements ExchangeClient {
private int heartbeat;
private int heartbeatTimeout;
private HashedWheelTimer heartbeatTimer;
public HeaderExchangeClient(Client client, boolean needHeartbeat) {
this.client = client;
this.channel = new HeaderExchangeChannel(client);
this.heartbeat = client.getUrl().getParameter(Constants.HEARTBEAT_KEY, dubbo != null && dubbo.startsWith("1.0.") ? Constants.DEFAULT_HEARTBEAT : 0);
this.heartbeatTimeout = client.getUrl().getParameter(Constants.HEARTBEAT_TIMEOUT_KEY, heartbeat * 3);
if (needHeartbeat) { <1>
long tickDuration = calculateLeastDuration(heartbeat);
heartbeatTimer = new HashedWheelTimer(new NamedThreadFactory("dubbo-client-heartbeat", true), tickDuration,
TimeUnit.MILLISECONDS, Constants.TICKS_PER_WHEEL); <2>
startHeartbeatTimer();
}
}
}
复制代码
<1> 默认开启心跳检测的定时器
<2> 建立了一个 HashWheelTimer
开启心跳检测,这是 Netty 所提供的一个经典的时间轮定时器实现,至于它和 jdk 的实现有何不一样,不了解的同窗也能够关注下,我就拓展了。
不只 HeaderExchangeClient
客户端开起了定时器,HeaderExchangeServer
服务端一样开起了定时器,因为服务端的逻辑和客户端几乎一致,因此后续我并不会重复粘贴服务端的代码。
Dubbo 在早期版本版本中使用的是 shedule 方案,在 2.7.x 中替换成了 HashWheelTimer。
private void startHeartbeatTimer() {
long heartbeatTick = calculateLeastDuration(heartbeat);
long heartbeatTimeoutTick = calculateLeastDuration(heartbeatTimeout);
HeartbeatTimerTask heartBeatTimerTask = new HeartbeatTimerTask(cp, heartbeatTick, heartbeat); <1>
ReconnectTimerTask reconnectTimerTask = new ReconnectTimerTask(cp, heartbeatTimeoutTick, heartbeatTimeout); <2>
heartbeatTimer.newTimeout(heartBeatTimerTask, heartbeatTick, TimeUnit.MILLISECONDS);
heartbeatTimer.newTimeout(reconnectTimerTask, heartbeatTimeoutTick, TimeUnit.MILLISECONDS);
}
复制代码
Dubbo 在 startHeartbeatTimer
方法中主要开启了两个定时器: HeartbeatTimerTask
,ReconnectTimerTask
<1> HeartbeatTimerTask
主要用于定时发送心跳请求
<2> ReconnectTimerTask
主要用于心跳失败以后处理重连,断连的逻辑
至于方法中的其余代码,其实也是本文的重要分析内容,先容我卖个关子,后面再来看追溯。
详细解析下心跳检测定时任务的逻辑 HeartbeatTimerTask#doTask
:
protected void doTask(Channel channel) {
Long lastRead = lastRead(channel);
Long lastWrite = lastWrite(channel);
if ((lastRead != null && now() - lastRead > heartbeat)
|| (lastWrite != null && now() - lastWrite > heartbeat)) {
Request req = new Request();
req.setVersion(Version.getProtocolVersion());
req.setTwoWay(true);
req.setEvent(Request.HEARTBEAT_EVENT);
channel.send(req);
}
}
}
复制代码
前面已经介绍过,Dubbo 采起的是设计是双向心跳,即服务端会向客户端发送心跳,客户端也会向服务端发送心跳,接收的一方更新 lastRead 字段,发送的一方更新 lastWrite 字段,超过心跳间隙的时间,便发送心跳请求给对端。这里的 lastRead/lastWrite 一样会被同一个通道上的普通调用更新,经过更新这两个字段,实现了只在链接空闲时才会真正发送空闲报文的机制,符合咱们一开始科普的作法。
注意:不只仅心跳请求会更新 lastRead 和 lastWrite,普通请求也会。这对应了咱们预备知识中的空闲检测机制。
继续研究下重连和断连定时器都实现了什么 ReconnectTimerTask#doTask
。
protected void doTask(Channel channel) {
Long lastRead = lastRead(channel);
Long now = now();
if (lastRead != null && now - lastRead > heartbeatTimeout) {
if (channel instanceof Client) {
((Client) channel).reconnect();
} else {
channel.close();
}
}
}
复制代码
第二个定时器则负责根据客户端、服务端类型来对链接作不一样的处理,当超过设置的心跳总时间以后,客户端选择的是从新链接,服务端则是选择直接断开链接。这样的考虑是合理的,客户端调用是强依赖可用链接的,而服务端能够等待客户端从新创建链接。
细心的朋友会发现,这个类被命名为 ReconnectTimerTask 是不太准确的,由于它处理的是重连和断连两个逻辑。
在 Dubbo 的 issue 中曾经有人反馈过定时不精确的问题,咱们来看看是怎么一回事。
Dubbo 中默认的心跳周期是 60s,设想以下的时序:
因为时间窗口的问题,死链不可以被及时检测出来,最坏状况为一个心跳周期。
为了解决上述问题,咱们再倒回去看一下上面的 startHeartbeatTimer()
方法
long heartbeatTick = calculateLeastDuration(heartbeat);
long heartbeatTimeoutTick = calculateLeastDuration(heartbeatTimeout);
复制代码
其中 calculateLeastDuration
根据心跳时间和超时时间分别计算出了一个 tick 时间,实际上就是将两个变量除以了 3,使得他们的值缩小,并传入了 HashWeelTimer
的第二个参数之中
heartbeatTimer.newTimeout(heartBeatTimerTask, heartbeatTick, TimeUnit.MILLISECONDS);
heartbeatTimer.newTimeout(reconnectTimerTask, heartbeatTimeoutTick, TimeUnit.MILLISECONDS);
复制代码
tick 的含义即是定时任务执行的频率。这样,经过减小检测间隔时间,增大了及时发现死链的几率,原先的最坏状况是 60s,现在变成了 20s。这个频率依旧能够加快,但须要考虑资源消耗的问题。
定时不许确的问题出如今 Dubbo 的两个定时任务之中,因此都作了 tick 操做。事实上,全部的定时检测的逻辑都存在相似的问题。
Dubbo 对于创建的每个链接,同时在客户端和服务端开启了 2 个定时器,一个用于定时发送心跳,一个用于定时重连、断连,执行的频率均为各自检测周期的 1/3。定时发送心跳的任务负责在链接空闲时,向对端发送心跳包。定时重连、断连的任务负责检测 lastRead 是否在超时周期内仍未被更新,若是断定为超时,客户端处理的逻辑是重连,服务端则采起断连的措施。
先不急着判断这个方案好很差,再来看看改进方案是怎么设计的。
实际上咱们能够更优雅地实现心跳机制,本小节开始,我将介绍一个新的心跳机制。
Netty 对空闲链接的检测提供了自然的支持,使用 IdleStateHandler
能够很方便的实现空闲检测逻辑。
public IdleStateHandler( long readerIdleTime, long writerIdleTime, long allIdleTime, TimeUnit unit) {}
复制代码
IdleStateHandler
这个类会根据设置的超时参数,循环检测 channelRead 和 write 方法多久没有被调用。当在 pipeline 中加入 IdleSateHandler
以后,能够在此 pipeline 的任意 Handler 的 userEventTriggered
方法之中检测 IdleStateEvent
事件,
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
//do something
}
ctx.fireUserEventTriggered(evt);
}
复制代码
为何须要介绍 IdleStateHandler
呢?其实提到它的空闲检测 + 定时的时候,你们应该可以想到了,这不自然是给心跳机制服务的吗?不少服务治理框架都选择了借助 IdleStateHandler
来实现心跳。
IdleStateHandler 内部使用了 eventLoop.schedule(task) 的方式来实现定时任务,使用 eventLoop 线程的好处是还同时保证了线程安全,这里是一个小细节。
首先是将 IdleStateHandler
加入 pipeline 中。
客户端:
bootstrap.handler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
ch.pipeline().addLast("clientIdleHandler", new IdleStateHandler(60, 0, 0));
}
});
复制代码
服务端:
serverBootstrap.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
ch.pipeline().addLast("serverIdleHandler",new IdleStateHandler(0, 0, 200));
}
}
复制代码
客户端配置了 read 超时为 60s,服务端配置了 write/read 超时为 200s,先在此埋下两个伏笔:
对于空闲超时的处理逻辑,客户端和服务端是不一样的。首先来看客户端
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
// send heartbeat
sendHeartBeat();
} else {
super.userEventTriggered(ctx, evt);
}
}
复制代码
检测到空闲超时以后,采起的行为是向服务端发送心跳包,具体是如何发送,以及处理响应的呢?伪代码以下
public void sendHeartBeat() {
Invocation invocation = new Invocation();
invocation.setInvocationType(InvocationType.HEART_BEAT);
channel.writeAndFlush(invocation).addListener(new CallbackFuture() {
@Override
public void callback(Future future) {
RPCResult result = future.get();
//超时 或者 写失败
if (result.isError()) {
channel.addFailedHeartBeatTimes();
if (channel.getFailedHeartBeatTimes() >= channel.getMaxHeartBeatFailedTimes()) {
channel.reconnect();
}
} else {
channel.clearHeartBeatFailedTimes();
}
}
});
}
复制代码
行为并不复杂,构造一个心跳包发送到服务端,接受响应结果
不只仅是心跳,普通请求返回成功响应时也会清空标记
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
channel.close();
} else {
super.userEventTriggered(ctx, evt);
}
}
复制代码
服务端处理空闲链接的方式很是简单粗暴,直接关闭链接。
为何客户端和服务端配置的超时时间不一致?
由于客户端有重试逻辑,不断发送心跳失败 n 次以后,才认为是链接断开;而服务端是直接断开,留给服务端时间得长一点。60 * 3 < 200 还说明了一个问题,双方都拥有断开链接的能力,但链接的建立是由客户端主动发起的,那么客户端也更有权利去主动断开链接。
为何客户端检测的是读超时,而服务端检测的是读写超时?
这实际上是一个心跳的共识了,仔细思考一下,定时逻辑是由客户端发起的,因此整个链路中不通的状况只有多是:服务端接收,服务端发送,客户端接收。也就是说,只有客户端的 pong,服务端的 ping,pong 的检测是有意义的。
主动追求别人的是你,主动说分手的也是你。
利用 IdleStateHandler
实现心跳机制能够说是十分优雅的,借助 Netty 提供的空闲检测机制,利用客户端维护单向心跳,在收到 3 次心跳失败响应以后,客户端断开链接,交由异步线程重连,本质仍是表现为客户端重连。服务端在链接空闲较长时间后,主动断开链接,以免无谓的资源浪费。
Dubbo 现有方案 | Dubbo 改进方案 | |
---|---|---|
主体设计 | 开启两个定时器 | 借助 IdleStateHandler,底层使用 shedule |
心跳方向 | 双向 | 单向(客户端 -> 服务端) |
心跳失败断定方式 | 心跳成功更新标记,借助定时器定时扫描标记,若是超过心跳超时周期未更新标记,认为心跳失败。 | 经过判断心跳响应是否失败,超过失败次数,认为心跳失败 |
扩展性 | Dubbo 存在 mina,grizzy 等其余通讯层实现,自定义定时器很容易适配多种扩展 | 多通讯层各自实现心跳,不作心跳的抽象 |
设计性 | 编码复杂度高,代码量大,方案复杂,不易维护 | 编码量小,可维护性强 |
私下请教过美团点评的长链接负责人:俞超(闪电侠),美点使用的心跳方案和 Dubbo 改进方案几乎一致,能够该方案是标准实现了。
鉴于 Dubbo 存在一些其余通讯层的实现,因此能够保留现有的定时发送心跳的逻辑。
双向心跳的设计是没必要要的,兼容现有的逻辑,可让客户端在链接空闲时发送单向心跳,服务端定时检测链接可用性。定时时间尽可能保证:客户端超时时间 * 3 ≈ 服务端超时时间
去除处理重连和断连的定时任务,Dubbo 能够判断心跳请求是否响应失败,能够借鉴改进方案的设计,在链接级别维护一个心跳失败次数的标记,任意响应成功,清除标记;连续心跳失败 n 次,客户端发起重连。这样能够减小一个没必要要的定时器,任何轮询的方式,都是不优雅的。
最后再聊聊可扩展性这个话题。其实我是建议把定时器交给更加底层的 Netty 去作,也就是彻底使用 IdleStateHandler
,其余通讯层组件各自实现本身的空闲检测逻辑,可是 Dubbo 中 mina,grizzy 的兼容问题囿住了个人拳脚,但试问一下,现在的 2019 年,又有多少人在使用 mina 和 grizzy?由于一些不太可能用的特性,而限制了主流用法的优化,这确定不是什么好事。抽象,功能,可扩展性并非越多越好,开源产品的人力资源是有限的,框架使用者的理解能力也是有限的,能解决大多数人问题的设计,才是好的设计。哎,谁让我不会 mina,grizzy,还懒得去学呢[摊手]。
欢迎关注个人微信公众号:「Kirito的技术分享」,关于文章的任何疑问都会获得回复,带来更多 Java 相关的技术分享。