Netty 原理与 API 网关

一、再谈谈什么是高性能

(一)性能指标与演示

  首先明确一下,高性能涵盖:高并发用户、高吞吐量、低延迟、容量四个方面,高并发用户是指可以承载海量的并发用户,例如十万个、百万个用户同时连接和并发访问,不会造成系统崩溃;吞吐量就是我们常说的TPS、QPS,即每秒的查询数和事务数;低延时指的是请求的响应速度;容量是指能容纳的量,例如并发量、用户量、磁盘容量、带宽、内存容量、CPU性能等等

  那如何来确定是否是高性能呢,就需要进行压力测试了,例如下面三段代码:

  1、BIO单线程模式

public class HttpServer01 {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        while (true){
            try{
                Socket socket = serverSocket.accept();
                service(socket);
            }catch (Exception e){
                e.printStackTrace();
            }

        }
    }

    private static void service(Socket socket) {
        String body = "hello lcl-nio-001";
        try{
            PrintWriter printWriter = new PrintWriter(socket.getOutputStream(), true);
            printWriter.println("HTTP/1.1 200 OK");
            printWriter.println("Content-Type:text/html;charset=utf-8");
            printWriter.println("Content-Length:" + body.getBytes().length);
            printWriter.println();
            printWriter.write(body);
            printWriter.flush();
            printWriter.close();
            socket.close();
        }catch (Exception e){

        }
    }

  2、Netty的NIO模式

public class MyHttpHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
        ctx.flush();
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }


    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        FullHttpRequest fullHttpRequest = (FullHttpRequest) msg;
        String uri = fullHttpRequest.getUri();
        if("/test".equals(uri)){
            handlerTest(ctx, fullHttpRequest, "hello lcl");
        }else {
            handlerTest(ctx, fullHttpRequest, "hello other");
        }
        ctx.fireChannelRead(msg);
    }

    private void handlerTest(ChannelHandlerContext ctx, FullHttpRequest fullHttpRequest, String body) {
        FullHttpResponse response = null;
        String value = body;

        try {
            response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, Unpooled.wrappedBuffer(value.getBytes("UTF-8")));
            response.headers().set("Content-Type", "application/json");
            response.headers().set("Content-Length", response.content().readableBytes());
        } catch (UnsupportedEncodingException e) {
            System.out.printf("处理异常" + e.getMessage());
            response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.NO_CONTENT);
        }finally {
            if(fullHttpRequest != null){
                if(!HttpUtil.isKeepAlive(fullHttpRequest)){
                    ctx.write(response).addListener(ChannelFutureListener.CLOSE);
                }else {
                    response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderNames.KEEP_ALIVE);
                    ctx.write(response);
                }
            }
        }
    }
}


============== 类分割线

public class MyHttpInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        ChannelPipeline pipeline = socketChannel.pipeline();
        pipeline.addLast(new HttpServerCodec());
        pipeline.addLast(new HttpObjectAggregator(1024*1024));
        pipeline.addLast(new MyHttpHandler());
    }
}


============== 类分割线

