Sentinel通信模块解析

690 阅读7分钟

Sentinel通信模块解析

概览

在Sentinel中有两个模块需要进行远程通信,分别是Sentinel Client与Dashboard的通信以及Sentinel Client与Token Server的通信,分别位于Sentinel项目中的sentinel-transport模块与sentinel-cluster模块。其中Sentinel Client与Dashboard之间相互通信采用的是传统HTTP方式,Client开放HTTP接口接收来自Dashboard的控制指令,Dashboard开放HTTP接口接收来自Client的数据上报。而Sentinel Client与Token Server的通信采用的TCP长链接的方式。

image-20210722161912357.png

Sentinel-Transport

sentinel-transport项目是用于Sentinel Client接收控制命令的,coomon子模块定义了一些通用的基础抽象类,例如请求体、请求响应体、命令处理器等等。实际的通信功能是由其余的子模块来提供的,目前提供了由netty、nio以及spring-mvc实现的三种不同HTTP端点。通常情况下我们希望sentinel的控制端口能够与业务的web端口分离,sentinel端口不对外暴露,所以一般采用的transport都是netty-http或是simple-http,这里我们着重来看一下netty实现的http transport。

netty server相关功能是由HttpServer类负责实现的,而启动是由NettyHttpCommandCenter负责的。NettyHttpCommandCenter实现了CommandCenter接口,这个接口定义了beforeStart、start、stop三个函数,从定义上来看CommandCenter更像是Lifecycle Listener,并没有定义命令处理的相关接口。那么CommandCenter的beforeStart、start是由谁负责调用的呢?在Sentinel中InitExecutor负责初始化所有实现了InitFunc的接口的实现类,其中查找所有实现InitFunc接口的实现类并不是采用我们常见的Bean查找的方式,而是利用自实现的SpiLoader在META-INF/services/目录下的文件定义来加载所有的实现类。

@InitOrder(-1)
public class CommandCenterInitFunc implements InitFunc {

    @Override
    public void init() throws Exception {
        CommandCenter commandCenter = CommandCenterProvider.getCommandCenter();

        if (commandCenter == null) {
            RecordLog.warn("[CommandCenterInitFunc] Cannot resolve CommandCenter");
            return;
        }

        commandCenter.beforeStart();
        commandCenter.start();
        RecordLog.info("[CommandCenterInit] Starting command center: "
                + commandCenter.getClass().getCanonicalName());
    }
}

在NettyHttpCommandCenter中首先初始化一个HttpServer对象,这里比较特别的是NettyHttpCommandCenter并没有通过直接创建一个线程的方式来启动Netty server,而是使用了一个单线程的线程池,将启动任务提交至线程池中,这么做的好处是方便了线程的管理。

@Override
public void start() throws Exception {
    pool.submit(new Runnable() {
        @Override
        public void run() {
            try {
                server.start();
            } catch (Exception ex) {
                RecordLog.warn("[NettyHttpCommandCenter] Failed to start Netty transport server", ex);
                ex.printStackTrace();
            }
        }
    });
}

接着来看下Sentinel对于Netty的使用,从HttpServer的start函数中我们可以很直观的看到与Seata的Netty Server不同的是,Sentinel并没有做过多的设置,只是完成了简单的HTTP功能的初始化,其中HttpServerHandler则是处理Http请求的关键类。

public class HttpServerInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        ChannelPipeline p = socketChannel.pipeline();

        p.addLast(new HttpRequestDecoder());
        p.addLast(new HttpObjectAggregator(1024 * 1024));
        p.addLast(new HttpResponseEncoder());

        p.addLast(new HttpServerHandler());
    }
}

HttpServerHandler继承自SimpleChannelInboundHandler,整体处理的逻辑也比较直观,将Http请求解析成CommandRequest对象,根据请求的Request Target找到命令处理器CommandHandler,处理完请求后返回CommandResponse对象。

private void handleRequest(CommandRequest request, ChannelHandlerContext ctx, boolean keepAlive)
    throws Exception {
    String commandName = HttpCommandUtils.getTarget(request);
    // Find the matching command handler.
    CommandHandler<?> commandHandler = getHandler(commandName);
    if (commandHandler != null) {
        CommandResponse<?> response = commandHandler.handle(request);
        writeResponse(response, ctx, keepAlive);
    } else {
        // No matching command handler.
        writeErrorResponse(BAD_REQUEST.code(), String.format("Unknown command \"%s\"", commandName), ctx);
    }
}

