前言
- 本文默认读者对netty有一定的使用经验,对于一些netty的基础概念不会说明
- 本文主要探讨对于netty实现的websocket服务端推送数据的一些优化点,如果存在错误的地方欢迎指出,并且文中有说明不清晰的地方也欢迎留言讨论
开始喽
- 通常我们使用如下的方式,创建一个基于netty的websocket服务,然后再此基础上进行业务开发。
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();
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new HttpObjectAggregator(65536));
pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
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);
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()) {
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积分,如果实在没有可以私信我,发给你
如果还有其他可优化的方向,欢迎留言指点