连接泄漏与资源治理:句柄、协程与内存排查

长连接服务的泄漏几乎不以「崩溃」形式出现,而是以「重启一次能多撑三天」的形式出现:文件句柄慢慢涨、协程慢慢涨、内存慢慢涨,直到某个凌晨触顶。能涨的东西只有三类——句柄(fd)、协程(goroutine)、内存,排查的第一步永远是把这三条曲线画在同一个时间轴上。

泄漏的四种形态

症状首查命令常见根因处置方向
fd 线性上涨ls /proc/PID/fd | wc -l连接已死但未 Close、pprof/日志句柄未关修关闭逻辑,统一 defer 收口
goroutine 线性上涨curl /debug/pprof/goroutine?debug=1ReadMessage 无读超时、writePump 未退出设 ReadDeadline,用 conn 关闭唤醒对端
内存缓慢上涨go tool pprof -inuse_spaceHub map 未清理、消息对象入队未释放差分快照定位持有者
连接数抖动上涨ss -tan state established | wc -l客户端重连但服务端未回收旧连接双向下线 + 连接上限保护

三者往往同源:一条连接泄漏会同时占住 1 个 fd、2 个协程、几十 KB 内存。所以排查时不要只盯一个指标,要看三者是否同步增长。

句柄排查(fd)

lsof -p 12345 | wc -l                              # 指定进程,别用不带 -p 的全量 lsof
ls /proc/12345/fd | wc -l                          # 更快的等价做法
ls -l /proc/12345/fd | grep -c socket              # 其中 socket 数量
ss -tan state established | wc -l                  # 内核侧已建立连接数
cat /proc/12345/limits | grep -i 'open files'      # 进程 fd 上限
cat /proc/sys/fs/file-nr                           # 系统级:已分配 / 未使用 / 上限
$ ls /proc/12345/fd | wc -l
102348
$ ss -tan state established | wc -l
102301
$ cat /proc/12345/limits | grep -i 'open files'
Max open files            1048576              1048576              files

判读规则:fd 数 ≈ established 连接数 + 少量固定文件(日志、监听套接字、pprof)。两者差值长期稳定在几十以内是正常的;差值随时间变大,就是关连接时漏了 Close,或者出现了大量 TIME_WAIT 之外的不明 socket。fd 上限要同时改 ulimit -n 与 systemd 的 LimitNOFILE,只改一处等于没改。

协程排查(goroutine)

curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=1' | head -20
curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=2' > /tmp/goroutine-$(date +%s).txt
grep -c '^goroutine ' /tmp/goroutine-*.txt                    # 协程总数
grep -A3 'created by' /tmp/goroutine-*.txt | grep -oE 'main\.[A-Za-z.]+' | sort | uniq -c | sort -rn | head
go tool pprof -http=:6061 'http://127.0.0.1:6060/debug/pprof/goroutine'   # 图形化看栈聚合
$ curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=1' | head -6
goroutine profile: total 204913
102301 @ 0x43e5f6 0x44a2b1 0x8c1d32 0x46a1e1
#	0x8c1d31	main.(*Client).readPump+0x141	/app/internal/ws/client.go:78
#	...
栈顶状态位置判读处置
[IO wait]net.(*conn).Read / readPump读超时没生效或未设置设 SetReadDeadline 并在 pong 处理里续期
[chan send]writePump写队列满且没有丢弃策略有界队列 + 慢客户端摘除(背压)
[select]Hub.run正常的调度循环无需处理
[semacquire]业务函数内锁竞争或并发限流排队查锁粒度、降级限流阈值
[sleep]time.Sleep 重试退避重试堆积加超时与并发上限

协程数关系式:goroutine 数 ≈ 2 × 在线连接数 + 常量(一读一写两个 pump)。若比值持续高于 3,先怀疑连接泄漏或业务分支里又开了额外协程。

内存排查

go tool pprof -inuse_space 'http://127.0.0.1:6060/debug/pprof/heap'    # 存活对象,找泄漏
go tool pprof -alloc_space 'http://127.0.0.1:6060/debug/pprof/heap'    # 累计分配,找热点
go tool pprof -base before.heap after.heap                             # 两次快照差分,最有效
GODEBUG=gctrace=1 ./im-gateway                                          # 看存活堆是否单调上涨