其中CommandHandler存储在HttpServer的静态变量handlerMap中,在CommandCenter的beforeStart函数中由CommandHandlerProvider同样是利用SpiLoader的方式完成CommandHandler实例化与Map填充,特别的是在获取到所有实例后,需要解析CommandHandler实现类上的注解,来完成命令与命令处理器的映射绑定,可以看到命令实际上即是请求的request path。

@CommandMapping(name = "api", desc = "get all available command handlers")
public class ApiCommandHandler implements CommandHandler<String> {

    @Override
    public CommandResponse<String> handle(CommandRequest request) {
    	  .....
        return CommandResponse.ofSuccess(array.toJSONString());
    }

}

而在writeResponse函数中可以看到下面这段代码,Sentinel对于每个http请求都采用了短链接的方式,为什么需要这么做呢?主要还是出于Sentinel本身的业务属性,控制命令的交互并不频繁,不依赖于长链接来节省连接建立的时间,这也是Sentinel transport为什么采用http作为通信端口而不是tcp作为通信端口的原因之一。

HttpResponseStatus status = response.isSuccess() ? OK : BAD_REQUEST;

FullHttpResponse httpResponse = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status,
    Unpooled.copiedBuffer(body));

httpResponse.headers().set("Content-Type", "text/plain; charset=" + SentinelConfig.charset());
httpResponse.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, httpResponse.content().readableBytes());
httpResponse.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);

在transport模块中还有一个HeartbeatSender组件用于定时的向Dashboard注册Sentinel Client的信息,由HeartbeatSenderInitFunc中的定时调度线程池来负责任务的调度。实现的方式为HTTP远程调用Dashboard较为简单不再细述,但是值得一提的是在Netty模块下的HeartbeatSender默认只会向第一个Dashboard的地址发送信息,而默认的simple-http模块中的HeartbeatSender是会轮流向Dashboard列表地址进行注册的。

//netty模块下的sender初始化
public HttpHeartbeatSender() {
    List<Endpoint> dashboardList = TransportConfig.getConsoleServerList();
    if (dashboardList == null || dashboardList.isEmpty()) {
        RecordLog.info("[NettyHttpHeartbeatSender] No dashboard server available");
        consoleProtocol = Protocol.HTTP;
        consoleHost = null;
        consolePort = -1;
    } else {
        consoleProtocol = dashboardList.get(0).getProtocol();
        consoleHost = dashboardList.get(0).getHost();
        consolePort = dashboardList.get(0).getPort();
        RecordLog.info("[NettyHttpHeartbeatSender] Dashboard address parsed: <{}:{}>", consoleHost, consolePort);
    }
    this.client = HttpClientsFactory.getHttpClientsByProtocol(consoleProtocol);
}
//simple-http模块下的地址选择函数
private Endpoint getAvailableAddress() {
    if (addressList == null || addressList.isEmpty()) {
        return null;
    }
    if (currentAddressIdx < 0) {
        currentAddressIdx = 0;
    }
    int index = currentAddressIdx % addressList.size();
    return addressList.get(index);
}

Sentinel-Cluster

sentinel-cluster模块主要是负责sentinel集群流控相关功能的,common模块同样是定义了一些请求体、响应体、Encoder之类的通用抽象模块,client、server模块则分别用来实现sentinel的客户端与服务端,enovy模块主要是用于k8s这里暂且不深入分析。在sentinel-core模块定义了集群流控的基本处理模式,client与server都实现了TokenService的接口,client侧主要用于实现像远程服务器请求,server侧主要用于实现请求的处理。

image-20210723114605994.png

Server

在Server侧,DefaultEmbeddedTokenServer主要功能可以分为由TokenService实现的token请求与释放功能,以及由ClusterTokenServer实现的TokenServer远程通信模块。这里我们重点看SentinelDefaultTokenServer中的NettyTransportServer。

从下面的代码上我们可以看到在Cluster Server采用的是基于长度的协议,头2个字节为数据帧的长度,后接数据内容字节。NettyRequestDecoder与NettyResponseEncoder负责将数据帧中的内容转换成Request对象(Common模块中定义)以及将Response对象转换成为数据帧内容。LengthFieldPrepender则是为了配合LengthFieldBasedFrameDecoder将在要传输的数据帧内容头部加上2字节的长度字段。TokenServerHandler则是最终用于处理Request的ChannelInboundHandler。

