基于netty的websocket服务优化

前言

  • 本文默认读者对netty有一定的使用经验,对于一些netty的基础概念不会说明
  • 本文主要探讨对于netty实现的websocket服务端推送数据的一些优化点,如果存在错误的地方欢迎指出,并且文中有说明不清晰的地方也欢迎留言讨论

开始喽

  • 通常我们使用如下的方式,创建一个基于netty的websocket服务,然后再此基础上进行业务开发。
// ai生成的demo
public class WebSocketServer {
    private final int port;
    public WebSocketServer(int port) {
        this.port = port;
    }

    public void run() throws Exception {
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class)
             .handler(new LoggingHandler(LogLevel.INFO))
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 protected void initChannel(SocketChannel ch) throws Exception {
                     ChannelPipeline pipeline = ch.pipeline();
                     // HTTP 编解码器
                     pipeline.addLast(new HttpServerCodec());
                     // HTTP 消息聚合器
                     pipeline.addLast(new HttpObjectAggregator(65536));
                     // WebSocket 协议处理器
                     pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
                     // 自定义 WebSocket 处理器
                     pipeline.addLast(new WebSocketFrameHandler());
                 }
             })
             .option(ChannelOption.SO_BACKLOG, 128)
             .childOption(ChannelOption.SO_KEEPALIVE, true);

            // 绑定端口并启动服务器
            ChannelFuture f = b.bind(port).sync();
            System.out.println("WebSocket 服务器启动,监听端口 " + port);

            // 等待服务器 socket 关闭
            f.channel().closeFuture().sync();
        } finally {
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }

    public static void main(String[] args) throws Exception {
        int port = 8080;
        new WebSocketServer(port).run();
    }
}
  • 此时的websocket服务仅仅是最基础的版本,虽然可以用,但是在成本,性能,健壮性上仍存在一些优化空间,以下是一些优化点的原理介绍

快速订阅优化

如下图是客户端正常订阅websocket流程(不包括TLS)

交互流程

  • 在这个流程中客户端从建立连接到收到消息一共要经过3个rtt时间
  • 如果我们将协议升级和发送订阅请求合并,则可以减少1个rtt事件

快速订阅流程

在这里插入图片描述

  • 将协议升级和订阅请求合并后只需要2个rtt事件即可
  • 协议升级本质上是一次get请求,所以get请求中就可以携带参数
  • 握手成功后netty会触发一个userEventTriggered方法,传递HandshakeComplete事件,事件中会包含请求的uri,header,协议等数据,所以我们只需要实现一个userEventTriggered方法来处理这个事件中的数据,将订阅参数处理即可
  • 在网络延迟较大的客户端中,节省一个rtt的时间可能就有几十或者上百的毫秒节省
  • 下面这个代码是netty中WebSocketServerProtocolHandshakeHandler读接口实现,包含了上面提到的发送HandshakeComplete事件的逻辑,感兴趣的可以自己debug一下
public void channelRead(final ChannelHandlerContext ctx, Object msg) throws Exception {
        final HttpObject httpObject = (HttpObject) msg;

        if (httpObject instanceof HttpRequest) {
            final HttpRequest req = (HttpRequest) httpObject;
            isWebSocketPath = isWebSocketPath(req);
            if (!isWebSocketPath) {
                ctx.fireChannelRead(msg);
                return;
            }

            try {
                final WebSocketServerHandshakerFactory wsFactory = new WebSocketServerHandshakerFactory(
                        getWebSocketLocation(ctx.pipeline(), req, serverConfig.websocketPath()),
                        serverConfig.subprotocols(), serverConfig.decoderConfig());
                final WebSocketServerHandshaker handshaker = wsFactory.newHandshaker(req);
                final ChannelPromise localHandshakePromise = handshakePromise;
                if (handshaker == null) {
                    WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel());
                } else {
                    WebSocketServerProtocolHandler.setHandshaker(ctx.channel(), handshaker);
                    ctx.pipeline().remove(this);

                    final ChannelFuture handshakeFuture = handshaker.handshake(ctx.channel(), req);
                    handshakeFuture.addListener(new ChannelFutureListener() {
                        @Override
                        public void operationComplete(ChannelFuture future) {
                            if (!future.isSuccess()) {
                                localHandshakePromise.tryFailure(future.cause());
                                ctx.fireExceptionCaught(future.cause());
                            } else {
                                localHandshakePromise.trySuccess();
                                ctx.fireUserEventTriggered(
                                        WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE);
                                // 发送握手成功事件
                                ctx.fireUserEventTriggered(
                                        new WebSocketServerProtocolHandler.HandshakeComplete(
                                                req.uri(), req.headers(), handshaker.selectedSubprotocol()));
                            }
                        }
                    });
                    applyHandshakeTimeout();
                }
            } finally {
                ReferenceCountUtil.release(req);
            }
        } else if (!isWebSocketPath) {
            ctx.fireChannelRead(msg);
        } else {
            ReferenceCountUtil.release(msg);
        }
    }
  • 除了上面这个快速订阅的过程可以节省1个rtt事件外,还可以和TCP Fast Open优化同时使用,可以再次节省一个rtt时间。这样可以将3个rtt压缩为1个rtt时间。
  • 不过这个优化暂时并没有应用到生产上,因此不过多介绍,感兴趣的可以自行查阅实现

数据压缩优化

