订单系统线程池被一个"智能"功能打满
三月下旬的一个下午,监控突然报警:订单履约服务的 Tomcat 线程池活跃数从平时的 40 冲到 200(最大值),接口 P99 从 180ms 涨到 12s,大量订单卡在"待履约"状态。
排查过程很快。看线程 dump,200 个线程里有 187 个卡在同一个地方:
"http-nio-8080-exec-143" #221 daemon prio=5 os_prio=31 tid=0x...
java.lang.Thread.State: RUNNABLE
at java.net.SocketInputStream.socketRead0(Native Method)
at okhttp3.internal.io.RealConnection$...
at com.xxx.ai.AddressParser.parse(AddressParser.java:47)
at com.xxx.order.FulfillmentService.submit(FulfillmentService.java:88)
问题出在两周前上线的"智能地址解析"——用户填的收货地址不规范,我们用 LLM 把它解析成标准的省市区 + 详细地址。这个功能上线时一切正常,响应时间 600~900ms。当天下午模型供应商网络抖动,P99 涨到 15s 以上,而我们没有设置任何超时,200 个线程全部堵在等响应上。
根因:把 LLM 当普通 RPC 用了
复盘时我发现根本问题不是"忘了设超时"这么简单,而是整个设计思路错了。我们下意识地把 LLM 调用当成了跟调用用户服务、库存服务一样的东西,但它们有本质区别:
| 维度 | 普通内部 RPC | LLM 调用 |
|---|---|---|
| 耗时 | 5~50ms,稳定 | 500ms~30s,方差极大 |
| 失败率 | < 0.1% | 1%~5%(含限流、超时、内容审核) |
| 返回值 | 结构化、可预期 | 非结构化,可能不合格式 |
| 幂等性 | 查询天然幂等 | 同样输入可能返回不同结果 |
| 成本 | 忽略不计 | 按 token 计费,重试要花钱 |
照搬 RPC 的治理方式必然出问题。我们花了两周重构了整个 LLM 调用层,下面是具体做法。
超时:三层都要设,且值不一样
重构后我们用 Resilience4j 统一管理。关键是三个超时值是不同的:
resilience4j:
timelimiter:
instances:
llmAddressParser:
timeout-duration: 3s # 非流式:整体超时
llmChatStream:
timeout-duration: 60s # 流式:允许更久,但要设上限
circuitbreaker:
instances:
llmAddressParser:
sliding-window-type: COUNT_BASED
sliding-window-size: 50
failure-rate-threshold: 40 # LLM 错误率本来就高,40% 才熔断
minimum-number-of-calls: 20
wait-duration-in-open-state: 15s
permitted-number-of-calls-in-half-open-state: 5
bulkhead:
instances:
llmAddressParser:
max-concurrent-calls: 60 # 关键:限制并发,保护线程池
三个值背后的考虑:
- 业务超时 3s:地址解析在下单主链路上,超过 3s 用户就跑了。这个值是从业务倒推的,不是拍脑袋;
- HTTP 客户端超时一定要比业务超时短:我们设的连接 1s、读取 2.5s。如果 HTTP 层不设超时,业务层超时返回了但底层连接还挂着,照样会耗尽资源;
- 并发数限制 60:这是最关键的一条。我们按"机器核数 × 4 + 预期等待量"算的,保证即使 LLM 全部超时,也只占用 60 个线程而不是全部 200 个。这一条直接解决了线程池被打满的问题。
熔断阈值设 40% 而不是常见的 50% 或 20%,是因为 LLM 调用天然有 1%~3% 的失败率(限流、内容审核、随机超时),阈值太低会频繁误熔断。但也不能太高,40% 是我们观察了两周正常波动后定的。
重试:不是所有失败都该重试
这是我最想强调的一点。一开始我们无脑重试 3 次,结果账单涨了,而且某些场景出错了。
现在的规则是按错误类型分类:
| 错误类型 | 是否重试 | 策略 |
|---|---|---|
| 连接超时 / 429 限流 | 是 | 指数退避,最多 2 次,带 jitter |
| 5xx 服务端错误 | 是 | 最多 1 次,立即重试 |
| 响应超时(已发出请求) | 否 | 走降级,可能已计费 |
| 内容审核拦截 | 否 | 重试也是同样结果 |
| 返回格式不合预期 | 是 | 最多 1 次,把错误原因塞回 prompt |
| 业务校验不通过 | 否 | 转人工 |
实现上我们写了个自定义的重试判定器:
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.intervalFunction(IntervalFunction.ofExponentialBackoff(
500, 2.0, 8000)) // 500ms → 1s → 2s,上限 8s
.retryOnException(e -> isRetryable(e))
.build();
static boolean isRetryable(Throwable e) {
if (e instanceof ModelRateLimitException) return true;
if (e instanceof ModelServerException ex) return ex.getCode() >= 500;
if (e instanceof java.net.SocketTimeoutException) return false; // 不重试
if (e instanceof ContentFilteredException) return false;
return false;
}
特别说明"响应超时不重试":请求已经发出去了,供应商可能已经在处理(并且计费)。重试一次就是双倍成本,而且可能得到两个不同的结果。我们宁可降级。
还有个成本上的考虑:重试会让 token 消耗翻倍。我们加了重试次数的监控,一旦重试率超过 5% 就告警,因为这通常意味着供应商在出问题或者我们的超时设得太激进。
结果校验:LLM 的返回不能直接信
这是 LLM 服务跟普通 RPC 最大的区别——返回 200 不代表结果可用。我们做了三层校验:
第一层:结构校验
要求模型返回 JSON,用 JSON Schema 校验。Spring AI 的 BeanOutputConverter 能帮一部分,但我们发现它只保证能反序列化,不保证字段值合理。自己加了 Schema 校验:
Schema schema = JsonSchemaFactory.getInstance()
.getSchema(addressSchemaJson);
Set<ValidationMessage> errors = schema.validate(parsedJson);
if (!errors.isEmpty()) {
metrics.counter("llm.output.schema_error").increment();
return ParseResult.invalid(errors);
}
实测在地址解析场景下,schema 校验失败率约 1.8%,主要出现在地址特别长或者含生僻字的情况。
第二层:业务规则校验
结构对了不代表内容对。地址解析的校验规则:
- 省份必须在标准行政区划表内;
- 市必须在对应省下;
- 详细地址长度在 4~100 字符之间;
- 不能出现"未知"、"无法解析"这类模型偷懒的占位词。
这一层抓出来的问题比 schema 层多,约 4.2%。其中最典型的是模型擅自"优化"地址——把用户写的"XX小区3期"改成"XX小区三期",或者自作主张补上一个它猜的门牌号。这类改动在快递场景下是致命的。
第三层:置信度兜底
对于实在判断不了的情况,我们要求模型返回置信度,低于 0.7 的一律转人工。这条规则上线后转人工率是 6.8%,但错误率从 3.1% 降到了 0.4%。对地址这种错了就送不到的场景,值得。
熔断降级:降级方案必须提前写好
熔断打开之后怎么办?这个问题必须在写功能的时候就回答,不能等熔断了再说。我们给地址解析准备了三级降级:
- 规则引擎:正则 + 行政区划词典解析,能覆盖大概 65% 的常见地址,准确率 88%。这是我们自己写的 Java 代码,零外部依赖,零成本;
- 返回原始地址 + 标记:规则引擎也解析不出来,就把原文存下来,标记
need_manual_review,由履约人员人工处理; - 允许用户重新填写:前端提示"地址识别失败,请检查"。
降级触发时的代码:
ParseResult parseWithFallback(String rawAddress) {
try {
return Try.ofSupplier(
decorators.decorateSupplier(this::callLlm))
.recover(throwable -> {
log.warn("llm fallback, reason={}", throwable.toString());
metrics.counter("llm.fallback",
"reason", throwable.getClass().getSimpleName())
.increment();
return ruleEngine.parse(rawAddress); // 第一级降级
})
.get();
} catch (Exception e) {
return ParseResult.manual(rawAddress); // 第二级降级
}
}
熔断打开期间我们不是直接报错,而是全部走规则引擎。这样用户几乎无感——只是地址解析准确率会下降一些,但流程不断。
SLA 怎么定义才不骗自己
最后说一下 SLA。一开始我们报的可用性是"接口成功率 99.95%",但这个数字是骗人的——它把降级成功的请求也算成功了。我们重新定义了三个指标分开报:
| 指标 | 定义 | 目标 | 当前 |
|---|---|---|---|
| 服务可用性 | 返回了可用结果(含降级) | 99.95% | 99.98% |
| 智能可用率 | LLM 成功且通过校验 | ≥ 95% | 95.7% |
| 结果准确率 | 人工抽检的正确比例 | ≥ 98% | 99.2% |
| P99 延迟 | 含降级路径 | ≤ 3s | 2.4s |
把"智能可用率"单独拿出来报很重要。如果只看服务可用性,降级常年生效你都发现不了——系统看起来一切正常,实际上 AI 部分早就废了。我们现在给"智能可用率 < 90% 持续 10 分钟"单独配了告警。
重构后的效果
上线一个月的数据对比:
- 因 LLM 导致的线程耗尽事故:从 2 次/月降到 0;
- 下单主链路 P99:从 180ms(事故时 12s)稳定到 210ms;
- LLM 调用失败对用户的影响:从直接报错变成无感降级;
- 因为不再盲目重试,地址解析的月度成本降了 11%。
就写到这。如果哪天你也被《把 LLM 能力封装成可靠的企业服务》里同一个坑绊住,回来翻这篇,能省半小时。