bigywater.tech
微服务 · 8

舱壁与连接池耗尽:一个慢依赖拖垮整个服务,线程池隔离、信号量隔离与网关的连接堆积

一个依赖变慢,怎么拖垮整个服务?线程池隔离和信号量隔离各怎么做?Java 线程池满了该选哪种拒绝策略?网关上又怎么防止慢上游把连接占满?这篇用本机的小服务、Java 和 nginx 量出来。

0.0% 对 100.0%
D2 变慢后,共享线程池与舱壁(信号量或独立线程池)下,与 D2 无关的 A 类请求 1 秒内的成功率
622 ms
CallerRunsPolicy 提交 20 个任务用的时间:拒绝策略把提交者也拖慢了(AbortPolicy 是 0 ms)
81% → 100%
nginx worker_connections = 64 被 /slow 占满时,不相关的 /fast 的探测成功率,加 limit_conn 6 之后

共享线程池与舱壁

实测 · 本机执行输出
  做法              | A 类请求数 | A 类 1 s 内成功 | A 类成功的中位数 | A 类 p99 | B 类请求数 | B 类 1 s 内成功 | B 类被立刻拒绝 | B 类超时
  ----------------+--------+-------------+-----------+---------+--------+-------------+----------+------
  共享线程池           | 350    | 0.0%        | -         | -       | 150    | 0.0%        | 0        | 150  
  信号量舱壁(D2 ≤ 5 个) | 350    | 100.0%      | 15 ms     | 17 ms   | 150    | 0.0%        | 140      | 10   
  独立线程池(14 + 6)   | 350    | 100.0%      | 14 ms     | 17 ms   | 150    | 0.0%        | 138      | 12   
  (连接类错误 0 次;不是超时,应为 0)

一个依赖变慢会占满共享的线程,没有问题的请求也饿死;给每个依赖划定上限后,慢的只影响自己。

Java 线程池的拒绝策略

实测 · 本机执行输出
  策略                  | 执行了的任务 | 抛出 RejectedExecutionException | 悄悄丢掉的 | 其中在提交者(main)线程里执行 | 提交完 20 个任务用了 | 执行了的任务编号                                                              
  --------------------+--------+-------------------------------+-------+-------------------+--------------+-----------------------------------------------------------------------
  AbortPolicy         | 8      | 12                            | 0     | 0                 | 0 ms         | [0, 1, 2, 3, 4, 5, 6, 7]                                              
  CallerRunsPolicy    | 20     | 0                             | 0     | 3                 | 622 ms       | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
  DiscardPolicy       | 8      | 0                             | 12    | 0                 | 0 ms         | [0, 1, 2, 3, 4, 5, 6, 7]                                              
  DiscardOldestPolicy | 8      | 0                             | 12    | 0                 | 1 ms         | [0, 1, 6, 7, 16, 17, 18, 19]                                          
  AbortPolicy 把拒绝变成异常交给调用方处理;CallerRunsPolicy 让提交者自己执行,所以提交会被拖慢(这是一种背压),但提交者线程也被占住;DiscardPolicy 悄悄丢最新的;DiscardOldestPolicy 悄悄丢队列里最老的(编号靠前的排队任务没有执行)

Abort 抛异常,CallerRuns 让提交者执行(背压),Discard 悄悄丢最新的,DiscardOldest 悄悄丢最老的。

网关上的连接

实测 · 本机执行输出
  配置                   | /fast 探测次数 | /fast 成功 | 成功的中位数 | p99    | /fast 的失败类型(error = 连接被拒或重置) | /slow 被立刻 503 的次数 | error.log 里 worker_connections are not enough 的行数
  ---------------------+------------+----------+--------+--------+------------------------------+-------------------+--------------------------------------------------
  不做隔离                 | 47         | 81%      | 1.5 ms | 2.5 ms | {'error': 9}                 | 0                 | 632                                              
  /slow 加 limit_conn 6 | 47         | 100%     | 1.1 ms | 4.9 ms | {}                           | 3281              | 0                                                
  limit_conn 的计数键用的是常量字符串 "slow":最初我用了 $server_name,这个 server 块没有写 server_name,变量值是空串,而 nginx 不统计键为空的请求,结果 limit_conn 悄悄没有任何效果(20 个并发请求全部 200)

慢上游占满 worker_connections 时无关的路由也受影响;限制慢路由的并发可以隔离。limit_conn 的键不能是空串。

分步演示

舱壁与连接池耗尽
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Go / nginx / Envoy / etcd / Consul / Nacos / OpenTelemetry / Prometheus(按各篇注明用到哪些及版本);所有服务都是本机自己起的进程,端口 9150 以上,只监听 127.0.0.1,不连任何外部服务,不碰本机已有的实例;故障用 kill -9 进程、让连接断开或在代码里注入延迟与错误来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。第一节的“服务”是我写的 20 个线程的小池子(Python),D1 / D2 是本进程里的 http.server;信号量舱壁是 B 类调用 D2 前非阻塞地拿最多 5 个的信号量;独立线程池是 14 + 6 个线程各有一个队列(B 的队列最多排 10 个);第二节是 OpenJDK 17.0.20.1 的 ThreadPoolExecutor(java Reject.java 单文件运行);第三节是 Homebrew 的 nginx 1.31.6(worker_connections 改成 64),40 个 client 线程
    • 没有验证:Resilience4j / Hystrix 的 Bulkhead 实现细节;线程池隔离时线程切换的开销;ThreadPoolExecutor 自定义拒绝策略(阻塞入队);nginx 之外的网关;第一节的 B 类 1 s 内都不会成功(D2 要 2 s),所以没有比较 B 类的成功率
    京ICP备2026067502号-1京公网安备11010502063487号