网关限流过了,线程池却先扛不住了——我们给每个接口单独配了线程隔离和排队
事情要从一次不怎么愉快的线上故障说起。
那天下午 3 点左右,监控告警突然炸了——网关返回 502 的比例从 0.01% 飙升到 12%。第一反应是下游服务挂了,但查了一圈发现服务进程活得好好的,CPU 没打满,内存也正常。问题出在 Tomcat 的请求线程池:800 个线程全部被占用,大量请求在排队,排到超时就返回 502 了。
可网关明明做了限流啊。我们用的是 Sentinel,QPS 阈值设在 2000,当时实际的入口流量才 1800 多,压根没触发限流规则。
那线程池是怎么满的?
翻了一下慢调用链路,发现有一个商品详情接口出了问题——依赖的推荐服务响应时间从平时的 80ms 涨到了 4 秒多。这个接口的 QPS 并不高,大概 200 左右,但每个请求占着 Tomcat 工作线程的时间翻了 50 倍。200 个并发请求就把线程池占掉了一大块,剩下的线程被其他正常接口争抢,整个服务就像堵车一样从局部蔓延到全局。
这就是典型的线程池资源争抢问题。网关限流保的是「入口流量不超过服务能承受的 QPS 上限」,但它不关心进来的这些请求分别是干什么的、各自要花多长时间。一个慢接口就能把整个线程池拖垮,其他正常接口跟着遭殃。
Tomcat 默认的线程模型是所有请求共用一个大线程池,这种"一锅烩"的方式在服务简单、接口响应时间均匀的时候没问题,一旦某个接口出现长尾延迟,就会产生连锁反应。而且更头疼的是,线程池满之后的拒绝策略是直接抛异常,用户看到的就是 502 或者白屏,连个体面的降级提示都没有。
所以我们的改造思路很明确:不能让一个慢接口影响其他接口,得给每个接口分配独立的线程资源,并且加上排队机制,让请求在资源不够时有序等待而不是直接失败。
从共用线程池到接口级线程隔离
改造的核心是把「一个大池子」拆成「多个小池子」,每个接口或每组接口使用独立的线程池。这样即使某个接口的线程池满了,也只会影响这个接口自己,其他接口不受牵连。
技术选型上我们看了 Hystrix 的线程池隔离模式,但 Hystrix 已经进入维护模式,而且它的线程池是绑定在 Command Key 上的,和我们的接口粒度匹配得不够自然。最终决定自己实现一套轻量级的隔离机制,基于 Java 的 ThreadPoolExecutor 来构建。
实现的逻辑是这样的:定义一个线程池注册表,key 是接口标识(我们用的是 URL 路径前缀作为分组维度),value 是对应的线程池实例。请求进入服务后,根据请求路径匹配到对应的线程池,把业务逻辑包装成 Runnable 提交到那个线程池里执行。Tomcat 的工作线程只负责接收请求和转发,不再执行具体业务。
public class IsolatedThreadPoolManager {
private final ConcurrentHashMap<String, ThreadPoolExecutor> poolMap = new ConcurrentHashMap<>();
public ThreadPoolExecutor getOrCreatePool(String poolKey, ThreadPoolConfig config) {
return poolMap.computeIfAbsent(poolKey, k -> {
return new ThreadPoolExecutor(
config.getCoreSize(),
config.getMaxSize(),
config.getKeepAliveSeconds(), TimeUnit.SECONDS,
new LinkedBlockingQueue<>(config.getQueueCapacity()),
new ThreadFactoryBuilder().setNameFormat("isolated-" + k + "-%d").build(),
new ThreadPoolExecutor.AbortPolicy()
);
});
}
public Future<?> submit(String poolKey, Runnable task) {
ThreadPoolExecutor pool = poolMap.get(poolKey);
if (pool == null) {
throw new IllegalArgumentException("No pool found for key: " + poolKey);
}
return pool.submit(task);
}
}
光有隔离还不够。直接用 AbortPolicy 的话,线程池满或者队列满了就直接抛 RejectedExecutionException,用户体验依然很差。我们需要一个排队机制,让请求在队列已满时不是立即失败,而是等待一段可配置的时间。
排队策略:让请求等一等,而不是直接挂掉
这里的排队不是简单地把 BlockingQueue 的容量调大。队列太大会导致请求排队时间过长,用户早就超时离开了,服务端还在傻等;队列太小又起不到削峰的作用。
我们设计了一个两级排队方案:第一级是线程池自带的任务队列,第二级是一个带超时的等待层。当线程池队列已满、无法立即提交任务时,调用方会阻塞等待一段时间,期间如果队列有空位了就塞进去,超时了才返回降级响应。
具体实现上,用 Semaphore 控制排队深度,配合 ThreadPoolExecutor 的 offer 机制:
public class BoundedWaitingTaskSubmitter {
private final ThreadPoolExecutor executor;
private final Semaphore semaphore;
private final long timeoutMs;
public BoundedWaitingTaskSubmitter(ThreadPoolExecutor executor, int maxQueueSize, long timeoutMs) {
this.executor = executor;
this.semaphore = new Semaphore(maxQueueSize);
this.timeoutMs = timeoutMs;
}
public <T> CompletableFuture<T> submit(Callable<T> task) {
boolean acquired = false;
try {
acquired = semaphore.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS);
if (!acquired) {
// 排队超时,返回降级结果
return CompletableFuture.completedFuture(fallbackResult());
}
return CompletableFuture.supplyAsync(() -> {
try {
return task.call();
} catch (Exception e) {
throw new CompletionException(e);
}
}, executor).whenComplete((r, e) -> semaphore.release());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return CompletableFuture.completedFuture(fallbackResult());
} finally {
if (!acquired) {
// 不需要释放信号量
}
}
}
}
这里 Semaphore 的许可数并不等于线程池队列的实际容量,而是额外的排队位置数。我们把它设置成线程池队列容量的一半,保证排队请求不会堆积太多。比如商品接口的线程池队列容量设成 200,Semaphore 许可数就设成 100,这样最多有 100 个请求在应用层排队等待。
配置落地:不同接口不同参数
每个接口的流量特征不一样,线程池参数也不能一刀切。我们把接口分成了三类:
核心接口(下单、支付、登录):核心线程数 40,最大线程数 80,队列容量 500,排队超时 2000ms。这些接口不能轻易降级,宁愿让用户多等 2 秒也要保证成功率。线程数给得比较足,队列也留得比较大。
常规查询接口(商品详情、列表、搜索):核心线程数 20,最大线程数 40,队列容量 200,排队超时 800ms。这类接口对延迟敏感,排队太久不如直接降级返回缓存数据或者空列表。
非关键接口(评论、推荐、广告):核心线程数 8,最大线程数 16,队列容量 50,排队超时 300ms。这些接口挂了不影响核心链路,快速失败、快速降级是首要目标。
这些参数不是拍脑袋定的,而是根据每个接口的历史 QPS、平均响应时间、P99 延迟反推出来的。核心线程数 = 预期 QPS × 平均响应时间 / 1000,再乘以 1.5 的缓冲系数;最大线程数在此基础上再翻一倍;队列容量按照能缓冲 2-3 秒的突发流量计算。
整个配置放在 Apollo 配置中心里,每个接口池子的参数都可以动态调整。上线第一周我们就调了三次商品接口的队列容量——从 100 调到 200 再调到 300,因为大促期间这个接口的流量波动比平时剧烈得多。
监控和告警配套
线程池隔离之后,监控维度也跟着细化。之前只看服务整体的线程使用率,现在要看每个接口池子的活跃线程数、队列长度、排队等待时间、拒绝次数。
我们用 Micrometer 把每个线程池的指标暴露到 Prometheus:
public class ThreadPoolMetrics {
private final MeterRegistry registry;
public void bindPool(String poolKey, ThreadPoolExecutor executor) {
Gauge.builder("threadpool.active.threads", executor, e -> e.getActiveCount())
.tag("pool", poolKey)
.register(registry);
Gauge.builder("threadpool.queue.size", executor, e -> e.getQueue().size())
.tag("pool", poolKey)
.register(registry);
Gauge.builder("threadpool.completed.tasks", executor, e -> e.getCompletedTaskCount())
.tag("pool", poolKey)
.register(registry);
}
}
告警规则也做了分级:单个接口线程池活跃线程超过最大线程数的 80% 持续 2 分钟发 warning,超过 95% 发 critical;队列使用率超过 70% 发 warning,超过 90% 发 critical。这样可以在接口真正崩溃之前就发现问题。
上线后的实际效果
改造上线两周后,又遇到了一次推荐服务抖动。商品详情接口的响应时间从 90ms 涨到 3 秒左右,它的线程池活跃线程数从 15 左右飙升到 38(最大 40),队列长度从个位数涨到 180 多(容量 200),排队时间上升到 600ms 左右。监控告警及时触发了,我们看到了商品接口的队列使用率超过 90% 的 critical 告警。
但这次,其他接口完全不受影响。下单接口的 P99 延迟稳定在 120ms,登录接口保持在 50ms 以内。整个服务的 502 比例只有 0.02%。
这就是线程隔离最直接的价值:故障被限制在了它该在的范围内,不会扩散。
当然,这套方案也有代价。线程总数比以前多了不少——原来 Tomcat 统一线程池 800 个线程,现在各个接口池子加起来有 1200 多个线程。好在我们的容器是 8 核 16G 的配置,线程数增加并没有带来明显的上下文切换开销,CPU 使用率只上升了不到 3%。如果容器资源比较紧张,线程数需要更精细地控制,或者考虑用协程方案来替代。
另外,线程池隔离后,跨接口的调用链路变得复杂了。原来同一个请求从头到尾在一个线程里执行,ThreadLocal 传递上下文很自然;现在请求可能从 Tomcat 线程跳到隔离线程池的线程,ThreadLocal 里的 traceId、用户信息需要显式传递。我们的解决方式是在任务提交时把上下文对象作为参数传入,在线程池线程里恢复:
public class ContextAwareRunnable implements Runnable {
private final Runnable task;
private final RequestContext context;
public ContextAwareRunnable(Runnable task) {
this.task = task;
this.context = RequestContextHolder.getContext();
}
@Override
public void run() {
RequestContextHolder.setContext(context);
try {
task.run();
} finally {
RequestContextHolder.clear();
}
}
}
常见问题
为什么不直接用 Hystrix 或者 Sentinel 的线程池隔离?
Hystrix 的线程池隔离依赖 Command 模式,需要把每个接口调用包装成 HystrixCommand,侵入性比较强,而且 Hystrix 已经停止维护。Sentinel 的线程隔离模式在 1.8.0 版本之前是实验性的,我们评估当时的版本(1.7.x)在生产环境不够稳定。自己实现的好处是灵活度极高,参数粒度、排队策略、监控埋点都能完全定制,代价是需要自己维护这套代码。
线程池隔离和信号量隔离怎么选?
看场景。线程池隔离适合被隔离的任务本身会阻塞的场景,比如调用第三方 HTTP 接口、查询数据库——这些操作会占用线程,用线程池隔离才能真正隔开资源。信号量隔离适合计算密集型、不会阻塞的任务,开销更小,但无法隔离阻塞调用。我们的大部分接口都涉及下游 RPC 调用和数据库查询,所以全部用了线程池隔离。
排队超时之后返回什么?
返回降级响应,状态码还是 200,但 body 里带一个 "fallback": true 的标记,客户端根据这个标记做对应的处理。对于商品接口,降级时返回 Redis 里缓存的上一次快照数据;对于列表接口,返回空列表;对于推荐接口,直接返回空。总之不能让用户看到报错页面。
线程池参数调错了怎么办,比如队列设太小?
Apollo 配置中心支持实时推送,改了参数后线程池不会重建(重建会导致正在执行的任务丢失),而是通过 ThreadPoolExecutor 的 setCorePoolSize、setMaximumPoolSize 方法动态调整。队列容量没法动态改,这个在设计时就定了,所以队列容量我们留了冗余空间,线上观察两周后再逐步收紧。