一个慢 SQL 把整个线程池拖垮

2024 年第三季度的某个周二下午,忽然告警群炸了——订单服务的 P99 延迟从 80ms 飙到了 12 秒。

一开始以为是数据库挂了。查了一圈,数据库好好的,QPS 也没涨。诡异的是,连健康检查接口都超时了——一个 /health 端点,只是返回个 {"status":"ok"},竟然要等 8 秒。

翻 Tomcat 线程池,200 个线程全部 BUSY。dump 线程栈,198 个线程卡在 socketRead0 上——全在等一个外部对账系统的 HTTP 响应。

真相很简单:那个对账接口的第三方服务挂了,HTTP 连接超时设了 30 秒。而我们的 Tomcat 默认线程池只有 200 个线程。当对账请求把所有线程占满后,连最基本的健康检查都拿不到线程了——整个服务对上游来说等于死了。

一个外部系统的故障,通过共享线程池这个「公共资源」,把整个服务拖下水。用船舶工程的术语说,这叫没有水密舱壁——一个舱室进水,整条船沉没。

舱壁隔离模式的核心思想
舱壁隔离(Bulkhead)这个词来自船舶设计。船体被分成多个水密隔舱,即使某个舱室破损进水,水也不会蔓延到其他舱室,船还能继续浮着。

在软件系统里,舱壁隔离的实践含义是:把资源(线程、连接、内存)按业务维度划分成独立的池子,一个池子耗尽不影响其他池子。

这个模式不在 GoF 23 种设计模式之列——GoF 诞生于 1994 年,那时候的软件还没有「微服务相互调用然后一起死」这种问题。舱壁隔离是 Resilience4j、Hystrix 这些韧性框架带火的,它解决的是分布式系统特有的级联故障。

线程池隔离:最直接的落地方式
回到那个事故现场——修复方案不是调大超时时间,而是拆线程池。

在 Spring Boot 里,Tomcat 默认一个线程池处理所有请求。用舱壁隔离的思路,应该这样改:

```java // 核心业务的线程池——订单、支付、库存 @Bean("coreExecutor") public Executor coreExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(50); executor.setMaxPoolSize(100); executor.setQueueCapacity(500); executor.setRejectedExecutionHandler(new CallerRunsPolicy()); return executor; }

// 外部调用的线程池——对账、短信、物流查询 @Bean("externalExecutor") public Executor externalExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(20); executor.setQueueCapacity(100); executor.setRejectedExecutionHandler(new CallerRunsPolicy()); return executor; } ```

核心逻辑就一句话:对账接口走 externalExecutor,哪怕它 20 个线程全卡在对账系统的超时等待里,coreExecutor 的 100 个线程照样处理订单请求。一个舱室进水,船不沉。

具体到 Controller 层:

```java @RestController public class OrderController {

@Autowired
@Qualifier("coreExecutor")
private Executor coreExecutor;

@Autowired
@Qualifier("externalExecutor")
private Executor externalExecutor;

@GetMapping("/order/{id}")
public CompletableFuture<Order> getOrder(@PathVariable Long id) {
return CompletableFuture.supplyAsync(
() -> orderService.findById(id), coreExecutor
);
}

@GetMapping("/reconciliation/{date}")
public CompletableFuture<Report> reconciliation(@PathVariable String date) {
return CompletableFuture.supplyAsync(
() -> reconciliationService.doReconciliation(date), externalExecutor
);
}

} ```

这里不是让所有请求都异步化——核心接口走同步也可以,只要保证不同风险的接口使用不同的线程池就行。异步化只是让线程池隔离的实现更直观。

有人可能会想:那我给每个接口都分配一个线程池行不行?这就掉进另一个坑了。

过度隔离比不隔离更危险
我在一个项目里见过有人给每个第三方调用都建了一个线程池——短信一个池子、物流查询一个池子、支付回调一个池子、对账单一个池子……12 个业务,12 个线程池。看起来很「安全」,每个都独立隔离,实际上问题比不隔离还大。

线程本身就是资源。JVM 里一个线程默认 1MB 栈空间,12 个池子每个 20 个核心线程就是 240MB 起步——这还是栈空间,不算线程切换的 CPU 开销。

更隐蔽的问题是:线程饥饿从「全局可见」变成了「局部暗箱」。以前 200 个线程全部 BUSY,你一眼就知道出问题了。现在 12 个池子各自为政,某些池子悄悄耗尽但告警阈值没配到池级别,问题闷在里面没人发现。

隔离的粒度应该跟业务风险对齐,不是跟接口数量对齐。我的经验是分三层就够了:

核心池:直接影响用户体验的接口(下单、支付、查询订单详情),线程数给足,拒绝策略用 CallerRunsPolicy 让调用方扛压。
外部调用池:依赖第三方服务的接口,线程数收紧,超时时间对齐 SLA,拒绝策略可以走降级逻辑。
后台任务池:报表、对账、批量处理,线程数可以更少,超时可以更长,但绝不能和核心池共享资源。
三层结构覆盖了绝大多数微服务场景。如果你发现自己在想「这个接口要不要再开一个池子」,先问自己:它的故障会拖死核心业务吗?如果不会,归到外部调用池就够了。

