在日常工作中,我们经常会接触到长连接这个概念,例如一些RPC框架(如Dubbo)、网络通信框架(Netty)等,都会涉及到长连接的概念,我们来探究一下长连接是如何保活的。
首先我们要知道,TCP本身是没有长连接和短连接的区分的,长短与否完全取决于我们怎么用它。
长连接和短连接的简单区分如下:
短连接和长连接的优势,分别是对方的劣势。想要图简单,不追求高性能,使用短连接合适,这样我们就不需要操心连接状态的管理;想要追求性能,使用长连接,我们就需要担心各种问题:比如端对端连接的维护,连接的保活。
长连接还常常被用来做数据的推送,我们大多数时候对通信的认知还是 request/response 模型,但 TCP 双工通信的性质决定了它还可以被用来做双向通信。在长连接之下,可以很方便的实现push模型,我们的手机消息push,就是通过建立一条手机与服务器的连接链路(长连接),当有消息需要发送到手机时,通过此链路发送即可。 比如iOS的APNS。
在一个分布式系统中,客户端请求的服务可能分布在多个服务器上,客户端自然需要跟对端创建多条长连接,我们遇到的第一个问题就是如何维护这些长连接。
/**
* 客户端
*/
public class NettyHandler extends SimpleChannelHandler {
//
private final Map<String, Channel> channels = new ConcurrentHashMap<String, Channel>();
}
/**
* 服务端
*/
public class NettyServer extends AbstractServer implements Server {
//
private Map<String, Channel> channels;
}
在dubbo中,客户端和服务端都使用ip:port维护了端对端的长连接,在连接建立完成后,会将Channel存放到集合中,Channel便是对连接的抽象。我们主要关注NettyHandler中的长连接,服务端同时维护一个长连接的集合是Dubbo的额外设计,我们将在后面提到。
这里解释下为什么我们主要关注NettyHandler中的长连接。TCP是一个双向通信的协议(全双工),任一方都可以是发送者,接收者,那为什么还抽象了Client和Server呢?因为建立连接需要有主动发起的一方,Client可以理解为主动建立连接的一方,实际上两端的地位可以理解为是对等的。
首先我们要理解,为什么需要连接的保活?当双方已经建立了连接,但因为网络问题,链路不通,这样长连接就不能使用了。需要明确的一点是,通过netstat,lsof 等指令查看到连接的状态处于ESTABLISHED状态并不是一件非常靠谱的事,因为连接可能已死,但没有被系统感知到,更不用提假死这种疑难杂症了。如何保证长连接可用是一个比较复杂的事情。
首先想到的是TCP中的KeepAlive机制。KeepAlive并不是TCP协议的一部分,但是大多数操作系统都实现了这个机制(所以需要在操作系统层面设置KeepAlive的相关参数)。KeepAlive机制开启后,在一定时间内(一般时间为7200s,参数tcp_keepalive_time)在链路上没有数据传送的情况下,TCP层将发送相应的KeepAlive探针以确定连接可用性,探测失败后重试10(参数 tcp_keepalive_probes)次,每次间隔时间75s(参数 tcp_keepalive_intvl),所有探测失败后,才认为当前连接已经不可用。
在Netty中开启KeepAlive:
bootstrap.option(ChannelOption.SO_KEEPALIVE, true)
Linux操作系统中设置KeepAlive相关参数,修改/etc/sysctl.conf
文件:
net.ipv4.tcp_keepalive_time=90
net.ipv4.tcp_keepalive_intvl=15
net.ipv4.tcp_keepalive_probes=2
KeepAlive机制是在网络层面保证了连接的可用性,但站在应用框架层面我们认为这还不够。主要体现在三个方面:
我们已经为应用层面的连接保活做了足够的铺垫,下面就来一起看看,怎么在应用层做连接保活。
以Dubbo为例,支持应用层的心跳,客户端和服务端都会开启一个 HeartBeatTask,客户端在HeaderExchangeClient中开启,服务端将在 HeaderExchangeServer开启。文章开头留了一个悬念:Dubbo为什么在服务端同时维护Map
// HeartBeatTask
if (channel instanceof Client) {
((Client) channel).reconnect();
} else {
channel.close();
}
Dubbo 2.7.x 相比 2.6.x 做了定时心跳的优化,使用 HashedWheelTimer 更加精准的控制了只在连接闲置时发送心跳。
Netty提供了IdleStateHandler可以让我们更加优雅的实现应用层心跳的机制。熟悉其他 RPC 框架的同学会发现,不同框架的心跳机制真的是差距非常大。心跳设计还跟连接创建,重连机制,黑名单连接相关,还需要具体框架具体分析。
除了定时任务的设计,还需要在协议层面支持心跳。最简单的例子可以参考 nginx 的健康检查,而针对 Dubbo 协议,自然也需要做心跳的支持,如果将心跳请求识别为正常流量,会造成服务端的压力问题,干扰限流等诸多问题。
其中 Flag 代表了 Dubbo 协议的标志位,一共 8 个地址位。低四位用来表示消息体数据用的序列化工具的类型(默认 hessian),高四位中,第一位为1表示是 request 请求,第二位为 1 表示双向传输(即有返回response),第三位为 1 表示是心跳事件。
心跳请求应当和普通请求区别对待。
这压根是两个概念。
启用TCP KeepAlive的应用程序,一般可以捕获到下面几种类型错误:
ETIMEOUT 超时错误,在发送一个探测保护包经过 (tcp_keepalive_time + tcp_keepalive_intvl * tcp_keepalive_probes)时间后仍然没有接收到 ACK 确认情况下触发的异常,套接字被关闭
java.io.IOException: Connection timed out
EHOSTUNREACH host unreachable(主机不可达)错误,这个应该是 ICMP 汇报给上层应用的。
java.io.IOException: No route to host
连接被重置,终端可能崩溃死机重启之后,接收到来自服务器的报文,然物是人非,前朝往事,只能报以无奈重置宣告之。
java.io.IOException: Connection reset by peer
各个框架的设计都有所不同,例如 Dubbo 使用的是方案三,这和框架整体的设计相关。