【转】Websocket推送中心(三)-单机100W连接(C1000K)达成

2021-08-25 15:03

来自https://shibd.github.io/2019/08/17/Message-Center-3/


本章主线
上篇文章介绍了如何使用WebSocket和STOMP来实现推送中心的需求,确定了单体下推送中心功能。本章将分享推送中心在服务器单体如何支持百万级连接,从测试案例编写开始,一步一步进行服务器调优,踩坑,突破连接数,最后达到百万连接的过程。

必备知识
我们在谈单机支持百万连接前,先来明确一些结论。

对于服务器,Linux内核是通过(local_ip,local_port,remote_ip,remote_port)四元组来标识一条TCP连接的。

对于WebSocket服务端,local_ip和local_port是确定的,所以在内存和cpu足够的情况下,服务端不会出现瓶颈。在现代CPU和内存的配置下,服务器支持百万级连接实际是很轻松的。

对于WebSocket客户端,如果我们使用Linux服务器作为客户端来压测Websocekt服务的话,在一个客户端服务器下,由于remote_ip是固定的,所以每一条连接需要占用客户端服务器的一个remote_port。对于Linux服务器来说,net.ipv4.ip_local_port_range最大端口值为65535,所以一个客户端压测试最大可以压满65535条连接。

压测案例
计划是通过一个客户端进程发起百万连接(暂时不考虑单机65535端口限制,后面有解决方案)。这里客户端采用Java编写,官方提供了Websocket STOMP Client使用教程,这里对代码稍作调整,使用线程池并发发起连接,并且做了连接失败重试等处理。完整代码参考github

/** * @author: baozi * @date: 2019/8/9 14:57 * @description: */
public class StompClient {

public static String TOKEN = "eyJ1c2VyTmFtZSI6Ind1ZGl4aWFvYmFvemkiLCJhbGciOiJSUzI1NiJ9.eyJleHAiOjE1Njc1OTE1NDYsInVzZXJOYW1lIjoid3VkaXhpYW9iYW96aSIsImlhdCI6MTU2NDk5OTU0Nn0.dEJzjgwwZCL6qh3NtluVSo0uZZZdUEzrNF2pLsUxprVOSE-pzaUVlOw2EmntXd4IpFs3qI0IwA4F51VOFIX65lc1RoX93AFeb44CYt9JpXKcGtGYWQr2D4nsNMaS7je8abtastBC8QIInCYtC7s8tvaAQRzYTvCZmSM8vtgu06g";

public static String REQ_URL = "ws://127.0.0.1:8080/msg-center/websocket";

public static WebSocketStompClient stompClient;

public static MyStompSessionHandler sessionHandler;

public static void main(String[] args) throws InterruptedException {

long clientThreadNum = 1;
if (args.length > 0 && !StringUtils.isEmpty(args[0])) {
clientThreadNum = Long.valueOf(args[0]);
System.out.println("客户端建立链接数:" + clientThreadNum);
}

if (args.length > 1 && !StringUtils.isEmpty(args[1])) {
REQ_URL = "ws://" + args[1] + "/msg-center/websocket";
System.out.println("远程连接地址:" + args[1]);
}

List<Transport> transports = new ArrayList<>(1);
transports.add(new WebSocketTransport(new StandardWebSocketClient()));
WebSocketClient transport = new SockJsClient(transports);
stompClient = new WebSocketStompClient(transport);

stompClient.setMessageConverter(new StringMessageConverter());
sessionHandler = new MyStompSessionHandler();

ExecutorService executorService = new ThreadPoolExecutor(50, 1000, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(500));

// 每条线程处理链接数
long singeDelNum = 100;
while (clientThreadNum > 0) {
long delNum = clientThreadNum > singeDelNum ? singeDelNum : clientThreadNum;
try {
executorService.submit(new DealThread(delNum));
}
catch (Exception e) {
Thread.sleep(5000);
continue;
}
clientThreadNum -= singeDelNum;
}

// 定时打印counter
new Thread(() -> {
while (true) {
try {
Thread.sleep(5000);
}
catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("counter: " + sessionHandler.getCounter());

}
}).start();

while (true) {
Thread.sleep(Integer.MAX_VALUE);
}

}

public static class DealThread implements Runnable {

private long dealNum;

public DealThread(long dealNum) {
this.dealNum = dealNum;
}

@Override
public void run() {
int waitTime = 1;
while (dealNum > 0) {

try {
Thread.sleep(waitTime);

StompHeaders stompHeaders = new StompHeaders();
stompHeaders.put("token", Arrays.asList(TOKEN));
stompHeaders.put("projectId", Arrays.asList("fm"));

// 设置心跳
// stompClient.setTaskScheduler();
// stompHeaders.setHeartbeat(new long[] { 10000, 10000 });

ListenableFuture<StompSession> connect = stompClient.connect(new URI(REQ_URL), null, stompHeaders,
sessionHandler);

StompSession stompSession = connect.get();
if (stompSession == null) {
waitTime += 3000;
continue;
}
}
catch (Exception e) {
System.out.println("this si errer ,retry:" + waitTime);
e.printStackTrace();
waitTime += 3000;
continue;
}

// 走到后面说明连接成功,置回正常等待时间,处理下一位
if (waitTime > 3000) {
waitTime = 1;
}
dealNum--;
}
System.out.println(Thread.currentThread().getName() + " is die");
}
}
}

