阅读完需:约 10 分钟
实现WebSocket的方式有很多种
之前用的tomcat自带的websocket
又或者 spring-boot-starter-websocke
集成,但是性能在数据并发很大时就会存在问题。
在高并发的情况下,出现了session抢占的问题,导致session的状态不一致,所以,不过可以去尝试加锁操作,或者事件驱动的方式去解决
不过自己尝试的时候没有发生错误,或许是还没有达到那个量级,可以参考下面的内容
https://blog.csdn.net/abu935009066/article/details/131218149
https://blog.csdn.net/qq_38263083/article/details/131811502
Netty-WebSocket搭建
所以这个采用Netty的方式去搭建WebSocket服务器
https://github.com/YeautyYE/netty-websocket-spring-boot-starter
jdk版本为1.8或1.8+
Maven依赖为
<dependency>
<groupId>org.yeauty</groupId>
<artifactId>netty-websocket-spring-boot-starter</artifactId>
<version>0.12.0</version>
</dependency>
在端点类上加上@ServerEndpoint
注解,并在相应的方法上加上@BeforeHandshake
、@OnOpen
、@OnClose
、@OnError
、@OnMessage
、@OnBinary
、@OnEvent
注解,样例如下:
@ServerEndpoint(path = "/ws/{arg}")
public class MyWebSocket {
@BeforeHandshake
public void handshake(Session session, HttpHeaders headers, @RequestParam String req, @RequestParam MultiValueMap reqMap, @PathVariable String arg, @PathVariable Map pathMap){
session.setSubprotocols("stomp");
if (!"ok".equals(req)){
System.out.println("Authentication failed!");
session.close();
}
}
@OnOpen
public void onOpen(Session session, HttpHeaders headers, @RequestParam String req, @RequestParam MultiValueMap reqMap, @PathVariable String arg, @PathVariable Map pathMap){
System.out.println("new connection");
System.out.println(req);
}
@OnClose
public void onClose(Session session) throws IOException {
System.out.println("one connection closed");
}
@OnError
public void onError(Session session, Throwable throwable) {
throwable.printStackTrace();
}
@OnMessage
public void onMessage(Session session, String message) {
System.out.println(message);
session.sendText("Hello Netty!");
}
@OnBinary
public void onBinary(Session session, byte[] bytes) {
for (byte b : bytes) {
System.out.println(b);
}
session.sendBinary(bytes);
}
@OnEvent
public void onEvent(Session session, Object evt) {
if (evt instanceof IdleStateEvent) {
IdleStateEvent idleStateEvent = (IdleStateEvent) evt;
switch (idleStateEvent.state()) {
case READER_IDLE:
System.out.println("read idle");
break;
case WRITER_IDLE:
System.out.println("write idle");
break;
case ALL_IDLE:
System.out.println("all idle");
break;
default:
break;
}
}
}
}
打开WebSocket客户端,连接到ws://127.0.0.1:80/ws/xxx
注解解释
- @ServerEndpoint
- 当ServerEndpointExporter类通过Spring配置进行声明并被使用,它将会去扫描带有@ServerEndpoint注解的类 被注解的类将被注册成为一个WebSocket端点 所有的配置项都在这个注解的属性中 ( 如:@ServerEndpoint(“/ws”) )
- @BeforeHandshake
- 当有新的连接进入时,对该方法进行回调 注入参数的类型:Session、HttpHeaders…
- @OnOpen
- 当有新的WebSocket连接完成时,对该方法进行回调 注入参数的类型:Session、HttpHeaders…
- @OnClose
- 当有WebSocket连接关闭时,对该方法进行回调 注入参数的类型:Session
- @OnError
- 当有WebSocket抛出异常时,对该方法进行回调 注入参数的类型:Session、Throwable
- @OnMessage
- 当接收到字符串消息时,对该方法进行回调 注入参数的类型:Session、String
- @OnBinary
- 当接收到二进制消息时,对该方法进行回调 注入参数的类型:Session、byte[]
- @OnEvent
- 当接收到Netty的事件时,对该方法进行回调 注入参数的类型:Session、Object
@ServerEndpoint 注解配置
所有的配置项都在这个注解的属性中
属性 | 默认值 | 说明 |
---|---|---|
path | “/” | WebSocket的path,也可以用value 来设置 |
host | “0.0.0.0” | WebSocket的host,"0.0.0.0" 即是所有本地地址 |
port | 80 | WebSocket绑定端口号。如果为0,则使用随机端口(端口获取可见 多端点服务) |
bossLoopGroupThreads | 0 | bossEventLoopGroup的线程数 |
workerLoopGroupThreads | 0 | workerEventLoopGroup的线程数 |
useCompressionHandler | false | 是否添加WebSocketServerCompressionHandler到pipeline |
optionConnectTimeoutMillis | 30000 | 与Netty的ChannelOption.CONNECT_TIMEOUT_MILLIS 一致 |
optionSoBacklog | 128 | 与Netty的ChannelOption.SO_BACKLOG 一致 |
childOptionWriteSpinCount | 16 | 与Netty的ChannelOption.WRITE_SPIN_COUNT 一致 |
childOptionWriteBufferHighWaterMark | 64*1024 | 与Netty的ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK 一致,但实际上是使用ChannelOption.WRITE_BUFFER_WATER_MARK
|
childOptionWriteBufferLowWaterMark | 32*1024 | 与Netty的ChannelOption.WRITE_BUFFER_LOW_WATER_MARK 一致,但实际上是使用 ChannelOption.WRITE_BUFFER_WATER_MARK
|
childOptionSoRcvbuf | -1(即未设置) | 与Netty的ChannelOption.SO_RCVBUF 一致 |
childOptionSoSndbuf | -1(即未设置) | 与Netty的ChannelOption.SO_SNDBUF 一致 |
childOptionTcpNodelay | true | 与Netty的ChannelOption.TCP_NODELAY 一致 |
childOptionSoKeepalive | false | 与Netty的ChannelOption.SO_KEEPALIVE 一致 |
childOptionSoLinger | -1 | 与Netty的ChannelOption.SO_LINGER 一致 |
childOptionAllowHalfClosure | false | 与Netty的ChannelOption.ALLOW_HALF_CLOSURE 一致 |
readerIdleTimeSeconds | 0 | 与IdleStateHandler 中的readerIdleTimeSeconds 一致,并且当它不为0时,将在pipeline 中添加IdleStateHandler
|
writerIdleTimeSeconds | 0 | 与IdleStateHandler 中的writerIdleTimeSeconds 一致,并且当它不为0时,将在pipeline 中添加IdleStateHandler
|
allIdleTimeSeconds | 0 | 与IdleStateHandler 中的allIdleTimeSeconds 一致,并且当它不为0时,将在pipeline 中添加IdleStateHandler
|
maxFramePayloadLength | 65536 | 最大允许帧载荷长度 |
useEventExecutorGroup | true | 是否使用另一个线程池来执行耗时的同步业务逻辑 |
eventExecutorGroupThreads | 16 | eventExecutorGroup的线程数 |
sslKeyPassword | “”(即未设置) | 与spring-boot的server.ssl.key-password 一致 |
sslKeyStore | “”(即未设置) | 与spring-boot的server.ssl.key-store 一致 |
sslKeyStorePassword | “”(即未设置) | 与spring-boot的server.ssl.key-store-password 一致 |
sslKeyStoreType | “”(即未设置) | 与spring-boot的server.ssl.key-store-type 一致 |
sslTrustStore | “”(即未设置) | 与spring-boot的server.ssl.trust-store 一致 |
sslTrustStorePassword | “”(即未设置) | 与spring-boot的server.ssl.trust-store-password 一致 |
sslTrustStoreType | “”(即未设置) | 与spring-boot的server.ssl.trust-store-type 一致 |
corsOrigins | {}(即未设置) | 与spring-boot的@CrossOrigin#origins 一致 |
corsAllowCredentials | “”(即未设置) | 与spring-boot的@CrossOrigin#allowCredentials 一致 |
通过application.properties进行配置
所有参数皆可使用${...}
占位符获取application.properties
中的配置。如下:
- 首先在
@ServerEndpoint
注解的属性中使用${...}
占位符
@ServerEndpoint(host = "${ws.host}",port = "${ws.port}")
public class MyWebSocket {
...
}
- 接下来即可在
application.properties
中配置
ws.host=0.0.0.0
ws.port=80
自定义Favicon
配置favicon的方式与spring-boot中完全一致。只需将favicon.ico
文件放到classpath的根目录下即可。如下:
src/
+- main/
+- java/
| + <source code>
+- resources/
+- favicon.ico
自定义错误页面
配置自定义错误页面的方式与spring-boot中完全一致。你可以添加一个 /public/error
目录,错误页面将会是该目录下的静态页面,错误页面的文件名必须是准确的错误状态或者是一串掩码,如下:
src/
+- main/
+- java/
| + <source code>
+- resources/
+- public/
+- error/
| +- 404.html
| +- 5xx.html
+- <other public assets>
多端点服务
- 在快速启动的基础上,在多个需要成为端点的类上使用
@ServerEndpoint
、@Component
注解即可 - 可通过
ServerEndpointExporter.getInetSocketAddressSet()
获取所有端点的地址 - 当地址不同时(即host不同或port不同),使用不同的
ServerBootstrap
实例 - 当地址相同,路径(path)不同时,使用同一个
ServerBootstrap
实例 - 当多个端点服务的port为0时,将使用同一个随机的端口号
- 当多个端点的port和path相同时,host不能设为
"0.0.0.0"
,因为"0.0.0.0"
意味着绑定所有的host
当我们使用SpringBoot 3.X 和 JDK 17+ 的时候上面的依赖会不兼容,所以要采用新的依赖,这个依赖也是大佬根据上面的进行增强和兼容
https://github.com/endlessc/netty-websocket-spring-boot
jdk >= 1.8 (兼容jdk 17)
<dependency>
<groupId>cn.twelvet</groupId>
<artifactId>netty-websocket-spring-boot-starter</artifactId>
<version>1.0.1</version>
</dependency>
还有一个项目也是基于上面的进行重写与增强
https://github.com/niezhiliang/netty-websocket-spring-boot
<dependency>
<groupId>com.niezhiliang</groupId>
<artifactId>netty-websocket-spring-boot-starter</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
这两个项目的使用方式与最初的项目基本一样,不过是兼容与增强
百万连接
对于百万websocket场连接的配置还需要做一些优化
主要是服务器的调优
TCP/IP参数配置
net.ipv4.tcp_wmem = 4096 87380 4161536
net.ipv4.tcp_rmem = 4096 87380 4161536
net.ipv4.tcp_mem = 786432 2097152 3145728
数值根据需求进行调整。执行/sbin/sysctl -p
即时生效。
最大文件描述符
Linux内核本身有文件描述符最大值的限制,你可以根据需要更改:
- 系统最大打开文件描述符数:/proc/sys/fs/file-max
- 临时性设置:
echo 1000000 > /proc/sys/fs/file-max
- 永久设置:修改
/etc/sysctl.conf
文件,增加fs.file-max = 1000000
- 临时性设置:
- 进程最大打开文件描述符数
使用ulimit -n
查看当前设置。使用ulimit -n 1000000
进行临时性设置。
要想永久生效,你可以修改/etc/security/limits.conf
文件,增加下面的行:
* hard nofile 1000000
* soft nofile 1000000
root hard nofile 1000000
root soft nofile 1000000
还有一点要注意的就是hard limit不能大于/proc/sys/fs/nr_open
,因此有时你也需要修改nr_open的值。
执行echo 2000000 > /proc/sys/fs/nr_open
查看当前系统使用的打开文件描述符数,可以使用下面的命令:
[root@localhost ~]# cat /proc/sys/fs/file-nr
1632 0 1513506
其中第一个数表示当前系统已分配使用的打开文件描述符数,第二个数为分配后已释放的(目前已不再使用),第三个数等于file-max。
总结一下:
- 所有进程打开的文件描述符数不能超过/proc/sys/fs/file-max
- 单个进程打开的文件描述符数不能超过user limit中nofile的soft limit
- nofile的soft limit不能超过其hard limit
- nofile的hard limit不能超过/proc/sys/fs/nr_open
客户端的参数调优
在一台系统上,连接到一个远程服务时的本地端口是有限的。根据TCP/IP协议,由于端口是16位整数,也就只能是0到 65535,而0到1023是预留端口,所以能分配的端口只是1024到65534,也就是64511个。也就是说,一台机器一个IP只能创建六万多个长连接。
要想达到更多的客户端连接,可以用更多的机器或者网卡,也可以使用虚拟IP来实现,比如下面的命令增加了19个IP地址,其中一个给服务器用,其它18个给client,这样,可以产生18 * 60000 = 1080000个连接。
ifconfig eth0:0 192.168.77.10 netmask 255.255.255.0 up
ifconfig eth0:1 192.168.77.11 netmask 255.255.255.0 up
ifconfig eth0:2 192.168.77.12 netmask 255.255.255.0 up
ifconfig eth0:3 192.168.77.13 netmask 255.255.255.0 up
ifconfig eth0:4 192.168.77.14 netmask 255.255.255.0 up
ifconfig eth0:5 192.168.77.15 netmask 255.255.255.0 up
ifconfig eth0:6 192.168.77.16 netmask 255.255.255.0 up
ifconfig eth0:7 192.168.77.17 netmask 255.255.255.0 up
ifconfig eth0:8 192.168.77.18 netmask 255.255.255.0 up
ifconfig eth0:9 192.168.77.19 netmask 255.255.255.0 up
ifconfig eth0:10 192.168.77.20 netmask 255.255.255.0 up
ifconfig eth0:11 192.168.77.21 netmask 255.255.255.0 up
ifconfig eth0:12 192.168.77.22 netmask 255.255.255.0 up
ifconfig eth0:13 192.168.77.23 netmask 255.255.255.0 up
ifconfig eth0:14 192.168.77.24 netmask 255.255.255.0 up
ifconfig eth0:15 192.168.77.25 netmask 255.255.255.0 up
ifconfig eth0:16 192.168.77.26 netmask 255.255.255.0 up
ifconfig eth0:17 192.168.77.27 netmask 255.255.255.0 up
ifconfig eth0:18 192.168.77.28 netmask 255.255.255.0 up
修改/etc/sysctl.conf
文件:
net.ipv4.ip_local_port_range = 1024 65535
执行/sbin/sysctl -p
即时生效。
https://colobu.com/2015/05/22/implement-C1000K-servers-by-spray-netty-undertow-and-node-js/