连接泄漏与资源治理:句柄、协程与内存排查
长连接服务的泄漏几乎不以「崩溃」形式出现,而是以「重启一次能多撑三天」的形式出现:文件句柄慢慢涨、协程慢慢涨、内存慢慢涨,直到某个凌晨触顶。能涨的东西只有三类——句柄(fd)、协程(goroutine)、内存,排查的第一步永远是把这三条曲线画在同一个时间轴上。
泄漏的四种形态
| 症状 | 首查命令 | 常见根因 | 处置方向 |
|---|---|---|---|
| fd 线性上涨 | ls /proc/PID/fd | wc -l | 连接已死但未 Close、pprof/日志句柄未关 | 修关闭逻辑,统一 defer 收口 |
| goroutine 线性上涨 | curl /debug/pprof/goroutine?debug=1 | ReadMessage 无读超时、writePump 未退出 | 设 ReadDeadline,用 conn 关闭唤醒对端 |
| 内存缓慢上涨 | go tool pprof -inuse_space | Hub 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、systemdLimitNOFILE、fs.file-max三处保持一致并纳入配置管理。- 每次压测与发布后检查三项指标能否回落到基线;关掉全部连接后 fd 与协程必须回落。
小结:泄漏排查靠三条曲线的交叉验证——fd 数对得上连接数、协程数约等于两倍连接数、内存涨幅能按每连接 20~50KB 解释;一旦某项对不上,就用 goroutine 栈定位到代码行、用 heap 差分定位到持有者,并优先检查「握手成功但鉴权失败」「读超时未设」「注销漏分支」这三类最常见的未闭合路径。