启动客户端时动态传入连接数进行压测

java -jar benchmark-1.0-SNAPSHOT.jar 1000 127.0.0.1

运行环境
1台服务端:16G内存,8核CPU
1台客户端:16G内存,8核CPU
开始默认配置,未对操作系统和Java虚拟机做任何参数调优。

测试历程(1024~500K)
虽然很多文章都介绍了C1000K在现代硬件机器的情况下都是很容易达到的。笔者在亲自试验后还是有很多坑存在,下面记录从1024到100W各个阶段踩坑的过程(一步一个坎),出现问题发现问题的解决过程。

根据前两篇文章我们清楚,推送中心的服务端是用的Spring WebSocket STOMP框架,其实网络这一层都是用的Spring封装的框架,并没有深入研究,所以对于Spring WebSocket的实现是否能支持C1000K的连接,我们来测试一下吧!

github完整代码
突破1024(调整最大文件描述符数量)
1台客户端压测虽然有65535的限制,期初先测试1W看看是否正常。在服务端正常启动后,客户端启动传入1W压测1W连接。发现客户端和服务端都在大概1024个连接后报错:open file many。

好了,我们遇到了服务器调优的第一个瓶颈:操作系统最大文件符限制,执行ulimit -n查看发现默认的进程的最大打开文件描述符为1024,和报错信息时的连接数一致。

🚑解决办法

⭐️ 系统最大打开文件描述符数:/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

突破1W(更换Spring的Web容器)
服务端和客户端操作系统增加了文件描述符后,成功突破了1024个连接数,但是在连接数达到1W时,服务端出现假死状态,任何http请求查询无响应。观察服务端内存,负载情况都没问题。

⭐️ 操作系统还是应用程序问题

期初一直怀疑是服务器有问题,怀疑是否是操作系统参数没调好导致的。所以就尝试在连接数达到1W后,在服务器上用curl http:127.0.0.1:8080/msg-center访问,发现请求照样阻塞,神奇的是,当客户端连接减少1个到9999时,其他请求就能打入。这种情况基本确定是某配置导致的,不是操作系统和应用程序出现了瓶颈。

另外使用tcpdump -i lo抓取本地回环网卡的包,发现在1W连接时虽然执行curl http:127.0.0.1:8080/msg-center阻塞,但是TCP三次握手是正常的,所以排除操作系统问题,基本确定是应用程序的问题。

07:00:18.997400 IP localhost.35750 > localhost.webcache: Flags [S], seq 1878590917, win 43690, options [mss 65495,sackOK,TS val 1152127886 ecr 0,nop,wscale 6], length 0
07:00:18.997412 IP localhost.webcache > localhost.35750: Flags [S.], seq 4293714672, ack 1878590918, win 43690, options [mss 65495,sackOK,TS val 1152127886 ecr 1152127886,nop,wscale 6], length 0
07:00:18.997421 IP localhost.35750 > localhost.webcache: Flags [.], ack 1, win 683, options [nop,nop,TS val 1152127886 ecr 1152127886], length 0
07:00:18.997485 IP localhost.35750 > localhost.webcache: Flags [P.], seq 1:89, ack 1, win 683, options [nop,nop,TS val 1152127886 ecr 1152127886], length 88: HTTP: GET /msg-center HTTP/1.1
07:00:18.997489 IP localhost.webcache > localhost.35750: Flags [.], ack 89, win 683, options [nop,nop,TS val 1152127886 ecr 1152127886], length 0

⭐️ 排查服务端Java程序

执行jstat -gc pid观察内存使用情况,发现内存使用正常,GC正常,没有负载,没有问题。
执行jstack pid查看线程状态,发现在连接数达到1W时有大量的tomcat线程处于WAITING状态,其中一段有关tomcat的Acceptor的countUpOrAwaitConnection方法,看名字有点像接收器,难道是这个停止了吗?
查看源码,done,找到问题,tomcat默认最大连接数是10000

private int maxConnections = 10000;
protected LimitLatch initializeConnectionLatch() {
if (maxConnections==-1) return null;
if (connectionLimitLatch==null) {
connectionLimitLatch = new LimitLatch(getMaxConnections());
}
return connectionLimitLatch;
}

🚑解决办法

在application.yml中修改tomcat最大连接数server.tomcat.max-connections: 1000000。
或者更换Spring默认的web容器为undertow,修改pom文件。 <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>

突破3W(增加操作系统端口范围)
修改了Spring的web容器为undertow,连接数突破了1W的限制,在望更高的链接数压测时,链接大概达到3W的时候,客户端报错,看信息是客户端端口申请不到了。

