第八章 一台机器最多能支持多少条 TCP 连接
8.1 相关实际问题
在网络开发中,很多同学对一个基础问题始终是没有彻底搞明白,那就是一台机器最多能支持多少条 TCP 连接。不过由于客户端和服务器端对端口的使用方式的不同,所以这个问题拆开来理解更容易一下。
注意这里说的客户端和服务器都只是角色,并不是指的具体某台机器。例如对于 PHP 接口机来说,当它响应来自客户端的请求的时候,它就是服务器。当它向 MySQL 请求数据的时候,它又变成了客户端。
1)“Too many open files” 报错是怎么回事,该如何解决?
你的服务进程在线上可能遭遇过“Too many open files”这个错误,那么你理解这个报错发生的原理吗?如果让你修复这个错误,你该如何操作呢?
2)一台服务器最大究竟能支持多少个连接?
因为我们这里要考虑的是最大数,因此先不考虑连接上的数据收发和处理,仅仅只考虑 ESTABLISH 状态的空连接。那么一台服务器上最大可以支持多少条 TCP 连接。这个连接数会受那些因素影响?
3)一台客户端最大能发起多少个网络连接?
和服务器端不同的是,客户端每次建立一条连接都需要消耗一个端口。在 TCP 协议中,端口是一个两个字节的整数,因此范围只能是 0 ~ 65535。那么客户端最大只能支持 65535 条连接吗?有没有办法突破这个限制,有的话都有哪几种办法?
4)做一个长连接推送产品,支持 1 亿用户需要多少台机器?
假设你是一个架构师,现在老板给你一个需求,让你做一个类似友盟 upush 这样的产品。要在服务器上保持一个和客户端的长连接,绝大部分情况下连接都是空闲的,每天也就顶多推送个 2 ~ 3 次左右。总用户规模预计是一个亿。那么现在请你来评估以下你需要多少台服务器可以支撑这 1 亿条的长连接。
带着这几个问题,让我们开始本章的学习。
8.2 理解 Linux 最大文件描述符限制
大家一定都听说过,Linux/Unix 下的哲学核心思想是一切皆文件。这其中的一切当然也包括我们在 TCP 连接中提到的 Socket。在第六章和第七章中我们看到了,进程在打开一个 socket 的时候需要申请好几个内核对象,换一句直白的话就是打开文件对象吃内存。所以 Linux 系统出于安全角度的考虑,在多个位置都限制了可打开的文件描述符的数量。那么既然我们本章中想要讨论单机最大并发连接数,那一定绕不开对 Linux 最大文件数的限制机制的讨论。
如果触发了这个限制机制的话,你的应用程序遇到的就是常见的 “Too many open files” 这个错误。在 Linux 上,限制打开文件数的内核参数包含以下三个:fs.nr_open、nofile 和 fs.file-max。想要加大可打开文件数的限制就需要涉及对这三个参数的修改。
但这几个参数里有的是进程级的、有的是系统级的、有的是用户进程级的。而且另外这几个参数还有依赖关系,如果修改的时候,稍有不慎还可能会把机器搞出问题,最严重的情况下都有可能导致你的服务器无法使用 ssh 登录。
这里分享两次飞哥遭遇的故障,还好这两次都是测试环境下,没对生产用户造成影响。
-
第一次:当时开了二十个子进程,每个子进程开启了五万个并发连接兴高采烈准备测试百万并发。结果倒霉催地忘了改
file-max了。实验刚开始没多大一会儿就开始报错 “Too many open files”。但问题是这个时候更悲催的是发现所有的命令包括ps、kill也同时无法使用了。因为它们也都需要打开文件才能工作。后来没办法,重启系统解决的。 -
另一次:重启机器完了之后发现无法 ssh 登录了。后来找运维工程部的同学报障以后才算是修复。最终发现是因为我用
echo的方式修改的fs.nr_open,但是一重启这个修改就失效了。导致hard nofile比fs.nr_open高了,系统直接无法登陆。
鉴于最大文件描述限制的重要程度和复杂性,所以今天我们花一个小节来深入理解一下 Linux 是如何限制最大文件打开数的。这只有深刻理解了它的原理,将来在应对相关问题的时候才能做到从容不迫!
8.2.1 找到源码入口
怎么把 fs.nr_open、nofile 和 fs.file-max 这三个参数的含义彻底搞明白?我想没有比把它的源码扒出来能看的更准确了。我们就拿创建 socket 来举例,首先找到 socket 系统调用的入口
//file: net/socket.c
SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
{
retval = sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
if (retval < 0)
goto out_release;
}我们看到 socket 调用 sock_map_fd 来创建相关内核对象。接着我们再进入 sock_map_fd 瞧瞧。
图 8.1 socket 的 fd 和
sock_alloc_file
为什么创建一个 socket 又要申请 fd,又要申请 sock_alloc_file 呢?我们看一个进程打开文件时的内核数据结构图就明白了,如图 8.1。
结合上图,就能轻松理解这两个函数的作用:
get_unused_fd_flags:申请 fd,这只是一个在找一个可用的数组下标而已sock_alloc_file:申请真正的 file 内核对象
//file: net/socket.c
static int sock_map_fd(struct socket *sock, int flags)
{
struct file *newfile;
//获取可用 fd 句柄号
//在这里会判断打开文件数是否超过 soft nofile 和 fs.nr_open
int fd = get_unused_fd_flags(flags);
if (unlikely(fd < 0))
return fd;
//创建 sock_alloc_file对象
//在这里会判断打开文件数是否超过 fs.file-max
newfile = sock_alloc_file(sock, flags, NULL);
if (likely(!IS_ERR(newfile))) {
fd_install(fd, newfile);
return fd;
}
put_unused_fd(fd);
return PTR_ERR(newfile);
}8.2.2 寻找进程级限制 nofile 和 fs.nr_open
接下来,我们再回到最大文件数量的判断上。这里我直接把结论抛出来。get_unused_fd_flags 中判断了 nofile 和 fs.nr_open。如果超过了这两个参数,就会报错。请看!
在 get_unused_fd_flags 中,调用了 rlimit(RLIMIT_NOFILE)。这个是读取 limits.conf 中配置的 soft nofile,代码如下:
//file: fs/file.c
int get_unused_fd_flags(unsigned flags)
{
// RLIMIT_NOFILE 是 limits.conf 中配置的 nofile
return __alloc_fd(
current->files,
0,
rlimit(RLIMIT_NOFILE),
flags
);
}通过当前进程描述访问到 rlim[RLIMIT_NOFILE],这个对象的 rlim_cur 是 soft nofile(rlim_max 对应 hard nofile)。紧接着让我们进入 __alloc_fd() 中来:
//file: include/linux/sched.h
static inline unsigned long task_rlimit(const struct task_struct *tsk,
unsigned int limit)
{
return ACCESS_ONCE(tsk->signal->rlim[limit].rlim_cur);
}//file: include/uapi/asm-generic/errno-base.h
#define EMFILE 24 /* Too many open files */
int __alloc_fd(struct files_struct *files,
unsigned start, unsigned end, unsigned flags)
{
...
error = -EMFILE;
//看要分配的文件号是否超过 end(limits.conf 中的 nofile)
if (fd >= end)
goto out;
error = expand_files(files, fd);
if (error < 0)
goto out;
...
}在 __alloc_fd() 中会判断要分配的句柄号是不是超过了 limits.conf 中 nofile 的限制。fd 是当前进程相关的,是一个从 0 开始的整数。如果超限,就报错 EMFILE(Too many open files)。
这里注意个小细节,那就是进程里的 fd 是一个从 0 开始的整数。只要确保分配出去的 fd 编号不超过 limits.conf 中的 nofile,就能保证该进程打开的文件总数不会超过这个数。
接着我们看到调用又会进入 expand_files:
static int expand_files(struct files_struct *files, int nr)
{
//2. 判断打开文件数是否超过 fs.nr_open
if (nr >= sysctl_nr_open)
return -EMFILE;
}在 expand_files 我们看到,又到 nr(就是 fd 编号)和 fs.nr_open 相比较了。超过这个限制,返回错误 EMFILE(Too many open files)。
由上可见,无论是和 fs.nr_open,还是和 soft nofile 比较,都用的是当前进程的文件描述符序号在比较的,所以这两个参数都是进程级别的。
有意思的是和这两个参数的比较几乎是前后脚进行的,所以它们的作用也基本一样。Linux 之所以分两个参数来控制,那是因为 fs.nr_open 是系统全局的,而 soft nofile 则可以分用户来分别控制。
所以,现在我们可以得出第一个结论。
结论1:
soft nofile和fs.nr_open的作用一样,它们都是限制的单个进程的最大文件数量。区别是soft nofile可以按用户来配置,而fs.nr_open所有用户只能配一个。
8.2.3 寻找系统级限制 fs.file-max
我们再回过头来看 sock_map_fd 中调用的另外一个函数 sock_alloc_file,在这个函数里我们发现它会和 fs.file-max 这个系统参数来比较。用啥比的呢?
//file: fs/file_table.c
struct file *sock_alloc_file(struct socket *sock, int flags, const char *dname)
{
file = alloc_file(&path, FMODE_READ | FMODE_WRITE,
&socket_file_ops);
}
struct file *alloc_file(struct path *path, fmode_t mode,
const struct file_operations *fop)
{
file = get_empty_filp();
...
}
struct file *get_empty_filp(void)
{
//files_stat.max_files 就是 fs.file-max 参数
if (get_nr_files() >= files_stat.max_files
&& !capable(CAP_SYS_ADMIN) //注意这里 root 账号并不受限制
) {
}
}可见是用 get_nr_files() 来和 fs.file-max 来比较的。根据该函数的注释我们能看到它是当前系统打开的文件描述符总量。如下:
/*
* Return the total number of open files in the system
*/
static long get_nr_files(void)
{
...
}另外注意下 !capable(CAP_SYS_ADMIN) 这行。看完这句,我才恍然大悟,原来 file-max 这个参数只限制非 root 用户。开篇中我提到的文件打开过多时无法使用 ps,kill 等命令,是因为我用的非 root 账号操作的。哎,下次再遇到这种文件直接用 root 去 kill 就行了。之前竟然丢脸地采用了重启机器大法……
所以现在我们可以得出另一个结论了。
结论2:
fs.file-max:整个系统上可打开的最大文件数,但不限制 root 用户。
8.2.4 小节总结
我们总结一下,其实在 Linux 上能打开多少个文件,限制有两种:
第一种,进程级别的,限制的是单个进程上可打开的文件数。具体参数是 soft nofile 和 fs.nr_open。它们两个的区别是 soft nofile 可以不同用户配置不同的值。而 fs.nr_open 在一台 Linux 上只能配一次。
第二种,系统级别的,整个系统上可打开的最大文件数,具体参数是 fs.file-max。但是这个参数不限制 root 用户。
另外这几个参数之间还有耦合关系,因此还要注意以下三点:
-
如果你想加大
soft nofile,那么hard nofile也需要一起调整。因为如果hard nofile设置的低,你的soft nofile设置的再高都没用,实际生效的值会按二者里最低的来。 -
如果你加大了
hard nofile,那么fs.nr_open也都需要跟着一起调整。如果不小心把hard nofile设置的比fs.nr_open大了,后果比较严重。会导致该用户无法登陆。如果设置的是*的话,那么所有的用户都无法登陆。 -
还要注意如果你加大了
fs.nr_open,但是用的是echo "xx" > ../fs/nr_open的方式,刚改完你可能觉得没问题。只要机器一重启你的fs.nr_open设置就会失效,还是会无法登陆。所以非常不建议用echo的方式修改内核参数。
假如你想让你的进程可以打开 100 万个文件描述符,我用修改 conf 文件的方式给出一个建议。如果日后你的工作中有这样的需求的话,可以把它作为参考。
# vi /etc/sysctl.conf
fs.file-max=1100000 //系统级别设置成 110 W,多留点 buffer.
fs.nr_open=1100000 //进程级别也设置成 110 W,因为要保证比 hard nofile 大
# sysctl -p
# vi /etc/security/limits.conf
//用户进程级别都设置成 100W
* soft nofile 1000000
* hard nofile 10000008.3 一台服务器最多可以支撑多少条 TCP 连接?
在网络开发中,我发现有很多同学对一个基础问题始终是没有彻底搞明白。那就是一台服务器最大究竟能支持多少个网络连接?很多同学看到这个问题的第一反应是 65535。原因是:“听说端口号最多有 65535 个,那长连接就最多保持 65535 个了”。是这样的吗?还有的人说:“应该受 TCP 连接里四元组的取值空间大小限制!”如果你对这个问题也是理解的不够彻底,那么今天讲个故事讲给你听!
8.3.1 一次关于服务器端并发的聊天
“TCP 连接四元组是源IP地址、源端口、目的IP地址和目的端口。任意一个元素发生了改变,那么就代表的一条完全不同的连接了。拿我的 Nginx 举例,它的端口是固定使用 80。另外我的 IP 也是固定的,这样目的 IP 地址、目的端口都是固定的。剩下源 IP 地址、源端口是可变的。所以理论上我的 Nginx 上最多可以建立 2 的 32 次方(ip 数)× 2 的 16 次方(port 数)个连接。这是两百多万亿的一个大数字!!”
“进程每打开一个文件(linux 下一切皆文件,包括 socket),都会消耗一定的内存资源。如果有不怀好心的人启动一个进程来无限的创建和打开新的文件,会让服务器崩溃。所以 linux 系统出于安全角度的考虑,在多个位置都限制了可打开的文件描述符的数量,包括系统级、用户级、进程级。这三个限制的含义和修改方式如下:”
- 系统级:当前系统可打开的最大数量,通过
fs.file-max参数可修改 - 用户级:指定用户可打开的最大数量,修改
/etc/security/limits.conf - 进程级:单个进程可打开的最大数量,通过
fs.nr_open参数可修改
“我的接收缓存区大小限制是可以通过一组内核参数配置的,通过 sysctl 命令就可以查看和修改。”
$ sysctl -a | grep rmem
net.ipv4.tcp_rmem = 4096 87380 8388608
net.core.rmem_default = 212992
net.core.rmem_max = 8388608“TCP 分配发送缓存区的大小受参数 net.ipv4.tcp_wmem 等另外一组参数来控制。”
8.3.2 服务端百万连接达成记
$ sysctl -a | grep wmem
net.ipv4.tcp_wmem = 4096 65536 8388608
net.core.wmem_default = 212992
net.core.wmem_max = 8388608准备啥呢,还记得前面说过 Linux 对最大文件对象数量有限制,所以要想完成这个实验,得在用户级、系统级、进程级等位置把这个上限加大。我们实验目的是 100 W,这里都设置成 110 W,这个很重要!因为得保证做实验的时候其它基础命令例如 ps,vi 等是可用的。
活动连接数量确实达到了 100 W:
$ ss -n | grep ESTAB | wc -l
1000024当前机器内存总共是 3.9 GB,其中内核 Slab 占用了 3.2 GB 之多。MemFree 和 Buffers 加起来也只剩下 100 多 MB 了:
$ cat /proc/meminfo
MemTotal: 3922956 kB
MemFree: 96652 kB
MemAvailable: 6448 kB
Buffers: 44396 kB
......
Slab: 3241244KB kB通过 slabtop 命令可以查看到 dentry、filp、sock_inode_cache、TCP 四个内核对象都分别有 100 W 个:
# slabtop
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
1008200 1008176 99% 0.19K 50410 20 201640K dentry
1004360 1004156 99% 0.19K ```bash
1004360 1004156 99% 0.19K 50218 20 200872K filp
1000215 1000210 99% 0.69K 200043 5 800172K sock_inode_cache
1000040 1000038 99% 1.62K 250010 4 2000080K TCP
25088 24433 97% 0.03K 224 112 896K size-32NOTE
本文中这台服务器是一台 4 GB 内存的虚机服务器,内核版本是 2.6.32.如果你手头的环境实验结果和这个不一致也不要惊慌,不同内核版本的 Socket 内核对象的确会存在一些差异.
8.3.3 小节总结
互联网后端的业务特点之一就是高并发.但是一台服务器最大究竟能支持多少个 TCP 连接,这个问题似乎却又在困惑着很多同学.
TCP 连接四元组是由源 IP 地址、源端口、目的 IP 地址和目的端口构成.当四元组中任意一个元素发生了改变,那么就代表的一条完全不同的新连接.因此从这个四元组理论上来计算的话,每个 server 可以接收的连接数量上限就是两百多万亿的一个大数.但是每条 TCP 连接即使是在无数据传输的空闲状态下,也是会消耗 3 KB 多的内存.所以,一台服务器的最大连接数总量受限于服务器的内存.
另外就是我们讨论的最大连接数只是在空连接状态下的.实际的业务中,每条连接上有数据的收发也需要消耗内存.另外就是每个连接上的业务处理逻辑有轻有重,也不太一致.
希望今天过后,你能够将这个问题彻底拿下!
8.4 一台客户端最多只能发起 65535 条连接吗?
上一节中我们以故事的形式讨论了一台服务器最多的 TCP 连接数,本节中我们来聊聊客户端.在 TCP 连接中客户端角色和服务器不同的是,每发起一条连接都需要消耗一个端口.而端口号在 TCP 协议中是一个 16 比特的整数,取值范围是 0 ~ 65535.那是不是说明一台客户端机就只能发起最大 65535 个 TCP 连接了呢? 让我们进入另外一个故事中来寻找答案!
8.4.1 65535 的束缚
echo "5000 65000" > /proc/sys/net/ipv4/ip_local_port_range连接1: 192.168.1.101 5000 192.168.1.100 8090
连接2: 192.168.1.101 5001 192.168.1.100 8090
连接N: 192.168.1.101 ... 192.168.1.100 8090
连接6W:192.168.1.101 65000 192.168.1.100 8090
8.4.2 多 IP 增加连接数
NOTE
limits.conf中的hard limit不能超过nr_open参数,否则启动的时候会有问题.
# vi /etc/sysctl.conf
fs.file-max=210000 //系统级
fs.nr_open=210000 //进程级
# sysctl -p
# vi /etc/security/limits.conf
* soft nofile 200000
* hard nofile 2000008.4.3 端口复用增加连接数
TIP
在知识星球中我们会进行内核等底层技术的视频串讲,还会进行线上问题排查以及性能优化等方面的交流.对大家技术深度和广度的积累很有好处.有想继续加入知识星球的同学微信扫描下面的二维码即可加入.另外在公众号后台发送「星球优惠券」可以获取开发内功修炼读者的专属优惠券.
(第一部分完,后续内容见下一部分)
9. 第八章 一台机器最多能支持多少条TCP连接
公众号后台发送「星球优惠券」可以获取开发内功修炼读者的专属优惠券.
8.4.2 多 IP 增加连接数
注意
limits.conf中的 hard limit 不能超过nr_open参数,否则启动的时候会有问题.
# vi /etc/sysctl.conf
fs.file-max=210000 //系统级
fs.nr_open=210000 //进程级
# sysctl -p
# vi /etc/security/limits.conf
* soft nofile 200000
* hard nofile 2000008.4.3 端口复用增加连接数
socket 中有一个主要的数据结构 sock_common,在它里面有两个联合体.
其中 skc_addrpair 记录的是 TCP 连接里的 IP 对儿,skc_portpair 记录的是端口对儿.
// file: include/net/sock.h
struct sock_common {
union {
__addrpair skc_addrpair; //TCP连接IP对儿
struct {
__be32 skc_daddr;
__be32 skc_rcv_saddr;
};
};
union {
__portpair skc_portpair; //TCP连接端口对儿
struct {
__be16 skc_dport;
__u16 skc_num;
};
};
......
}图8.2 网络包接收过程
在网络包到达网卡之后,依次经历 DMA、硬中断、软中断等处理,最后被送到 socket 的接收队列中了.
对于 TCP 协议来说,协议处理的入口函数是 tcp_v4_rcv.我们看一下它的代码
// file: net/ipv4/tcp_ipv4.c
int tcp_v4_rcv(struct sk_buff *skb)
{
......
th = tcp_hdr(skb); //获取tcp header
iph = ip_hdr(skb); //获取ip header
sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);
......
}// file: include/net/inet_hashtables.h
static inline struct sock *__inet_lookup(struct net *net,
struct inet_hashinfo *hashinfo,
const __be32 saddr, const __be16 sport,
const __be32 daddr, const __be16 dport,
const int dif)
{
u16 hnum = ntohs(dport);
struct sock *sk = __inet_lookup_established(net, hashinfo,
saddr, sport, daddr, hnum, dif);
return sk ? : __inet_lookup_listener(net, hashinfo, saddr, sport,
daddr, hnum, dif);
}先判断有没有连接状态的 socket,这会走到 __inet_lookup_established 函数中
struct sock *__inet_lookup_established(struct net *net,
struct inet_hashinfo *hashinfo,
const __be32 saddr, const __be16 sport,
const __be32 daddr, const u16 hnum,
const int dif)
{
//将源端口、目的端口拼成一个32位int整数
const __portpair ports = INET_COMBINED_PORTS(sport, hnum);
......
//内核用hash的方法加速socket的查找
unsigned int hash = inet_ehashfn(net, daddr, hnum, saddr, sport);
unsigned int slot = hash & hashinfo->ehash_mask;
struct inet_ehash_bucket *head = &hashinfo->ehash[slot];
begin:
//遍历链表,逐个对比直到找到
sk_nulls_for_each_rcu(sk, node, &head->chain) {
if (sk->sk_hash != hash)
continue;
if (likely(INET_MATCH(sk, net, acookie,
saddr, daddr, ports, dif))) {
if (unlikely(!atomic_inc_not_zero(&sk->sk_refcnt)))
goto begintw;
if (unlikely(!INET_MATCH(sk, net, acookie,
saddr, daddr, ports, dif))) {
sock_put(sk);
goto begin;
}
goto out;
}
}
}
在 INET_MATCH 中将网络包 tcp header 中的 __saddr、__daddr、__ports 和 Linux 中的 socket 中 inet_portpair、inet_daddr、inet_rcv_saddr 进行对比.如果匹配 socket 就找到了.当然除了 ip 和端口,INET_MATCH 还比较了其它一些东东,所以 TCP 还有五元组、七元组之类的说法.
// include/net/inet_hashtables.h
#define INET_MATCH(__sk, __net, __cookie, __saddr, __daddr, __ports, __dif) \
((inet_sk(__sk)->inet_portpair == (__ports)) && \
(inet_sk(__sk)->inet_daddr == (__saddr)) && \
(inet_sk(__sk)->inet_rcv_saddr == (__daddr)) && \
(!(__sk)->sk_bound_dev_if || \
((__sk)->sk_bound_dev_if == (__dif))) && \
net_eq(sock_net(__sk), (__net)))再看 slabtop 输出的内核对象明细.
# cat /etc/redhat-release
CentOS Linux release 7.6.1810 (Core)
# uname -a
Linux hbhly_SG11_130_50 3.10.0-957.el7.x86_6 ......
# ss -ant | grep ESTAB |wc -l
1000013
# cat /proc/meminfo
MemTotal: 8009284 kB
MemFree: 3279816 kB
MemAvailable: 4318676 kB
Buffers: 7172 kB
Cached: 538996 kB
......
Slab: 3526808 kB
# slabtop
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
1357062 1357062 100% 0.19K 64622 21 258488K dentry
1112064 1110997 99% 0.06K 17376 64 69504K kmalloc-64
1003456 1003202 99% 0.25K 62716 16 250864K kmalloc-256
1000152 1000152 100% 0.62K 83346 12 666768K sock_inode_cache
1000032 1000032 100% 1.94K 62502 16 2000064K TCP
343836 343836 100% 0.64K 28653 12 229224K proc_inode_cache8.4.4 小节总结
客户端每建立一个连接就要消耗一个端口,所以很多同学当看到客户端机器上连接数一旦超过 3W、5W 就紧张的不行,总觉得机器要出问题了.
通过源码来看:TCP 连接就是在客户机、服务器上的一对儿的 socket.它们都在各自内核对象上记录了双方的 ip 对儿、端口对儿(也就是我们常说的四元组),通过这个在通信时找到对方.
TCP 连接发送方在发送网络包的时候,会把这份信息复制到 IP Header上.网络包带着这份信物穿过互联网,到达目的服务器.目的服务器内核会按照 IP 包 header 中携带的信物(四元组)去匹配找到正确的 socket (连接).
在这个过程里我们可以看到,客户端的端口只是这个四元组里的一元而已.哪怕两条连接用的是同一个端口号,只要客户端 ip 不一样,或者是服务器不一样都不影响内核正确寻找到对应的连接,而不会串线!
所以在客户端增加 TCP 最大并发能力有两个方法.第一个办法,为客户端配置多个 ip.第二个办法,连接多个不同的 server.
不过这两个办法最好不要混用.因为使用多 IP 时,客户端需要 bind.一旦 bind 之后,内核建立连接的时候就不会选择用过的端口了.bind 函数会改变内核选择端口的策略~~
最后我们实验证明了客户端也可以突破百万的并发量级.相信读过此文的你,以后再也不必再惧怕 65535 这个数字了.
8.5 单机百万并发连接的动手实验
俗话说的好,百看不如一练
如果你能亲手在服务器上测试出百万条连接,相信会理解更深.只有动手实践过,很多东西才能真正掌握.根据金字塔学习理论,实践要比单纯的阅读效率要高好几倍.和前文相对,测试百万连接我用到的方案有两种,在本小节中我来分享详细的实验过程.
第一种是服务器端只开启一个进程,然后使用很多个客户端 ip 来连接 第二种是服务器开启多个进程,这样客户端就可以只使用一个 ip 即可
为了能让大部分同学都能用最低的时间成本达成百万连接结果,飞哥提供了 c、java、php 三种版本的源码.两个方案对应的代码关注飞哥的公众号「开发内功修炼」,在后台回复「配套源码」后获取.
整个实验做起来还是有点小复杂,本文会配合从头到尾讲述每一个试验步骤,让大家动手起来更轻松.本文描述的步骤适用于任意一种语言.建议大家有空都动手试试.另外由于实验步骤比较多,任意一个环节都有可能会出现 问题,遇到问题不要慌,解决它就是了.飞哥当年自己在做这个实验的时候花了有两个星期左右,虽然我把实验源码和步骤都提炼好了,但你最好也不要指望你能一把就做通过.
8.5.1 方案一 多 IP 客户端发起百万连接
本文实验需要准备两台机器.一台作为客户端,另一台作为服务器.如果你选用的是 c 或者 php 源码,这两台机器内存只要大于 4GB 就可以. 如果使用的是 Java 源码,内存要大于 6 GB.对 cpu 配置无要求,哪怕只有 1 个核都够用.
本方案中采用的方法是在一台客户端机器上配置多个 ip 的方式来发起所有的 tcp 连接请求.所以需要为你的客户端准备 20 个 IP,而且要确保这些 IP 在内网环境中没有被其它机器使用.如果实在选不出这些 IP,那么可以直接跳到 8.5.2 小节中的方案 2.
除了用 20 个 IP 以外,也可以使用 20 台客户端.每个客户端发起 5 万个连接同时来连接这一个 server.但是这个方 法实际操作起来太困难了.
客户端机和服务器分别下载指定源码,然后进入 chapter-08/8.5/test-01 目录,再选择一个自己擅长的语言.我用 Makefile 封装了编译和运行的命令,所以不管你选用何种语言,下面的实验步骤的描述都是适用的.
客户端准备
调整客户端可用端口范围
默认情况下,Linux 只开启了 3 万多个可用端口.但我们今天的实验里,客户端一个进程要达到 5 万的并发.所以,端口范围的内核参数需要修改.
执行 sysctl -p 使之生效.
调整客户端最大可打开文件数
我们要测试百万并发,所以客户端的系统级参数 fs.file-max 需要加大到 100 万.另外 Linux 上还会存在一些其它的进程要使用文件,所以我们需要多打一些余量出来,直接设置到 110 万.
对于进程级参数 fs.nr_open 来说,因为我们开启 20 个进程来测,所以它设置到 60000 就够了.这些都在 /etc/sysctl.conf 中修改.
# vi /etc/sysctl.conf
net.ipv4.ip_local_port_range = 5000 65000# vi /etc/sysctl.conf
fs.file-max=1100000
fs.nr_open=60000
# sysctl -p
# sysctl -a
fs.file-max = 1100000
fs.nr_open = 60000sysctl -p 使得设置生效.并使用 sysctl -a 查看是否真正 work.
接着再加大用户进程的最大可打开文件数量限制(nofile).这两个是用户进程级的,可以按不同的用户来区分配置. 这里为了简单,就直接配置成所有用户 * 了.每个进程最大开到 5 万个文件数就够了.同样预留一点余地,所以设置成 55000. 这些是在 /etc/security/limits.conf 文件中修改.
注意
hard nofile 一定要比
fs.nr_open要小,否则可能导致用户无法登陆.
配置完后,开个新控制台即可生效. 使用 ulimit 命令校验是否生效成功.
# vi /etc/security/limits.conf
* soft nofile 55000
* hard nofile 55000# ulimit -n
55000为客户端配置额外 20 个 IP
假设可用的 ip 分别是 CIP1,CIP2,…,CIP20,你也知道你的子网掩码.
注意
这 20 个 ip 必须不能和局域网 的其它机器冲突,否则会影响这些机器的正常网络包的收发.
在客户端机器上下载的源码目录 chapter-08/8.5/test-01 下特定语言的目录中,找到 tool.sh.修改该 shell 文件,把 IPS 和 NETMASK 都改成你真正要用的.
修改完后为了确保局域网内没有这些 ip,最好先执行代码中提供的一个小工具来验证一下
当所有的 ip 的 ping 结果均为 false 时,进行下一步真正配置 ip 并启动网卡.
# make ping
# make ifup使用 ifconfig 命令查看 ip 是否配置成功.
# ifconfig
eth0
eth0:0
eth0:1
...
eth:19清理各种 Cache
操作系统在运行的过程中,会生成很多的内核对象 Cache.因为我们实验做成后要观察内核对象消耗,所以最好把这些 Cache 都清理一下.使用如下命令清理 pagecache, dentries 和 inodes 这些缓存.
# echo "3" > /proc/sys/vm/drop_caches服务器准备
服务器最大可打开文件句柄调整
服务器系统级参数 fs.file-max 也直接设置成 110 万. 另外由于这个方案中服务器是用单进程来接收客户端所有的连接的,所以进程级参数 fs.nr_open, 也一起改成 110 万.
# vi /etc/sysctl.conf
fs.file-max=1100000
fs.nr_open=1100000
# sysctl -psysctl -p 使得设置生效.并使用 sysctl -a 验证是否真正生效.
接着再加大用户进程的最大可打开文件数量限制(nofile),也需要设置到 100 万以上.
# vi /etc/security/limits.conf
* soft nofile 1010000
* hard nofile 1010000配置完后,开个新控制台即可生效. 使用 ulimit 命令校验是否成功生效.
服务器全连接队列调整
在很多服务器上,全连接队列的默认长度控制参数 net.core.somaxconn 只有 128.这会导致在实验过程中发生握手丢包,然后客户端收不到 ack 就会超时重传.当超时重传发生的时候,由于定时器都是秒级别的,所以会导致握手特别的慢.虽然在代码中我设置了 server listen 时传入的 backlog 为 1024,但是如果 net.core.somaxconn 太小的话,代码中的设置就不会生效.所以我们也要修改一下服务器上的 net.core.somaxconn.
# vi /etc/sysctl.conf
net.core.somaxconn = 1024
# sysctl -p清理各种 Cache
同样为了后续观察服务器内核对象消耗,清理 pagecache, dentries 和 inodes 这些缓存.
# echo "3" > /proc/sys/vm/drop_caches开始实验
ip 配置完成后,可以开始实验了.
在服务端中的 tool.sh 中可以设置服务器监听的端口,默认是 8090.启动 server
# make run-srv使用 netstat 命令确保 server 监听成功.
# netstat -nlt | grep 8090
tcp 0 0.0.0.0:8090 0.0.0.0:* LISTEN在客户端的 tool.sh 中设置好服务器的 ip 和端口.然后开始连接
# make run-cli同时,另启一个控制台.使用 watch 命令来实时观测 ESTABLISH 状态连接的数量.
# watch "ss -ant | grep ESTABLISH"实验过程中不会一帆风顺,可能会有各种意外情况发生.
这个实验我前前后后至少花了有一周时间,所以你也不要第一次不成功就气馁.遇到问题根据错误提示看下是哪里不对.然后调整一下,重新做就是了. 重做的时候需要重启客户端和服务器.
例如有的同学可能碰到实验的时候连接非常慢的问题,这个问题发生的原因是因为你的客户端和服务器离的太近了.连接太快导致全连接队列溢出丢包.一旦握手发生丢包,就需要依赖重传定时器.而重传定时器的过期时间单位都是几秒的,所以会很慢.如果你忘了改 net.core.somaxconn ,那全连接队列默认可能只有 128,极容易满.如果改了还有问题,那就再加的大一些,或者在客户端的连接代码中多 sleep 几次就行了.
如果需要重新做实验:
- 服务器端是单进程的,直接
CTRL + C退出即可 - 但客户端是多进程的,杀起来稍稍有点麻烦.我提供了一个工具命令,可以杀掉所有的客户端进程
# make stop-cli对于服务器来说由于是单进程的,所以直接 ctrl + c 就可以终止服务器进程了. 如果重启发现端口被占用,那是因为操作系统还没有,等一会儿再启动 server 就行.
当你发现连接数量超过 100 万的时候,你的实验就成功了.
# watch "ss -ant | grep ESTABLISH"
1000013这个时候别忘了使用 cat proc/meminfo 和 slabtop 命令查看一下你的服务端、客户端的内存开销.
结束实验
实验结束的时候,服务器进程直接 ctrl + c 取消运行就可以.客户端由于是多进程的,可能需要手工关闭一下.
# make stop-cli最后记得取消为实验临时配置的新 ip
# make ifdown8.5.2 方案二 单 IP 客户端发起百万连接
如果不纠结于非得让一个 Server 进程达成百万连接,只要是 Linux 服务器上总共能达到就行,那么就还有另外一个方法.
那就是在服务器端的 Linux 上开启多个 server,每个 server 都监听不同的端口.然后在客户端也启动多个进程来连接.每一个客户端进程都连接不同的 server 端口.客户端上发起连接时只要不调用 bind,那么一个特定的端口是可以在不同的 server 之间复用的.
同样,实验源码也有 c、java、php 三个语言的版本.
准备好两台机器.一台作为客户端,另一台作为服务器.分别下载配套源码,并进入 chapter-08/8.5/test-02,然后再选择一个擅长的语言进行测试.
客户端准备
调整可用端口范围
同方案一,客户端机端口范围的内核参数也是需要修改的.
# vi /etc/sysctl.conf
net.ipv4.ip_local_port_range = 5000 65000执行 sysctl -p 使之生效.
客户端加大最大可打开文件数
同方案一,客户端的 fs.file-max 也需要加大到 110 万.进程级的参数 fs.nr_open 设置到 60000.
# vi /etc/sysctl.conf
fs.file-max=1100000
fs.nr_open=60000 sysctl -p 使得设置生效.并使用 sysctl -a 查看是否真正生效
客户端的 nofile 设置成 55000
# vi /etc/security/limits.conf
* soft nofile 55000
* hard nofile 55000配置完后,开个新控制台即可生效.
清理各种 Cache
同样为了后续观察客户端内核对象消耗,清理 pagecache, dentries 和 inodes 这些缓存.
# echo "3" > /proc/sys/vm/drop_caches服务器准备
服务器最大可打开文件句柄调整
同方案一,调整服务器最大可打开文件数.不过方案二服务端是分了 20 个进程,所以 fs.nr_open 改成 60000 就足够了.
# vi /etc/sysctl.conf
fs.file-max=1100000
fs.nr_open=60000
net.core.somaxconn = 1024sysctl -p 使得设置生效.并使用 sysctl -a 验证. 同 8.5.1 也修改了 net.core.somaxconn.
接着再加大用户进程的最大可打开文件数量限制(nofile),这个也是 55000.
再次提醒
hard nofile 一定要比
fs.nr_open要小,否则可能导致用户无法登陆.
# vi /etc/security/limits.conf
* soft nofile 55000
* hard nofile 55000配置完后,开个新控制台即可生效.
清理各种 Cache
同样为了后续观察客户端内核对象消耗,清理 pagecache, dentries 和 inodes 这些缓存.
# echo "3" > /proc/sys/vm/drop_caches开始实验
启动 server
# make run-srv 使用 netstat 命令确保 server 监听成功.
# netstat -nlt | grep 8090
tcp 0 0 0.0.0.0:8100 0.0.0.0:* LISTEN
tcp 0 0 0.0.0.0:8101 0.0.0.0:* LISTEN
......
tcp 0 0 0.0.0.0:8119 0.0.0.0:* LISTEN回到客户端机器,修改 tool.sh 中的服务器 ip.端口会自动从 tool.sh 中加载.然后开始连接
# make run-cli同时,另启一个控制台.使用 watch 命令来实时观测 ESTABLISH 状态连接的数量.
# watch "ss -ant | grep ESTABLISH"期间如果做失败了,需要重新开始的话,那需要先杀掉所有的进程.在客户端执行 make stop-cli ,在服务器端是执行 make stop-srv .重新执行上述步骤.
当你发现连接数量超过 100 万的时候,你的实验就成功了.
# watch "ss -ant | grep ESTABLISH"
1000013同样记得使用 cat /proc/meminfo 和 slabtop 查看一下你的服务端、客户端的内存开销.
实验结束的时候,记得在客户端用 make stop-cli 结束所有客户端进程,在服务端用 make stop-srv 结束所有服务器进程.
欢迎大家加入我的知识星球,也欢迎加入我的技术交流群 Github:https://github.com/yanfeizhang/coder-kung-fu
8.5.4 最后多扯一点
经过最近的学习,相信大家已经不会再觉得百万并发有多么的高深了.一条不活跃的 TCP 连接开销仅仅只是 3 KB 多点的内存而已.现代的一台服务器都上百 GB 的内存,如果只是说并发,单机千万(C10000K)都可以.
但并发只是描述服务器端程序的指标之一,并不是全部.在互联网服务器端应用场景里,除了一些基于长连接的 push 场景以外.其它的大部分业务里讨论并发都要和业务结合起来.抛开业务逻辑单纯地说并发多高其实并没有太大的意义.
因为在这些场景中,服务器开销大头往往都不是连接本身,而是在每条连接上的数据收发、以及请求业务逻辑处理.
这就好比作为一个开发同学,在公司内建立了和十个产品经理的业务联系.这并不代表你的并发能力真的能达到十,很有可能是 一位产品的需求就能把你的时间打满.
另外就是不同的业务之间,单纯比较并发也不一定有意义.
假设同样的服务器配置,单机能支撑 1 万并发,B 业务只能撑 1 千.这也并不一定就说明 A 业务的性能比 B 业务好.因为 B 业务的请求处理逻辑可能是相当的复杂,比如要进行复杂的压缩、加解密.而 A 业务的处理很简单内存读取个变量就返回了.
扩展说一下,本文配套代码中仅仅只是作为测试使用,所以写的比较简单.是直接阻塞式地 accept,将接收过来的新连接也雪藏了起来,并没有读写发生.
如果在你的项目实践中真的确实需要百万条 TCP 连接,那么一般来说还需要高效的 IO 事件管理.在 c 语言中,就是直接用 epoll 系列的函数来管理.对于 Java 语言来说,就是 NIO.(Golang 中不用操心,net 包中把 IO 事件管理都已经封装好了)
8.6 本章总结
在本章中,我们先是系统地介绍了 Linux 内核在最大可打开文件数上的限制.理解了这个原理,再修改 fs.nr_open、nofile 和 fs.file-max 这些参数的时候就能更得心应手地应用,也不容易把服务器搞出问题了.接着我们分别讨论了服务器角色和客户端角色单机都能达到多少个连接.而且我们还提供了 C、Java、PHP 三种语言的测试源码,你可以选择一种语言然后照着实验步骤来达成百万连接测试.
好了,回到我们开篇的问题.
1) “Too many open files” 报错是怎么回事,该如何解决?
因为每打开一个文件(包括 socket),都需要消耗一定的内存资源.为了避免个别进程不受控制地打开了过多的文件而导致整个服务器崩溃,Linux 对打开的文件描述符数量有限制.如果你的进程触发了内核的限制,“Too many open files” 报错就产生了.
内核中限制可打开文件描述符的参数分两类.第一类是进程级别的,包括 fs.nr_open 和 soft nofile.第二种是整个系统级别的,参数名是 fs.file-max.这些参数的耦合关系有点小复杂,为了避免你踩坑,飞哥给出一个修改建议如下.假如你的进程需要打开 100 万个文件描述符,那么建议这样配置:
# vi /etc/sysctl.conf
fs.file-max=1100000 // 系统级别设置成 110W,多留点 buffer。
fs.nr_open=1100000 // 进程级别也设置成 110W,因为要保证比 hard nofile 大
# sysctl -p
# vi /etc/security/limits.conf
// 用户进程级别都设置成 100W
* soft nofile 1000000
* hard nofile 1000000参数配置说明
fs.file-max:系统级最大文件描述符数,设为 110W 留有余量。fs.nr_open:进程级最大文件描述符数,必须大于或等于hard nofile。soft/hard nofile:通过limits.conf配置的用户进程级别限制,设为 100W。
2) 一台服务器最大究竟能支持多少个连接?
在不考虑连接上的数据收发和处理,仅仅只考虑 ESTABLISH 状态的空连接的情况下,一台服务器上最大可以支持 TCP 连接数基本上可以说是由内存的大小来决定的。
四元组唯一确定一条连接,但服务器可以接收来自任意客户端的连接请求,所以根据这个理论计算出来的数字太大,几乎没有啥意义。另外文件描述符限制其实也是内核为了防止某些应用程序不受限制地打开文件句柄而添加的限制。这个限制只要修改几个内核参数就可以加大。
一个 socket 大约消耗 3.3KB 左右的内存,这样真正制约服务器最大并发数的就是内存。拿一台 4GB 内存的服务器来举例,可以支持的 TCP 连接大约是 100 多万些。
内存估算
- 每个 socket 约消耗 3.3KB 内存。
- 4GB 内存服务器 ≈ 100万+ 连接(空连接,无数据收发)。
3) 一台客户端最大能发起多少个连接?
的确客户端每次建立一条连接都需要消耗一个端口。从数字上来看,貌似最多只能发起 65536 条连接(除去保留端口号,最大可用是 64KB 左右)。但是其实我们有两种办法可以破除这个 64KB 的限制。
方法一:为客户端配置多 IP。 这样每个 IP 就都有 64KB 个可用端口了。只需要向外发起连接请求之前,分别 bind 不同的端口即可。假设你配置了 20 个 IP,则最多能发起 20 × 64KB = 128 万条连接。
方法二:分别连接不同的 Server。 即使你只有一个 IP,也可以通过连接不同的服务端来突破 65535 的限制。只要服务器端的 IP 或者端口任意一个不同就算是不同的服务端。其原理是客户端在 connect 请求发起的时候,如果连接的是不同的 Server,那么端口是可以复用的。
突破 64KB 端口限制
- 多 IP 法:每个额外 IP 提供 64KB 端口,20 个 IP 可达 128 万连接。
- 多 Server 法:不同目标 IP/端口可复用客户端端口,理论上无上限。
综上所述,一台客户端可以发起百万条以上的连接没有任何的问题。
4) 做一个长连接推送产品,支持 1 亿用户需要多少台机器?
对于长连接的推送模块这种服务来说,给客户端发送数据只是偶尔的,一般一天也就顶多发送一次两次的。绝大部分情况下 TCP 连接都会空闲的,CPU 开销可以忽略。
我们再来考虑内存,假设你的服务器内存是 128GB 的。那么一台服务器可以考虑支持 500 万条的并发。这样会消耗大约不到 20GB 的内存用来保存这 500 万条连接对应的 socket。还剩下 100GB 多的内存,用来应对接收、发送缓存区等其它的开销足够了。所以,1 亿用户,仅仅只需要 20 台机器就差不多够用了!
估算结论
- 128GB 内存服务器:可支持约 500 万并发连接(空连接)。
- 内存分配:socket 消耗 < 20GB,剩余 > 100GB 用于缓冲区等其他开销。
- 1 亿用户 → 约 20 台服务器。
在知识星球中我们会进行内核等底层技术的视频讲解,能让你的底层学起来更快,事半功倍。还会进行线上问题排查以及性能优化等方面案例分享和交流。对大家技术深度和广度的积累很有好处。有想继续加入知识星球的同学微信扫描下面的二维码即可加入。另外在公众号后台发送「星球优惠券」可获取开发内功修炼读者的专属优惠券。
原文中的图像
原文中在相关页面包含了多幅图表和代码截图(页码 228–265),用于辅助说明内核参数配置、文件描述符限制、连接数测试结果等。如需查看详细内容,请参考原书对应页码。