AI 接口高并发 ≠ 秒杀高并发:为什么我把并发闸门挂在 LLM 调用汇聚点

28 阅读6分钟

AI 接口高并发 ≠ 秒杀高并发:为什么我把并发闸门挂在 LLM 调用汇聚点

主题:资源治理与流量防护

先说结论

给 AI 接口做限流,照抄秒杀那套 QPS 限流是不够的 —— 因为它们的请求形态根本不同。三个关键判断:

判断内容
AI 高并发 ≠ 秒杀高并发:秒杀是"大量快请求",AI 是"少量慢请求"
所以核心手段不是 QPS 限流,而是并发闸门
而且闸门必须挂在真实 LLM 调用的汇聚点,不是 Controller —— 按请求限流 ≠ 按 LLM 调用限流

顺带讲一条更早的教训:内存硬边界这件事,-Xmx 管不住。


一、先看两张表:秒杀 vs AI

维度秒杀AI
请求特征大量快请求(毫秒级)少量慢请求(SSE 流式,秒级~十几秒挂一个线程)
主要风险瞬时 QPS 冲高少量并发即占满框架异步执行器(默认并发 8,实测),拖垮其他接口
外部约束无(内部资源)LLM API 共享配额(QPS 限制 + 按 token 收费)
应对限流削峰(Sentinel QPS)限流 + 并发闸门 + 频控 + 缓存 + 降级

两条铁约束

  1. 每个 SSE 请求 = 一个执行槽占用秒级 → 不设闸门,按默认并发 8 算,个位数用户同时流式对话就可能吃满执行器
  2. LLM API 是外部共享资源,不能无限并发调

QPS 限流解决的是"入口打爆"(瞬时大量请求快速失败),但解决不了"慢请求堆积" —— QPS=5 意味着每秒进 5 个,每个挂 15 秒,稳态下也会有 75 个线程被 AI 占着。


二、三层防护设计

机制挂点超限行为
① 入口 QPS 限流Sentinel,按接口轻重分 3 组:对话 5 / 推理 10 / 轻量 30Controller 的 @SentinelResource + blockHandler429 JSON / SSE error 事件
② 并发闸门(核心)SemaphoretryAcquire,满即抛自定义 AiBusyException所有真实 LLM 调用的汇聚点(客户端 chat 方法 + SSE 流式)走服务内既有降级路径 → 繁忙永不 500
③ 每用户频控Redis INCR + TTL,60 秒窗口,阈值 10 次/分各重接口入口429「操作太频繁」

⭐ 为什么闸门挂"LLM 调用汇聚点"而不是 Controller

这是本方案最值得讲的一个设计决策:

  • 一次 /ai/search 内部可能调 2 次 LLM(意图提取 + 重排);一次 /ai/chat 内部是意图提取 + 流式生成各一次
  • 所以 按请求限流 ≠ 按 LLM 调用限流
  • 闸门挂在客户端 chat 方法 + 流式方法上后,统计的是真实并发 LLM 调用数(外部 API 的真实占用),与 token 预算检查同层、语义一致
  • Controller 层的 @SentinelResource 只解决"入口打爆"(快速失败);闸门解决"慢请求堆积"(限并发)—— 两层互补,不是重复
AiController@SentinelResource QPS 限流 + 频控)
  └→ service(Search / Ask / Compare / Chat)
       └→ DeepSeekAiClient.chat/chatWithModel/doChat   ← Semaphore.acquire("chat")
       └→ ChatServiceImpl.streamDeepSeekSemaphore.acquire("stream")
             │ 闸门满 → AiBusyException
             ├→ service 内 try-catch → 既有降级(Search→纯 ES / Ask→busy VO / SSE→error 事件)
             └→ Controller 直抛 → AiBusyExceptionAdvice429 JSON

关键设计:闸门满时抛的异常走服务内既有的降级路径 —— 所以繁忙是降级,不是 500


三、两个部署踩的坑(都很有代表性)