Caused by: java.net.BindException: Cannot assign requested address
at sun.nio.ch.Net.connect0(Native Method)
at sun.nio.ch.Net.connect(Net.java:454)
at sun.nio.ch.Net.connect(Net.java:446)
at sun.nio.ch.UnixAsynchronousSocketChannelImpl.implConnect(UnixAsynchronousSocketChannelImpl.java:326)
at sun.nio.ch.AsynchronousSocketChannelImpl.connect(AsynchronousSocketChannelImpl.java:199)
at org.apache.tomcat.websocket.WsWebSocketContainer.connectToServerRecursive(WsWebSocketContainer.java:305)
... 4 more

🚑解决办法

我们知道服务器端口是从0~65535,但是操作系统会预留很多端口出来,我们这里减少操作系统的预留端口即可。

vim /etc/sysctl.conf
net.ipv4.ip_local_port_range = 1024 65535
sysctl -p

突破4W(调整JVM进程内存大小)
连接数达到4W时,客户端/服务端会报错OOM,通过jstat -gc pid观察Java进程,老年代GC特别频繁,堆内存不够用了。

🚑解决办法

加大JVM内存即可

java -Xmx10G -Xms8G -XshowSettings:vm -jar benchmark-1.0-SNAPSHOT.jar 70000 139.217.99.53:8080

突破6W(客户端增加虚拟IP)
虽然外面在突破3W内容中增大了客户端的端口范围,但是单客户端端口上线为65535,在压满后照样会报错Cannot assign requested address,如果朝着服务端支持100W连接压测,那就需要10多台客户机才行。

🚑解决办法

可以增加更多的网卡,也可以使用虚拟IP来实现。比如可以使用命令增加20个IP地址,那么客户端就可以发起100多W条连接,可以使用下面命令给某网卡增加虚拟ip:

ifconfig eth0:0 192.168.10.10 netmask 255.255.255.0 up
ifconfig eth0:1 192.168.10.11 netmask 255.255.255.0 up
ifconfig eth0:2 192.168.10.12 netmask 255.255.255.0 up

突破7W(调整服务端nf_conntrack_max)
当连接数达到6W多的时候, 客户端压测程序开始报错连接失败: The HTTP request to initiate the WebSocket connection failed。服务端日志无报错,使用curl http:127.0.0.1:8080/msg-center发现同样无响应,但是不同的是使用tcpdump没有抓到tcp握手的包。怀疑是从操作系统层面把连接丢弃了。

使用dmesg命令查看系统信息,发现有大量的nf_conntrack: table full, dropping packet日志,问题很明显,nf_conntrack模块报错丢弃了包。

🚑解决办法

vim /etc/sysctl.conf增加一行 net.nf_conntrack_max = 2000000,执行sysctl -p生效。

有些操作系统没有启动nf_conntrack模块,则不会遇到该问题。

突破50W(调整tcp socket参数)
突破了7W的连接,本以为后面一番风顺,可以一直压了,大概到50W的时候,又出现了服务端假死的情况,同样适用dmesg命令查看,发现出现大量TCP: too many of orphaned sockets的错误,错误显示tcp socket过多。

🚑解决办法

这时候需要调整tcp socket参数了,关于参数的说明可以google学习。

echo "net.ipv4.tcp_mem = 786432 2097152 3145728">> /etc/sysctl.conf
echo "net.ipv4.tcp_rmem = 4096 4096 16777216">> /etc/sysctl.conf
echo "net.ipv4.tcp_wmem = 4096 4096 16777216">> /etc/sysctl.conf

执行sysctl -p生效,大功告成!

调优总览
从1024到100W其实调整的参数也就没几个,简单总结一下。

更换Spring的Web容器为undertow。
服务端和客户端。 echo "* - nofile 1048576" >> /etc/security/limits.conf
echo "fs.file-max = 1048576" >> /etc/sysctl.conf
echo "net.ipv4.ip_local_port_range = 1024 65535" >> /etc/sysctl.conf
echo "net.nf_conntrack_max = 2000000" >> /etc/sysctl.conf

echo "net.ipv4.tcp_mem = 786432 2097152 3145728" >> /etc/sysctl.conf
echo "net.ipv4.tcp_rmem = 4096 4096 16777216" >> /etc/sysctl.conf
echo "net.ipv4.tcp_wmem = 4096 4096 16777216" >> /etc/sysctl.conf

增加服务端和客户端JVM的内存大小。
总结
到此,服务端通过调优,单体推送中心支持了百万级的连接,实际公司目前还没有这么大的规模。目前并没有测试百万连接下,推送数据,心跳保持是否正常。后续会测试再完善。

推送中心到此,设计和性能压测都已经完毕,但到现在为止还是单体的。那么推送中心如何做集群,集群模式下怎样共享数据,怎样高可用,怎样实现横向扩展,下章节会分享。

推送中心地址