前面九篇基本都在讲"怎么把一个请求做对"。但一个接口一旦上线,面对的是每秒几十次调用,这时候冒出来的问题跟前九篇完全不是一类:连接池、线程池、上游配额。这一篇讲这些,其中一个坑特别隐蔽——线程池复用导致上一个请求的数据串到下一个请求上。
一、单请求跑通之后,三个瓶颈会依次出现
本地测通了,放进服务里,压力一上来,问题会按这个顺序暴露:
第一个是连接池。 第四篇讲过 HttpClient 要单例,如果每个请求都 new 一个,每个客户端各自持有连接池和线程池,几百个请求下去内存就被吃光。这个坑在低并发时不明显,一上量就致命。
第二个是线程池。 流式请求的特点是"大部分时间在等 IO"。这听起来很省资源,但线程仍然被占住。100 个并发流式请求,就要有 100 个线程同时活着等数据,而不是 100 个线程轮流干活。
第三个是上游配额。 你自己的资源够,但上游给的是有限额度。并发一高,429 就来了。这时候如果代码里没有限流,你会在短时间内发起远超配额分量的请求,结果是全都被拒——本来能成功的那些也一起失败了。
三个瓶颈的应对方式不同:连接池靠复用、线程池靠定容、上游配额靠限流。下面逐个说。
二、线程数和内存的关系,先算清楚
这一节给一个估算方法,因为"线程池开多大"是个必须自己算的数。
每个 Java 线程都要占一块栈内存,默认大约 0.5MB 到 1MB(取决于 -Xss 设置)。所以:
线程内存 ≈ 线程数 × 1MB
听上去不大。但要注意,除了栈,每个请求还持有别的对象——请求体、响应缓冲、拼接中的 StringBuilder、解析出来的 JSON 树。一个处理长回答的流式请求,这些加起来可能远超 1MB。
所以"1000 个线程"这件事,光是栈就是 1GB,再加上业务对象,很容易让 GC 变得频繁,进而拖慢所有请求。
反过来,线程数也不是越小越好。太小则并发能力不足,请求排队,延迟上升。
一个务实的估算顺序:
- 先看上游允许你多少并发(这是硬上限,算再多也没用);
- 再看峰值 QPS 和单请求平均耗时,估算需要的并发数(
并发数 ≈ QPS × 平均耗时(秒)); - 取两者较小值,再留一点余量;
- 最后用
-Xss和堆大小验证一下内存是否扛得住。
注意这个公式里的"平均耗时"在大模型场景下很不稳定——同一个接口短问题 1 秒、长回答 30 秒。所以估算时要用你业务的实际分布,或者干脆按上游并发上限来定,更简单也更安全。
三、限流:目标是不把上游打挂
限流有两条思路,先分清它们管的东西不一样。
信号量:限制"同时有多少个在跑"(并发数)。
public final class ConcurrencyLimiter {
private final Semaphore permits;
public ConcurrencyLimiter(int maxConcurrent) {
this.permits = new Semaphore(maxConcurrent, true); // 公平模式,避免饥饿
}
public <T> T call(long timeoutMs, Callable<T> task) throws Exception {
if (!permits.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS)) {
// 拿不到许可:说明当前已经满载
throw new IllegalStateException("并发已满,请稍后重试");
}
try {
return task.call();
} finally {
permits.release(); // 必须放在 finally,否则泄漏一次就永久少一个许可
}
}
}
tryAcquire 带超时这一点很关键。如果写成 acquire() 不带超时,满载时请求会无限等待,用户侧一直转圈,而你这边线程全被占住——比直接拒绝更糟。
令牌桶:限制"单位时间内允许多少个"(速率/QPS)。
它和信号量的区别是:令牌桶允许短时突发(桶里攒了令牌就一次性放行),信号量不允许。如果你的业务有明显的波峰,令牌桶更合适;如果只是想让并发别太高,信号量更简单。
该放在哪一层:
- 网关层限流:保护整个系统,按 IP 或用户维度,粒度粗但统一;
- 客户端限流:保护上游,按"我对上游的配额"来限,粒度细。
两个都要,因为它们防的不是同一件事。 网关防的是别人把你打挂,客户端限流防的是你把自己依赖的上游打挂,以及把自己的配额耗光。
429 的含义要理解对。 它不是"出错了",而是上游明确在说"你现在太快了,慢一点"。所以收到 429 的正确反应是退避后重试,而不是立刻重试——立刻重试只会继续被拒,并且浪费你的配额。
这里有个反直觉的点:收到 429 说明你的限流没做好。 因为限流的意义就是在打到上游之前自己先控制住。如果经常收到 429,说明限流阈值设得比自己的配额高。
四、一个隐蔽的坑:ThreadLocal 遇上线程池
这一节是本文的重点,因为它的症状和原因离得很远,排查起来很痛苦。
ThreadLocal 常被用来存请求上下文:traceId、当前用户、会话 ID。在传统同步模型里用得很顺手。但线程池会复用线程——一个线程处理完请求 A,接着去处理请求 B,而它身上的 ThreadLocal 值不会自动清空。
于是出现这种情况:
private static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();
// 处理请求时
CURRENT_USER.set(userId);
try {
callLlm();
} finally {
// 忘了 remove,或者根本没写 finally
}
请求 A 由线程 T1 处理,设了 CURRENT_USER = "user_A"。请求结束后没有 remove。请求 B 也落到 T1 上,如果 B 的代码路径里没重新 set(比如某个分支跳过了),它读到的就是 user_A。
结果就是数据串了。 表现可能非常离奇:A 用户看到了 B 用户的对话历史、日志里 traceId 对不上、统计指标算到别人头上。而且它是偶发的——取决于线程调度,本地几乎复现不出来。
三个防护做法,按推荐程度排:
第一,永远用 try-finally 清理。
CURRENT_USER.set(userId);
try {
callLlm();
} finally {
CURRENT_USER.remove(); // remove 而不是 set(null),避免 Map 里留下空值条目
}
注意是 remove() 而不是 set(null)。后者会让 ThreadLocalMap 里留下一个 key 为 null 的条目,长期运行可能造成内存泄漏。
第二,优先用显式传参,而不是 ThreadLocal。
如果上下文只是要传给下游几个方法,直接作为参数往下传是最安全的——没有隐式状态,就没有串数据的可能。 ThreadLocal 的便利性换来的是这类隐藏风险,能不用就不用。
第三,确实需要跨线程传递时(比如提交到线程池执行),要显式捕获和恢复。
因为新线程拿不到原线程的 ThreadLocal。做法是在提交任务前把值取出来,在任务内部重新 set 并在结束时清理:
String userId = CURRENT_USER.get();
executor.submit(() -> {
CURRENT_USER.set(userId);
try {
doWork();
} finally {
CURRENT_USER.remove();
}
});
阿里开源的 TransmittableThreadLocal(TTL)就是为这个场景做的,能自动完成"提交时捕获、执行时恢复、结束后清理"。如果这类需求很多,直接用它比自己手写可靠。
还有一点容易被忽略:异步框架和线程池组合时,清理责任在谁。 如果用了 @Async、CompletableFuture、Reactive 这些东西,执行线程可能换了好几次。要确认每一段切换都有人负责清理——往往就是这里漏掉一处,导致偶发串数据。
五、异步编排:别用默认的 commonPool
当你需要并行发多个模型调用(比如同时问两个模型做对比,或者一个任务拆成几段并行处理),CompletableFuture 是最直接的写法。
但有个默认行为要改掉:CompletableFuture 的异步方法如果不指定 executor,会用 ForkJoinPool.commonPool()。 这个池是 JVM 全局共享的,池大小默认是 CPU 核数减一,而且被整个应用共用。
把它用在大模型调用上问题很大:
- 池太小:4 核机器只有 3 个线程,而你的任务全在等网络 IO,白白浪费并发能力;
- 污染全局:你的长任务会占住 commonPool,影响应用里其他用到它的代码(比如并行流);
- 不好观测:这个池的指标和你的业务混在一起,出问题难定位。
所以显式给一个专用线程池:
// IO 密集型:线程数可以给得比 CPU 核数大
private static final ExecutorService LLM_POOL =
Executors.newFixedThreadPool(32, r -> {
Thread t = new Thread(r, "llm-call");
t.setDaemon(true);
return t;
});
CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> ask(modelA), LLM_POOL);
CompletableFuture<String> b = CompletableFuture.supplyAsync(() -> ask(modelB), LLM_POOL);
超时要统一控制。 CompletableFuture 自身没有超时机制,get() 会一直等。要显式加:
String result = future.get(30, TimeUnit.SECONDS);
或者用 orTimeout / completeOnTimeout 给 future 挂超时。注意超时之后任务不会自动停止——get 抛异常了,但底层那个 HTTP 请求还在跑,还在烧 token。要做到真正取消,得把取消信号传到实际执行的那一层(这就是第四篇里讲的"用户断开要能取消上游")。
异常在异步链里的传播也要小心。 CompletableFuture 会把异常包在 CompletionException 里。如果你用 join() 会抛未检查异常,用 get() 抛的是 ExecutionException,里面再包着真正的原因。取根因的时候要用 getCause() 或遍历 suppressed,别只打最外层的异常信息——否则日志里只看到"ExecutionException",看不到真正是超时还是鉴权失败。
六、背压:排队会让超时发生错的地方
请求量超过处理能力时,只有两个选择:排队或者拒绝。
很多人默认选排队,觉得"排着总比拒绝好"。但这里有个容易被忽视的后果:
如果队列没有上限,请求会在排队阶段就超时。
设想接口超时是 30 秒。来了 500 个并发请求,实际只能处理 50 个。剩下 450 个进入队列。排在后面的请求,光等前面处理完就花了 60 秒——它最终抛出的超时异常,实际上跟模型一点关系都没有。 而你排查的时候会以为是模型慢,去看上游监控,发现上游一切正常,完全对不上。
所以:
- 队列必须有界。 无界队列只是把"崩溃"推迟成"缓慢劣化",而且掩盖了真正的问题。
- 拒绝策略要明确。 满了之后是同步返回"系统繁忙",还是把请求转到异步通知,这属于业务决策,不要用默认的
AbortPolicy稀里糊涂地抛异常。 - 监控排队时长,而不只是看总耗时。 把"排队时长"和"实际调用时长"分开统计。前者能立刻告诉你系统是不是过载了,后者才是模型性能。混在一起看,你无法区分是上游慢还是自己排队。
这条对成本也有影响:排队超时的那批请求,可能已经发出去了(如果超时检测在发出之后),token 已经烧了,用户却没拿到结果。所以排队深度应该尽量浅。
七、小结与检查清单
把这一篇收成一份可对照的清单:
- 连接:
HttpClient单例共享,别每请求 new(第四篇) - 线程:显式给固定大小线程池,别用默认
CachedThreadPool;按上游并发上限定容量 - 限流:网关限流 + 客户端限流都要,前者防被打挂,后者防耗光配额
- 429:是"慢一点"的信号,退避重试;收到它就说明限流阈值设高了
ThreadLocal:finally里remove();能显式传参就别用;跨线程用 TTL- 异步:
CompletableFuture显式指定专用池,别用commonPool - 超时:
get(timeout)要加;注意超时后底层任务不会自动停止 - 背压:队列有界、拒绝策略明确、排队时长与调用时长分开监控
最后一条判断标准:如果出现了偶发的、本地复现不了的串数据问题,先去查 ThreadLocal 和线程池组合的地方。 这类问题八成就在那里。
下一篇
并发控制住了,还剩最后一个问题:控制不住的时候怎么办。 上游会挂、配额会用完、网络会分区,这时如果没准备降级方案,接口就只能一路报错到底。下一篇讲超时分级与降级、熔断与半开、以及"降级的四种层次"——从换模型到明确告诉用户稍后再试。
本文是「用 Java 接入大模型」系列第十篇。上一篇讲了 token 计数与上下文预算,建议按顺序看。文中线程数与池大小取值为估算起点,实际值请按你的上游配额与压测结果标定。