OKHTTP连接保持

53 阅读13分钟

Retrofit2 本身并不包含缓存功能,且代码中目前也没有开启 OkHttp 的磁盘缓存功能

如果在网络交互中,观察到的“第二次请求特别快”,并不是因为数据被缓存了,而是因为 OkHttp 的连接池复用机制(Connection Pooling / Keep-Alive)

下面详细解释为什么会这样,以及如果真的想要“缓存”该怎么做

1. 为什么第二次请求变快了?(连接复用)

在网络请求中,建立连接是最耗时的步骤之一。

  • 第一次请求:DNS 解析 -> 建立 TCP 连接 (三次握手) -> SSL/TLS 握手 (加密验证) -> 发送数据 -> 接收数据。
  • 第二次请求:由于 OkHttp 默认开启了连接池,它发现还在请求同一个域名(BASE_URL),且之前的连接还没有断开,它会直接跳过前面的握手步骤:直接利用已有连接发送数据 -> 接收数据

这省去了几百毫秒甚至上秒的握手时间,所以会感觉“特别快”

注意:这并不意味着数据被缓存了。如果手机断网,第二次请求依然会失败

2. Retrofit 和 OkHttp 的关系

  • Retrofit:只是一个外壳(Wrapper),它负责把 Interface 方法转换成 HTTP 请求,解析 JSON 数据等。它不负责底层的网络传输,也不负责缓存。
  • OkHttp:是 Retrofit 底下的引擎,它负责真正的网络传输和缓存

3. 代码中为何没有缓存?

在 HTTP 协议中,标准的缓存是基于磁盘的(Disk Cache)。要启用它,需要显式地给 OkHttpClient 配置一个 Cache 对象。

查看代码:

return OkHttpClient.Builder()
    // ...
    // 这里缺少了 .cache(cache) 的配置
    .addInterceptor(logging)
    .addInterceptor(TokenHeaderInterceptor())
    .build()

因为没有配置 .cache(),OkHttp 不会将服务器返回的数据写入手机存储

4. 还有一个关键点:POST 请求通常不被缓存

ApiService,大部分请求是 @POST

@POST("/v1/media/getRecapInfo")
fun getRecapInfo(...)

HTTP 标准缓存协议通常只针对 GET 请求有效。 对于 POST 请求(通常代表提交数据或获取非幂等数据),浏览器和 OkHttp 默认都不会去缓存结果

所以,即便开启了 OkHttp 的缓存, getRecapInfo 这种 POST 请求依然不会被缓存。这进一步印证感觉到的“快”纯粹是因为网络连接复用


5. 如果想要真正的“数据缓存”该怎么做?

如果希望在没网的时候也能读取上次的数据,或者减少服务器压力,有两种选择:

方案 A:开启 OkHttp 标准缓存(仅对 GET 有效)

需要修改 buildOkHttpClient,传入 Context 来获取缓存路径:

// 需要传入 context
fun buildOkHttpClient(context: Context): OkHttpClient {
    // 设置缓存大小,比如 10MB
    val cacheSize = 10 * 1024 * 1024L 
    val cache = Cache(File(context.cacheDir, "http_cache"), cacheSize)

    return OkHttpClient.Builder()
        .cache(cache) // 开启缓存
        // ... 其他配置
        .build()
}

前提:后端接口必须是 GET 请求,并且后端返回的 Header 中包含 Cache-Control 信息(比如 max-age=60)。

方案 B:业务层缓存(推荐,适合 POST 和复杂业务)

既然接口多是 POST,且业务逻辑较重,建议不要依赖 HTTP 协议缓存,而是自己控制:

  1. 数据库缓存 (Room):请求成功后,把 JSON 数据存入本地数据库(SQLite/Room)。下次请求前,先读数据库显示旧数据,同时去请求网络更新。
  2. RxJava 内存缓存:如果只是想在页面没销毁前缓存,可以使用 RxJava 的操作符。
    // 示例:使用 replay(1).autoConnect() 让流变热,从而缓存最近一次的结果
    val cachedObservable = apiService.getRecapInfo(req).replay(1).autoConnect()
    

总结

目前的框架没有缓存

  • 第一次慢:建立连接 + 传输数据
  • 第二次快:复用连接 + 传输数据

HTTP 协议中的 Keep-Alive(HTTP 持久连接) 机制,在 OkHttp 中,这个机制是通过 ConnectionPool(连接池) 来实现的

1. 为什么叫“类似长连接”?

严格来说,它不是像 WebSocket 那种一直不断的“永久长连接”,而是 “在一段时间内保持连接不断开”

  • 没有复用(短连接):请求 -> 建立连接(3次握手+TLS) -> 传输 -> 断开连接。下次再请求,重新建立。
  • 连接池复用(Keep-Alive):请求 -> 建立连接 -> 传输 -> 连接闲置放入池中(不断开)。
    • 如果在 5分钟(默认)内又有请求 -> 直接复用该连接
    • 如果超过 5分钟 没人用 -> 自动断开并回收。

