4. 缓存拦截器原理:拦截器链、CacheInterceptor 源码分析

这一章咱们聊聊 OkHttp 的拦截器链,以及 CacheInterceptor 到底在链条里扮演什么角色。说实话,很多同学用了好几年 Retrofit+OkHttp,却从来没仔细看过这个链条是怎么串起来的。我以前也这样,直到线上出了个缓存不更新的 bug,排查了整整两天……后来才被迫把 OkHttp 的 interceptor 源码翻了个底朝天。嗯,今天就把这些经验嚼碎了喂给你。

OkHttp 拦截器链与 CacheInterceptor 流程 应用层请求 RealCall.getResponseWithInterceptorChain() 一、RetryAndFollowUp 二、Bridge 三、Cache ⭐ 本章主角 四、Connect 五、Network CacheInterceptor.intercept() 内部逻辑 1. cacheCandidate 是否存在? 2. 网络请求是否必要? ✅ 缓存可用,直接返回 (根据max-age / stale-while-revalidate) ❌ 缓存过期/强制刷新 走网络请求,并更新缓存 最终响应 / 抛出异常

4.1 拦截器链:一层一层剥洋葱

OkHttp 的网络请求,本质上就是一条拦截器链。每一层拦截器只关心自己的事,做完就传给下一层。这设计有点像 Unix 的管道,职责单一,组合灵活。

  • RetryAndFollowUpInterceptor:负责重试和重定向。我遇到过几次连接超时自动重试后缓存没更新,后来发现这哥们重试时不会通知缓存层——注意,这是坑。
  • BridgeInterceptor:把应用层的请求转换成网络请求,设置 Content-Length、Gzip 等。说白了就是帮我们补全 Http 头。
  • CacheInterceptor(重点):我们的缓存管理核心。它在 Connect 之前,Network 之后——这个顺序很关键!
  • ConnectInterceptor:建立 TCP/SSL 连接。缓存没过期的话,根本轮不到它出场。
  • NetworkInterceptors:用户自定义的网络拦截器,只有真正走网络时才触发。
小提示:我自己调试时,经常在应用层添加一个日志拦截器放在链首,但要注意——如果缓存直接命中,后面所有拦截器都不执行了。别问我怎么知道的,线上日志丢过啊。

4.2 CacheInterceptor 源码核心方法

直接打开 okhttp3.internal.cache.CacheInterceptor,看看它最核心的 intercept() 方法。我挑关键行讲,代码附在下面。

public Response intercept(Chain chain) throws IOException {
    // 1. 尝试从缓存中拿候选响应
    Response cacheCandidate = cache != null ? cache.get(chain.request()) : null;
    long now = System.currentTimeMillis();

    // 2. 构造缓存策略(这就是上讲讲的 CacheStrategy)
    CacheStrategy strategy = new CacheStrategy.Factory(now, chain.request(), cacheCandidate).get();
    Request networkRequest = strategy.networkRequest;
    Response cacheResponse = strategy.cacheResponse;

    // 3. 如果不需要网络,且缓存可用
    if (networkRequest == null && cacheResponse != null) {
        return cacheResponse.newBuilder()
                .cacheResponse(stripBody(cacheResponse))
                .build();
    }

    // 4. 如果需要网络,继续往下走
    Response networkResponse = chain.proceed(networkRequest != null ? networkRequest : chain.request());

    // 5. 如果有缓存候选,做条件更新(304 Not Modified)
    if (cacheCandidate != null) {
        if (networkResponse.code() == HTTP_NOT_MODIFIED) {
            Response combined = cacheCandidate.newBuilder()
                    .headers(networkResponse.headers())
                    .cacheResponse(stripBody(cacheCandidate))
                    .networkResponse(stripBody(networkResponse))
                    .build();
            cache.trackConditionalCacheHit();
            cache.update(cacheCandidate, combined);
            return combined;
        }
        // 服务端返回新的资源,删除旧缓存
        cache.remove(cacheCandidate);
    }

    // 6. 把新响应写入缓存(如果可缓存)
    if (cache != null) {
        Response cacheableResponse = cache.put(networkResponse);
        if (cacheableResponse != null) {
            return cacheableResponse;
        }
    }
    return networkResponse;
}

我来拆解这几个关键步骤:

  1. 获取缓存候选:先问 Cache 有没有这个请求的响应。注意,这里拿到的是原始副本,也可能为 null。
  2. 策略裁决:把当前时间、请求、缓存候选交给 CacheStrategy 工厂。它会根据过期时间、max-age、immutable 等参数,决定要不要用缓存。返回的 networkRequest 如果为 null,说明缓存尚可用。
  3. 直接返回缓存:网络请求为 null 且缓存响应非空,直接返回。这就是“缓存命中”最快的路径。
  4. 发起网络请求:如果不满足,就调用 chain.proceed() 继续走 Connect 和 Network 拦截器。
  5. 条件更新(304):如果服务返回 304 Not Modified,说明缓存还能用,就把新请求的头信息和旧缓存的 body 合并。我见过不少同学没处理这个分支,导致总是重新下载 body——浪费流量。
  6. 写入新缓存:cache.put() 会把响应存入磁盘,同时根据 Cache-Control 设置过期时间。
⚠️ 注意:CacheInterceptor 的 stripBody() 是为了避免内存泄漏——它把响应体替换成空体。但如果你直接操作 response.body(),后面再用就可能报错。
我曾经在自定义拦截器中想二次读取缓存响应体,结果抛了“closed”异常,排查半天发现是 stripBody 搞的鬼。

4.3 拦截器链的执行顺序为什么重要?

你看上图,CacheInterceptor 位于 ConnectInterceptor 之前。这设计很巧妙:
如果缓存命中,直接返回,根本不需要建立网络连接。但如果缓存策略允许使用过期缓存(比如 stale-while-revalidate),它会在后台发起异步网络请求来更新缓存,同时先用旧缓存响应用户。这种“幕后刷新”的机制,正是 OkHttp 比很多其他 HTTP 库高明的地方。

拦截器名 位置 缓存相关行为
RetryAndFollowUp 链首 重试时不会主动清缓存,需自行处理
Bridge 第二 添加 Cache-Control 头部,影响策略
Cache 第三 拦截网络、返回缓存、更新缓存
Connect 第四 缓存未命中时才执行
Network 第五 用户自定义,可修改响应内容
核心结论:
缓存策略的最终决策权在 CacheStrategy,而 CacheInterceptor 只是执行者。想调优缓存,要么改请求头(Cache-Control),要么自定义 Cache 实现,要么在拦截器链中“插队”提前干预。

4.4 个人遇到的一个典型坑

有一次我们 App 的图片列表加载很慢,查了发现每次请求都走了网络。看日志发现 Cache-Interceptor 返回的 cacheResponse 始终为 null。问题出在哪?我们服务端返回的响应头没有 Cache-Control 也没有 Last-Modified。OkHttp 对不可缓存的响应默认不存缓存。
当时我写了一行代码在应用层添加默认缓存头:

// 在应用拦截器中注入默认缓存策略
request.newBuilder()
    .header("Cache-Control", "public, max-age=300")
    .build();

但注意:不能写死在所有请求上,否则更新接口也会被缓存。后来我改成只对 GET 请求添加,加了个白名单。嗯,这种细节不趟一次坑是真不知道。