线程池与协程泄漏排查
线程池和协程泄漏的共同特征是「慢慢变坏」:刚发布时一切正常,几小时后延迟上升、内存上涨,重启就好,然后循环发生。它们的根因往往是同一个——任务用了但没还。本章按 Java 线程池/连接池与 Go 协程两类分别给出定位与处置。
两类泄漏的共性与差异
| 维度 | Java 线程池泄漏 | Go 协程泄漏 |
|---|---|---|
| 现象 | 线程数持续上涨、请求排队 | goroutine 数持续上涨、内存缓慢增长 |
| 快照手段 | jstack、arthas thread | pprof 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 send | channel 无人收发 |
大量协程停在 select 且带 context 分支 | context 未取消或缺少超时 |
大量协程停在 net/http.(*body).readLocked | 响应体未读未关 |
| 协程数正常但内存持续上涨 | 不是泄漏,查大对象或本地缓存 |
常见泄漏点与修法:
| 泄漏点 | 典型代码 | 修法 |
|---|---|---|
| 无缓冲 channel | 发送方阻塞在 ch <- v 且无人接收 | 用带缓冲 channel,或保证接收方一定消费 |
| context 未取消 | ctx, _ := context.WithTimeout(...) 丢掉 cancel | defer cancel() 必须写 |
| HTTP body 未关闭 | 只读一部分 body 就返回 | defer resp.Body.Close() 并读空或丢弃剩余 |
| 定时器未停止 | time.NewTicker 未 Stop() | 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 快照做差;处置顺序永远是先加超时快速失败止血、再隔离慢下游、最后改代码,重启不算修复。