public class MyNettyHttpServer {
    public static void main(String[] args) {
        int port = 8888;

        EventLoopGroup bossGroup = new NioEventLoopGroup(2);
        EventLoopGroup workerGroup = new NioEventLoopGroup(16);


        try {
            ServerBootstrap serverBootstrap = new ServerBootstrap();

            serverBootstrap.option(ChannelOption.SO_BACKLOG, 128)
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    .childOption(ChannelOption.SO_REUSEADDR, true)
                    .childOption(ChannelOption.SO_RCVBUF, 21*1024)
                    .childOption(ChannelOption.SO_SNDBUF, 32*1024)
                    .childOption(EpollChannelOption.SO_REUSEPORT, true)
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
            serverBootstrap.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class)
                    .handler(new LoggingHandler(LogLevel.INFO))
                    .childHandler(new MyHttpInitializer());

            Channel channel = serverBootstrap.bind(port).channel();
            System.out.println("开启netty http服务器,监听地址和端口为 http://127.0.0.1:" + port + '/');
            channel.closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

 

  然后使用压测工具进行压测:

conglongli@localhost ~ % wrk -c40 -d30s --latency http://localhost:8080
Running 30s test @ http://localhost:8080
  2 threads and 40 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     0.93ms    2.66ms 198.89ms   99.59%
    Req/Sec     0.91k     1.12k    3.84k    76.29%
  Latency Distribution
     50%  844.00us
     75%    1.05ms
     90%    1.30ms
     99%    2.32ms
  21989 requests in 30.10s, 8.35MB read
  Socket errors: connect 39, read 176095, write 10, timeout 13
Requests/sec:    730.62
Transfer/sec:    284.21KB
conglongli@localhost ~ % wrk -c40 -d30s --latency http://localhost:8888
Running 30s test @ http://localhost:8888
  2 threads and 40 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     5.16ms   32.86ms 454.95ms   97.13%
    Req/Sec    50.05k    10.28k   58.73k    90.64%
  Latency Distribution
     50%  347.00us
     75%  367.00us
     90%  640.00us
     99%  148.79ms
  2732276 requests in 30.10s, 276.20MB read
  Socket errors: connect 0, read 0, write 0, timeout 40
Requests/sec:  90777.41
Transfer/sec:      9.18MB

  首先说一下压测输出的内容:

    -c:实际上就是在压测时使用的连接数,对应的是高并发用户指标;

    Requests/sec:吞吐量,例如上面使用Netty的QPS为 90777.41,对应的是吞吐量指标;

    Latency 延迟时间,例如上面netty的案例压测中,50%的请求在347us中返回等,用来衡量具体的请求中位数,这种衡量一般使用TP来表示,例如这里的 TP99,即为148.79ms,其对应的是延迟指标;

    Thread Stats:是对吞吐量和延迟的平均值(Avg)、标准偏差(Stdev)、最大值(Max)、正负一个标准差占比(+/-) Stdev的统计,一般主要关注Avg和Max。Stdev如果太大说明样本本身离散程度比较高,有可能系统性能波动很大。

  另外,从上面对于BIO和NIO的压测结果看,NIO的吞吐量和延迟要比BIO好的多。

(二)高性能系统优缺点和应对策略

  高并发的优势:系统处理的更快、吞吐量更大、能承受更大的压力、能够容纳更大的压力和更大的请求量、客户体验更好,线上部署的机器也可以更少。

  高性能缺点:为了实现高性能,系统的复杂度会更高,甚至要高十倍以上;建设和维护成本也非常高;如果线上出现问题带来的破坏性也是成倍增加。

  应对策略:对于系统稳定性有一个专门的学科,就是混沌工程,基于混沌工程的思想,就可以从容量、爆炸半径、工程积累和改进这三个方面进行应对。

    容量,要考虑目前系统的容量和目标容量,例如如果提前预知后面某一个时间段的TPS会增大,那么就需要提前加机器,然后进行压测。

    爆炸半径:需要提前了解系统的关系,知道出了问题影响范围,那么就要求我们尽量少的重启或者上线,另外要假设这次上线会出现问题,影响范围是什么。

    工程积累和改进:即问题复盘,可以从问题描述、问题现象、造成原因、解决过程、后续优化等几个温度进行

二、Netty 如何实现高性能

  关于Netty高性能的体现,就是Reactor模式,分为单Reactor单线程、单Reactor多线程、主从Reactor多线程三种,具体的原理可以参考之前的文章 Netty高性能架构设计

  这里主要说几个之前没有理清的概念。

  1、NioEventLoopGroup

    NioEventLoopGroup可以看做是一组EventLoop,一个EventLoop其实是一个线程数为一的线程池,同时EventLoop中还有一个selector,一个selector可以绑定一个或多个Channel,EventLoop线程循环selector中的Channel,如果有就绪的,就交给业务handler处理。

  2、BossEventLoopGroup和WorkerEventLoopGroup的关系

    单Reactor单线程模型:Reactor维护的职责是负责客户端连接事件、事件状态的维护,如果IO完成,则分发请求给Handler处理,这样实际上作为处理网络事件、事件分发、业务处理用的是同一个线程,之间会有相互影响

    单Reactor多线程模型:Reactor维护的职责是负责客户端连接事件、事件状态的维护,如果IO完成,则分发请求给Handler线程池处理,这样将IO操作和业务操作做了隔离,如果业务线程不够,可以单独调整handler线程池参数即可。

    主从Reactor多线程模型:单Reactor单线程的Reactor模型,Reactor维护的职责是负责客户端连接事件、事件状态的维护,就绪事件的分发,其实这是两部分内容,一部分是连接事件和事件状态维护,另一部分是当IO处理完成后,需要进行业务处理时的分发操作,这样对于Reactor来说仍然较重,因此主从Reactor模型将连接事件&状态维护和网络分发做了分离,主Reactor负责连接事件和状态维护,子Reactor负责就绪事件分发给Handler线程池。

  3、Netty对于三种模式的具体实现

    如果是单Reactor单线程,直接定义NioEventLoopGroup时,传入参数 1,则直接创建一个handler线程。

    如果是单Reactor多线程,则可以将参数设置为大于 1,则会创建多个handler,如果不传参数,则会取系统配置,如果系统没有配置,则默认取可运行CPU数量的2倍。

    如果是主从Reactor多线程,需要同时设置BossGroup和WorkerGroup

        

  4、Netty运行原理

    Netty的启动和处理流程如下图所示,在启动时,首先创建bossgroup和workergroup并将其绑定到ServerBootStrap上,同时设置了监听端口,当客户端的请求时,bossgroup接收请求并将请求分配给workergroup。

         

    上面是一个大致的流程,实际上在启动时,设置了workergroup中Eventloop的数量,每一个EventLoop中都有一个TaskQueue和DelayTaskqueue,同时还有一个或多个Channel,每一Channel上都帮定了一个ChannelPipeine,当有事件从BossGroup分发到WorkerGroup中时,wokergroup会将事件放入Channel中,同时Eventloop会轮询所有的Channel,当事件IO处理完毕后,则将请求通过ChannelPipeline传递给业务处理线程。

        

三、Netty 网络程序优化

  1、粘包拆包

    这个在我之前的文章中有过说明:Netty编解码器&粘包拆包

  2、Nagle与TCP_NODELAY

    在发送请求时,如果发送的数据量很小,但是发送了很多,那么在网络上就会有很多小网络包数据在传输,就有可能会引起网络拥堵;但是如果等网络缓冲区满了再传输请求,就可能会导致请求的延迟变高。

    为了平衡网络拥堵和延迟,TCP协议使用了Nagle算法,即在网络通信时,等待网络缓冲区满了再进行传输,但是如果在一定时间范围内落网缓冲区还没有满,例如200ms,同样也将数据进行传输。这这样的处理中,既保证了网络传输中不会有大量的小数据包导致网络拥堵,又不会一直等待导致很大的延迟。

    如果我们的系统对延迟也别敏感,同时并发又不是很大,那么就可以将TCP_NODELAY打开,那么Nagle算法就会被禁用,这样只要网络缓冲区有数据就会发送,可以提高响应时间。

    上面说的是操作系统的网络缓冲区,也就是网卡的缓冲区,实际上在发送数据时,TCP底层仍然会将网卡缓冲区中的数据进行分块发送,每一块数据的大小使用MTU表示,Maxitum Transmission Unit 最大传输单元,其值为 1500 Byte,但是在TCP协议中,由于还需要传输TCP协议头和IP协议头的相关信息,因此最大可传输的数据MSS大小为 1460 Byte,MSS:Maxitum Segment Size,最大分段大小,为 MTU-20(IP)-20(TCP),如果发送数据的字节数大于1460Byte,那么TCP仍然会将其拆分发送,在接收端会将数据做合并。

    基于MTU和MSS,如果发送的数据大于1460Byte时,在压测时性能会比数据小于1460Byte的性能要差。

  3、连接优化

    首先看下TCP的三次握手和四次挥手,流程如下图所示,如果要看详细的解读,可以看下这篇文章 《两张动图-彻底明白TCP的三次握手与四次挥手》

        

     在TCP的四次挥手中,如果服务端断开连接,客户端响应后,会等待两个时钟周期,然后才会将客户端的状态改为关闭,在Linux操作系统中,一个时钟周期为一分钟,在Windows中为两分钟,这个时间还是比较长的,那么客户端的相关资源就会被这些半死不活的连接占用,例如在进行压测时,如果第一次压测完后直接进行第二次压测,有可能会失败,例如出现连接不够用的情况。

    基于上述分析,在做高性能的压测分析时,做完一次压测后,可以等待几分钟后再做第二次压测,同时也可以调整服务器的配置,将时间窗口降低;甚至在使用NIO时,可以直接复用这些连接。

  4、Netty优化

  (1)不要阻塞 EventLoop

    EventLoop是单线程的,如果阻塞EventLoop就会导致其他请求不能处理,如果有比较耗时间的处理,可以新创建一个线程或者使用线程池进行处理,不要在EventLoop中处理。

  (2)系统参数优化

    文件描述符:在Linux操作系统中,一切皆是文件,如果开了大量的连接,就会占用大量的文件描述符,可以使用 ulimit -a 来查看资源描述符的限制,可以将其调大

    时钟周期:另外上面连接优化中说到可以更改时钟周期的大小,在Linux中可以调整 /proc/sys/net/ipv4/tcp_fin_timeout,,在Windows中可以调整路由注册表中的 TcpTimedWaitDelay

  (3)缓冲区优化

    网络缓冲区大小:即上面描述的Nagle算法中使用的缓冲区,包括接收缓冲区SO_RCVBUF和发送缓冲区SO_SNDBUF,这两个可以在启动Netty时直接设置

    半连接数量(SO_BACKLOG):这个指的是在TCP三次握手时,还在有建立连接的情况下,可以创建多少个连接,也就是方服务处理不过来时,可以有多少个连接进行等待,如果等待的连接超过了该数值,则会报错。

    复用TIME-WAIT连接:即上面提到处于TIME-WAIT状态的连接虽然不可用,但是仍然会占用很多的资源,如果使用REUSEXXX配置,则可以复用这些连接,这种处理比重新创建连接更经济实惠。其也可以通过在Netty启动时进行配置。

    下面是一个Netty启动时的样例代码,同时设置了接收缓冲区、发送缓冲区、backlog缓冲区和复用连接的配置

            serverBootstrap.option(ChannelOption.SO_BACKLOG, 128)
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    .childOption(ChannelOption.SO_REUSEADDR, true)
                    .childOption(ChannelOption.SO_RCVBUF, 21*1024)
                    .childOption(ChannelOption.SO_SNDBUF, 32*1024)
                    .childOption(EpollChannelOption.SO_REUSEPORT, true)
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);