舱壁隔离 vs 熔断器:两个容易搞混的东西
写了舱壁隔离,大概率会有人问:这和熔断器(Circuit Breaker)有什么区别?不都是防止故障扩散吗?

区别在于防守层级。

熔断器守的是「调用关系」——A 调用 B,B 挂了,熔断器让 A 不再继续调用 B 而是直接走降级,避免 A 被 B 拖住。但熔断是在单次调用层面工作的——它阻止的是无效调用的堆积,不解决资源抢占问题。

舱壁隔离守的是「资源边界」——不管 B 挂不挂,A 内部用于调用 B 的线程/连接数有上限,超过上限就不再分配。这比熔断器更底层:不需要等 B 挂掉才开始保护,资源限制本身就是保护。

真实场景里,两者是配合关系。舱壁隔离给线程池设了个天花板,熔断器在天花板之内做快速失败。没有舱壁,100 个请求同时打到熔断器上,熔断器还没判断完要不要断,线程池已经满了。

用 Resilience4j 的例子可以最直观地看到两者的配合:

```java // 舱壁:限制并发调用数 BulkheadConfig bulkheadConfig = BulkheadConfig.custom() .maxConcurrentCalls(10) .maxWaitDuration(Duration.ofMillis(500)) .build();

// 熔断:监控失败率 CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom() .failureRateThreshold(50) .waitDurationInOpenState(Duration.ofSeconds(30)) .slidingWindowSize(10) .build();

// 组合使用 Supplier decorated = Decorators.ofSupplier(() -> remoteCall()) .withBulkhead(Bulkhead.of("external", bulkheadConfig)) .withCircuitBreaker(CircuitBreaker.of("external", cbConfig)) .decorate(); ```

先过舱壁(线程隔离),再过熔断(失败判断)。舱壁确保你不会用光线程,熔断确保你不会在对方挂了之后继续浪费线程。

另一个容易忽略的舱壁:连接池
线程池的隔离大家比较容易想到,连接池的隔离却经常被漏掉。

假设你的应用连接同一个 Redis 集群做两件事:缓存商品信息和存放用户 Session。某天商品缓存因为大 key 操作导致 Redis 响应变慢,所有 Redis 连接被商品缓存的请求占满——结果是用户 Session 也读不到了,所有用户被踢下线。

这在架构上完全不应该发生:商品缓存挂了,最多是用户体验变差(多查一次数据库),但不应该导致登录态丢失。问题就在于两件事共享了同一个连接池。

解决方案是分池:商品缓存走一个 Redis 连接池(比如 maxTotal=50),Session 走另一个(maxTotal=10)。Session 池哪怕只有 5 个连接可用,也足够支撑正常流量。数据库连接池同理——读写分离之后,报表查询和在线交易的连接池也应该拆开。

本质上这和线程池隔离是同一个逻辑:不要让非核心功能挤占核心功能的生存资源。

生产落地要留意三个坑
光知道「要隔离」不够,有几个落地细节不注意会翻车。

坑一:线程池拒绝策略的选择。 默认的 AbortPolicy 直接抛异常,外部调用这样还行,核心业务抛异常就影响用户体验了。CallerRunsPolicy 让调用线程自己执行任务——线程池满了就让 Tomcat 的工作线程去跑,虽然慢点但不会直接报错。DiscardPolicy 直接丢弃,除非你确认任务丢了没关系,否则别用。

坑二:监控粒度要对齐隔离粒度。 拆了三个线程池但要监控每个池的使用率、拒绝次数、队列长度。某个池子的队列开始积压而监控里看不出来,隔离等于白做——你是把问题藏起来了,不是解决了。

坑三:舱壁不是万能的,有些故障隔离不了。 如果你的服务本身就部署在同一个 JVM 上,一个死循环或 Full GC 照样会拖死所有线程池。舱壁隔离管的是资源分配,管不了资源共享——CPU、内存、GC 暂停对所有线程是一视同仁的。真正致命的故障需要进程级隔离,也就是把高风险模块拆成独立部署的服务。舱壁是成本最低的隔离手段,但不能替代架构层面的拆分决策。

回到那个周二下午
那次事故之后,我们在所有服务里加了舱壁隔离。配置不复杂——每个服务最多三个线程池,外部依赖统一走 external 池,连接池也按业务拆开了。

后来也遇到过对账系统再次挂掉的情况。这一次,externalExecutor 的 20 个线程很快耗尽,新来的对账请求收到「系统繁忙请稍后重试」的降级响应。核心订单接口的 P99 延迟从 80ms 涨到了 120ms——因为部分依赖对账结果的逻辑走了降级分支,稍微慢了 40ms。

120ms 对比 12 秒。这就是舱壁隔离的价值——不是让你不挂,是让你挂了也死得有尊严,核心业务不受牵连。
————————————————
版权声明:本文为CSDN博主「zhouhui001」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/zhouhui001/article/details/162259186

上一篇 LiveCD工具介绍
下一篇 为什么有了IP地址,还需要每一个网卡都有的Mac地址?