4. OkHttp 内置缓存机制:Cache 类、缓存目录、DiskLruCache 结构

在本章中,我们将深入分析 OkHttp 内置缓存的核心组件:Cache 类、缓存目录结构以及底层的 DiskLruCache 引擎。理解这些原理是编写动态缓存拦截器的基础,能帮助你精准控制缓存行为与诊断异常。

4.1 Cache 类——拦截器链中的缓存门面

Cache 类位于 okhttp3 包中,是缓存系统对外的主要 API。它并不是独立工作的,而是被 CacheInterceptor 调用,充当网络请求与本地存储之间的协调者。

构造一个 Cache 实例只需指定目录与最大大小:

File cacheDir = new File(context.getCacheDir(), "okhttp_cache");
int maxCacheSize = 10 * 1024 * 1024; // 10 MB
Cache cache = new Cache(cacheDir, maxCacheSize);

一旦配置到 OkHttpClient 中,CacheInterceptor 会通过 Cache.get(CacheStrategy) 尝试读取缓存,并通过 Cache.put(Response) 写入新响应。下表列出了 Cache 类中与调试和运维相关的核心方法:

方法返回值说明
get(CacheStrategy)Response?根据缓存策略返回缓存的响应或 null。
put(Response)void将一个网络响应持久化到磁盘缓存中。
remove(Request)boolean删除指定请求对应的缓存条目。
size()long当前缓存占用的字节数。
maxSize()long缓存的最大字节限制。
hitCount()int缓存命中次数(自创建或重置以来)。
requestCount()int总请求次数(自创建或重置以来)。
networkCount()int实际发起网络请求的次数。
运维指标: 缓存命中率 = hitCount() / requestCount() * 100。在监控面板中定期采集这个指标,可以直观评估缓存策略的有效性。

4.2 缓存目录结构分析

当 OkHttp 的缓存被启用后,你会在指定的目录下看到类似下面的文件布局:

/data/data/com.example.app/cache/okhttp_cache/
├── journal
├── 0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d.0
├── 0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d.1
├── e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0.0
└── e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0.1

文件命名规则以 缓存键(Cache Key) 为核心,该键是对请求 URL 取 MD5 哈希 后生成的 32 位十六进制字符串:

// OkHttp 内部实现
String key = ByteString.encodeUtf8(url).md5().hex();

每个缓存条目对应两个文件:

文件存储内容角色
{key}.0请求行 / 请求头 / 响应头 / TLS 元数据元数据文件,用于重建 Response 对象头信息
{key}.1响应体(原始字节流)HTTP Body 实体
journalDiskLruCache 操作日志崩溃恢复与 LRU 清理的依据
注意: {key}.0 中保存的不仅仅是 HTTP 头,还包含请求的完整 URL 和 OkHttp 内部使用的缓存元数据(如存储时间、TTL)。不要尝试手动修改这些文件,否则会导致日志校验失败并触发缓存重建。

4.3 DiskLruCache——本地缓存引擎

DiskLruCache 是 OkHttp 持久化缓存的底层实现,它是一种基于 日志结构(Log-Structured) 的键值存储,针对 Android/Java 环境做了深度优化。

核心原理:

  • Journal(日志):所有写操作(DIRTY、CLEAN、READ、REMOVE)会先追加到 journal 文件中,然后再操作实际数据文件。这类似于数据库的 WAL(Write-Ahead Log)机制,确保在进程崩溃或断电时能够快速恢复状态。
  • 条目状态机:每个缓存条目在日志中维护一个状态:INITIAL → DIRTY → CLEAN / REMOVE。如果遇到 DIRTY 但没有对应的 CLEAN,重放时会自动删除该条目。
  • LRU 淘汰:当当前缓存大小超过 maxSize 时,DiskLruCache 会同步遍历日志,删除最近最少使用的条目,直到满足大小限制。

虽然通常我们通过 Cache 间接操作它,但了解其底层 API 有助于深入排查问题:

// OkHttp 内部构造(示意)
DiskLruCache diskLruCache = DiskLruCache.create(
    cacheDir,       // 目录
    1,              // appVersion(版本号,用于数据格式不兼容时清理)
    2,              // valueCount(每个 key 对应几个文件:.0 和 .1)
    maxSize         // 最大字节数
);

// 读取快照
DiskLruCache.Snapshot snapshot = diskLruCache.get(key);
if (snapshot != null) {
    Source source0 = snapshot.getSource(0); // .0 文件
    Source source1 = snapshot.getSource(1); // .1 文件
}

// 写入编辑器
DiskLruCache.Editor editor = diskLruCache.edit(key);
if (editor != null) {
    BufferedSink sink0 = Okio.buffer(editor.newSink(0));
    BufferedSink sink1 = Okio.buffer(editor.newSink(1));
    // ... 写入数据
    editor.commit();
}

4.4 集成与数据流

下面这张架构图直观地展示了从 OkHttpClient 发起请求到数据写入磁盘的完整路径:

OkHttp 内置缓存机制架构图 OkHttpClient CacheInterceptor Cache CacheStrategy DiskLruCache journal {key}.0(响应头) {key}.1(响应体) 提示:日志文件 (journal) 提供 O(1) 的写入弹性,并支持 LRU 清理。

数据流的关键步骤:

  1. 拦截决策:CacheInterceptor 调用 Cache.get(cacheStrategy),CacheStrategy 根据请求头与缓存元数据决定是否使用网络。
  2. 磁盘读取:如果缓存有效,Cache 通过 DiskLruCache.get(key) 获取 Snapshot,解析 {key}.0 和 {key}.1 组装成 Response。
  3. 磁盘写入:如果缓存未命中或需要更新,CacheInterceptor 发起网络请求,然后将响应通过 Cache.put(response) 写入,内部调用 DiskLruCache.edit(key) 获取 Editor,将数据写入 .0 和 .1 文件,最后 commit()。

4.5 常见错误与性能陷阱

常见错误:

  • 日志文件损坏 (IOException):如果 journal 文件因意外写入或磁盘故障损坏,DiskLruCache 会抛出 IOException。OkHttp 内部捕获该异常后会调用 delete() 并重建缓存目录。
  • 文件描述符泄漏:虽然 OkHttp 会正确关闭 Snapshot 和 Editor,但在高并发场景下,如果 DiskLruCache 的内部清理线程被阻塞,可能导致文件描述符耗尽。此时需要检查操作系统的 ulimit -n 限制。
  • 目录权限:在 Android 中,如果传入的缓存目录没有写权限(例如从外部存储获取),DiskLruCache 会在首次写操作时失败。

性能优化建议:

场景优化措施
高并发写入DiskLruCache 的 edit() 和 commit() 是同步的,尽量将缓存大小限制在合理范围(如 10MB~50MB),避免单次清理时间过长。
缓存抖动对于 Cache-Control: no-cache 或频繁变化的接口,可以结合 CacheStrategy.FORCE_CACHE 或自定义拦截器隔离缓存策略,避免 LRU 算法频繁淘汰热点数据。
监控与调试定期采集 Cache.size() 和命中率,设置告警。当命中率低于 50% 时,需要检查缓存头配置是否合理。