Files
crawler-plugin/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java
T
huangzd1997 228d481211 perf(全链路): 连接池/事务边界/轮询与 IO 效率收口
Java:
- LLM 180s 读超时不再被全局 call-timeout 静默截断成 90s(长思考请求被掐断→重试→付费网关二次计费)
- 代理 HttpClient 缓存改有界 LRU(jikip 每次提取新 IP,无界缓存持续泄漏 selector 线程与连接池)
- 12 个 service 的 Redis 任务锁移出 @Transactional(自旋最坏 10s 白占 DB 连接,池仅 30),远端对象删/传改 afterCommit
- 结果文件 Job 闸门拒绝时不再回退内联执行(改重新入队,避免把背压转嫁给 MQ 消费线程)
- imagevideo 每秒扫描加列投影、过期清理加 LIMIT;权限页整表查询改列投影(不再拉回密码哈希)
- 哈希改 HexFormat;补 5 处"不能改"的技术依据注释(批量插入会丢回填主键、流式丢模板与图片等)

前端:4 个工具页轮询改轻量端点(带 fallback);PriceTrack 快照节流写盘;候选店铺表分页;页面隐藏时停表

客户端:HTTP 连接池按出口复用(Session 仍每请求新建,保持无跨请求状态);品牌检测 WIPO 逐请求握手;
代理配置按 mtime 缓存;串行任务改专属池;异常降级为标签页重连;紫鸟启动改端口轮询;模板编译缓存;
日志上报连接与落盘收口;Flask 版本 API 改按请求复用连接
2026-09-15 23:01:59 +08:00

