单机十万连接:内存与内核参数调优

单机十万并发连接不是一个"改大某个参数"就能拿到的数字,它是文件描述符(file descriptor)、内核收发缓冲、应用层每连接内存、运行时参数四笔账同时算清的结果。任何一笔漏掉,压测就会卡在两三万连接上不去,而且报错各不相同。

先把账算清:十万连接要多少内存

内核和用户态各扣一份,内核那份来自 slab,不可换出(unswappable),OOM 时也压不掉:

归属每连接典型占用十万连接合计
内核 socket 结构(struct sock、fd 表、inode)1.5~3 KB0.15~0.3 GB
内核收发缓冲(tcp_rmem / tcp_wmem 下限 4KB + 4KB)8~16 KB0.8~1.6 GB
Go 协程栈(读、写各一个,初始 2 KB 起)4~16 KB0.4~1.6 GB
应用层读写缓冲(每条连接 bufio 各 4 KB)8 KB0.8 GB
连接对象与会话状态(uid、订阅列表、序列号)4~16 KB0.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 万却报 EMFILEfs.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;压测上不去先查客户端,再查队列溢出。

笔记加载中…