同一个 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. 自测
- 两个 host、port、TLS 配置完全相同的客户端实例,会共用连接池吗?
maxIdleConnections = 5是不是说这个客户端最多只能有 5 个连接?- 一次请求的
connectionAcquired计为 1、connectStart计为 0,说明发生了什么? - 为什么应用拦截器与网络拦截器的执行次数可能不一样?
- HTTP/2 下
maxRequestsPerHost = 5还限制连接数吗? callTimeout与readTimeout的分工差别在哪?