压缩扩展

  • 如果推送数据较多时,通过压缩数据可以节省大量的网络流量。那么如何压缩数据就有多种选择,首先可以在业务层手动将数据压缩后发送出去。但是这种方式导致数据在业务层不可读,比如浏览器中会发现消息是乱码,无法查看。因此我们更推荐在传输层进行压缩
  • websocket协议中本身支持permessage-deflate扩展,permessage-deflate扩展主要用于将推送数据进行压缩处理。通过此扩展即可在发送后由框架自动将数据压缩
  • 具体实现只需要将WebSocketServerProtocolHandler处理器前面加上WebSocketServerCompressionHandler处理器,并且将WebSocketServerProtocolHandler处理器中的支持扩展参数配置为true,即可支持压缩功能
  • 下面是WebSocketServerProtocolHandler的其中一个构造函数,其中第3个参数allowExtensions配置为true则代表开启扩展功能
public WebSocketServerProtocolHandler(String websocketPath, String subprotocols,
                                          boolean allowExtensions, int maxFrameSize, boolean allowMaskMismatch,
                                          boolean checkStartsWith, boolean dropPongFrames) {
        this(websocketPath, subprotocols, allowExtensions, maxFrameSize, allowMaskMismatch, checkStartsWith,
             dropPongFrames, DEFAULT_HANDSHAKE_TIMEOUT_MILLIS);
    }

选择性压缩

  • 在开了压缩功能后,netty会默认将所有的BinaryWebSocketFrame和TextWebSocketFrame消息进行压缩,但是如果消息体较小,则可能额外消耗了cpu,但无法有比较好的压缩率,因此可以选择将超过一定大小的消息进行压缩
  • WebSocketExtensionFilter接口决定是否跳过扩展功能,只要实现这个接口并配置到netty中即可完成选择性压缩功能
@Component
public class CustomWebSocketExtensionFilter implements WebSocketExtensionFilter {
    @Resource
    private WebSocketConfig webSocketConfig;

    @Override
    public boolean mustSkip(WebSocketFrame frame) {
        if(frame instanceof TextWebSocketFrame || frame instanceof BinaryWebSocketFrame) {
        	// 小于一定长度的消息则跳过压缩,返回值代表是否跳过压缩
            if(frame.content().readableBytes() < webSocketConfig.getMinCompressionLength()) {
                return true;
            }
        }
        return false;
    }
}
  • 构造一个新的WebSocketServerExtensionHandler,替换掉WebSocketServerCompressionHandler即可,代码如下
@Component
public class WebSocketServerExtensionHandlerFactory {

    @Resource
    private CustomWebSocketExtensionFilter customWebSocketExtensionFilter;
    private CustomWebSocketExtensionFilterProvider customWebSocketExtensionFilterProvider = new CustomWebSocketExtensionFilterProvider();

    public WebSocketServerExtensionHandler newInstance() {
        return new WebSocketServerExtensionHandler(new PerMessageDeflateServerExtensionHandshaker(6,
                ZlibCodecFactory.isSupportingWindowSizeAndMemLevel(),
                PerMessageDeflateServerExtensionHandshaker.MAX_WINDOW_SIZE,
                false, false,
                customWebSocketExtensionFilterProvider
        ));
    }

    private class CustomWebSocketExtensionFilterProvider implements WebSocketExtensionFilterProvider {
        @Override
        public WebSocketExtensionFilter encoderFilter() {
            return customWebSocketExtensionFilter;
        }

        @Override
        public WebSocketExtensionFilter decoderFilter() {
            return WebSocketExtensionFilter.NEVER_SKIP;
        }
    }
}
  • 需要额外关注的一点是,写入数据时如果我们手动将一条消息依次发送给多个channel,则每个channel的管道都会将消息压缩一次,但是如果通过channelGroup将消息发送给多个channel,则消息只会压缩一次

缓冲区

缓冲区打满

  • tcp连接中每个连接都有自己的读写缓冲区,如果接收方接受数据过慢,那么最终会阻塞到发送方的写缓冲区
  • 但是由于netty是事件驱动的,因此当写缓冲区打满时,消息会堆积到内存中,直到把内存打满引发fullgc问题,因此推送数据时必须处理写缓存区打满的问题
  • 这个问题处理方式比较简单,netty中提供了是否可写的方法,当不可写入的时候丢弃掉消息就不会将消息堆积到内存,但是如果不关闭channel,则已经写入缓冲区的数据无法释放,直到连接断开或接收方接受了数据
public class WritableHandler extends ChannelOutboundHandlerAdapter {
    @Resource
    private WebSocketConfig webSocketConfig;
    @Resource
    private ChannelStore channelStore;

    @Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
        if (!ctx.channel().isWritable()) {
            Channel channel = ctx.channel();
            // 释放掉消息,不进行推送
            ReferenceCountUtil.safeRelease(msg);
            if (webSocketConfig.isCloseNotWritableChannel() && ctx.channel().isOpen()) {
                // 根据配置决定是否关闭这个channel
                Monitor.counter("closeNotWritableChannel").end();
                channelStore.closeChannel(channel, CloseReason.NOT_WRITABLE);
            }

            return;
        }
        super.write(ctx, msg, promise);
    }
}

安全防护

  • 如果我们的websocket服务是对外暴露的,通常来讲需要一定的安全防护措施,避免外部用户恶意攻击我们的接口,比如如下限制
    • 相同ip的连接频率限制
    • 相同ip的最大连接数量限制
    • 同一连接接收消息频率限制
    • 同一连接最大连接时长限制
    • 违规操作过多时,禁用对方ip连接
  • 通过这些措施可以避免掉很多恶意流量,至于具体实现等后续再分享出来

结尾

  • 以上功能的源码参考代码:https://download.youkuaiyun.com/download/wsss_fan/90429500
  • 下载需要5积分,如果实在没有可以私信我,发给你

如果还有其他可优化的方向,欢迎留言指点

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值