177 lines
7.9 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.nanri.aiimage.config;
import com.nanri.aiimage.common.util.BoundedLruCache;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import java.io.IOException;
import java.io.InputStream;
import java.net.Authenticator;
import java.net.InetSocketAddress;
import java.net.PasswordAuthentication;
import java.net.ProxySelector;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* Task 77:外部 HTTP 客户端统一连接复用池。
* LLM / 品牌检查 / 紫鸟三个外部客户端共用同一个 java.net.http.HttpClient
* (内置 keep-alive 连接池),避免各自新建短命客户端导致连接无法复用、
* 每次请求都重新建连。各客户端按自身超时创建独立的
* JdkClientHttpRequestFactory(共享底层连接池),RestClient 单例懒加载。
*/
@Slf4j
public class HttpClientPool {
private static volatile HttpClient sharedHttpClient;
private static volatile long configuredCallTimeoutMillis;
/** 由 Spring 配置属性在启动阶段调用,确保共享客户端使用实际的 connect/call 配置。 */
public static void configure(long connectTimeoutMillis, long callTimeoutMillis) {
configuredCallTimeoutMillis = Math.max(1_000L, callTimeoutMillis);
synchronized (HttpClientPool.class) {
if (sharedHttpClient == null) {
sharedHttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(Math.max(1_000L, connectTimeoutMillis)))
.version(HttpClient.Version.HTTP_1_1)
.build();
}
}
}
/** 共享连接池实例:单一 HttpClient 承载全部外部调用的连接复用。 */
public static HttpClient sharedHttpClient() {
HttpClient client = sharedHttpClient;
if (client != null) {
return client;
}
synchronized (HttpClientPool.class) {
if (sharedHttpClient == null) {
sharedHttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(10_000L))
.version(HttpClient.Version.HTTP_1_1)
.build();
}
return sharedHttpClient;
}
}
/**
* 打开远程文件流(带超时),调用方负责关闭返回的流。
*
* <p>替代裸 {@code URI.create(url).toURL().openStream()}:后者走 JVM 默认超时(0 = 无限),
* 上游半开连接或挂起时会把 Tomcat 工作线程无限占用(管理端批量打包可同时挂多个)。
* 返回的流是流式的,适用于「服务端代理下载 OSS 文件转发给浏览器」这类不落盘场景。
*
* @param url 远程地址
* @param timeout 等待响应超时(连接建立 + 响应头);非法值钳制到 1 秒
* @throws IOException 非 2xx 响应或网络异常
* @throws InterruptedException 线程被中断
*/
public static InputStream openStreamWithTimeout(String url, Duration timeout) throws IOException, InterruptedException {
Duration effective = (timeout == null || timeout.isZero() || timeout.isNegative())
? Duration.ofSeconds(1)
: timeout;
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(effective)
.GET()
.build();
HttpResponse<InputStream> response = sharedHttpClient().send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() / 100 != 2) {
try {
response.body().close();
} catch (Exception ignored) {
// 关闭失败不影响错误上报
}
throw new IOException("远程文件返回非 2xx: HTTP " + response.statusCode());
}
return response.body();
}
/** 按 readTimeout(毫秒)创建共享连接池工厂;非法值钳制到最小正数。 */
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis) {
return requestFactory(readTimeoutMillis, null);
}
/**
* 按 readTimeout 创建请求工厂;proxyUrl 非空时改走该静态代理(用于出口 IP 需白名单的场景)。
* 代理 HttpClient 按代理地址缓存复用,避免每次请求新建连接池。
*/
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis, String proxyUrl) {
long safeReadTimeout = Math.max(1L, readTimeoutMillis);
long callTimeout = configuredCallTimeoutMillis;
if (callTimeout > 0L && safeReadTimeout > callTimeout) {
// 读超时以调用方显式值为准,不再被全局 call-timeout 截断:
// LLM 长思考配的是 180sllm-read-timeout-millis),曾被静默压到 90s
// 导致请求在 90s 被掐断 → 上层重试 → 付费网关二次计费(2026-09-15 修复)。
// 各调用方的超时已由各自的 HttpConfigResolver 钳制,此处不再二次收敛。
log.debug("读超时 {}ms 超过全局 call-timeout {}ms,按调用方显式值生效", safeReadTimeout, callTimeout);
}
JdkClientHttpRequestFactory factory =
new JdkClientHttpRequestFactory(httpClientFor(proxyUrl));
factory.setReadTimeout(Duration.ofMillis(safeReadTimeout));
return factory;
}
/** 解析代理地址并返回对应 HttpClient;地址为空/非法时回退共享直连客户端。 */
private static HttpClient httpClientFor(String proxyUrl) {
URI uri = parseProxyUri(proxyUrl);
if (uri == null) {
return sharedHttpClient();
}
ProxyEndpoint endpoint = new ProxyEndpoint(uri.getHost(), uri.getPort(), uri.getUserInfo());
return PROXY_CLIENTS.computeIfAbsent(endpoint, HttpClientPool::buildProxyClient);
}
private static HttpClient buildProxyClient(ProxyEndpoint endpoint) {
HttpClient.Builder builder = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(10_000L))
.version(HttpClient.Version.HTTP_1_1)
.proxy(ProxySelector.of(new InetSocketAddress(endpoint.host(), endpoint.port())));
if (endpoint.userInfo() != null && !endpoint.userInfo().isBlank()) {
String[] parts = endpoint.userInfo().split(":", 2);
String user = parts[0];
char[] password = parts.length > 1 ? parts[1].toCharArray() : new char[0];
builder.authenticator(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(user, password);
}
});
}
return builder.build();
}
/**
* 解析 http(s)://[user:pass@]host:port;不合法返回 null(由调用方回退直连)。
*/
private static URI parseProxyUri(String proxyUrl) {
if (proxyUrl == null || proxyUrl.isBlank()) {
return null;
}
try {
URI uri = URI.create(proxyUrl.trim());
if (uri.getHost() == null || uri.getHost().isBlank() || uri.getPort() <= 0) {
return null;
}
String scheme = uri.getScheme();
if (scheme != null && !scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https")) {
return null;
}
return uri;
} catch (Exception ex) {
return null;
}
}
private record ProxyEndpoint(String host, int port, String userInfo) {
}
private static final BoundedLruCache<ProxyEndpoint, HttpClient> PROXY_CLIENTS =
new BoundedLruCache<>(BoundedLruCache.DEFAULT_MAX_SIZE);
}