线程池与协程泄漏排查

线程池和协程泄漏的共同特征是「慢慢变坏」:刚发布时一切正常,几小时后延迟上升、内存上涨,重启就好,然后循环发生。它们的根因往往是同一个——任务用了但没还。本章按 Java 线程池/连接池与 Go 协程两类分别给出定位与处置。

两类泄漏的共性与差异

维度Java 线程池泄漏Go 协程泄漏
现象线程数持续上涨、请求排队goroutine 数持续上涨、内存缓慢增长
快照手段jstackarthas threadpprof goroutine
直接可见线程名与栈顶方法goroutine 栈与阻塞原因
典型根因阻塞在下游、连接池耗尽、任务死循环channel 无人接收、context 未取消、body 未关
是否立刻报错不报错,直到线程耗尽不报错,直到 OOM

两者都不会立刻抛错,所以必须靠监控曲线发现,而不是靠报错发现。

Java:先确认线程数是在涨

jstack <pid> | grep -c '^"'                 # 线程总数
jstack <pid> | grep '^"' | awk -F'"' '{print $2}' | sed 's/-[0-9]*$//' | sort | uniq -c | sort -rn
jstack <pid> | grep -c 'java.lang.Thread.State: BLOCKED'

线程名是重要线索:Tomcat 工作线程、pool- 开头的业务线程池、HikariPool-1/DruidDataSource 的连接池线程、下游 HTTP 客户端的 IO 线程,各自数量与配置上限对照即可判断谁在膨胀。

观测值判断处置方向
http-nio-8080-exec-* 长期等于 maxThreads工作线程打满,请求在排队查线程栈卡在哪,不要先调大 maxThreads
业务线程池线程数持续上涨且不回落任务阻塞,队列在堆积查阻塞点,加超时与隔离
HikariPool-1 connection adder 频繁出现连接池反复扩缩容查连接泄漏与 maxLifetime
WAITING (parking) 占多数线程在等锁或等任务看等的是哪个锁/队列
BLOCKED 数量持续大于 0存在锁竞争jstack 里找 blocked on 的对象
线程总数与 QPS 无关地单调上涨每请求泄漏一个线程查线程池创建位置与复用方式

三次采样比一次采样有用得多:

for i in 1 2 3; do jstack <pid> > /tmp/stack-$i.txt; sleep 5; done
grep -A8 'http-nio-8080-exec' /tmp/stack-2.txt | head -40

三次栈顶都停在同一个方法,那就是阻塞点;三次各停在不同地方,说明是真实负载而不是阻塞。

Tomcat 与业务线程池的关键参数

maxThreads=200       同时处理请求的最大线程数,等于并发处理上限
acceptCount=100      maxThreads 用满后的等待队列长度,超出直接拒绝
maxConnections=8192  同一时刻的最大连接数,NIO 下可远大于 maxThreads

判读:maxThreads 打满而 CPU 不高,说明线程在等(等 DB、等下游、等锁),加线程只会让等待更长;CPU 打满而线程数不高,说明是计算问题,该优化代码或扩容。队列满后的拒绝策略必须是快速失败,不能无限等待,否则超时会从下游一路传导到用户。

现象判断
线程数打满 + CPU 低 + 延迟高阻塞型故障,加线程无效
线程数不高 + CPU 高计算型故障,优化代码或扩容
线程数打满 + 队列满 + 大量拒绝已过载,必须限流与降级
线程数稳定但延迟抖动看 GC 与连接池等待,而不是线程池

连接池耗尽

连接池是线程池的孪生故障,监控里必须同时有这几个数:

指标含义阈值
active已借出连接数长期等于 maximumPoolSize 即耗尽
idle空闲连接数长期为 0 说明没有余量
threadsAwaitingConnection等待获取连接的线程数大于 0 就该告警
获取连接耗时 P99借出等待时间超过 50ms 说明池子不够或语句太慢
# HikariCP 关键配置
maximumPoolSize: 20            # 单实例 20 通常够用,盲目调大反而压垮数据库
connectionTimeout: 3000        # 获取连接最多等 3 秒,等不到就快速失败
maxLifetime: 1800000           # 小于数据库 wait_timeout,避免用到被服务端关掉的连接
leakDetectionThreshold: 20000  # 20 秒未归还可疑,用于定位泄漏

观点:maximumPoolSize 不是越大越好,它应该等于「数据库能同时高效服务的并发数」,通常十几到几十。把池子调到 200,只是把数据库的排队挪到了应用里,还多占了 200 个连接的内存。

Go:协程泄漏

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.txt   # 完整栈
go tool pprof -http=:6061 http://127.0.0.1:6060/debug/pprof/goroutine   # 图形化视图
go tool pprof -top http://127.0.0.1:6060/debug/pprof/goroutine          # 直接看栈聚合

最可靠的判读是把两次快照做差:间隔 5 分钟各抓一次,比较 goroutine profile: total N 与各栈前的 N @ 计数。

观测值判断
协程数随 QPS 波动,之后回落到基线正常
协程数单调上涨,QPS 归零后不回落泄漏
大量协程停在 chan receive / chan sendchannel 无人收发
大量协程停在 select 且带 context 分支context 未取消或缺少超时
大量协程停在 net/http.(*body).readLocked响应体未读未关
协程数正常但内存持续上涨不是泄漏,查大对象或本地缓存

常见泄漏点与修法:

泄漏点典型代码修法
无缓冲 channel发送方阻塞在 ch <- v 且无人接收用带缓冲 channel,或保证接收方一定消费
context 未取消ctx, _ := context.WithTimeout(...) 丢掉 canceldefer cancel() 必须写
HTTP body 未关闭只读一部分 body 就返回defer resp.Body.Close() 并读空或丢弃剩余
定时器未停止time.NewTickerStop()defer ticker.Stop()
管道型 goroutine 上游退出worker 阻塞在读 channel增加 ctx.Done() 分支退出
无限重试未退避失败后立刻 go retry()限制重试次数与并发

处置动作

1. 止血:给该下游加超时与快速失败,禁止无限排队
2. 隔离:慢下游用独立线程池/连接池,防止拖垮核心链路
3. 定位:三次线程栈,或两次 goroutine 快照做差,指认阻塞点
4. 修复:补超时、补 close、补 cancel、补兜底
5. 验证:压测同一路径,确认线程数/协程数在有界范围内波动

止血时不要重启了事。重启能恢复,但掩盖根因,而且下一次会更早发生——线程泄漏通常是每请求泄漏一点点,重启周期会越来越短。

预防与巡检项

  • 所有外部调用(HTTP、DB、Redis、RPC)必须有超时,且超时时间有统一默认值。
  • 线程池、连接池、协程数进监控,设「持续 10 分钟上涨」类告警,而不是只设绝对值阈值。
  • 队列必须有界,拒绝策略必须显式指定,禁止使用无界队列。
  • Go 服务统一开启 pprof 内网端口,故障时第一时间能抓快照。
  • 压测验收标准里写明「压测 30 分钟后线程数与协程数回落基线」。

小结:线程池与协程泄漏都是「用了没还」,Java 侧靠三次 jstack 采样和线程名分布定位,Go 侧靠两次 goroutine 快照做差;处置顺序永远是先加超时快速失败止血、再隔离慢下游、最后改代码,重启不算修复。

笔记加载中…