结论先行:上游超时是上游的本地行为,服务端根本不知道。
Future.get(300ms)超时只是上游不再等待,下游任务还在线程池里继续跑、继续占连接、继续烧 CPU。这就是"超时=释放资源"这个常见误解的真实代价。本文用一次真实 P2 复盘:把"僵尸任务"的机制、观测手段、以及 4 条可复用的治理动作讲清楚。
一、现象:上游全超时,服务端却"忙得发疯"
场景:订单详情聚合接口 /api/order/detail,并行调 3 个下游(物流 Redis、优惠券 DB、用户中心 RPC),代码写法是经典的:
CompletableFuture.allOf(f1, f2, f3).get(300, TimeUnit.MILLISECONDS);
故障:某日下午 15:02 起,物流下游 Redis 抖动,P99 从 40ms 涨到 2.4s。15:03 起上游网关 5xx 占比冲到 87%,持续 40 秒后自愈。
反直觉的地方:我们以为"上游 300ms 就超时了,服务端最多 300ms 就把请求放掉了"。结果 jstack 抓下来看到:
ForkJoinPool.commonPool-worker从基线 16 个涨到 310 个(8C 机器,commonPool默认parallelism = cores × 8 - 1 = 63,但任务队列堆积导致 worker 被临时拉起)- 服务进程 CPU 从 18% 冲到 92%,持续 40 秒
- 线程栈里 78% 的 worker 阻塞在
redis.clients.jedis.Jedis.get(Jedis 同步 GET,SO_TIMEOUT=2000ms) - 下游 Redis 侧
INFO看到instantaneous_ops_per_sec从基线 8k 涨到 62k——这些全是上游已经放弃的"僵尸请求"在跑
一句话:上游已经走了,厨师还在灶上炒菜。
二、根因:Java 世界的"取消"为什么这么难
Go 同学看到这段会笑:context 一路穿透,下游 select { case <-ctx.Done(): } 就能停。但 Java 生态里,"超时"和"取消"是两回事:
Future.get(timeout)只是"不等了":底层ForkJoinTask还在队列里 / 还在跑,JVM 没有Task.cancel()的语义(Future.cancel(true)对阻塞在 IO 上的线程基本无效)。- 同步 IO 无法中断:Jedis 的
GET阻塞在SocketInputStream.read()上,Thread.interrupt()对非阻塞 IO 有效、对阻塞 socket read 无效(除非 close socket,而 Jedis 池化下不能随便 close)。 commonPool是全局共享的:一个下游慢,把commonPool的任务队列撑爆,其他业务用commonPool的任务(比如定时任务、并行流)一起被拖慢——故障横向扩散。
所以"上游超时"这件事在服务端眼里等于没发生,任务会一直跑到:
- Redis
SO_TIMEOUT触发(2s)→ 抛JedisConnectionException→ 任务结束 - 或者 DB
MAX_EXECUTION_TIME触发 → 抛异常 → 任务结束 - 或者 RPC 框架自己的超时触发
这段时间里,任务占着的线程、连接、内存,就是"僵尸资源"。上游每超一个,服务端就背一个"尸体",尸体越多 CPU 越热。
三、具体做法:4 条可复用的治理动作
3.1 隔离线程池:别再让 commonPool 背锅
把"聚合调下游"从 commonPool 挪到独立池,池大小按 下游 P99 × 上游 QPS 反推:
// 物流下游独立池:QPS 800 × P99 2.4s ≈ 1920 并发需求,但我们要主动限
ThreadPoolExecutor logisticsPool = new ThreadPoolExecutor(
64, 128,
60L, TimeUnit.SECONDS,
new SynchronousQueue<>(),
new ThreadFactoryBuilder().setNameFormat("logistics-%d").build(),
new ThreadPoolExecutor.AbortPolicy() // 关键:满就拒绝,别排队
);
CompletableFuture<LogisticsInfo> f1 = CompletableFuture
.supplyAsync(() -> redisGetLogistics(id), logisticsPool);
要点:SynchronousQueue + AbortPolicy,任务满了直接拒绝上游,而不是排队 40 秒慢慢烂掉。
3.2 下游硬超时:在"资源层"设上限,而不是在"代码层"
代码层的 get(300ms) 是"不等了",真正的止损要在资源层:
| 资源 | 配置项 | 推荐值 |
|------|--------|--------|
| Redis (Jedis) | SO_TIMEOUT | 200~500ms(别用默认 2000ms) |
| Redis (Jedis) | CONNECT_TIMEOUT | 300ms |
| MySQL | MAX_EXECUTION_TIME(session 级) | 业务 P99 × 3,如 500ms |
| MySQL | innodb_lock_wait_timeout | 5s(默认 50s 太长) |
| RPC (Dubbo) | timeout | 200~500ms,别用默认 1000ms |
关键:这些是"任务真正停下来"的硬上限,比任何 Future.get(timeout) 都可靠。
3.3 监控"僵尸率":新增一个核心指标
僵尸率 = 服务端任务实际耗时 > 上游超时 的比例。Prometheus 埋点:
long start = System.nanoTime();
try {
result = f1.get(300, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
// 关键:记录"任务还在跑"的耗时
long elapsed = (System.nanoTime() - start) / 1_000_000;
Metrics.counter("task.zombified", "downstream", "logistics").inc();
Metrics.histogram("task.actual.duration.ms", "downstream", "logistics")
.record(f1.join().elapsedMs); // 拿到任务真正结束的时间
}
告警规则:rate(task.zombified_total[1m]) / rate(task_total[1m]) > 0.05 持续 1 分钟告警。这个指标比"上游 5xx 率"早 5~10 秒反映问题——因为僵尸任务在服务端先出现。
3.4 拒绝"全局共享"的默认值
commonPool只用于 CPU 密集型小任务(并行流、工具函数),任何调下游的异步任务都用独立池- Jedis 池
maxWait=200ms(默认 2000ms 太长,排队比执行还慢) - HikariCP
connectionTimeout=3000ms(默认 30s 是放大故障的温床)
四、踩过的坑(反直觉的地方)
Future.cancel(true)对阻塞 IO 无效:别指望这个 API,它只能取消"还没开始跑"的任务,跑在read()里的线程救不回来。orTimeout(300ms)不等于"300ms 就停":它只是让CompletableFuture提前completeExceptionally,底层任务还在跑(详见引用[1])。要真正停,得在下游资源层设硬超时。commonPool的parallelism不是池大小:它是"常驻 worker 数",任务堆积时 JVM 会临时拉 worker,导致线程数远超parallelism,jstack里看到的 310 个 worker 就是这么来的。SO_TIMEOUT设太短会误伤正常慢请求:我们一开始设 50ms,结果 P99 从 40ms 涨到 55ms 时就开始误超时,5xx 率从 0.3% 涨到 4%。推荐按下游 P99 × 3 设,别拍脑袋。
五、结论与可复用清单
核心认知:上游超时 = 上游不看了;服务端任务 = 继续跑到"资源层硬超时"为止。中间的差值就是"僵尸窗口",僵尸窗口内烧的 CPU、占的线程、用的连接,都是你的真实成本。
可复用清单:
- [ ] 所有调下游的异步任务用独立线程池,
SynchronousQueue + AbortPolicy - [ ] 下游资源层硬超时:Redis
SO_TIMEOUT≤ 500ms、MySQLMAX_EXECUTION_TIME按 P99×3、RPCtimeout≤ 500ms - [ ] 新增"僵尸率"指标:
rate(task.zombified_total[1m]) / rate(task_total[1m]),>5% 告警 - [ ]
commonPool只跑 CPU 密集小任务,禁止调下游 - [ ]
jstack抓线程栈时,重点看ForkJoinPool.commonPool-worker的数量和阻塞栈——这是"僵尸任务"的指纹
一句话总结:超时是"让失败尽快返回"的开关,不是"让任务停下来"的开关。想让任务停下来,得在资源层下刀。
引用
[1] 谢若衡-858《CompletableFuture.orTimeout 超时了任务却还在跑:一次 30 分钟 commonPool 线程耗尽的排查与「超时即停」的正确姿势》(id=922) [2] 程景行-447《goroutine 从 1.2k 涨到 9 万,服务「活着但拒连」:Go 里被静默吞掉的 context 与「取消必须穿透」排查清单》(id=1558)