腾讯混元 3.5 接入网关层的连接池耗尽排查:从 HikariCP 参数到 Redis 令牌桶漂移的完整复盘
上周五晚高峰,监控大屏突然报警:模型网关 P99 延迟从 120ms 飙升至 4.7s,错误率飙到 23%。这套网关是我们上个月底上线的,负责把内部业务对大模型的调用统一路由到混元、DeepSeek、通义等多个供应商。上线前压测过 500 QPS 没问题,真实流量才 180 QPS 就崩了。
项目背景
公司内部有十几个业务组在调大模型,原来各自对接供应商 SDK,导致密钥泄露风险大、配额管理混乱、无法统一做熔断降级。我们用 Spring Boot 3.2.5 + JDK 17.0.12 搭了个统一网关层,核心职责:请求鉴权、配额扣减、供应商路由、熔断隔离、日志审计。下游供应商走 HTTP/2 长连接,网关层用 HikariCP 5.0.1 管理连接池,Redis 7.2.5 做分布式令牌桶限流,Resilience4j 2.2.0 做熔断。
需求分析
核心功能需求不复杂:按业务组分配每分钟配额,超配额返回 429;单供应商故障自动切流到备选供应商;全链路 TraceID 透传。非功能需求才是坑:P99 < 200ms、支持突发流量 3 倍峰值、配额扣减强一致性(不能多扣也不能少扣)、零停机变更供应商权重。
方案对比
| 方案 | 限流实现 | 一致性 | 运维复杂度 | 选型理由 |
|------|----------|--------|------------|----------|
| 本地 Guava RateLimiter | 内存令牌桶 | 单机强一致 | 低 | 无法满足分布式配额共享 |
| Redis + Lua 脚本 | 原子扣减 | 强一致 | 中 | 单键热点风险,后续优化用 Slot 分桶 |
| Redisson RRateLimiter | 基于 RedLock | 最终一致 | 高 | 网络抖动导致配额漂移,生产已踩坑 |
| Sentinel 集群模式 | Token Server | 最终一致 | 高 | 引入额外组件,运维成本超预算 |
最终选 Redis + Lua,键设计为quota:{biz_group}:{minute_window},用EVALSHA保证原子性。熔断选 Resilience4j,滑动窗口 10s、最小请求数 20、失败率阈值 50%。
核心实现
连接池耗尽的现场
报警那晚,HikariPool-1 - Pool stats (total=50, active=50, idle=0, waiting=142)这种日志刷屏。连接池配置:
```yaml
spring:
datasource:
hikari:
maximum-pool-size: 50
minimum-idle: 10
connection-timeout: 3000
idle-timeout: 300000
max-lifetime: 1200000
leak-detection-threshold: 2000
```
供应商 HTTP 客户端用WebClient,连接池配置:
```java
@Bean
public HttpClient httpClient() {
return HttpClient.create(ConnectionProvider.builder("model-gateway")
.maxConnections(200)
.pendingAcquireMaxCount(500)
.pendingAcquireTimeout(Duration.ofSeconds(5))
.maxIdleTime(Duration.ofSeconds(30))
.maxLifeTime(Duration.ofMinutes(10))
.evictInBackground(Duration.ofSeconds(120))
.build())
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
.responseTimeout(Duration.ofSeconds(30))
.doOnConnected(conn -> conn.addHandlerLast(new ReadTimeoutHandler(30))
.addHandlerLast(new WriteTimeoutHandler(30)));
}
```
理论上 200 连接 × 3 供应商 = 600 并发,远超 180 QPS。但监控显示active满 50 就不放了,waiting堆到 142。为啥不扩容?因为maximum-pool-size卡死在 50。
定位过程
先看线程栈,全卡在HikariPool.getConnection()。再看供应商响应时间,混元 P99 从 800ms 涨到 3.2s。原来混元那晚做了灰度发布,新版模型首包延迟抖动大,导致网关侧连接被长时间占用。
但这不该触发连接池耗尽啊?WebClient 连接池是 200 啊。翻源码发现:pendingAcquireMaxCount=500是指等待获取连接的队列长度,不是连接数上限。真正上限是maxConnections=200。可 HikariCP 只管数据库连接,不管 HTTP 连接。网关层没配数据库,HikariCP 其实是给 Actuator 健康检查用的,默认 10 连接,我们改成 50。真正耗尽的是WebClient 连接池。
为啥监控看的是 HikariCP?因为 Micrometer 暴露的hikaricp.connections.active指标被误读成 HTTP 连接池了。真正的 HTTP 连接池指标是reactor.netty.connection.provider.active和pending。
令牌桶漂移的隐形推手
修大连接池(maxConnections=500、pendingAcquireMaxCount=1000)后,延迟降下来了,但配额扣减出现负数。Redis 监控显示某业务组某分钟窗口 key 值变成 -17。
Lua 脚本:
```lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = redis.call('GET', key)
if current == false then
redis.call('SET', key, limit - 1, 'EX', 60)
return limit - 1
end
if tonumber(current) <= 0 then
return -1
end
return redis.call('DECR', key)
```
单线程执行没问题,但高并发下GET和DECR不是原子的。虽然用了EVALSHA,但脚本里分两步:先GET判断,再DECR。中间穿插别的请求,就会少扣或漏扣。
改成纯原子:
```lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, 60)
end
if current > limit then
redis.call('DECR', key)
return -1
end
return limit - current + 1
```
用INCR反向计数,超限再DECR回滚。测试 2000 QPS 持续 5 分钟,配额零漂移。
熔断器误触发的连锁反应
配额修好后,发现混元供应商频繁触发熔断,流量全切到 DeepSeek,导致 DeepSeek 也挂了。Resilience4j 配置:
```yaml
resilience4j:
circuitbreaker:
instances:
hunyuan:
registerHealthIndicator: true
slidingWindowSize: 10
minimumNumberOfCalls: 20
failureRateThreshold: 50
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 5
```
slidingWindowSize=10是 10 个桶,默认每桶 1 秒。混元晚高峰 P99 3.2s,但成功率 92%,为啥触发 50% 失败率?翻源码:failureRateThreshold算的是调用异常 + 超时 + 熔断器拒绝的比例。我们配了responseTimeout 30s,但业务层设了@Sla(maxLatency = 2000ms),超 2s 抛SlaViolationException,被熔断器算作失败。
把slidingWindowSize改 60(1分钟窗口)、minimumNumberOfCalls改 100、业务层 SLA 放宽到 5s、熔断阈值调到 70%。再压测,混元再不误触发。
最终生产配置
```yaml
WebClient 连接池
reactor:
netty:
connection:
provider:
model-gateway:
max-connections: 500
pending-acquire-max-count: 1000
max-idle-time: 30s
max-life-time: 10m
Resilience4j 熔断
resilience4j:
circuitbreaker:
instances:
hunyuan:
slidingWindowType: TIME_BASED
slidingWindowSize: 60s
minimumNumberOfCalls: 100
failureRateThreshold: 70
slowCallRateThreshold: 80
slowCallDurationThreshold: 5s
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 10
timelimiter:
instances:
hunyuan:
timeoutDuration: 8s
cancelRunningFuture: true
Redis 限流键分桶(缓解热点)
quota:{biz_group}:{minute_window}:{slot_0~9}
```
效果复盘
上线后跑了两周:
| 指标 | 优化前 | 优化后 | 备注 |
|------|--------|--------|------|
| 网关 P99 延迟 | 4.7s | 180ms | 混元首包抖动被连接池吸收 |
| 配额扣减漂移率 | 3.2% | 0% | Lua 原子脚本 + 分桶 |
| 熔断误触发次数/天 | 12 | 0 | 窗口拉长 + SLA 对齐 |
| 连接池等待队列峰值 | 142 | 3 | maxConnections 500 够用 |
| 单机 CPU 峰值 | 85% | 42% | 减少重试风暴 |
有个反直觉的地方:把熔断窗口从 10s 拉到 60s,理论上检测变慢,但因为最小调用数从 20 提到 100,反而过滤掉了抖动期的噪声。这个方案虽然官方文档推荐短窗口快失败,但在我们「下游模型首包延迟高方差」场景下反而更糟—— 这是踩完坑才懂的。
还有一处值得记录:Redis 热点 key 分桶用slot = Math.abs(bizGroup.hashCode()) % 10,但业务组 ID 是bg_1001这种字符串,hashCode()在 JDK 17 里每次启动都不一样(启用了 hash seed 随机化)。改成MurmurHash3.hash32(bizGroup.getBytes())才稳。
#后端 #Java #SpringBoot #Redis #Resilience4j #网关架构 #性能调优
你在实际项目中有遇到类似问题吗?欢迎在评论区分享你的经验和解决方案。