企业🤖AI智能体构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
### 1 前言 在前一篇文章**《聊聊 TCP 长连接和心跳那些事》**中,我们已经聊过了 TCP 中的 KeepAlive,以及在应用层设计心跳的意义,但却对长连接心跳的设计方案没有做详细地介绍。事实上,设计一个好的心跳机制并不是一件容易的事,就我所熟知的几个 RPC 框架,它们的心跳机制可以说大相径庭,这篇文章我将探讨一下**如何设计一个优雅的心跳机制,主要从 Dubbo 的现有方案以及一个改进方案来做分析**。 ### [](https://www.cnkirito.moe/heartbeat-design/#2-%E9%A2%84%E5%A4%87%E7%9F%A5%E8%AF%86 "2 预备知识")2 预备知识 因为后续我们将从源码层面来进行介绍,所以一些服务治理框架的细节还需要提前交代一下,方便大家理解。 #### [](https://www.cnkirito.moe/heartbeat-design/#2-1-%E5%AE%A2%E6%88%B7%E7%AB%AF%E5%A6%82%E4%BD%95%E5%BE%97%E7%9F%A5%E8%AF%B7%E6%B1%82%E5%A4%B1%E8%B4%A5%E4%BA%86%EF%BC%9F "2.1 客户端如何得知请求失败了?")2.1 客户端如何得知请求失败了? 高性能的 RPC 框架几乎都会选择使用 Netty 来作为通信层的组件,非阻塞式通信的高效不需要我做过多的介绍。但也由于非阻塞的特性,导致其发送数据和接收数据是一个异步的过程,所以当存在服务端异常、网络问题时,客户端接是接收不到响应的,那我们如何判断一次 RPC 调用是失败的呢? 误区一:Dubbo 调用不是默认同步的吗? Dubbo 在通信层是异步的,呈现给使用者同步的错觉是因为内部做了阻塞等待,实现了异步转同步。 误区二:`Channel.writeAndFlush`会返回一个`channelFuture`,我只需要判断`channelFuture.isSuccess`就可以判断请求是否成功了。 注意,writeAndFlush 成功并不代表对端接受到了请求,返回值为 true 只能保证写入网络缓冲区成功,并不代表发送成功。 避开上述两个误区,我们再来回到本小节的标题:客户端如何得知请求失败?**正确的逻辑应当是以客户端接收到失败响应为判断依据**。等等,前面不还在说在失败的场景中,服务端是不会返回响应的吗?没错,既然服务端不会返回,那就只能客户端自己造了。 一个常见的设计是:客户端发起一个 RPC 请求,会设置一个超时时间`client_timeout`,发起调用的同时,客户端会开启一个延迟`client_timeout`的定时器 * 接收到正常响应时,移除该定时器。 * 定时器倒计时完毕,还没有被移除,则认为请求超时,构造一个失败的响应传递给客户端。 Dubbo 中的超时判定逻辑: ~~~ 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 调用的失败,必须以客户端收到失败响应为准**。 #### [](https://www.cnkirito.moe/heartbeat-design/#2-2-%E5%BF%83%E8%B7%B3%E6%A3%80%E6%B5%8B%E9%9C%80%E8%A6%81%E5%AE%B9%E9%94%99 "2.2 心跳检测需要容错")2.2 心跳检测需要容错 网络通信永远要考虑到最坏的情况,一次心跳失败,不能认定为连接不通,多次心跳失败,才能采取相应的措施。 #### [](https://www.cnkirito.moe/heartbeat-design/#2-3-%E5%BF%83%E8%B7%B3%E6%A3%80%E6%B5%8B%E4%B8%8D%E9%9C%80%E8%A6%81%E5%BF%99%E6%A3%80%E6%B5%8B "2.3 心跳检测不需要忙检测")2.3 心跳检测不需要忙检测 忙检测的对立面是空闲检测,我们做心跳的初衷,是为了保证连接的可用性,以保证及时采取断连,重连等措施。如果一条通道上有频繁的 RPC 调用正在进行,我们不应该为通道增加负担去发送心跳包。**心跳扮演的角色应当是晴天收伞,雨天送伞。** ### [](https://www.cnkirito.moe/heartbeat-design/#3-Dubbo-%E7%8E%B0%E6%9C%89%E6%96%B9%E6%A1%88 "3 Dubbo 现有方案")3 Dubbo 现有方案 > 本文的源码对应 Dubbo 2.7.x 版本,在 apache 孵化的该版本中,心跳机制得到了增强。 介绍完了一些基础的概念,我们再来看看 Dubbo 是如何设计应用层心跳的。Dubbo 的心跳是双向心跳,客户端会给服务端发送心跳,反之,服务端也会向客户端发送心跳。 #### [](https://www.cnkirito.moe/heartbeat-design/#3-1-%E8%BF%9E%E6%8E%A5%E5%BB%BA%E7%AB%8B%E6%97%B6%E5%88%9B%E5%BB%BA%E5%AE%9A%E6%97%B6%E5%99%A8 "3.1 连接建立时创建定时器")3.1 连接建立时创建定时器 ~~~ 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(); } } } ~~~ **默认开启心跳检测的定时器** **创建了一个`HashedWheelTimer`开启心跳检测**,这是 Netty 所提供的一个经典的时间轮定时器实现,至于它和 jdk 的实现有何不同,不了解的同学也可以关注下,我就不拓展了。 不仅`HeaderExchangeClient`客户端开起了定时器,`HeaderExchangeServer`服务端同样开起了定时器,由于服务端的逻辑和客户端几乎一致,所以后续我并不会重复粘贴服务端的代码。 > Dubbo 在早期版本版本中使用的是 schedule 方案,在 2.7.x 中替换成了 HashWheelTimer。 #### [](https://www.cnkirito.moe/heartbeat-design/#3-2-%E5%BC%80%E5%90%AF%E4%B8%A4%E4%B8%AA%E5%AE%9A%E6%97%B6%E4%BB%BB%E5%8A%A1 "3.2 开启两个定时任务")3.2 开启两个定时任务 ~~~ 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` `HeartbeatTimerTask`主要用于定时发送心跳请求 `ReconnectTimerTask`主要用于心跳失败之后处理重连,断连的逻辑 至于方法中的其他代码,其实也是本文的重要分析内容,先容我卖个关子,后面再来看追溯。 #### [](https://www.cnkirito.moe/heartbeat-design/#3-3-%E5%AE%9A%E6%97%B6%E4%BB%BB%E5%8A%A1%E4%B8%80%EF%BC%9A%E5%8F%91%E9%80%81%E5%BF%83%E8%B7%B3%E8%AF%B7%E6%B1%82 "3.3 定时任务一:发送心跳请求")3.3 定时任务一:发送心跳请求 详细解析下心跳检测定时任务的逻辑`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,普通请求也会。这对应了我们预备知识中的空闲检测机制。 #### [](https://www.cnkirito.moe/heartbeat-design/#3-4-%E5%AE%9A%E6%97%B6%E4%BB%BB%E5%8A%A1%E4%BA%8C%EF%BC%9A%E5%A4%84%E7%90%86%E9%87%8D%E8%BF%9E%E5%92%8C%E6%96%AD%E8%BF%9E "3.4 定时任务二:处理重连和断连")3.4 定时任务二:处理重连和断连 继续研究下重连和断连定时器都实现了什么`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 是不太准确的,因为它处理的是重连和断连两个逻辑。 #### [](https://www.cnkirito.moe/heartbeat-design/#3-5-%E5%AE%9A%E6%97%B6%E4%B8%8D%E7%B2%BE%E7%A1%AE%E7%9A%84%E9%97%AE%E9%A2%98 "3.5 定时不精确的问题")3.5 定时不精确的问题 在 Dubbo 的 issue 中曾经有人反馈过定时不精确的问题,我们来看看是怎么一回事。 Dubbo 中默认的心跳周期是 60s,设想如下的时序: * 第 0 秒,心跳检测发现连接活跃 * 第 1 秒,连接实际断开 * 第 60 秒,心跳检测发现连接不活跃 由于**时间窗口的问题,死链不能够被及时检测出来,最坏情况为一个心跳周期**。 为了解决上述问题,我们再倒回去看一下上面的`startHeartbeatTimer()`方法 ``` long heartbeatTick = calculateLeastDuration(heartbeat); long heartbeatTimeoutTick = calculateLeastDuration(heartbeatTimeout); heartbeatTimer.newTimeout(reconnectTimerTask, heartbeatTimeoutTick, TimeUnit.MILLISECONDS); ``` 其中`calculateLeastDuration`根据心跳时间和超时时间分别计算出了一个 tick 时间,实际上就是将两个变量除以了 3,使得他们的值缩小,并传入了`HashedWheelTimer`的第二个参数之中 ``` heartbeatTimer.newTimeout(heartBeatTimerTask, heartbeatTick, TimeUnit.MILLISECONDS); heartbeatTimer.newTimeout(reconnectTimerTask, heartbeatTimeoutTick, TimeUnit.MILLISECONDS); ``` tick 的含义便是定时任务执行的频率。这样,通过减少检测间隔时间,增大了及时发现死链的概率,原先的最坏情况是 60s,如今变成了 20s。这个频率依旧可以加快,但需要考虑资源消耗的问题。 > 定时不准确的问题出现在 Dubbo 的两个定时任务之中,所以都做了 tick 操作。事实上,所有的定时检测的逻辑都存在类似的问题。 #### [](https://www.cnkirito.moe/heartbeat-design/#3-6-Dubbo-%E5%BF%83%E8%B7%B3%E6%80%BB%E7%BB%93 "3.6 Dubbo 心跳总结")3.6 Dubbo 心跳总结 Dubbo 对于建立的每一个连接,同时在客户端和服务端开启了 2 个定时器,一个用于定时发送心跳,一个用于定时重连、断连,执行的频率均为各自检测周期的 1/3。定时发送心跳的任务负责在连接空闲时,向对端发送心跳包。定时重连、断连的任务负责检测 lastRead 是否在超时周期内仍未被更新,如果判定为超时,客户端处理的逻辑是重连,服务端则采取断连的措施。 先不急着判断这个方案好不好,再来看看改进方案是怎么设计的。 ### [](https://www.cnkirito.moe/heartbeat-design/#4-Dubbo-%E6%94%B9%E8%BF%9B%E6%96%B9%E6%A1%88 "4 Dubbo 改进方案")4 Dubbo 改进方案 实际上我们可以更优雅地实现心跳机制,本小节开始,我将介绍一个新的心跳机制。 #### [](https://www.cnkirito.moe/heartbeat-design/#4-1-IdleStateHandler-%E4%BB%8B%E7%BB%8D "4.1 IdleStateHandler 介绍")4.1 IdleStateHandler 介绍 Netty 对空闲连接的检测提供了天然的支持,使用`IdleStateHandler`可以很方便的实现空闲检测逻辑。 ``` public IdleStateHandler( long readerIdleTime, long writerIdleTime, long allIdleTime, TimeUnit unit){} ``` * readerIdleTime:读超时时间 * writerIdleTime:写超时时间 * allIdleTime:所有类型的超时时间 `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 线程的好处是还同时保证了**线程安全**,这里是一个小细节。 #### [](https://www.cnkirito.moe/heartbeat-design/#4-2-%E5%AE%A2%E6%88%B7%E7%AB%AF%E5%92%8C%E6%9C%8D%E5%8A%A1%E7%AB%AF%E9%85%8D%E7%BD%AE "4.2 客户端和服务端配置")4.2 客户端和服务端配置 首先是将`IdleStateHandler`加入 pipeline 中。 **客户端:** ``` bootstrap.handler(new ChannelInitializer&lt;NioSocketChannel&gt;() { @Override protected void initChannel(NioSocketChannel ch) throws Exception { ch.pipeline().addLast("clientIdleHandler", new IdleStateHandler(60, 0, 0)); } }); ``` **服务端:** ``` serverBootstrap.childHandler(new ChannelInitializer&lt;NioSocketChannel&gt;() { @Override protected void initChannel(NioSocketChannel ch) throws Exception { ch.pipeline().addLast("serverIdleHandler",new IdleStateHandler(0, 0, 200)); } } ``` 客户端配置了 read 超时为 60s,服务端配置了 write/read 超时为 200s,先在此埋下两个伏笔: 1. 为什么客户端和服务端配置的超时时间不一致? 2. 为什么客户端检测的是读超时,而服务端检测的是读写超时? #### [](https://www.cnkirito.moe/heartbeat-design/#4-3-%E7%A9%BA%E9%97%B2%E8%B6%85%E6%97%B6%E9%80%BB%E8%BE%91-%E2%80%94-%E5%AE%A2%E6%88%B7%E7%AB%AF "4.3 空闲超时逻辑 — 客户端")4.3 空闲超时逻辑 — 客户端 对于空闲超时的处理逻辑,客户端和服务端是不同的。首先来看客户端 ``` @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() &gt;= channel.getMaxHeartBeatFailedTimes()) { channel.reconnect(); } } else { channel.clearHeartBeatFailedTimes(); } } }); } ``` 行为并不复杂,构造一个心跳包发送到服务端,接受响应结果 * 响应成功,清空请求失败标记 * 响应失败,心跳失败标记 +1,如果超过配置的失败次数,则重新连接 > 不仅仅是心跳,普通请求返回成功响应时也会清空标记 #### [](https://www.cnkirito.moe/heartbeat-design/#4-4-%E7%A9%BA%E9%97%B2%E8%B6%85%E6%97%B6%E9%80%BB%E8%BE%91-%E2%80%94-%E6%9C%8D%E5%8A%A1%E7%AB%AF "4.4 空闲超时逻辑 — 服务端")4.4 空闲超时逻辑 — 服务端 ``` @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { channel.close(); } else { super.userEventTriggered(ctx, evt); } } ``` 服务端处理空闲连接的方式非常简单粗暴,直接关闭连接。 #### [](https://www.cnkirito.moe/heartbeat-design/#4-5-%E6%94%B9%E8%BF%9B%E6%96%B9%E6%A1%88%E5%BF%83%E8%B7%B3%E6%80%BB%E7%BB%93 "4.5 改进方案心跳总结")4.5 改进方案心跳总结 1. 为什么客户端和服务端配置的超时时间不一致? 因为客户端有重试逻辑,不断发送心跳失败 n 次之后,才认为是连接断开;而服务端是直接断开,留给服务端时间得长一点。60 \* 3 < 200 还说明了一个问题,双方都拥有断开连接的能力,但连接的创建是由客户端主动发起的,那么客户端也更有权利去主动断开连接。 2. 为什么客户端检测的是读超时,而服务端检测的是读写超时? 这其实是一个心跳的共识了,仔细思考一下,定时逻辑是由客户端发起的,所以整个链路中不通的情况只有可能是:服务端接收,服务端发送,客户端接收。也就是说,只有客户端的 pong,服务端的 ping,pong 的检测是有意义的。 > 主动追求别人的是你,主动说分手的也是你。 利用`IdleStateHandler`实现心跳机制可以说是十分优雅的,借助 Netty 提供的空闲检测机制,利用客户端维护单向心跳,在收到 3 次心跳失败响应之后,客户端断开连接,交由异步线程重连,本质还是表现为客户端重连。服务端在连接空闲较长时间后,主动断开连接,以避免无谓的资源浪费。 ### [](https://www.cnkirito.moe/heartbeat-design/#5-%E5%BF%83%E8%B7%B3%E8%AE%BE%E8%AE%A1%E6%96%B9%E6%A1%88%E5%AF%B9%E6%AF%94 "5 心跳设计方案对比")5 心跳设计方案对比 | | Dubbo 现有方案 | Dubbo 改进方案 | | --- | --- | --- | | **主体设计** | 开启两个定时器 | 借助 IdleStateHandler,底层使用 schedule | | **心跳方向** | 双向 | 单向(客户端 -> 服务端) | | **心跳失败判定方式** | 心跳成功更新标记,借助定时器定时扫描标记,如果超过心跳超时周期未更新标记,认为心跳失败。 | 通过判断心跳响应是否失败,超过失败次数,认为心跳失败 | | **扩展性** | Dubbo 存在 mina,grizzy 等其他通信层实现,自定义定时器很容易适配多种扩展 | 多通信层各自实现心跳,不做心跳的抽象 | | **设计性** | 编码复杂度高,代码量大,方案复杂,不易维护 | 编码量小,可维护性强 | 私下请教过**美团点评的长连接负责人:俞超(闪电侠)**,美点使用的心跳方案和 Dubbo 改进方案几乎一致,可以说该方案是标准实现了。 ### [](https://www.cnkirito.moe/heartbeat-design/#6-Dubbo-%E5%AE%9E%E9%99%85%E6%94%B9%E5%8A%A8%E7%82%B9%E5%BB%BA%E8%AE%AE "6 Dubbo 实际改动点建议")6 Dubbo 实际改动点建议 鉴于 Dubbo 存在一些其他通信层的实现,所以可以保留现有的定时发送心跳的逻辑。 * **建议改动点一:** 双向心跳的设计是不必要的,兼容现有的逻辑,可以让客户端在连接空闲时发送单向心跳,服务端定时检测连接可用性。定时时间尽量保证:客户端超时时间 \* 3 ≈ 服务端超时时间 * **建议改动点二:** 去除处理重连和断连的定时任务,Dubbo 可以判断心跳请求是否响应失败,可以借鉴改进方案的设计,在连接级别维护一个心跳失败次数的标记,任意响应成功,清除标记;连续心跳失败 n 次,客户端发起重连。这样可以减少一个不必要的定时器,任何轮询的方式,都是不优雅的。 最后再聊聊可扩展性这个话题。其实我是建议把定时器交给更加底层的 Netty 去做,也就是完全使用`IdleStateHandler`,其他通信层组件各自实现自己的空闲检测逻辑,但是 Dubbo 中 mina,grizzy 的兼容问题囿住了我的拳脚,但试问一下,如今的 2019 年,又有多少人在使用 mina 和 grizzy?因为一些不太可能用的特性,而限制了主流用法的优化,这肯定不是什么好事。抽象,功能,可扩展性并不是越多越好,开源产品的人力资源是有限的,框架使用者的理解能力也是有限的,能解决大多数人问题的设计,才是好的设计。哎,谁让我不会 mina,grizzy,还懒得去学呢 \[摊手\]。