单连接的内存构成可以直接估算,估算清楚了才知道涨 5% 是多少 MB:

组成部分参考值说明
两个协程栈8 ~ 16KB每协程初始 2~8KB,深调用链会增长
读缓冲2 ~ 4KB按最大帧长设置,不要用 64KB 默认值
写缓冲2 ~ 4KB与写队列长度相乘才是真实占用
TLS 连接+30 ~ 80KB含握手缓冲与会话缓存,可用会话复用降低
Hub 映射与订阅~200B / 连接map 条目、房间索引、路由信息
合计20 ~ 50KB(无 TLS)10 万连接约 2 ~ 5GB

典型泄漏与修法

泄漏点代码特征修法
读协程不退出ReadMessage() 阻塞且无 SetReadDeadline设置读超时;对端不关就一直阻塞
写协程不退出writePump 阻塞在 channel,读侧已退出读侧退出时 close(conn),写侧用 ctx.Done() 收尾
Hub 未注销register 有而 unregister 漏(鉴权失败、超限分支)注册成功后立刻 defer unregister,只留一条收口路径
房间索引未清理map[room]map[*Conn]struct{} 只在断连时删一半注销时同时清 uid 索引与房间索引
定时器泄漏循环里 time.After / 每次重连 NewTicker复用 ticker,或用 time.Timer 并显式 Stop
消息未释放把大 payload 存进 Hub 缓冲复用池限制帧长,池化只缓存固定尺寸切片

一个最容易被忽略的分支:握手阶段 Upgrade 成功、鉴权失败时直接 return,连接既没注册也没 Close。这类连接不计入在线数,但实实在在占着 fd 与协程,表现为「在线数正常、fd 与内存却在涨」。

排查顺序

1. ls /proc/PID/fd | wc -l 与 cat /proc/PID/status | grep Threads     先确认是不是线性上涨
2. ss -s 与 ss -tan state established | wc -l                          内核侧连接数与状态分布
3. curl /debug/pprof/goroutine?debug=1                                 协程数是否约等于 2× 连接数
4. go tool pprof goroutine → top / traces                              卡在 client.go 第几行
5. 两次 heap 快照做 -base 差分                                         内存涨在哪个结构
6. fd 数、连接数、协程数、在线数四者交叉验证                            不同步的那一项就是泄漏点

重启不是排查,是掩盖。重启前先抓一份 goroutine 与 heap 快照归档,否则第二天问题重演时还得从头开始。

常见坑

坑后果正确做法
lsof | wc -l 不带 -p几十秒才出结果,数据还混入无关进程一律 lsof -p PID 或读 /proc/PID/fd
把 GC 未回收当成泄漏白白重启,问题还在看 inuse_space 是否单调上涨,而非 RSS
设了读超时但没在 pong 里续期心跳正常也整批掉线在 SetPongHandler 中重置 deadline
只调大 fd 上限只是把触顶时间推迟上限提高后必须查泄漏源
客户端重连风暴掩盖泄漏在线数看着正常,资源在涨看 connects_total - disconnects_total
用 runtime.NumGoroutine() 单点采样看不到卡在哪抓完整栈快照做聚合

巡检项

  • fd 数、goroutine 数、RSS 与在线数四组指标进监控,并设「比值上界」与「小时涨幅」双阈值。
  • 每日定时抓一次 goroutine 快照归档,保留 30 天,用于对比泄漏前后。
  • ulimit -n、systemd LimitNOFILE、fs.file-max 三处保持一致并纳入配置管理。
  • 每次压测与发布后检查三项指标能否回落到基线;关掉全部连接后 fd 与协程必须回落。

小结:泄漏排查靠三条曲线的交叉验证——fd 数对得上连接数、协程数约等于两倍连接数、内存涨幅能按每连接 20~50KB 解释;一旦某项对不上,就用 goroutine 栈定位到代码行、用 heap 差分定位到持有者,并优先检查「握手成功但鉴权失败」「读超时未设」「注销漏分支」这三类最常见的未闭合路径。

笔记加载中…