#现象 → 根因 → 解法
pom 缺 sentinel-datasource-nacos启动崩 ClassNotFoundException: NacosDataSource → yml 配了 Nacos datasource 但 pom 只加了 starter → 补依赖。教训:新增 datasource 配置必须同步加依赖,缺了启动即崩(不是运行时才崩)
blockHandler 签名缺原参数首测 30 并发出现 20×500(应该是 429)→ blockHandler 只写了 (BlockException e),而 Sentinel 反射要求 原方法全部参数 + BlockException,找不到匹配 → FlowException 落到全局 Throwable → 500 → 改为每接口专属签名(如 searchBlock(SearchDTO, BlockException))。教训:500 而不是 429,就是"blockHandler 没接住"的典型信号

实测验证:30 并发 /ai/search10×200 + 20×429(精确命中 QPS=10,无 500);单用户连打 15 次 → 全 429。


四、更早的一条教训:内存硬边界

-Xmx 是 JVM 自限,mem_limit 是容器硬限

两者是"自限 + 硬限"两层 —— 只设 -Xmx 挡不住线程栈/堆外内存/agent 失控。

当时的现实:服务器 4C16G,available 仅 2.0G、无 Swap、21 个容器 mem_limit 全为 0 → 任何进程失控都能直接吃满宿主被 OOM Kill(历史上真杀过 ES)。

四项优化:

改动收益/风险
① 全容器 mem_limit按当前 RSS +30% 余量设置docker update --memory 零重启即时生效;recreate 后靠 compose 持久化
② Nacos 堆 1g→512mJVM_XMS/XMX/XMN省 ~480M(最大单点);重启有注册抖动,微服务自动重连
③ Sentinel 显式限堆JAVA_OPTS: -Xms128m -Xmx256m消除默认 ~3.5G 堆上限隐患
④ Swap 2Gfallocate + mkswap + swapon + fstab仅防瞬时峰值 OOM;SSD 慢,不能依赖

预期效果:available 2.1G → ~2.7G,OOM 失控风险基本消除。

⭐ 一个"知道什么时候该停"的判断

8-04 JVM 调优后,某个业务服务容器的 RSS 仍然 965M。用 smaps 实测分解:

堆 ~250M + Metaspace ~50M + 线程栈 350 线程 × 1M = 350M
  + DirectMemory 64M + CodeCache 37M + SkyWalking agent

线程栈是调优后的隐形大头。 但我没有继续压 -Xss256k —— 收益每服务 ~200-300M,但 Dubbo 序列化/复杂 SQL 可能超栈 → 偶发 StackOverflow,排查极其困难

结论:JVM 层已到合理下限,剩余靠 mem_limit + Swap 做安全网,不继续压榨。 这体现的是"知道什么时候该停"的工程判断。


五、踩坑清单

认知 / 坑说明
规则权威源只能有一个Sentinel 的 FlowRuleManager全局唯一 + 整体替换,不是叠加 —— 配了 Nacos datasource 后,Nacos 空配置会擦空本地代码规则(限流静默失效);代码规则只对"Nacos 宕机/不可达"有效
"生效" ≠ "可见"SCA 的 spring.cloud.sentinel.eager 默认 false → CommandCenter 首次流量才启动 → Dashboard 看不到规则/监控,但限流实际在工作。生产必须 eager: true
繁忙要降级,不要 500闸门满 → 走既有降级路径(返回降级结果),而不是把异常抛给用户
⚠️ compose 未同步改了本地 compose 但服务器是旧的 → 心跳连错地址。部署前必须同步 compose
⚠️ 规则内容的双重 URL 编码python quote() + curl --data-urlencode 各编一次 → 规则变成 %5B%0A... → 改用 curl 的 content@file(自动只编一次)
⚠️ 中间件必须挂数据卷当时 Nacos 是唯一没挂 volumes 的中间件 → 重建容器 derby 配置全丢 → 补数据卷

六、一句总结

限流管的是"入口",闸门管的是"占用"。 想清楚"限流对象是什么",才知道闸门该挂在哪 —— 这是本方案里最值钱的一句话。


本文基于一个个人微服务电商演示项目的真实实施记录整理,涉及的环境信息已做脱敏处理。