关注一下sentinel cluster中对于tcp相关的设置。so_backlog设置了tcp连接的等待队列,如果是大规模集群的token server需要处理大量的连接,这里的等待队列才需要设置的很大来防止连接被拒绝。allocator设置了netty的缓冲分配方式,这里netty会根据jdk来判断是否能够优先使用直接缓冲区,判断条件是否能启用缓冲区清理器。tcp_nodelay设置为true,是为了能够使得短小的数据帧能够被立即发送出去而不用等待更多的数据包批量发送减少数据往复次数,对于请求token这种及时性强的业务该属性是必须的。

ServerBootstrap b = new ServerBootstrap();
this.bossGroup = new NioEventLoopGroup(1);
this.workerGroup = new NioEventLoopGroup(DEFAULT_EVENT_LOOP_THREADS);
b.group(bossGroup, workerGroup)
    .channel(NioServerSocketChannel.class)
    .option(ChannelOption.SO_BACKLOG, 128)
    .handler(new LoggingHandler(LogLevel.INFO))
    .childHandler(new ChannelInitializer<SocketChannel>() {
        @Override
        public void initChannel(SocketChannel ch) throws Exception {
            ChannelPipeline p = ch.pipeline();
            p.addLast(new LengthFieldBasedFrameDecoder(1024, 0, 2, 0, 2));
            p.addLast(new NettyRequestDecoder());
            p.addLast(new LengthFieldPrepender(2));
            p.addLast(new NettyResponseEncoder());
            p.addLast(new TokenServerHandler(connectionPool));
        }
    })
    .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
    .childOption(ChannelOption.SO_SNDBUF, 32 * 1024)
    .childOption(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
    .childOption(ChannelOption.SO_TIMEOUT, 10)
    .childOption(ChannelOption.TCP_NODELAY, true)
    .childOption(ChannelOption.SO_RCVBUF, 32 * 1024);

DefaultRequestEntityDecoder则是前面提到的NettyRequestDecoder的具体实现类,从下面的代码中比较直观的可以知道协议由4字节的request-id,1字节的请求类型以及类型数据组成。EntityDecoder则负责根据类型将类型数据组装成对应的pojo对象,例如FlowRequestData|由8字节的flow id组成,4字节的token请求数量以及1字节的优先级组成。

    @Override
    public ClusterRequest decode(ByteBuf source) {
        if (source.readableBytes() >= 5) {
            int xid = source.readInt();
            int type = source.readByte();

            EntityDecoder<ByteBuf, ?> dataDecoder = RequestDataDecodeRegistry.getDecoder(type);
            if (dataDecoder == null) {
                RecordLog.warn("Unknown type of request data decoder: {}", type);
                return null;
            }

            Object data;
            if (source.readableBytes() == 0) {
                data = null;
            } else {
                data = dataDecoder.decode(source);
            }

            return new ClusterRequest<>(xid, type, data);
        }
        return null;
    }

TokenServerHandler中的处理逻辑也比较的类似,根据Request中的类型获取到对应的处理器,在处理器中再最后与前面提到的TokenService进行交互。加载处理器的方式与transport模块中的CommandHandler类似,同样是利用Spi加载所有RequestProcessor接口的实现类,解析类上的@RequestType注解,来注册类型处理器。

值得一提的是在TokenServerHandler中对于每一个已经建立的连接都会存放在ConnectionPool中,每次处理Channel对应的Connection时都会刷新Conntcion的最后读取时间,ConnectionPool会通过一个定时任务定期的清理闲置的链接。除了ConnectionPool以外,还定义了一个ConnectionManager用来管理连接与命名空间的绑定关系,直白的说就是将从属于一个服务的链接通过ConnectionGroup整合到一起。

public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    globalConnectionPool.refreshLastReadTime(ctx.channel());
    if (msg instanceof ClusterRequest) {
        ClusterRequest request = (ClusterRequest)msg;

        // Client ping with its namespace, add to connection manager.
        if (request.getType() == ClusterConstants.MSG_TYPE_PING) {
            handlePingRequest(ctx, request);
            return;
        }

        // Pick request processor for request type.
        RequestProcessor<?, ?> processor = RequestProcessorProvider.getProcessor(request.getType());
        if (processor == null) {
            RecordLog.warn("[TokenServerHandler] No processor for request type: " + request.getType());
            writeBadResponse(ctx, request);
        } else {
            ClusterResponse<?> response = processor.processRequest(request);
            writeResponse(ctx, response);
        }
    }
}

