用 Java 接入大模型(十):并发调用与限流

简介: 本文是《用Java接入大模型》系列第十篇,聚焦高并发下的三大瓶颈:连接池滥用、线程池膨胀与上游配额超限。重点揭示线程池复用导致ThreadLocal数据串扰的隐蔽陷阱,并给出连接复用、定容线程池、双层限流(信号量+令牌桶)、显式清理ThreadLocal、专用异步池及有界背压等实战方案。

前面九篇基本都在讲"怎么把一个请求做对"。但一个接口一旦上线,面对的是每秒几十次调用,这时候冒出来的问题跟前九篇完全不是一类:连接池、线程池、上游配额。这一篇讲这些,其中一个坑特别隐蔽——线程池复用导致上一个请求的数据串到下一个请求上。


一、单请求跑通之后,三个瓶颈会依次出现

本地测通了,放进服务里,压力一上来,问题会按这个顺序暴露:

第一个是连接池。 第四篇讲过 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 计数与上下文预算,建议按顺序看。文中线程数与池大小取值为估算起点,实际值请按你的上游配额与压测结果标定。

目录
相关文章
|
1天前
|
云安全 安全 网络安全
|
1天前
|
JavaScript 安全 前端开发
Nodejs Brotli decoder 代码执行漏洞 (CVE-2026-111497)10629
Node.js发布安全更新修复Brotli解码器高危RCE漏洞(CVE-2026-111497),建议受影响版本用户立即升级。
1605 0
|
7天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
1305 28
|
3天前
|
存储 人工智能 自然语言处理
阿里云千问办公官网登录入口:qwenwork.cn网页版免下载,注册送2000积分、登录每日再送100积分
千问办公(QwenWork)是阿里巴巴推出的一站式AI办公平台,定位"不止于对话,更注重交付"。它基于千问大模型,具备任务拆解与多步骤自动执行能力,用户一句话即可完成PPT生成、数据分析、网页搭建、视频剪辑、信息调研等复杂任务,覆盖网页端、桌面端及钉钉生态。网页版免下载,访问qwenwork.cn注册即得2000积分,每日登录再送100积分。个人版免费起用,另有付费版与企业版可选。
|
12天前
|
存储 人工智能 安全
千问办公官网入口网页版登录:qwenwork.cn 注册送2000积分、登录送100积分
阿里云千问办公(QwenWork)是AI智能工作平台,支持一句话生成PPT、表格、网页、视频及数据分析。个人免费版送2000积分+每日登录赠100分,含1GB存储、5个发布页;企业版198元/席/月起。阿里千问办公QwenWork官网:https://t.aliyun.com/U/0VCTGt 阿里AI工作平台,一句话完成数据分析、PPT 生成、视频剪辑、网页搭建等复杂任务
|
7天前
|
人工智能 开发者
Qoder CN 大使计划正式开启|无限创造,等你一起
全年免费积分、优先内测、活动支持与更多共创机会,邀请用 Qoder 把想法变成现实的你。
615 6
|
9天前
|
安全 Linux 芯片
【万字长文】Ollama下载、安装、配置、使用保姆级教程(2026最新图解)
Ollama 是一款免费开源的本地大模型运行工具,支持 DeepSeek、Qwen 等主流模型,一条命令即可下载运行。2026 最新版 v0.35.1 官方安装包(1.47GB)已提供国内直连下载,纯本地、离线可用、无广告、无隐私泄露风险。
841 2
|
1月前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
10105 33
|
1天前
|
安全 应用服务中间件 Go
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
PHP2Shell 双 RCE 已遭在野利用:WordPress、Drupal 全在射程内,nginx 其实另有洞。拆成因与修复顺序
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
|
9天前
|
Web App开发 人工智能 JavaScript
太猛了,一个月干到15W+,给大家推荐一个最近爆火的开源逆向工具:REA
REA(Reverse Engineer Anything)是近期爆火的开源AI逆向工具,4月发布,1个月内斩获15万+ Star。它让AI Agent无需源码即可分析二进制、APK、JS/Electron、HAR包等17类目标,自动反编译、生成可运行代码,并强制返回带证据与局限的结论。全本地执行,MIT协议,支持Hopper/Ghidra/IDA等引擎,一行命令即可上手。
846 2
太猛了,一个月干到15W+,给大家推荐一个最近爆火的开源逆向工具:REA

热门文章

最新文章