问题现象&背景
背景知识
服务端是自研的rpc层,客户端是使用的我司rpc框架(基于开源的grpc又封装了一层)
问题现象
正常现象:rpc和graphd只会创建一个长连接,通过这个长连接进行数据的通信; 异常现象:服务端通过监控发现连接数一直在上涨
问题分析
拿到问题以后,先看下问题现象;
- 通过netstat/ss 命令可以发现在一个主调实例对一个被调实例建立了104个连接,
- 又通过headpdump后,也发现有104个相同的ip:port的NettyClientTransport,
- 随后又通过arthas可以看到这104个NettyClientTransport在同一个InternalSubchannel的transports列表中,说明一个InternalSubchannel创建了104个;
而正常情况下,一个InternalSubchannel只会创建一个NettyClientTransport;所以这里显然出现问题了; 需要探究两个问题:
- 什么时候会往transports里面添加Transport,
- 什么时候会删除Transport?
看下NettyClientTransport源码发现,
- 只有在InternalSubchannel.startNewTransport() 会往transports中增加transport;
- 只有在回调通知TransportListener.transportTerminated() 才会从transports中移除transport;
什么时候只调用startNewTransport, 什么时候又不会调用notifyTerminated呢?
- 调用InternalSubchannel.startNewTransport代表新建一个连接;
- 调用Transport.notifyTerminated,代表这个连接关闭
什么时候新建连接?什么时候关闭连接呢?追下源码看看
源码分析
我司的rpc框架是在grpc的基础上做的稳定性、可用性方面的封装,所以关于连接方面的功能还需要看下grpc的源码,分析下其原理; grpc关于连接的核心类
- InternalSubchannel: 一个后端address(ip:port)对应一个InternalSubchannel;
- NettyClientTransport: 负责管理一个与被调实例的连接;
- ManagedChannel : 用于管理InternalSubchannel,正常在grpc中对应规则:一个集群对应一个ManagedChannel, 一个ManagedChannel下面管理多个InternalSubchannel,用途:负载均衡等功能;
- TransportListener: 监听事件,通知NettyClientTransport的状态机发生变化; 比如:连接建立成功(transportReady), 连接关闭(transportShutdwon),连接终止(transportTerminated);
- Http2Connection.Listener:监控Http2的各种状态, 比如:streamAdd, streamHalfClosed,streamClosed, goAwayReceived等;
- Http2ConnectionHandler:管理Http/2的生命周期、编解码、和上层应用逻辑交互;
建立连接源码分析
建立连接的源码分析
主要逻辑:
- 从EventLoopGroup中获取一个EventLoop,事件轮询器;
- 把NettyClientHandler包装、功能增强后,放到boostrap.handler() ; 作为后续监听到网络IO后的消息处理器
- 创建客户端Bootstrap,配置好客户端参数(keepalive等),和服务端进行建连
- 从regFuture获取客户端channel,将其赋值给NettyClientTransport对象;
- 通过逻辑梳理,可以看到每次创建的channel都会放到NettyClientTransport
io.grpc.netty.NettyClientTransport#start
java
public Runnable start(Listener transportListener) {
....
// 获取EventLoop 轮训器
EventLoop eventLoop = group.next();
.....
// 创建一个NettyClientHandler,在这里面创建了Http2Connection.Listener ;注意
handler = NettyClientHandler.newHandler(
lifecycleManager,
keepAliveManager,
autoFlowControl,
flowControlWindow,
maxHeaderListSize,
GrpcUtil.STOPWATCH_SUPPLIER,
tooManyPingsRunnable,
transportTracer,
eagAttributes,
authorityString);
// 包装一层,将NettyClientHandler放到GrpcHttp2ConnectionHandler
ChannelHandler negotiationHandler = negotiator.newHandler(handler);
// 创建Bootstrap 和服务端通信
Bootstrap b = new Bootstrap();
b.option(ALLOCATOR, Utils.getByteBufAllocator(false));
b.attr(LOGGER_KEY, channelLogger);
b.group(eventLoop); // 复用EventLoop
b.channelFactory(channelFactory);
// For non-socket based channel, the option will be ignored.
b.option(SO_KEEPALIVE, true);
// For non-epoll based channel, the option will be ignored.
if (keepAliveTimeNanos != KEEPALIVE_TIME_NANOS_DISABLED) {
ChannelOption<Integer> tcpUserTimeout = Utils.maybeGetTcpUserTimeoutOption();
if (tcpUserTimeout != null) {
b.option(tcpUserTimeout, (int) TimeUnit.NANOSECONDS.toMillis(keepAliveTimeoutNanos));
}
}
for (Map.Entry<ChannelOption<?>, ?> entry : channelOptions.entrySet()) {
// Every entry in the map is obtained from
// NettyChannelBuilder#withOption(ChannelOption<T> option, T value)
// so it is safe to pass the key-value pair to b.option().
b.option((ChannelOption<Object>) entry.getKey(), entry.getValue());
}
ChannelHandler bufferingHandler = new WriteBufferingAndExceptionHandler(negotiationHandler);
/**
* We don't use a ChannelInitializer in the client bootstrap because its "initChannel" method
* is executed in the event loop and we need this handler to be in the pipeline immediately so
* that it may begin buffering writes.
*/
b.handler(bufferingHandler);
ChannelFuture regFuture = b.register();
.....
channel = regFuture.channel();
// Start the write queue as soon as the channel is constructed
handler.startWriteQueue(channel);
......
channel.writeAndFlush(NettyClientHandler.NOOP_MESSAGE).addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
if (!future.isSuccess()) {
// Need to notify of this failure, because NettyClientHandler may not have been added to
// the pipeline before the error occurred.
lifecycleManager.notifyTerminated(Utils.statusFromThrowable(future.cause()));
}
}
});
// Start the connection operation to the server.
SocketAddress localAddress =
localSocketPicker.createSocketAddress(remoteAddress, eagAttributes);
if (localAddress != null) {
channel.connect(remoteAddress, localAddress);
} else {
channel.connect(remoteAddress);
}
if (keepAliveManager != null) {
keepAliveManager.onTransportStarted();
}
return null;
}
创建连接的时机1--初始化时
简要介绍一下整个建连流程;后续在详细介绍下 主要逻辑
- 在调用grpc API,经过rpc框架层动态代理(Invoker),在动态代理里面,会检查AbstractCluster.checkClusterState,然后调用GrpcClientTransporter.init() ,进行初始化
- 初始化时,会调用HealthGrpc.newFutureStub(grpcChannel).warmup(WarmupRequest.getDefaultInstance()) 这一步才触发grpc的建连流程;前面是在做rpc框架层能力的初始化;(比如:注册中心、管控等)
- 经过ManagedChannelImpl中的InterceptorChannel功能拦截链(比如:ktrace/stresstest/credential/header等)后,才会到达RealChannel; 执行RealChannel.exitIdleMode()
- DnsNameResolver在解析完成以后,回调NameResolverListener,做lb.tryHandleResolveAddresses() 进行域名解析;解析完成以后,执行InternalSubchannel.obtainActiveTransport();获取活跃的transport
- 如果没有活跃的transport,则会执行startNewTransport(),就会执行到transport.start() ;进行创建连接
java
public ClientTransport obtainActiveTransport() {
ClientTransport savedTransport = activeTransport;
if (savedTransport != null) {
return savedTransport;
}
syncContext.execute(new Runnable() {
@Override
public void run() {
// 如果当前的状态机的状态是IDLE状态,那么就会重新建连一个;很重要!
if (state.getState() == IDLE) {
channelLogger.log(ChannelLogLevel.INFO, "CONNECTING as requested");
gotoNonErrorState(CONNECTING);
startNewTransport();
}
}
});
return null;
}
创建连接的时机2 -- 关闭连接
主要逻辑是:
- 设置shudown标识位
- 如果当前activeTransport = listener.transport, 说明当前活跃的连接需要被关闭了;
- 那么就会将activeTransport置为null, 同时将状态机state = IDLE;(ps: 是不是和上面的获取连接[obtainActiveTransport] 相呼应了)
java
public void transportShutdown(final Status s) {
channelLogger.log(
ChannelLogLevel.INFO, "{0} SHUTDOWN with {1}", transport.getLogId(), printShortStatus(s));
shutdownInitiated = true;
syncContext.execute(new Runnable() {
@Override
public void run() {
if (state.getState() == SHUTDOWN) {
return;
}
if (activeTransport == transport) {
activeTransport = null;
addressIndex.reset();
gotoNonErrorState(IDLE);
} else if (pendingTransport == transport) {
Preconditions.checkState(state.getState() == CONNECTING,
"Expected state is CONNECTING, actual state is %s", state.getState());
addressIndex.increment();
// Continue reconnect if there are still addresses to try.
if (!addressIndex.isValid()) {
pendingTransport = null;
addressIndex.reset();
// Initiate backoff
// Transition to TRANSIENT_FAILURE
scheduleBackoff(s);
} else {
startNewTransport();
}
}
}
});
}
创建连接的时机3 -- 指数退避重连
- 创建失败后,进行指数退避重连; 这个可能性也不大; 原因在于:这个连接是好用的,仍然在正常收发数据;业务并没有感知到异常
java
private void scheduleBackoff(final Status status) {
syncContext.throwIfNotInThisSynchronizationContext();
class EndOfCurrentBackoff implements Runnable {
@Override
public void run() {
reconnectTask = null;
channelLogger.log(ChannelLogLevel.INFO, "CONNECTING after backoff");
gotoNonErrorState(CONNECTING);
startNewTransport();
}
}
.....
创建连接时机4 -- 手动重连
这个也可以排除掉,因为不会做手动连接重试;
java
void resetConnectBackoff() {
syncContext.execute(new Runnable() {
@Override
public void run() {
if (state.getState() != TRANSIENT_FAILURE) {
return;
}
cancelReconnectTask();
channelLogger.log(ChannelLogLevel.INFO, "CONNECTING; backoff interrupted");
gotoNonErrorState(CONNECTING);
startNewTransport();
}
});
创建连接时机5 -- 地址更新
具体源码大家可以参看下io.grpc.internal.InternalSubchannel#updateAddresses 这个可以排除掉,因为没有地址更新;
建连总结
综合其建连的源码分析,大概推测到是因为建连成功以后,执行一段时间后,触发了TransportListener.transportShutdown()将activeTransport=null,state=IDLE后,并没有触发TransportListener.transportTerminated() (因为没有将NettyClientTransport中transports列表里面transport移除)
那下一步就需要追一下断连的逻辑了;
断连源码分析
notifyShutdown 触发原因
逻辑比较复杂,大致过一下逻辑;后续在补充这块的源码逻辑; 首先,服务端发送GOAWAY帧, (如果是grpc server,则触发点io.grpc.netty.shaded.io.grpc.netty.NettyServerHandler.GracefulShutdown#secondGoAwayAndClose)
- 客户端AbstracUnsafe.read获取到后,调用ChannelPipeline.fireChannelRead进行read事件的传播;
- 经过一系列的handler处理,最终到Http2ConnectionHandler做关于Http2协议格式的解析,(列一下关键的几个类; Http2Connectionhandler,FrameDecoder, DefaultHttp2FrameDecoder)
- 根据帧的类型(DefaultHttp2FrameDecoder.processPayloadState),会判断走哪个分支,最终会GOAWAY分支;
- 然后在NettyClientHandler.goingAway回调通知ClientTransportLifecycleManager.notifyGracefulShutdown
- 最终调用到TransportListener的notifyShutdown 贴一个调用栈,有兴趣大家可以瞅瞅 (ps:如果是优化关闭,goaway中的debugData的信息是app_requested)
为什么会选择GOAWAY这一种帧呢? 其实还是和上面创建连接的排查逻辑是一致的,逐一排查;这里就不详细讲述;正常来说,服务端发送完GOAWAY帧以后,应该立刻关闭连接,发送一个FIN标记给客户端,客户端启动关闭流程;进而回调notifyTerminated;但是并没有启动该流程;原因为何? 推测是服务端的HTTP2实现格式不规范导致;那就通过抓包看看,是不是这么回事?
发现果然是这样
行到此时,终于破案;服务端发送了GOAWAY帧以后,并没有关闭连接;
问题根因
服务端发送了GOAWAY帧以后,并没有关闭连接;grpc客户端接收到GOAWAY帧以后,通过回调TransportListener.notifyShutdown将InternalSubchannel中的activaTransport置为null;如果主调服务还有对被调集群的请求,就会调用obtainActiveTransport,在调用obtainActivaTransport时,发现activaTransport=null,所以去执行startNewTransport() 去创建连接了;
总结
- 经此一役,深入学习了Http2的协议内容;
- 对grpc的建连、断连有了更深一步的了解;比如:如何优雅关闭连接?为什么需要先发GOAWAY帧,然后在进行断连;
- strace工具没有使用过,有时候确实比tcpdump要更简单、方便一些
感谢
在解决这个问题的时候,有很多同学提供了帮助;感谢这些同学鼎力支持;