单机测百万 TCP 长连接,我折腾了半天才想明白一件事
最近在给自己写的 C++ Web 框架 Hical 做容量评估。起因是个很朴素的问题:要是挂一百万条长连接,服务端得吃多少内存?
我一开始的想法特别直接------造一百万连接,量一下 RSS,完事。然后真坐下来动手,才发现没那么简单。整个过程踩了几个坑,绕了点弯路,最后想明白的那件事跟我出发时的预期完全不一样。这篇就把这趟折腾记下来。
先撞了一堵端口墙
脚本写起来不难,本机自己连自己,循环 connect,建到目标数量为止。我估摸着十万连接顶天了,先小步试,建到六万左右,脚本开始报错连不上了。
愣了一下才反应过来:TCP 连接是靠四元组唯一确定的------源 IP、源端口、目标 IP、目标端口。我现在一个客户端连一个服务端的固定 ip:port,四元组里前三个全锁死了,能变的只有源端口。而源端口理论上 65535 个,扣掉保留端口、扣掉一堆卡在 TIME_WAIT 的,实际稳定能用的也就六万出头。
所以单客户端、单源 IP、连单个目标端口,撑死六万条。这堵墙我之前压根没意识到,因为平时压测都是短连接打完就走,源端口循环复用,从来没把六万个端口同时占满过。
这堵墙在 Linux 上有个绕法,但我在 Windows 上先碰了一鼻子灰。Windows 的动态端口是全局共享的,不像 Linux 那样每个源 IP 各带一份独立的端口空间------就算给本机绑了四个 IP,四个 IP 挤的还是同一份端口,实测下来照样卡在 ~63,643,跟一个 IP 没差,根本叠不起来。所以后面那个挂别名、轮询源 IP 的玩法,只在 Linux 上有效,在 Windows 上我也没折了,只能换多机。
行,那十万就先别想了。我退一步:六万也能用,先把"每条连接多少内存"这个数测出来再说。
造不满就换个测法:测斜率,然后外推
既然造不满,那换个思路------我把"每加一条连接,内存涨多少"这个斜率测准,再线性外推到一百万,效果其实是一样的。
原理很土:分几个档位建连接(1万、2万、3万、5万),每档量一次服务端 RSS,拿这几个(连接数,内存)点做最小二乘线性回归。斜率 k 就是每条连接多少 KB,截距 b 是固定开销。再看 R²(决定系数)够不够贴近 1------越贴近 1,说明内存涨得越是一条直线,那外推到一百万就只剩一个乘法。
写了个 Python 脚本,纯标准库,没拉 numpy。跑出来第一版:
ini
连接数 10000, RSS 176.41 MB, 每连接 17.45 KB
连接数 20000, RSS 346.69 MB, 每连接 17.44 KB
连接数 30000, RSS 517.10 MB, 每连接 17.45 KB
连接数 50000, RSS 857.68 MB, 每连接 17.44 KB
回归模型: RSS增量 = 17.44 KB/连接 × N + 99.31 KB
R^2 = 1.0000
四个档位的每连接成本:17.45、17.44、17.45、17.44。R² 直接顶到 1.0000。外推一百万:
1,000,000 连接预计需要约 16.63 GB
按说到这儿日常够用了。但我盯着这数据看了一会儿,心里有个疙瘩怎么都摁不下去:
我最高只测到五万,回归线覆盖的就是 1 到 5 万这一小段。万一过了五万,内存不按这条线走了呢?比如服务端存连接的哈希表涨到某个规模触发 rehash,内存哐当跳一格;再比如内存池到某个阈值阶梯式扩容。这些在五万以下根本没机会暴露,我外推过去就偏乐观了。
这个数据漂亮归漂亮,但我没底。得想办法把测试点推得更高,起码到十万,看看这条线在那个位置还成不成立。
卡了一会儿,AI 给了一句话
卡了一会儿没头绪,我干脆把那堵端口墙的限制原样丢给了 AI------"单客户端 IP 连单服务端 ip:port,源端口只有六万,有没有办法在单机上突破"。
它的回话点醒了我:这是四元组的墙,不是机器的墙。源端口只是四元组里的一个维度,只要再让另一个维度能动,墙就破了。我当时脑子里只盯着源端口那六万个名额死磕,完全忘了四元组里还有源 IP 这一格可以做文章。
最省事的办法:给本机环回网卡(lo)挂几个别名 IP。
bash
sudo ip addr add 127.0.0.2/8 dev lo
sudo ip addr add 127.0.0.3/8 dev lo
sudo ip addr add 127.0.0.4/8 dev lo
这样我手上就有了 127.0.0.1 到 127.0.0.4 四个源 IP。建连接时让脚本轮着用不同源 IP 出站(connect 前先 bind 到某个源 IP),每个源 IP 各带一份独立的六万源端口空间。四个 IP,理论上能凑二十二万。
服务端那头一行都不用动,还是单进程监听一个端口,内存采样逻辑原样不变。脚本加个 --src-ips 参数轮询分配源 IP,也就十来行的事。
路上又栽了个跟头:ulimit -n 300000 直接被拒
改完准备跑十万,又被绊了一下,挺典型的。ulimit -n 300000 直接被拒:
bash
-bash: ulimit: open files: cannot modify limit: Operation not permitted
这个得说道说道。十万连接在单机是"自连接"------客户端和服务端在同一台机上,两边各占十万个 fd,加起来二十万。所以 fd 上限必须往大了调。
但 ulimit -n 有软硬上限之分,普通用户能设的软上限(soft)不能越过硬上限(hard)。查了下当前状态:
bash
echo "soft=$(ulimit -Sn) hard=$(ulimit -Hn) nr_open=$(cat /proc/sys/fs/nr_open)"
输出:soft=65535 hard=65535 nr_open=1048576
hard 卡在 65535,我想设三十万自然被拒。这里其实是三个东西:
nr_open:系统级天花板,104 万,够大,不用碰hard limit:65535,挡路的就是这玩意soft limit:65535
解决办法就是改 /etc/security/limits.conf,把 hard/soft 都放开,然后重新登录------这配置只在登录时由 PAM 加载,不重登不生效:
bash
echo 'hical soft nofile 300000' | sudo tee -a /etc/security/limits.conf
echo 'hical hard nofile 300000' | sudo tee -a /etc/security/limits.conf
exit # 重新登录
重登之后 ulimit -n 300000 就不吭声了。
顺带提一嘴,我以前老搞混的点:ulimit -n(fd 上限)和 somaxconn(accept 队列深度)完全是两码事。前者管你能开多少 fd,是十万连接的硬约束;后者管的是"握手完成、排队等 accept 取走的连接能排多长",只管瞬时积压不管总量。撞 fd 上限报 EMFILE,撞 accept 队列报 ECONNREFUSED,两者根本不搭界。
十万的数据,干净得有点不真实
环境都备齐了,服务端关掉空闲超时和连接数限制(不然空闲连接要么被踢、要么建不满),跑十万级对齐,档位设五万、八万、十万,四个源 IP 轮着用:
ini
连接数 50000, RSS 857.57 MB, 每连接 17.44 KB
连接数 80000, RSS 1.34 GB, 每连接 17.44 KB
连接数 100000, RSS 1.67 GB, 每连接 17.44 KB
回归模型: RSS增量 = 17.44 KB/连接 × N + 243.26 KB
R^2 = 1.0000
三个档位,每连接 17.44、17.44、17.44,小数点后两位纹丝不动。R² 还是 1.0000。
但真正让我松口气的是另一件事:十万这个实测点,精确落在之前用五万外推出来的那条回归斜率上,偏差是 0。
也就是说从一万到十万,内存全程是一条直线。我之前担心的 rehash 跳变、内存池阶梯扩容,这些非线性的妖蛾子一个都没冒头。那条外推线,从"我假设它成立"变成了"我实测验证过它成立"。百万 16.63 GB 这个数,到这儿心里才算踏实。
这次每档也都建满了,没出 EMFILE 也没撞端口------十万除以四个源 IP,每个才两万五,离六万的墙还远。数据自然干净。
那一刻冒出来的反问:既然别名能叠,干嘛不直接怼满百万?
数据跑完,我自己先冒出个反问:nr_open 都 104 万了,别名又能一直加(17 个源 IP × 6 万就破百万了),那我多挂几个别名,单机不就能直接造满一百万?还搞什么外推、还张罗多机干嘛?
这反问把我自己问住了,越想越觉得有道理,差点就动手去加别名了。索性把这个纠结又丢给了 AI------"既然别名能一直叠加,那单机直接造满一百万行不行"。它的回答才让我想明白:建得起是一件事,测得准是另一件事,这俩我从一开始就没分清楚。
单机加别名,推开的只是 fd 墙和端口墙。但还有三堵墙,加几个别名根本推不动:
内存这关就过不去。 单机自连接,客户端和服务端的 socket 在同一个内核里,这台机要同时维护两百万个 socket 端点。服务端 RSS 16.63 GB,加上内核两边的收发缓冲(两百万端点,就算每端点压到最小 8KB,也是 16 GB 量级),再加客户端 Python 进程拿着一百万个 socket 对象------单机百万自连接,35 到 40 GB 起步。我这台 VM 哪有这么多内存。换成多机,客户端内存散到十几台上,服务端只扛自己那 16.63 GB 加单边缓冲,完全是两个量级。
CPU 也抢不过来。 单机自连接,建连脚本(客户端)和服务端抢同一份 CPU。我这次十万就花了两分半钟,而且建连耗时是随连接数非线性变慢的------established 哈希表越大,新连接查重越慢。百万级在四核小机器上,可能要几十分钟,甚至中途瞬时积压超过 accept 队列,开始大批 ECONNREFUSED,根本跑不完。多机十几台并行,每台只建六万,几秒钟的事。
就算前两关都硬扛过去,数据也是失真的------这才是根本。 退一万步,我真上 64G 内存、耐着性子等一个钟头建满了,这数据照样不能用:
- 环回连接(
lo)根本不走网卡,没有真实 TCP 拥塞控制、没有真实 RTT、没有丢包重传。生产环境的百万连接是真网卡接真客户端,行为跟环回完全两码事。 - 想验"百万空闲连接 CPU 接近 0"?单机上客户端进程一直在跑,服务端真实的空闲 CPU 我根本量不出来。
- 想验"新连接 accept 延迟不随存量连接劣化"?单机自连接的延迟里混进了客户端自己的调度延迟,分不清是谁的锅。
绕了这一大圈我才看清:单机这套测法能干的事,就是我无意中做出来的这个样子------用十万的实测点确认那条外推线没跑偏(R²=1.0000、十万点零偏差,够实了),剩下的交给外推。
真要做百万的终极验证,非上多机不是因为单机建不起,而是要分摊内存、分摊建连 CPU、走真实网卡,让数据真正反映生产。我要的是"测准",不是"建起"------单机硬怼百万,只会换来一份又慢又失真的数据,没用。
最后,这个数怎么用才不坑自己
测出来的 16.63 GB,是空闲连接的内存下限。有两个地方它低估了真实情况,得心里有数:
一是空闲态低估活跃态。我建的是连上就不发数据的空闲长连接,可很多内存(请求缓冲之类)是收到请求那一刻才分配的。真实业务里连接是活跃的,每条会比这个更吃内存。
二是内核侧没全算进去。报告的是服务端进程 RSS,内核给每个 TCP socket 还留着自己的收发缓冲,几 KB 一条。
所以落到生产容量规划,我会在 16.63 GB 上再留 30% 到 50% 余量,大概 21.6 到 25 GB。这个数有"五万外推 + 十万实测对齐"双重背书,我敢写进规划文档。
折腾完回头看,出发时我以为这是个"造连接、量内存"的体力活,做完才发现真正的活儿是"把模型测准、把每个数字的来历讲清楚"。16.63 GB 不是蒙的,是测出来、又验证过的。比起"我们扛过了一百万"那种没头没尾的口号,这个我用着踏实。
后续: 17.44 KB 花在哪,能往下压多少
测完拿到这个数之后,思前想后实在没忍住,仔细分析了写的代码,想看看每条连接的内存到底怎么分的。
大头有两块:
第一块是读缓冲区(~8 KB) 。handleSession 协程一启动就 readBuf.resize(8192),这块内存跟着连接的整个生命周期------哪怕连接挂着一分钟没发一个字节,8 KB 照占不误。keep-alive 下多个请求复用同一块缓冲区,这个设计本来是对的,省掉了每请求分配的开销,但代价是空闲期也要占着这份内存。
第二块是 PmrBuffer (~2 KB) 。这是 GenericConnection 里的接收缓冲,连接对象一建就分配,同样不管有没有数据都常驻。
两块加起来 10 KB,剩下的 7 KB 左右是 Boost.Asio socket 内部状态、MPSC 发送队列、几个 std::function 回调、地址信息、对齐填充之类,碎碎的加起来也不少。
怎么压?让空闲连接不持有读缓冲区------读请求时从池里借一块,写完响应就还回去,连接进入空闲等待时手里什么都不拿。这个模式参考了 cinatra 的做法,不过更激进一点:做法是真正还回线程本地池,其他连接用完也能复用同一块内存。
arduino
连接建立 → 不分配读缓冲区
等下一个请求 → 从 ReadBufferPool 借一块 8 KB buffer
读到完整请求 → 正常解析,string_view 指向 buffer
响应写完 → 归还 buffer,连接回空闲,不再占这块内存
需要处理一个小细节:HTTP/1.1 的 pipelining 场景下,读完一个请求的头部时,缓冲区里可能已经有下一个请求的头几个字节了。还 buffer 之前得把这点残留数据先存起来,下一轮借到新 buffer 再拷进去。大多数 keep-alive 连接不做 pipelining,这条路径触发概率很低,一次 memcpy 的事,没影响。
改完之后空闲连接从 17.44 KB 降到约 10 KB ,百万连接需要的内存从 16.63 GB 降到 ~9 GB 。PmrBuffer 也改成懒分配,第一次读才初始化,再省 ~2 KB,降到 ~7 GB。
优化落地后:7.58 KB,砍掉 57%
前文说的 ReadBufferPool 借还和 PmrBuffer 懒分配,后来我在 v2.6.5 里全部落地了。测出来长什么样?
同样的 4 核 VM、同样的四源 IP 档位、同样的脚本,跑一遍:
ini
连接数 50000, RSS 增量 370.30 MB, 每连接 7.58 KB
连接数 80000, RSS 增量 592.33 MB, 每连接 7.58 KB
连接数 100000, RSS 增量 740.34 MB, 每连接 7.58 KB
回归模型: RSS增量 = 7.58 KB/连接 × N + 269.05 KB
R^2 = 1.0000
>>> 1,000,000 连接预计需要约 7.23 GB 服务端 RSS
从 17.44 KB 砍到 7.58 KB,降了 57%。而且这次 R² 还是 1.0000,三档每连接成本小数点后两位纹丝不动------优化没把线性搞歪,外推照样能用。
百万人内存预算,也从之前的 16.63 GB 降到了 7.23 GB。
剩的 7.58 KB 又花在哪了
改完我又仔细确认了一遍。17.44 → 7.58,砍掉的 10 KB 里,8 KB 来自 readBuf 借还、2 KB 来自 PmrBuffer 懒分配,基本跟之前预估的一模一样。
那剩下的 7.58 KB 是什么?
ReadBufferPool 的 BufferHandle 占一点、pipelineSpill 字符串也占一点(SSO 优化下大多数时候零堆分配)、IdleScanner 的 Entry(原子时间戳 + socket 指针)嵌在协程栈上不占额外堆。真正的固定开销有几个:
- 连接对象基础结构:
shared_ptr控制块、MPSC 写队列节点、socket 对象本身、各类回调函数包装------加起来四五 KB。 - 操作系统 TCP 控制块:内核给每个 socket 维护的结构,不在进程 RSS 里,但占系统内存。
- 还有各种 alignment 凑整,A 给 B 让个位,这边垫几字节那边垫几字节,碎碎的合起来也有一两 KB。
这里头已经没有"可优化"的大头了。想再往下压,就得动更底层的东西------比如换个更紧凑的事件循环模型,或者把 MPSC 队列从每个连接一个改成全局共享。但那些是架构层面的东西了,为了省几 KB 去折腾,值不值得另说。
还不踏实,再推到 50 万看看
10 万对齐过了,17.44 那条线从 1 万到 10 万是直的。但我还是想再推一步。VM 有 16 GB 内存,够我搞到 50 万。
50 万跟 10 万比,不只是翻五倍那么简单。多了几层新约束:
- conntrack 表会满 :
nf_conntrack_max默认 26 万,50 万连接下去直接撑爆,内核会开始丢包。这个不改,建连到一半就卡死了(踩坑点)。 - TCP 全局内存不够 :
tcp_mem三个水位默认按总内存算的,50 万连接双边 100 万个端点,6~8 GB 打底,不动它的话内核到压力线就会自动限流(踩坑点2)。 - 源端口要 9 个 IP:50 万除以 6 万,至少 9 个源 IP,加上原生 127.0.0.1 一共 10 个,才算留够余量。
- fd 要 120 万 :两边各 50 万,加上系统日常开销,
ulimit -n得开到 120 万------这下 linux 默认的 limits.conf 也要扩。
搞完这一套系统配置,跑了全程 5 个档位------10 万、20 万、30 万、40 万、50 万。每档建连约 3~5 分钟,全程二十多分钟。
结果:
ini
连接数 100000, RSS 增量 740.35 MB, 每连接 7.58 KB
连接数 200000, RSS 增量 1.45 GB, 每连接 7.58 KB
连接数 300000, RSS 增量 2.17 GB, 每连接 7.58 KB
连接数 400000, RSS 增量 2.89 GB, 每连接 7.58 KB
连接数 500000, RSS 增量 3.61 GB, 每连接 7.58 KB
回归模型: RSS增量 = 7.58 KB/连接 × N + 252.00 KB
R^2 = 1.0000
>>> 1,000,000 连接预计需要约 7.23 GB 服务端 RSS
5 个档位,每连接全是 7.58 KB,R² 还是 1.0000。
50 万点建完时整机状况:
vbnet
total used free shared buff/cache available
Mem: 15 5 9 0 1 10
VmRSS: 4877616 kB (≈ 4.65 GB)
可用还有 10 GB,离 OOM 远得很。
到这一步,那条外推线的可信度从"十万实测对齐"又升了一级,变成"五十万实测对齐"。从一万到五十万,全程严格线性。剩下的就交给生产实践了。
最终容量规划
| 版本 | 每连接 | 百万 RSS | 含余量 (30-50%) |
|---|---|---|---|
| v2.6.4 | 17.44 KB | 16.63 GB | 21.6 ~ 25.0 GB |
| v2.6.5 | 7.58 KB | 7.23 GB | 9.4 ~ 10.9 GB |
v2.6.5 的数据是五十万实测 + 五档零偏差 + R²=1.0000 背书的,不是猜的。这个数我敢写进正式容量规划文档。
最后,如果这个数你觉得有用------整个项目开源在 GitHub,文章里测的服务端、优化前后的代码都在里面。有疑问直接提 issue,或者在评论区聊都行。