2. 配置在哪里?(默认配置)

在代码中,没有显式配置它,因为 OkHttp 默认已经帮你配置好了

源码位置: okhttp3.OkHttpClient.Builder

// OkHttp 源码片段 (Kotlin 版本)
class Builder constructor() {
    // ...
    internal var connectionPool: ConnectionPool = ConnectionPool() // 这里!默认创建了一个连接池
    // ...
}

如果点进去看 ConnectionPool 的无参构造函数:

// okhttp3.ConnectionPool 源码
class ConnectionPool(
    maxIdleConnections: Int = 5, // 默认最大闲置连接数:5个
    keepAliveDuration: Long = 5, // 默认保持存活时间:5
    timeUnit: TimeUnit = TimeUnit.MINUTES // 单位:分钟
) {
    // 这意味着:默认情况下,OkHttp 会维护最多 5 个空闲连接,
    // 每个空闲连接如果不被使用,5 分钟后会被自动清理。
}

如果想修改这个配置(比如为了优化性能,想让连接保持更久,或者允许更多并发闲置连接),可以这样写:

private fun buildOkHttpClient(): OkHttpClient {
    // 自定义连接池
    val myConnectionPool = ConnectionPool(10, 10, TimeUnit.MINUTES)

    return OkHttpClient.Builder()
        .connectionPool(myConnectionPool) // 显式传入
        // ... 其他配置
        .build()
}

3. 源码探究:它是如何“偷偷”复用的?

来看看 OkHttp 是如何管理这些连接的。核心逻辑在 okhttp3.internal.connection 包下

A. 存:请求结束后不关连接

当请求(比如 getRecapInfo)执行完毕拿到数据后,OkHttp 的底层流处理类(StreamAllocation 或新版的 Exchange)会调用 release 方法

正常逻辑是关闭 Socket,但 OkHttp 做了一个判断: “如果是 HTTP/1.1 且 header 里有 keep-alive,我不关 Socket,而是把它标记为 idle(闲置),放回 ConnectionPool 的双端队列(Deque)中。”

B. 取:请求开始前先找连接

当发起第二次请求时,OkHttp 不会立刻 new Socket()

它会去 ConnectionPool 里找(源码类 ExchangeFinder):

  1. 遍历池子:看有没有 host 和 port 都匹配的、而且是可以复用的连接
  2. 找到了:直接返回这个 RealConnection 对象
  3. 没找到:才去执行 TCP 三次握手和 TLS 握手(就是觉得耗时的那部分)

C. 清理:后台线程自动打扫

OkHttp 怎么知道什么时候关闭那些 5 分钟没用的连接?

ConnectionPool 内部,有一个静态的线程池(executor),专门运行一个 cleanupRunnable 任务。

// ConnectionPool 伪代码逻辑
private val cleanupRunnable = Runnable {
    while (true) {
        // 1. 计算下一个连接多久过期
        val waitNanos = cleanup(System.nanoTime())
        
        // 2. 如果池子空了,退出循环,线程结束
        if (waitNanos == -1L) return
        
        // 3. 还没到期,线程挂起等待(wait),到时间再醒来检查
        if (waitNanos > 0) {
            lock.wait(waitNanos) 
        }
    }
}

这个设计非常精妙:如果没有空闲连接,清理线程是不运行的,完全不占资源。一旦有了空闲连接,它才会启动并计时。

4. 为什么握手这么耗时?(直观对比)

感觉到的“第一次慢,第二次快”,在 HTTPS 请求中差异巨大

第一次请求(冷启动):

  1. DNS:域名 -> IP (几毫秒到几百毫秒)
  2. TCP 握手:SYN -> SYN/ACK -> ACK (1个 RTT,往返时延)
  3. TLS 握手(最贵):
    • Client Hello
    • Server Hello + Certificate
    • Key Exchange
    • Finished
    • (如果是 TLS 1.2,这里至少消耗 2个 RTT。跨国网络下,这可能就是 500ms+ 的时间)
  4. 发送 HTTP 数据
  5. 接收数据

第二次请求(复用):

  1. (跳过 DNS)
  2. (跳过 TCP 握手)
  3. (跳过 TLS 握手) -> 省下了最耗时的部分!
  4. 发送 HTTP 数据
  5. 接收数据

所以,在移动端开发中,保持 OkHttpClient 单例(像代码里做的 object NetworkApi + lazy)是非常重要的,这样才能利用连接池。如果每次请求都 new OkHttpClient(),连接池就会失效,每次都要重新握手,速度就会很慢