  (4)心跳周期优化

    这个没什么可说的,就是使用心跳机制与断线重连来保证网络通信是好的,提高可用性。可以在Netty启动时设置参数SO_KEEPALIVE,具体示例如上面代码所示。

  (5)内存与 ByteBuffer 优化

    Netty内存可以分为直接内存DirectBuffer 与 堆内存HeapBuffer,其中直接内存不会受JVM GC影响,效率会更高,因此使用Netty时,一般会使用堆外内存,可以在Netty启动时设置重用缓冲池参数 ALLOCATOR具体示例如上面代码所示。

  (6)其他优化

    ioRatio: IO 消耗的CPU资源和业务处理消耗的CPU资源比例,默认为50:50,这一块也是可以调整的。

    Watermark:高低水位,用于配置是否可写。业务数据不可能无限制向Netty缓冲区写入数据,TCP缓冲区也不可能无限制写入数据.Netty通过高低水位控制向Netty缓冲区写入数据的多少,它的大体流程就是向Netty缓冲区写入数据的时候,会判断写入的数据总量是否超过了设置的高水位值,如果超过了就设置通道(Channel)不可写状态。当Netty缓冲区中的数据写入到TCP缓冲区之后,Netty缓冲区的数据量变少,当低于低水位值的时候,就设置通过(Channel)可写状态。

