单机十万连接:内存与内核参数调优
单机十万并发连接不是一个"改大某个参数"就能拿到的数字,它是文件描述符(file descriptor)、内核收发缓冲、应用层每连接内存、运行时参数四笔账同时算清的结果。任何一笔漏掉,压测就会卡在两三万连接上不去,而且报错各不相同。
先把账算清:十万连接要多少内存
内核和用户态各扣一份,内核那份来自 slab,不可换出(unswappable),OOM 时也压不掉:
| 归属 | 每连接典型占用 | 十万连接合计 |
|---|---|---|
| 内核 socket 结构(struct sock、fd 表、inode) | 1.5~3 KB | 0.15~0.3 GB |
| 内核收发缓冲(tcp_rmem / tcp_wmem 下限 4KB + 4KB) | 8~16 KB | 0.8~1.6 GB |
| Go 协程栈(读、写各一个,初始 2 KB 起) | 4~16 KB | 0.4~1.6 GB |
| 应用层读写缓冲(每条连接 bufio 各 4 KB) | 8 KB | 0.8 GB |
| 连接对象与会话状态(uid、订阅列表、序列号) | 4~16 KB | 0.4~1.6 GB |
单连接合计约 2540 KB,十万连接约 **2.54 GB**;Go 堆在 GC 峰值时还可能翻倍,再叠加 TLS 会话状态(若在本进程做 TLS 终结,每连接再 2030 KB,即 23 GB)。结论:十万连接的机器至少 16 GB 内存,8 GB 会先在 GC 与内核缓冲上垮掉。
第一道墙:文件描述符
每个 TCP 连接占用一个 fd,监听 fd、epoll 实例、eventfd、日志与数据库连接另外占。三层上限要一起调,缺一层就白调:
# 系统级上限(所有进程合计)
sysctl -w fs.file-max=2097152
# 单进程 fd 硬顶,nofile 的值不能超过它
sysctl -w fs.nr_open=1048576
# 会话级(只对当前 shell 及其子进程有效,重开就没了)
ulimit -n 1048576
# 永久生效:/etc/security/limits.conf(需要重新登录)
* soft nofile 1048576
* hard nofile 1048576
服务由 systemd 拉起时上面两处都不生效,必须写进 unit:
[Service]
LimitNOFILE=1048576
cat /proc/sys/fs/file-nr # 已分配 / 未用 / fs.file-max
PID=$(pgrep -f im-server | head -1)
grep -i 'open files' /proc/$PID/limits # 确认进程真实生效值
ls /proc/$PID/fd | wc -l # 该进程当前 fd 数
第二道墙:内核网络参数
# /etc/sysctl.d/99-im.conf
fs.file-max = 2097152
fs.nr_open = 1048576
net.core.somaxconn = 32768 # accept 队列上限,也是 Go net.Listen 的 backlog
net.core.netdev_max_backlog = 32768 # 网卡收包队列,突发流量不丢包
net.ipv4.tcp_max_syn_backlog = 65536 # 半连接(SYN)队列
net.ipv4.ip_local_port_range = 10000 65535 # 主动外连时可用端口(压测客户端同样吃紧)
net.ipv4.tcp_tw_reuse = 1 # 允许复用 TIME_WAIT 出向连接
net.ipv4.tcp_fin_timeout = 15 # 主动关闭方 FIN_WAIT2 超时
net.ipv4.tcp_max_tw_buckets = 262144 # TIME_WAIT 数量上限
net.ipv4.tcp_keepalive_time = 120 # 首个探测包前的空闲秒数(默认 7200 太长)
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 3
net.ipv4.tcp_rmem = 4096 65536 4194304 # min / default / max,默认值给大只会多占缓冲
net.ipv4.tcp_wmem = 4096 65536 4194304
net.core.rmem_max = 4194304
net.core.wmem_max = 4194304
net.ipv4.tcp_slow_start_after_idle = 0 # 长连接空闲后不重置拥塞窗口
net.ipv4.tcp_notsent_lowat = 131072 # 未发送数据超 128KB 即停止可写通知,天然背压
net.ipv4.tcp_syncookies = 1 # 队列溢出时的保底
三条口径要记牢:somaxconn 是内核 accept 队列长度,Go 的 net.Listen 会读 /proc/sys/net/core/somaxconn 作为 listen(2) 的 backlog,而 http.Server 没暴露 backlog 参数,只能靠 sysctl;tcp_rmem / tcp_wmem 的第一个值才是常驻下限(4 KB),调大第三个数只是放宽上限,但把 default 设成 1 MB 就会立刻吃掉十万份缓冲;tcp_tw_reuse 只对出向连接有效,服务端被动关闭的 TIME_WAIT 仍要占端口。
第三道墙:Go 运行时参数
GOMAXPROCS=16 # 等于可用 CPU 核数;Go 1.25 起默认已感知 cgroup CPU 限额
GOGC=200 # 堆涨到上次两倍再 GC,长连接服务多为分配大、存活久,可放宽
GOMEMLIMIT=12800MiB # 软内存上限,容器 limit 为 16GiB 时留 ~20% 余量,避免 OOMKilled
每连接内存是可控的,关键在别让小消息背上大缓冲:
func readLoop(c *websocket.Conn) {
c.SetReadLimit(32 << 10) // 单帧上限,防止异常客户端撑爆内存
for {
_, data, err := c.ReadMessage() // 小缓冲 + 帧上限,别让客户端决定你的内存
if err != nil {
return
}
handle(data) // 立即处理完,不要长期持有 data 切片
}
}
压缩(EnableWriteCompression)、WriteBufferPool、ReadBufferSize 都是同一类取舍:省带宽就吃 CPU 与内存,聊天文本消息通常不如直接不开压缩。
验证与压测
ss -s # 全局连接摘要(对照 cat /proc/net/sockstat)
ss -tn state established '( sport = :8081 )' | wc -l # 本服务已建立连接数
netstat -s | grep -iE 'listen|overflow' # listen 队列溢出次数,非 0 就要调 somaxconn
压不出十万连接时,先怀疑压测端:单台客户机的临时端口只有 ip_local_port_range 那么多,fd 上限也常常没调,表现是 cannot assign requested address 或 too many open files。多机、多源 IP 是标准解法。
常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 只压到 2~3 万就上不去 | 压测客户端 ulimit 与临时端口没调 | 客户端一并调 nofile、扩端口段、多机施压 |
| 报 cannot assign requested address | 客户端临时端口耗尽 | 扩大 ip_local_port_range,开 tcp_tw_reuse,或多源 IP |
| 握手成功却大量被拒 | accept 队列溢出 | 调大 somaxconn 与 tcp_max_syn_backlog,看 netstat -s |
| 连接数达标但内存爆 | 大帧、压缩、默认缓冲把每连接成本撑大 | 限帧长、裁缓冲,用 pprof 看 heap 增量 |
ulimit -n 调了不生效 | 只在当前 shell 生效,服务由 systemd 启动 | 写 LimitNOFILE 或 limits.conf 后重启进程 |
| fd 明明没到 100 万却报 EMFILE | fs.nr_open 或 fs.file-max 先到顶 | 三个上限一起抬,fs.nr_open ≥ nofile |
小结:十万连接 = 三层 fd 上限(file-max / nr_open / nofile)+ 内核队列与缓冲(somaxconn、syn_backlog、tcp_rmem、tcp_notsent_lowat)+ 每连接 2540 KB 的内存预算(十万连接约 2.54 GB,机器至少 16 GB)+ Go 的 GOMAXPROCS/GOGC/GOMEMLIMIT;压测上不去先查客户端,再查队列溢出。