不需要显式设置,header中Keep-Alive默认是开启的。 而且,目前的日志里可能看不到这个 Header,是因为拦截器的位置原因

下面一步步验证和揭秘

1. 为什么不需要设置?(协议默认值)

  • HTTP/1.1 协议规定:只要是 HTTP/1.1,默认就是 Keep-Alive(持久连接)。除非显式发送 Connection: close,否则服务器和客户端都默契地保持连接。
  • HTTP/2 协议规定:在 HTTP/2 中,连接复用是核心特性(Multiplexing),Connection header 甚至被视为非法或被忽略,因为连接默认就是持久且多路复用的。

2. 为什么日志里可能看不到?(拦截器陷阱)

现在的代码是这样写的:

// ...
.addInterceptor(logging) // <--- 注意这里
// ...

这叫做 Application Interceptor(应用拦截器)

OkHttp 的工作流程是这样的:

  1. 应用拦截器 (Application Interceptor):就是 logging。这时候请求还是“原始”的,是代码里写的样子。OkHttp 还没来得及加默认 Header。
  2. 桥接拦截器 (BridgeInterceptor)OkHttp 内部的一个拦截器。它负责补全 Header(比如 Host, User-Agent, Gzip, Connection: Keep-Alive)。
  3. 网络拦截器 (Network Interceptor):在真正发包之前。
  4. 发送网络请求

结论:因为日志拦截器在第 1 步,而添加 Keep-Alive 发生在第 2 步,所以在日志里看不到它

3. 如何亲眼看到它?(两个方法)

方法一:临时修改代码(最快验证)

把代码中的 addInterceptor(logging) 改为 addNetworkInterceptor(logging)

// NetworkApi.kt 修改

private fun buildOkHttpClient(): OkHttpClient {
    // ... 前面省略
    return OkHttpClient.Builder()
        // ...
        // 删掉这行 .addInterceptor(logging)
        // 改用下面这行:
        .addNetworkInterceptor(logging) 
        .addInterceptor(TokenHeaderInterceptor())
        .build()
}

重新运行并看日志: 会在 Header 里清晰地看到: Connection: Keep-Alive Accept-Encoding: gzip (OkHttp 也会自动帮加这个,之前看不到,现在能看到了) User-Agent: okhttp/4.x.x

验证完记得改回来,通常我们开发用 addInterceptor 就够了,addNetworkInterceptor 会打印一些底层的、解压前的数据,有时候比较乱

方法二:使用 Android Studio Network Inspector(不用改代码)

  1. 运行 App。
  2. 打开 Android Studio 底部的 App InspectionNetwork Inspector 标签页。
  3. 点击发起一个网络请求。
  4. 点击左侧列表里的请求,查看右侧的 Request Headers
  5. 会看到 Connection: Keep-Alive 赫然在列。这是最真实的、发给服务器的数据。

4. 源码验证(核心证据)

既然想看源码实现,这个逻辑在 okhttp3.internal.http.BridgeInterceptor 类中

OkHttp 的责任链模式中,BridgeInterceptor 专门负责把用户的请求转换为网络友好的请求

// OkHttp 源码:BridgeInterceptor.kt
class BridgeInterceptor(private val cookieJar: CookieJar) : Interceptor {

  @Throws(IOException::class)
  override fun intercept(chain: Interceptor.Chain): Response {
    val userRequest = chain.request()
    val requestBuilder = userRequest.newBuilder()

    // ... 省略其他 Header 设置 (Content-Type, Content-Length 等)

    // 重点在这里!!!
    if (userRequest.header("Connection") == null) {
      // 如果用户没有自己设置 Connection Header,OkHttp 帮加上 Keep-Alive
      requestBuilder.header("Connection", "Keep-Alive")
    }

    // ... 省略 Gzip 设置 (Accept-Encoding)

    val networkResponse = chain.proceed(requestBuilder.build())
    
    // ...
  }
}

代码逻辑解读: 它检查 userRequest.header("Connection") == null。 没有设置,所以它确实是空的 于是它执行 requestBuilder.header("Connection", "Keep-Alive")

这就是为什么没写,但它确实存在,且 OkHttp 会帮助维护连接池的原因


设定:

  • 某个App:OkHttp 配置了 100 年的连接超时,极其自信地认为连接活着
  • Server:现实且冷酷,60秒没收到数据,直接踢人
  • Android 底层:负责搬运 TCP 数据包的系统内核

当 Server 销毁连接(发送 TCP FIN 包)后,你的 App 会经历以下几个阶段的“挣扎”和“幻灭”:


第一阶段:Server 动手了(TCP FIN)

  1. 服务器端:计时器到了,Nginx 发送一个 FIN (Finish) 包给你的手机,意思是:“我不玩了,挂电话吧”
  2. 手机操作系统(Linux内核):收到了 FIN
    • 内核自动回复一个 ACK
    • 内核把这个 Socket 的状态标记为 CLOSE_WAIT(被动关闭等待)
    • 关键点:此时,你的 App 进程(Java层/OkHttp)可能还完全不知情! 除非 App 此时此刻正好在读写这个 Socket。如果 App 只是把它放在连接池里睡觉,App 根本收不到通知

第二阶段:App 醒来,取出“僵尸连接”

过了 10 分钟,用户点了一个按钮。OkHttp 接到任务,去连接池找连接

  1. OkHttp:看了一眼连接池:“嘿,这有个连接,过期时间是 2125 年。太棒了,还没过期!”
  2. 健康检查(Crucial Step):OkHttp 其实很鸡贼,它拿出来之前会试探性地检查一下连接是否健康(isHealthy 方法)。
    • 它会尝试读取一下 Socket 的状态,或者判断 socket.isClosed()
    • 悲剧的分岔路
      • 情况 A(运气好):OkHttp 发现 Socket 已经是 CLOSE_WAIT 或者输入流已经关闭了。它会叹口气:“唉,虽然没到 100 年,但它死了。” —— 动作:默默销毁,重新建立新连接。(用户无感知,只是稍微慢一点点)。
      • 情况 B(运气差/竞态条件):如果操作系统还没来得及同步状态,或者检测机制漏网,OkHttp 会误判它是健康的。

第三阶段:向“僵尸”尸体开枪(写入数据)

假设 OkHttp 误判连接是活的(或者连接被取出的那一毫秒是活的,下一毫秒 Server 的 FIN 到了):

  1. 写入请求(Write):OkHttp 开始往 OutputStream 里写 HTTP 请求头(GET /v1/...)。
  2. 系统内核反应
    • 因为 Socket 处于 CLOSE_WAIT 或类似状态,或者 Server 那边已经发送了 RST (Reset,表示你发错人了,我不认识这个连接)
    • 当试图往一个对方已经关闭的连接里写数据时,会触发 SocketException: Broken pipe (管道破裂)或者 SocketException: Software caused connection abort

第四阶段:惊慌与补救(Retry)

这时候 App 抛出了异常 会 Crash 吗?

通常不会 Crash,但会经历一次“剧烈震荡”

OkHttp 默认开启了 retryOnConnectionFailure(失败重试)

  1. 捕获异常:OkHttp 的拦截器链条捕获到了这个 SocketException
  2. 判定:它会分析:“这是一个网络层面的路由故障?还是我复用的这个旧连接有问题?”
    • 结论:“这大概率是因为我复用了旧连接,但旧连接坏了。”
  3. 亡羊补牢
    • OkHttp 会立刻把这个 100 年的连接丢进垃圾桶(关闭 Socket)
    • 重新走冷启动流程:DNS 解析 -> TCP 握手 -> TLS 握手 -> 建立全新的连接。
    • 再次发送请求

最终结果:用户的感知

虽然设置了 100 年缓存想让 App 变快,但结果适得其反:

  1. 延迟增加(抖动)

    • 正常复用:50ms。
    • 正常新建:300ms。
    • 你的“僵尸”复用:50ms (尝试复用) + 10ms (报错) + 300ms (新建重试) = 360ms
    • 结论:比不缓存还要慢!
  2. 潜在的“请求失败”风险(POST 请求)

    • 如果是 GET 请求,OkHttp 敢大胆重试。
    • 如果是 POST 请求(比如提交订单):
      • OkHttp 可能会犹豫:“刚才那半截数据发出去了吗?服务器收到了吗?如果我重试,会不会导致用户重复下单?”
      • 为了安全,OkHttp 有时会放弃重试,直接抛出异常给业务层。
      • 用户界面:弹出 Toast “网络请求失败”,用户一脸懵逼(明明信号满格)。

总结

如果设置 100 年超时:

  1. 服务端会无视你,到时间就把连接掐断(Close FD)。
  2. Android 底层知道连接断了,标记为不可用。
  3. App (OkHttp) 可能会被“连接池里的过期时间”欺骗,拿出一个已经死掉的连接试图使用。
  4. 结果:写入失败 -> 触发异常 -> 触发重试(或者直接报错)。

这就像保存了一张 100 年有效期的“餐厅订座券”,但去的时候,那家餐厅早就倒闭或者换锁了。你拿着券在门口干瞪眼(报错),最后还得重新找一家新餐厅(重建连接)

所以,OkHttp 默认的 5 分钟,是经过精确计算的“保鲜期”。 超过 5 分钟,它假设这盘菜大概率馊了,宁愿倒掉也不给你吃,免得你拉肚子(报错)