很多人第一次接触 OkHttp,是从这样几行代码开始的:
vbscript
Request request = new Request.Builder()
.url("https://example.com/api")
.build();
try (Response response = client.newCall(request).execute()) {
String body = response.body().string();
}
代码看起来很简单:构造一个 Request,创建一个 Call,然后执行它。但如果这个调用发生在生产环境里,事情就没有这么简单了。它背后会经历请求调度、拦截器链、连接复用、DNS、TLS、HTTP/1.1 或 HTTP/2 编码、超时控制、失败重试、响应释放等一整套流程。
OkHttp 好用的地方,也正是在这里。它把 HTTP 客户端里很多容易出错、又很影响性能的细节封装好了。但如果只是把它当成一个"发请求工具",很容易在生产环境里踩坑,比如每次请求都 new 一个 client、忘记关闭 response、把 Dispatcher 并发调到几万、误以为连接池限制的是最大连接数,或者把 OkHttp 的连接失败重试当成业务重试。
这篇文章试着从一次请求的生命周期出发,把 OkHttp 的核心设计讲清楚:它怎么调度请求,怎么通过拦截器链组织逻辑,怎么管理连接,HTTP/2 对并发意味着什么,以及在后端服务里应该如何配置和使用它。
OkHttp 到底是什么
OkHttp 是一个成熟的 HTTP 客户端。它不是简单封装 socket,也不只是把 URL 打出去然后拿回响应。一个完整的 HTTP 客户端要处理很多事情:请求构造、同步和异步执行、连接池、DNS、代理、TLS、证书校验、HTTP/1.1、HTTP/2、多路复用、缓存、重试、重定向、超时控制、事件观测等。
在后端系统里,OkHttp 常见于服务间 HTTP 调用、内部 SDK、网关访问下游服务、第三方 API 调用、模型服务调用、批处理任务等场景。Android 里也大量使用 OkHttp,不过本文更偏后端工程视角。
先看几个最核心的对象。
OkHttpClient 是客户端实例,里面持有 Dispatcher、ConnectionPool、拦截器、DNS、代理、TLS、超时、缓存等配置。它是线程安全的,应该被复用,而不是每次请求都创建一个新的实例。
Request 表示一次 HTTP 请求,包括 URL、method、headers、body 等。
Call 表示一次已经准备好执行的请求。client.newCall(request) 会创建一个 Call,一个 Call 只能执行一次。
Response 表示 HTTP 响应。它必须被关闭,否则底层连接不能及时回收到连接池。
一个基础同步调用通常长这样:
scss
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(3))
.readTimeout(Duration.ofSeconds(30))
.writeTimeout(Duration.ofSeconds(30))
.callTimeout(Duration.ofSeconds(60))
.build();
Request request = new Request.Builder()
.url("https://example.com/api")
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("HTTP error: " + response.code());
}
String body = response.body().string();
}
这里有一个非常重要的细节:try (Response response = ...)。这个写法不是为了优雅,而是为了确保响应被关闭。OkHttp 能不能复用连接,很大程度上取决于你有没有正确释放 response。
从整体上看 OkHttp 的架构
可以把 OkHttp 的一次请求想象成一条流水线:业务代码把请求交给 OkHttpClient,Call 负责承载这次请求,异步请求会经过 Dispatcher 调度,然后进入拦截器链。拦截器链会依次处理重试、桥接、缓存、连接获取和网络读写。最后,请求落到底层 socket,通过 HTTP/1.1 或 HTTP/2 与服务端通信。
整体结构可以抽象成这样:
sql
业务代码
↓
OkHttpClient
↓
Call / RealCall
↓
Dispatcher
↓
Interceptor Chain
↓
ConnectionPool / Route / RealConnection
↓
Socket / TLS / HTTP/1.1 / HTTP/2
↓
远端服务
其中最值得关注的是四块:Dispatcher 负责异步请求调度;拦截器链负责组织 HTTP 请求处理流程;ConnectionPool 负责连接复用;底层协议层负责真正的网络读写。
不同 OkHttp 版本内部类名会有变化,比如 OkHttp 3、4、5 在实现上并不完全一致,但这条主线基本稳定。理解这条主线,比记住某个版本的内部类细节更重要。
一次 execute 请求是怎么执行的
先看同步请求,也就是 execute()。
同步请求的特点是:谁调用它,谁就会被阻塞。网络请求会在当前调用线程里完成,直到响应返回、失败或超时。
它的大致流程是:
vbscript
client.newCall(request)
↓
RealCall.execute()
↓
Dispatcher.executed(call)
↓
进入拦截器链
↓
RetryAndFollowUpInterceptor
↓
BridgeInterceptor
↓
CacheInterceptor
↓
ConnectInterceptor
↓
CallServerInterceptor
↓
返回 Response
↓
关闭 Response,连接释放或回收到连接池
这里容易产生一个误解:既然 execute() 也会经过 Dispatcher.executed(call),是不是同步请求也会被 Dispatcher 的线程池调度?不是。
同步请求只是被 Dispatcher 记录到 running sync calls 里,真正执行请求的是调用方线程。也就是说,如果你在业务线程池里调用 execute(),占用的是你的业务线程;如果你在主线程里调用它,就会阻塞主线程。
对于后端服务来说,这反而通常是一个优点。因为大多数后端系统本来就有自己的并发模型,比如 Tomcat/Jetty 工作线程、业务线程池、CompletableFuture、Kotlin coroutine、Reactor wrapper 或批处理 worker。用 execute() 时,你可以把并发、限流、熔断、trace、超时和错误处理统一放在自己的体系里。
enqueue 又做了什么
enqueue() 是异步请求。调用 enqueue() 后,当前线程不会等待 HTTP 响应,而是把请求交给 OkHttp 的 Dispatcher。Dispatcher 再决定这个请求是立刻执行,还是先放到等待队列里。
流程大致是:
scss
client.newCall(request).enqueue(callback)
↓
Dispatcher.enqueue(asyncCall)
↓
检查 maxRequests / maxRequestsPerHost
↓
未超过限制:提交到 executor 执行
超过限制:进入 readyAsyncCalls 等待队列
↓
后台线程执行拦截器链
↓
回调 onResponse / onFailure
异步调用示例:
less
client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
// 请求失败,回调运行在 OkHttp 后台线程
}
@Override
public void onResponse(Call call, Response response) throws IOException {
try (response) {
String body = response.body().string();
}
}
});
注意,onResponse 和 onFailure 默认运行在 OkHttp 的后台线程里,不会自动切回 Android 主线程,也不会自动进入你业务系统的线程上下文。如果你有 MDC、trace context、租户上下文之类的东西,需要自己考虑上下文传递。
什么时候用 execute(),什么时候用 enqueue()?一个简单判断是:如果当前代码已经在可阻塞的 worker 线程里,并且你想自己管理并发,用 execute();如果当前线程不能阻塞,或者你想让 OkHttp 的 Dispatcher 管理这批 HTTP 请求,用 enqueue()。
在后端服务里,我通常更偏向 execute() 外面套业务自己的并发控制。它的调用链更直,错误处理更自然,也更容易和限流、熔断、tracing 体系整合。
Dispatcher:别把它当成队列容量
Dispatcher 是 OkHttp 的请求调度器,重点管理异步请求的并发和排队。它不是 Android 的主线程调度器,也不是 Kotlin coroutine 的 dispatcher。
它内部可以粗略理解成维护三组请求:
readyAsyncCalls 等待执行的异步请求
runningAsyncCalls 正在执行的异步请求
runningSyncCalls 正在执行的同步请求
默认情况下,OkHttp 异步请求的并发限制通常是:
ini
maxRequests = 64;
maxRequestsPerHost = 5;
也就是说,全局最多同时运行 64 个异步请求,同一个 host 最多同时运行 5 个异步请求。超过任一限制,请求就会进入等待队列。
配置方式如下:
ini
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(256);
dispatcher.setMaxRequestsPerHost(64);
OkHttpClient client = new OkHttpClient.Builder()
.dispatcher(dispatcher)
.build();
这里有一个生产中很常见的问题:如果配置成 dispatcher-max-requests: 20000,是不是超过 20000 才进队列?
如果这个配置最终映射到:
ini
dispatcher.setMaxRequests(20000);
那答案是:对,它表示最多允许 20000 个异步请求同时运行,超过这个运行中数量后,新的异步请求会进入等待队列。但它不是"队列容量",而是"运行中异步请求的并发上限"。
而且 maxRequestsPerHost 仍然会生效。比如全局并发是 20000,但单 host 并发还是默认 5,那么同一个域名下第 6 个异步请求依然会排队。
更重要的是,20000 这样的值通常非常危险。OkHttp 异步请求背后会使用 executor 执行,过高的并发可能带来线程膨胀、内存上涨、连接数增加、文件描述符消耗、端口消耗,以及下游服务被瞬间打爆。很多时候,把这个值调大不是在提高吞吐,而是在允许更多请求堆积到网络层。
估算并发可以用一个很朴素的公式:
所需并发 ≈ QPS × 平均耗时秒数
如果你要打 1000 QPS,下游平均耗时 100ms,那么平均并发大约是:
ini
1000 × 0.1 = 100
真实配置还要考虑 P95/P99 长尾、重试、下游限流、HTTP/2 多路复用、业务隔离和机器资源。但无论如何,Dispatcher 并发都不应该凭感觉调到一个特别大的数字。
拦截器链:OkHttp 的骨架
OkHttp 的拦截器链是它架构里非常漂亮的一部分。它用责任链模式把一次 HTTP 请求拆成多个阶段,每个阶段只负责自己的事情,然后调用 chain.proceed(request) 把请求交给下一个阶段。
典型顺序可以理解为:
Application Interceptors
↓
RetryAndFollowUpInterceptor
↓
BridgeInterceptor
↓
CacheInterceptor
↓
ConnectInterceptor
↓
Network Interceptors
↓
CallServerInterceptor
应用层拦截器,也就是 addInterceptor() 加进去的 interceptor,适合处理业务侧通用逻辑,比如添加鉴权 header、trace id、统一日志、指标埋点、租户信息等。
ini
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(chain -> {
Request oldRequest = chain.request();
Request newRequest = oldRequest.newBuilder()
.header("X-Request-Id", UUID.randomUUID().toString())
.build();
return chain.proceed(newRequest);
})
.build();
RetryAndFollowUpInterceptor 负责连接失败重试、重定向、认证挑战等 follow-up 请求。这里的重试主要是 HTTP 客户端语义和连接层面的处理,不等同于业务状态码重试。
BridgeInterceptor 负责把用户请求转换成网络请求,比如补充 Host、Connection、Accept-Encoding、Cookie 等 header,也会处理 gzip 解压。
CacheInterceptor 负责 HTTP 缓存。如果配置了 Cache,它会根据 Cache-Control、ETag、Last-Modified 等响应头决定是否直接用缓存,或者是否发条件请求。
ConnectInterceptor 负责获取连接。到了这里,请求开始和连接池、DNS、路由选择、TCP 连接、TLS 握手打交道。
网络层拦截器,也就是 addNetworkInterceptor() 加进去的 interceptor,更接近真实网络层。它可以看到重定向、重试之后的实际网络请求。
最后是 CallServerInterceptor,它真正把请求写到连接上,并从连接里读回响应。到这里,HTTP/1.1 或 HTTP/2 的 codec 会参与实际的数据编解码。
拦截器链的好处是结构清晰。应用层逻辑、HTTP 语义、缓存、连接、网络读写被拆开了。用户也能在合适的位置插入自己的逻辑,而不是把所有东西塞进一个巨大的 HTTP 执行函数里。
连接管理:OkHttp 性能的关键
HTTP 客户端性能很大一部分来自连接复用。一次 HTTPS 请求最贵的部分通常不是传几个字节,而是 DNS 查询、TCP 握手、TLS 握手和连接建立。如果每个请求都重新建连接,延迟和资源消耗都会很高。
OkHttp 用 ConnectionPool 复用连接。一次请求获取连接的过程大致是:
arduino
请求 https://api.example.com
↓
查连接池里有没有可复用连接
↓
有:直接复用
↓
没有:DNS 解析
↓
选择 Route
↓
建立 TCP 连接
↓
如果是 HTTPS,进行 TLS 握手
↓
如果协商到 HTTP/2,创建 HTTP/2 连接
↓
发送请求并读取响应
↓
响应结束后,连接释放或回收到连接池
连接池可以这样配置:
ini
ConnectionPool connectionPool = new ConnectionPool(
100, // maxIdleConnections
5, TimeUnit.MINUTES // keepAliveDuration
);
OkHttpClient client = new OkHttpClient.Builder()
.connectionPool(connectionPool)
.build();
这里也有一个容易误解的点:maxIdleConnections 不是最大总连接数,而是最大空闲连接数。正在使用中的连接不算 idle。
连接能不能复用,不是只看 host 一不一样。OkHttp 会综合判断 scheme、host、port、代理、TLS 配置、证书校验、连接状态、协议类型等因素。对于 HTTP/1.1,通常要求连接对应的 address 兼容。对于 HTTP/2,还可能发生 connection coalescing,也就是在证书、DNS、IP、TLS 等条件满足时,不同 hostname 复用同一个 HTTP/2 连接。
所以连接池不是一个简单的 Map,也不是"host 到 socket"的粗暴缓存。它背后有完整的路由、协议和安全校验逻辑。
HTTP/1.1 和 HTTP/2 对连接的影响
HTTP/1.1 和 HTTP/2 的并发模型很不一样。
在 HTTP/1.1 下,一个连接通常同一时刻处理一个请求/响应。虽然 keep-alive 可以让连接被复用,但多个并发请求通常需要多条连接:
rust
HTTP/1.1:
connection 1 -> request A
connection 2 -> request B
connection 3 -> request C
HTTP/2 支持多路复用,一个 TCP/TLS 连接上可以同时承载多个 stream:
less
HTTP/2:
connection 1
├─ stream A
├─ stream B
├─ stream C
这会显著影响连接数和并发模型。如果下游支持 HTTP/2,高并发请求不一定意味着大量 TCP 连接。少量连接也能承载较高并发。
但要注意,OkHttp 的几个限制不在同一层:Dispatcher.setMaxRequestsPerHost() 是异步请求调度层面的同 host 并发;ConnectionPool 管理的是空闲连接复用;HTTP/2 stream 并发是协议层能力。这三者有关联,但不能混为一个参数。
举个例子,即使 HTTP/2 可以在一条连接上跑多个 stream,如果 maxRequestsPerHost 设得很小,OkHttp 的异步调度层仍然可能让请求排队。反过来,如果 Dispatcher 并发很大,也不意味着一定会创建同样数量的 TCP 连接,因为 HTTP/2 可以复用连接。
DNS、Route 和代理
当连接池里没有可复用连接时,OkHttp 需要找到一条可用的连接路线。这个过程通常包括代理选择、DNS 解析和 IP 尝试。
scss
URL
↓
ProxySelector 选择代理
↓
DNS 解析 host 到 IP 列表
↓
组合 Route(proxy + ip + tls)
↓
逐个尝试连接
如果一个域名解析出多个 IP,OkHttp 可以在某个 IP 连接失败后尝试下一个 route。失败信息也会被记录,避免短时间内反复选择明显失败的路线。
后端系统里,有时会自定义 DNS,比如接入内部服务发现、私有 DNS、多机房路由、灰度解析或容灾切换:
ini
OkHttpClient client = new OkHttpClient.Builder()
.dns(hostname -> {
// 可以接入内部服务发现、DNS 缓存、灰度解析等
return Dns.SYSTEM.lookup(hostname);
})
.build();
自定义 DNS 很有用,但也容易引入新的问题。比如 DNS 缓存时间过长,会导致客户端一直访问已经不可用的 IP;缓存时间过短,又可能增加解析压力和延迟。服务发现、负载均衡、故障恢复这些逻辑,最好和业务的整体治理体系一起设计。
TLS 和证书校验
HTTPS 请求会经历 TLS 握手。OkHttp 默认使用平台信任链校验服务端证书。大多数情况下,直接使用默认配置就足够。
OkHttp 也支持 Certificate Pinning:
java
CertificatePinner certificatePinner = new CertificatePinner.Builder()
.add("example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build();
OkHttpClient client = new OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build();
证书固定可以提高安全性,但要谨慎。它会增加证书轮换风险。一旦 pin 配错,或者服务端证书更新而客户端没有同步,线上请求可能会大面积失败。
企业内部环境还可能涉及私有 CA、mTLS、代理 TLS 拦截、证书自动轮换等问题。无论哪种情况,都不建议为了"先跑通"就信任所有证书。这种做法会直接破坏 HTTPS 的安全边界,后续也很容易遗留成生产风险。
超时:不要只配 readTimeout
OkHttp 常见的超时有四类:
scss
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(2))
.readTimeout(Duration.ofSeconds(30))
.writeTimeout(Duration.ofSeconds(30))
.callTimeout(Duration.ofSeconds(35))
.build();
connectTimeout 控制建立 TCP 连接的时间。
readTimeout 控制读取响应数据时的等待时间。如果服务端长时间没有返回数据,会触发读超时。
writeTimeout 控制写请求体的时间。上传大 body 或网络拥塞时,它会比较重要。
callTimeout 控制整个调用的总耗时,从请求开始到响应结束都算。它是后端服务里非常重要的兜底超时。
生产环境不建议只配置 connectTimeout 和 readTimeout。在重试、重定向、连接等待、慢响应体读取等复杂情况下,如果没有总超时,一次请求的整体耗时可能比你预期长得多。
更稳妥的做法是:用 callTimeout 控制总预算,再用 connect/read/write timeout 控制具体阶段。比如一次下游调用最多允许 35 秒,那 callTimeout 就不应该缺失。
重试:连接失败重试不等于业务重试
OkHttp 默认支持连接层失败重试和 HTTP 重定向:
java
OkHttpClient client = new OkHttpClient.Builder()
.retryOnConnectionFailure(true)
.followRedirects(true)
.followSslRedirects(true)
.build();
retryOnConnectionFailure(true) 主要处理连接层问题,比如某个 IP 连接失败后尝试另一个 route。它不是对所有失败状态码都重试,也不是完整的业务重试机制。
如果你需要对 500、502、503、504、429 等状态码做重试,通常应该在业务层或 resilience 框架里实现,并且明确最大重试次数、退避策略、抖动、错误分类、熔断和限流。
还要特别注意幂等性。GET、HEAD 通常更适合自动重试;POST、PATCH 这类请求如果没有 idempotency key,不应该随便重试。否则可能导致重复创建、重复下单、重复扣费或重复提交任务。
最常见的坑:忘记关闭 Response
如果只记住一个 OkHttp 使用规则,那就是:一定要关闭 Response。
推荐写法:
vbscript
try (Response response = client.newCall(request).execute()) {
String body = response.body().string();
}
异步调用里也一样:
less
client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
// handle failure
}
@Override
public void onResponse(Call call, Response response) throws IOException {
try (response) {
String body = response.body().string();
}
}
});
如果 response 没有关闭,OkHttp 无法及时把连接回收到连接池。短期看可能只是连接复用率下降,长期看可能变成连接泄漏、请求变慢、文件描述符耗尽,甚至拖垮整个服务。
还有一个相关坑:在拦截器里读取 response body 后没有重建 body。响应体通常只能读一次。如果日志拦截器里写了下面这种代码:
ini
String body = response.body().string();
return response;
后续业务代码再读 body,就可能读不到了。如果确实要读取并继续传递,需要重新构造 response body。
OkHttpClient 应该复用
不要这样写:
scss
for (...) {
OkHttpClient client = new OkHttpClient();
client.newCall(request).execute();
}
这会让每个 client 都有自己的连接池和线程资源,连接无法有效复用,资源也会重复创建。
更合理的方式是创建长生命周期 client:
java
private static final OkHttpClient CLIENT = new OkHttpClient.Builder()
.connectionPool(new ConnectionPool(100, 5, TimeUnit.MINUTES))
.build();
如果只是少量配置不同,可以从已有 client 派生:
ini
OkHttpClient shortTimeoutClient = CLIENT.newBuilder()
.callTimeout(Duration.ofSeconds(10))
.build();
这种方式适合在统一基础配置上,对某个下游或某类请求做局部调整。
缓存:不是所有后端调用都需要,但要知道它存在
OkHttp 支持 HTTP 缓存,但需要显式配置:
ini
Cache cache = new Cache(
new File("/tmp/okhttp-cache"),
100L * 1024 * 1024
);
OkHttpClient client = new OkHttpClient.Builder()
.cache(cache)
.build();
它遵循标准 HTTP 缓存语义,比如 Cache-Control、ETag、Last-Modified、Expires、Vary 等。
在服务间调用里,很多团队更依赖业务缓存、Redis、本地缓存或网关缓存,所以 OkHttp 自带 cache 不一定常用。但在第三方 API、配置拉取、静态资源、低频可缓存数据等场景中,它是一个值得考虑的能力。
用 EventListener 看清请求慢在哪里
线上排查 HTTP 慢请求时,只看"总耗时"通常不够。慢可能发生在 DNS、建连、TLS、等待服务端响应、下载响应体,甚至是连接池复用失败。
OkHttp 提供了 EventListener,可以观察一次请求内部的多个阶段:
typescript
OkHttpClient client = new OkHttpClient.Builder()
.eventListenerFactory(call -> new EventListener() {
@Override
public void dnsStart(Call call, String domainName) {
System.out.println("dnsStart: " + domainName);
}
@Override
public void connectStart(Call call, InetSocketAddress address, Proxy proxy) {
System.out.println("connectStart: " + address);
}
@Override
public void responseHeadersEnd(Call call, Response response) {
System.out.println("responseHeadersEnd: " + response.code());
}
})
.build();
生产环境可以把这些事件接到 metrics 或 tracing 系统里,观察 DNS 耗时、建连耗时、TLS 握手耗时、连接复用率、响应头耗时、响应体下载耗时等指标。这样排查问题时,就不会只剩下一句"HTTP 调用很慢"。
一个后端服务的配置起点
下面这个配置不是通用最优值,只能作为后端服务的起点参考:
scss
Dispatcher dispatcher = new Dispatcher();
dispatcher.setMaxRequests(256);
dispatcher.setMaxRequestsPerHost(64);
ConnectionPool connectionPool = new ConnectionPool(
200,
5, TimeUnit.MINUTES
);
OkHttpClient client = new OkHttpClient.Builder()
.dispatcher(dispatcher)
.connectionPool(connectionPool)
.connectTimeout(Duration.ofSeconds(2))
.readTimeout(Duration.ofSeconds(30))
.writeTimeout(Duration.ofSeconds(30))
.callTimeout(Duration.ofSeconds(35))
.retryOnConnectionFailure(true)
.build();
真正调优时,要看 QPS、平均耗时、P95/P99、下游 host 数量、是否使用 HTTP/2、请求体和响应体大小、机器 CPU/内存/FD 限制、下游承载能力、业务幂等性等因素。
如果某个下游调用量很大,或者调用耗时明显更长,最好不要和所有请求共享同一套无限制资源。可以考虑为不同下游设计独立 client、独立 Dispatcher、独立连接池或业务层限流隔离。高优先级下游、低优先级下游、第三方 API、长耗时模型服务,通常不应该无差别共享同一组并发预算。
最后总结
OkHttp 的核心设计可以概括为一句话:用 OkHttpClient 承载全局配置,用 Call 表示一次请求,用 Dispatcher 管理异步请求并发,用拦截器链分层处理 HTTP 语义,用 ConnectionPool 和 RealConnection 复用底层连接,最终通过 HTTP/1.1 或 HTTP/2 完成网络通信。
对于后端工程来说,稳定使用 OkHttp 的关键不在于会不会写 newCall(request).execute(),而在于理解这行代码背后的资源模型。
客户端要复用,response 要关闭,超时要有总预算,Dispatcher 并发要根据吞吐和下游能力设置,连接池不是最大连接数,HTTP/2 会改变连接和并发关系,OkHttp 的连接失败重试也不能替代业务重试。
理解了这些,再看 OkHttp 就不只是一个 HTTP 工具库,而是一个围绕请求调度、协议处理和连接复用构建起来的完整客户端运行时。用得好,它可以帮你把 HTTP 调用做得稳定、高效、可观测;用得随意,它也会把资源泄漏、过载和长尾延迟放大到生产环境里。