      netty默认设置的高水位为64KB,低水位为32KB,设置好了高低水位参数,需要自己在写代码的时候,判断 channel.isWritable() ,否则仍然会继续写入。

bootstrap.childOption(ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK, 32 * 1024);
bootstrap.childOption(ChannelOption.WRITE_BUFFER_LOW_WATER_MARK, 8 * 1024);

 

    TrafficShaping:Netty自带了 流量整形 的流控,当网络请求特别大时,可以使用内存将请求缓存,Netty使用一定的速率处理请求,这样可以保证系统的稳定,不会被突发的大量请求冲垮。Netty提供了GlobalTrafficShapingHandler、ChannelTrafficShapingHandler、GlobalChannelTrafficShapingHandler三个类来实现流量整形,他们都是AbstractTrafficShapingHandler抽象类的实现类,可以在组装ChannelPipeline时,将该Handler组装到ChannelPipeline中。

四、典型应用:API 网关

  API网关的四大职能:

    请求接入:作为所有API接口服务请求的接入点,例如有成千上万的连接,或者是同一时间有十万的连接,API网关需要有这样的能力,将所有的请求都Hold住,因此这一块一般都需要类似Netty的NIO能力。

    业务聚合:作为所有后端业务服务的聚合点

    中介策略:实现安全、验证、路由、过滤、流控等策略

