RPC源码探究rpc连接数上涨的原因?

问题现象&背景

背景知识

服务端是自研的rpc层,客户端是使用的我司rpc框架(基于开源的grpc又封装了一层)

问题现象

正常现象:rpc和graphd只会创建一个长连接,通过这个长连接进行数据的通信; 异常现象:服务端通过监控发现连接数一直在上涨

问题分析

拿到问题以后,先看下问题现象;

  1. 通过netstat/ss 命令可以发现在一个主调实例对一个被调实例建立了104个连接,
  2. 又通过headpdump后,也发现有104个相同的ip:port的NettyClientTransport,
  3. 随后又通过arthas可以看到这104个NettyClientTransport在同一个InternalSubchannel的transports列表中,说明一个InternalSubchannel创建了104个;

而正常情况下,一个InternalSubchannel只会创建一个NettyClientTransport;所以这里显然出现问题了; 需要探究两个问题:

  1. 什么时候会往transports里面添加Transport,
  2. 什么时候会删除Transport?

看下NettyClientTransport源码发现,

  1. 只有在InternalSubchannel.startNewTransport() 会往transports中增加transport;
  2. 只有在回调通知TransportListener.transportTerminated() 才会从transports中移除transport;

什么时候只调用startNewTransport, 什么时候又不会调用notifyTerminated呢?

  1. 调用InternalSubchannel.startNewTransport代表新建一个连接;
  2. 调用Transport.notifyTerminated,代表这个连接关闭

什么时候新建连接?什么时候关闭连接呢?追下源码看看

源码分析

我司的rpc框架是在grpc的基础上做的稳定性、可用性方面的封装,所以关于连接方面的功能还需要看下grpc的源码,分析下其原理; grpc关于连接的核心类

  1. InternalSubchannel: 一个后端address(ip:port)对应一个InternalSubchannel;
  2. NettyClientTransport: 负责管理一个与被调实例的连接;
  3. ManagedChannel : 用于管理InternalSubchannel,正常在grpc中对应规则:一个集群对应一个ManagedChannel, 一个ManagedChannel下面管理多个InternalSubchannel,用途:负载均衡等功能;
  4. TransportListener: 监听事件,通知NettyClientTransport的状态机发生变化; 比如:连接建立成功(transportReady), 连接关闭(transportShutdwon),连接终止(transportTerminated);
  5. Http2Connection.Listener:监控Http2的各种状态, 比如:streamAdd, streamHalfClosed,streamClosed, goAwayReceived等;
  6. Http2ConnectionHandler:管理Http/2的生命周期、编解码、和上层应用逻辑交互;

建立连接源码分析

建立连接的源码分析

主要逻辑:

  1. 从EventLoopGroup中获取一个EventLoop,事件轮询器;
  2. 把NettyClientHandler包装、功能增强后,放到boostrap.handler() ; 作为后续监听到网络IO后的消息处理器
  3. 创建客户端Bootstrap,配置好客户端参数(keepalive等),和服务端进行建连
  4. 从regFuture获取客户端channel,将其赋值给NettyClientTransport对象;
  5. 通过逻辑梳理,可以看到每次创建的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--初始化时

简要介绍一下整个建连流程;后续在详细介绍下 主要逻辑

  1. 在调用grpc API,经过rpc框架层动态代理(Invoker),在动态代理里面,会检查AbstractCluster.checkClusterState,然后调用GrpcClientTransporter.init() ,进行初始化
  2. 初始化时,会调用HealthGrpc.newFutureStub(grpcChannel).warmup(WarmupRequest.getDefaultInstance()) 这一步才触发grpc的建连流程;前面是在做rpc框架层能力的初始化;(比如:注册中心、管控等)
  3. 经过ManagedChannelImpl中的InterceptorChannel功能拦截链(比如:ktrace/stresstest/credential/header等)后,才会到达RealChannel; 执行RealChannel.exitIdleMode()
  4. DnsNameResolver在解析完成以后,回调NameResolverListener,做lb.tryHandleResolveAddresses() 进行域名解析;解析完成以后,执行InternalSubchannel.obtainActiveTransport();获取活跃的transport
  5. 如果没有活跃的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 -- 关闭连接

主要逻辑是:

  1. 设置shudown标识位
  2. 如果当前activeTransport = listener.transport, 说明当前活跃的连接需要被关闭了;
  3. 那么就会将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 -- 指数退避重连
  1. 创建失败后,进行指数退避重连; 这个可能性也不大; 原因在于:这个连接是好用的,仍然在正常收发数据;业务并没有感知到异常
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)

  1. 客户端AbstracUnsafe.read获取到后,调用ChannelPipeline.fireChannelRead进行read事件的传播;
  2. 经过一系列的handler处理,最终到Http2ConnectionHandler做关于Http2协议格式的解析,(列一下关键的几个类; Http2Connectionhandler,FrameDecoder, DefaultHttp2FrameDecoder)
  3. 根据帧的类型(DefaultHttp2FrameDecoder.processPayloadState),会判断走哪个分支,最终会GOAWAY分支;
  4. 然后在NettyClientHandler.goingAway回调通知ClientTransportLifecycleManager.notifyGracefulShutdown
  5. 最终调用到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() 去创建连接了;

总结

  1. 经此一役,深入学习了Http2的协议内容;
  2. 对grpc的建连、断连有了更深一步的了解;比如:如何优雅关闭连接?为什么需要先发GOAWAY帧,然后在进行断连;
  3. strace工具没有使用过,有时候确实比tcpdump要更简单、方便一些

感谢

在解决这个问题的时候,有很多同学提供了帮助;感谢这些同学鼎力支持;

相关推荐
poemyang7 天前
站在巨人的肩膀上:gRPC通过HTTP/2构建云原生时代的通信标准
网络协议·云原生·rpc·grpc·http2.0
鼠鼠我捏,要死了捏25 天前
基于Spring Boot与gRPC的高性能微服务架构设计分享
spring boot·微服务·grpc
zhuyasen1 个月前
Sponge:一个重构Go开发体验的框架,让你在开发项目开"外挂"
go·gin·grpc
梦兮林夕1 个月前
04 gRPC 元数据(Metadata)深入解析
后端·go·grpc
鼠鼠我捏,要死了捏2 个月前
Spring Boot中REST与gRPC并存架构设计与性能优化实践指南
springboot·restful·grpc
爱吃香蕉的阿豪2 个月前
在.NET Core API 微服务中使用 gRPC:从通信模式到场景选型
微服务·.netcore·信息与通信·grpc
梦兮林夕2 个月前
深入理解 gRPC 四种 RPC 通信模式:一元、服务端流、客户端流与双向流
后端·go·grpc
Code季风2 个月前
gRPC与Protobuf集成详解—从服务定义到跨语言通信(含Go和Java示例)
go·grpc·protobuf