除此之外在Server模块中定义了多个CommandHandler用于配合Transport来完成集群流控的控制与信息获取。

image-20210723151511716.png

Client

client侧的netty设置与server侧的设置大同小异,我们首先来关注一下client侧对于连接的管理。可以注意到在TokenClientHandler中的channelUnregistered会回调一个disconnectCallback,在这个回调函数中会尝试一次连接。这里提一下的是channelUnregistered在连接失败时会从eventloop中移除注册,就会触发这个函数,所以连接失败时又会重新回调这个函数,重连的时间间隔会因失败次数增加。

private Runnable disconnectCallback = new Runnable() {
    @Override
    public void run() {
        if (!shouldRetry.get()) {
            return;
        }
        SCHEDULER.schedule(new Runnable() {
            @Override
            public void run() {
                if (shouldRetry.get()) {
                    RecordLog.info("[NettyTransportClient] Reconnecting to server <{}:{}>", host, port);
                    try {
                        startInternal();
                    } catch (Exception e) {
                        RecordLog.warn("[NettyTransportClient] Failed to reconnect to server", e);
                    }
                }
            }
        }, RECONNECT_DELAY_MS * (failConnectedTime.get() + 1), TimeUnit.MILLISECONDS);
        cleanUp();
    }
};

其次我们来关注一下client侧对于token请求的发送处理,如下代码所示,首先会为请求设置一个请求id,使用的是AtomicInteger,然后将请求通过channel发送出去。我们知道netty是一个异步的操作,但是流量控制的token请求是一个同步操作,为了将异步转换为同步操作,这里利用了ChannelPromise的await操作来进行阻塞等待。而新生成的ChannelPromise会根据请求id统一放入到TokenClientPromiseHolder中,当client侧收到请求对应的response时,从TokenClientPromiseHolder获取到请求对应的ChannelPromise,设置其响应结果,那么此时阻塞在await函数的线程则会继续执行获取到集群的Response。如果在配置时间内超时,则抛出异常,client侧根据配置来判断是否进行降级或直接放行。

@Override
public ClusterResponse sendRequest(ClusterRequest request) throws Exception {
    if (!isReady()) {
        throw new SentinelClusterException(ClusterErrorMessages.CLIENT_NOT_READY);
    }
    if (!validRequest(request)) {
        throw new SentinelClusterException(ClusterErrorMessages.BAD_REQUEST);
    }
    int xid = getCurrentId();
    try {
        request.setId(xid);

        channel.writeAndFlush(request);

        ChannelPromise promise = channel.newPromise();
        TokenClientPromiseHolder.putPromise(xid, promise);

        if (!promise.await(ClusterClientConfigManager.getRequestTimeout())) {
            throw new SentinelClusterException(ClusterErrorMessages.REQUEST_TIME_OUT);
        }

        SimpleEntry<ChannelPromise, ClusterResponse> entry = TokenClientPromiseHolder.getEntry(xid);
        if (entry == null || entry.getValue() == null) {
            // Should not go through here.
            throw new SentinelClusterException(ClusterErrorMessages.UNEXPECTED_STATUS);
        }
        return entry.getValue();
    } finally {
        TokenClientPromiseHolder.remove(xid);
    }
}

最后我们来看下DefaultClusterTokenClient,除了负责启停NettyClient以外,在构造函数中还注册了一个Server信息变更的监听器,当Server信息变更时会调用ChangeServer函数来关闭原来的连接启动新的连接。

public DefaultClusterTokenClient() {
    ClusterClientConfigManager.addServerChangeObserver(new ServerChangeObserver() {
        @Override
        public void onRemoteServerChange(ClusterClientAssignConfig assignConfig) {
            changeServer(assignConfig);
        }
    });
    initNewConnection();
}

    private void changeServer(/*@Valid*/ ClusterClientAssignConfig config) {
        if (serverEqual(serverDescriptor, config)) {
            return;
        }
        try {
            if (transportClient != null) {
                transportClient.stop();
            }
            // Replace with new, even if the new client is not ready.
            this.transportClient = new NettyTransportClient(config.getServerHost(), config.getServerPort());
            this.serverDescriptor = new TokenServerDescriptor(config.getServerHost(), config.getServerPort());
            startClientIfScheduled();
            RecordLog.info("[DefaultClusterTokenClient] New client created: {}", serverDescriptor);
        } catch (Exception ex) {
            RecordLog.warn("[DefaultClusterTokenClient] Failed to change remote token server", ex);
        }
    }