    统一管理:对所有API服务和策略进行统一管理

  从网关的职能上来看,可以分为流量网关和业务网关:

    流量网关:流量网关是在整个集群的最前端,做一些通用的处理,和具体的业务无关,主要关注稳定与安全,例如全局性流控、日志统计、防止SQL注入、防止WEB攻击、屏蔽工具扫描、黑白IP名单、证书或加解密处理等。因此流量网关有部分LoadBalance的能力、有部分安全领域WAF网络应用防火墙的能力,流量网关关注的是整个微服务集群,因此流量会非常大,对其性能也有很高的要求。

    业务网关:主要是提供更好的服务,例如服务级别流控、服务降级与熔断、路由与负载以及灰度策略、服务过滤聚合发现、权限验证与用户等级策略、业务规则与权限校验、多级缓存策略等

  网关的主要流程都是先对请求进行拦截过滤,然后对请求进行路由转发,获取到响应结果后,对结果进行拦截过滤,最终返回结果。

  在Java体系中,常见的网关有 Zuul、Zuul2、Spring Cloud Gateway、OpenRestry、Kong等,其中OpenRestry和Kong都是基于Nginx + lua脚本实现的,性能非常好,适合做流量网关,而Zuul2和SpringCloud Gateway是基于Netty实现的,扩展性好,可以做二次开发,适合做业务网关。

  如果想要自己动手实现 API 网关,那就是上面提到的,先对请求进行拦截过滤,然后对请求进行路由转发,获取到响应结果后,对结果进行拦截过滤,最终返回结果。

  手写的网关可以参考我的另外一篇文章 使用Netty手撸一个简单网关

  

 

posted @ 2022-05-23 00:03  李聪龙  阅读(1116)  评论(0编辑  收藏  举报