2512 字
约 8 分钟
13
S02-X03 · OkHttp 的连接池与复用:一次请求到底建了几次连接

同一个 OkHttpClient 连发 100 次请求,正常只建 1 次 TCP 连接;一旦有人手写 new OkHttpClient() 或忘了关 ResponseBody,这个数字会变成 100,或者连接干脆卡在池里还不回去。连接复用是最容易被几行坏代码吃掉的收益点。

下面按"复用条件 → 池参数 → 并发控制 → 客户端生命周期 → 拦截器链 → 观测手段 → 多路复用"的顺序拆,最后落在几个真正常见的坑上。


1. 连接复用的四个前提

OkHttp 4.x 里,一个 Connection 能被复用要同时满足:目标地址相同、连接空闲、协议允许。地址比较用的是 Address 对象的 equals,它包含下面这些字段:

复用前提 具体内容 不满足的后果
host 主机名完全一致(不是解析后的 IP) 建新连接
port 端口一致 建新连接
scheme / proxy http 或 https,代理类型与地址一致 建新连接
TLS 配置 ConnectionSpec、SSLSocketFactory、HostnameVerifier、客户端证书一致 建新连接
连接空闲 当前没有正在进行的 exchange HTTP/1.1 下必须等上一条响应读完

最后一条是 HTTP/1.1 的硬约束:一个连接同一时刻只能承载一个请求。所以第 6 个并发请求不会去抢前 5 个连接,而是再建连接——这正是 maxRequestsPerHost 默认 5 的由来。

TLS 那一行最容易被忽略:给两个客户端分别传了不同的 SSLContext 或不同的自定义 ConnectionSpec,即使 host、port 一样也不会复用,因为 Address 的 equals 把它们算进了身份。


2. ConnectionPool:默认 5 个空闲连接 / 5 分钟

ConnectionPool pool = new ConnectionPool(
        5,                       // maxIdleConnections:最多缓存 5 个空闲连接
        5, TimeUnit.MINUTES      // keepAliveDuration:空闲连接最长存活 5 分钟
);
OkHttpClient client = new OkHttpClient.Builder().connectionPool(pool).build();
参数 默认值 含义 调大的代价
maxIdleConnections 5 池里最多留几个空闲连接 每个空闲连接仍占一个文件描述符与服务端资源
keepAliveDuration 5 分钟 空闲连接的存活时长 服务端先关连接时,会拿到一个"看似可用实则失效"的连接
清理时机 每次连接归还时触发 清理线程按最久未用顺序回收 —

maxIdleConnections 是空闲上限,不是连接总数上限。并发 20 个请求时池里完全可能有 20 个活跃连接;只有请求结束、连接归还后,超出 5 个的空闲部分才会被回收。

keepAliveDuration 要和服务端对齐。nginx keepalive_timeout、Tomcat connectionTimeout 通常比客户端短,客户端留着的连接可能已被对端关闭,复用失败后 OkHttp 重试一次新连接——表现为 p99 偶发尖刺。把客户端 keepAlive 调得比服务端更短最省事。


3. Dispatcher:并发上限与"排队不等于失败"

Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(64);          // 全客户端同时在飞的请求数上限
dispatcher.setMaxRequestsPerHost(5);    // 单个 host 的并发上限
OkHttpClient client = new OkHttpClient.Builder().dispatcher(dispatcher).build();
参数 默认值 作用范围 超出后
maxRequests 64 整个 Dispatcher 请求进 readyAsyncCalls 队列等待
maxRequestsPerHost 5 单个 host(按 Request.url().host() 归组) 同样进队列
executorService 无界线程池 实际执行 Call 线程按需创建,空闲 60 秒回收

两个参数只作用于异步的 enqueue;同步 execute() 不走队列,并发由调用方线程池决定。

maxRequestsPerHost = 5 常被误读成"只能有 5 个连接"。它限制的是并发请求数:HTTP/2 下 5 个请求可跑在 1 个连接上,HTTP/1.1 下至少要 5 个。


4. OkHttpClient 必须是单例

// 正确:一个进程一个实例,连接池、线程池、缓存都共享
private static final OkHttpClient CLIENT = new OkHttpClient.Builder()
        .connectTimeout(3, TimeUnit.SECONDS)
        .readTimeout(10, TimeUnit.SECONDS)
        .writeTimeout(10, TimeUnit.SECONDS)
        .build();

// 错误:每次调用都新建,连接池与线程池每次都重置
public Response bad() throws IOException {
    return new OkHttpClient().newCall(req).execute();
}

OkHttpClient 文档明确要求复用实例,因为它内部持有:ConnectionPool(连接缓存)、Dispatcher.executorService(后台线程池)、Cache(磁盘缓存)、以及各 EventListener.Factory。每 new 一次就是一套新的池与线程池,旧的那套要靠 GC 慢慢收,表现为"连接数随 QPS 线性上涨、线程数也涨"。

newBuilder() 的副本是安全的:共享同一个连接池与线程池,只覆盖改动的字段。"不同场景不同超时"应写 CLIENT.newBuilder().readTimeout(...).build()。


5. 拦截器链的五段

阶段 拦截器 职责 能做的事
1 应用 addInterceptor 注册的自定义拦截器 业务级处理 改请求、读响应、直接短路返回
2 重试 RetryAndFollowUpInterceptor 失败重试、重定向 不缓存、不建连接
3 桥接 BridgeInterceptor 补 Host、Content-Length、Cookie、gzip 解压 对调用方透明
4 缓存 CacheInterceptor 命中缓存则直接返回 缓存策略来自 Cache-Control
5 连接 ConnectInterceptor → CallServerInterceptor 从池里取/建连接,写字节、读响应 连接复用就发生在这里

两个实际用处:应用拦截器只跑一次,网络拦截器(addNetworkInterceptor)会因重定向、重试跑多次——请求计数用前者,真实流量统计用后者;缓存命中时链路在第 4 段结束,不会走到连接段。


6. 观测:这次请求到底新建了连接没有

EventListener 是唯一不需要抓包的观测手段,把 connectStart/connectEnd 与 connectionAcquired/connectionReleased 分别计数就能回答:

class ReuseListener extends EventListener {
    final AtomicLong connects = new AtomicLong();
    final AtomicLong acquires = new AtomicLong();

    @Override public void connectStart(Call call, InetSocketAddress addr, Proxy proxy) {
        connects.incrementAndGet();                       // 真正发起 TCP 握手
    }
    @Override public void connectionAcquired(Call call, Connection c) {
        acquires.incrementAndGet();                       // 从池里拿到连接(含复用)
    }
}

OkHttpClient client = new OkHttpClient.Builder()
        .eventListenerFactory(new EventListener.Factory() {
            @Override public EventListener create(Call call) { return new ReuseListener(); }
        })
        .build();

读法:一次请求如果 connects 与 acquires 都加 1,说明它建了新连接;如果只有 acquires 加 1,说明命中了池里的空闲连接。想区分 DNS 与握手耗时,再补 dnsStart/dnsEnd 和 secureConnectStart/secureConnectEnd。

回调名按 4.x 的 EventListener 接口核对;小版本会新增回调,实现类应继承 EventListener 而不是直接实现接口,否则升级编译不过。


7. HTTP/2 多路复用:5 个连接上限不再是瓶颈

HTTP/1.1 一个连接一个请求,连接数 ≈ 并发数。HTTP/2 在单个连接上开多条 stream,同一 OkHttpClient 对同一 host 的并发请求会尽量挤进同一条连接。

项 HTTP/1.1 HTTP/2
单连接并发 1 个 exchange 多路复用,多 stream
5 个并发请求的连接数 5 通常 1
连接建立成本 每次并发都要付 一次付清
队头阻塞 连接级,靠多连接缓解 TCP 层仍在(应用层已解决)

协商是自动的:ALPN 协商到 h2 后 protocol() 返回 HTTP_2。确认复用效果看 connectionAcquired 次数是否远小于请求数。


8. 常见坑

现象 原因 处理
连接数随 QPS 线性上涨 每次请求 new OkHttpClient() 改成静态单例,或 newBuilder() 派生
日志出现 "A connection to ... was leaked"(提示文案按版本核对) ResponseBody 没关闭,连接还不回池 try (Response resp = ...; ResponseBody b = resp.body());或 resp.close()
p99 偶发高延迟,connectStart 频繁 复用了服务端已关闭的空闲连接 客户端 keepAliveDuration 调小于服务端 keepalive_timeout
只设了 connectTimeout,请求仍然挂死 readTimeout 默认 10s,但被上游改成 0(无限)或大数据量读不完 显式设 readTimeout,必要时设 callTimeout 兜底
扩缩容后仍打到旧 IP JVM/DNS 缓存了记录(networkaddress.cache.ttl),OkHttp 不额外做长缓存 自定义 Dns 实现,或调小 JVM DNS 缓存 TTL
复用了连接但报 StreamResetException 服务端主动重置了 HTTP/2 stream 交给 RetryAndFollowUpInterceptor 重试,或降级到 HTTP/1.1 排查

超时四项的分工容易混:

.connectTimeout(3, SECONDS)   // TCP 握手
.readTimeout(10, SECONDS)     // 两次数据到达之间的间隔
.writeTimeout(10, SECONDS)    // 两次写之间的间隔
.callTimeout(30, SECONDS)     // 整条调用链兜底(含重试与重定向),默认 0 表示不限

callTimeout 是唯一能兜住"重定向叠加后总耗时失控"的参数,但别设得比 readTimeout 小,否则正常大响应会被掐断。


9. 边界:什么时候该换个思路

场景 更合适的选择 理由
需要 HTTP/3 / QUIC 换支持 QUIC 的客户端 OkHttp 4.x 面向 HTTP/1.1 与 HTTP/2
复杂服务发现、动态路由 交给负载均衡器或自定义 Dns 连接池按 host 复用,路由变化会打乱复用
极高并发的短连接场景 评估 maxRequestsPerHost 与连接池一起调 单靠调大池容量会堆积半开连接
只在启动时调几个接口 用 JDK HttpClient 即可 不值得引入连接池语义

10. 自测

  1. 两个 host、port、TLS 配置完全相同的客户端实例,会共用连接池吗?
  2. maxIdleConnections = 5 是不是说这个客户端最多只能有 5 个连接?
  3. 一次请求的 connectionAcquired 计为 1、connectStart 计为 0,说明发生了什么?
  4. 为什么应用拦截器与网络拦截器的执行次数可能不一样?
  5. HTTP/2 下 maxRequestsPerHost = 5 还限制连接数吗?
  6. callTimeout 与 readTimeout 的分工差别在哪?
S02-X03 · OkHttp 的连接池与复用:一次请求到底建了几次连接
https://www.clxhxhhr.top/posts/5677/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。