2. OkHttp缓存组件结构:Cache类、DiskLruCache与读写流程

OkHttp的缓存模块并非一个黑盒,它由清晰的组件层构成:上层的 Cache 类负责策略协调与对外接口,中层的 DiskLruCache 负责日志化持久化与一致性保障,底层则是标准的文件系统目录。理解这三层结构及其协作关系,是精准配置缓存、排查异常和进行性能调优的前提。

2.1 Cache类构造方法与核心参数

Cache 类是开发者接触缓存的入口。其构造函数需要两个核心参数:缓存目录和最大字节数。

// 推荐在应用内部的缓存目录下划分子目录
File cacheDirectory = new File(context.getCacheDir(), "okhttp_responses");
long maxCacheSize = 50 * 1024 * 1024; // 50 MB
Cache cache = new Cache(cacheDirectory, maxCacheSize);

实例化后,通过 OkHttpClient.Builder.cache(cache) 注入即可生效。下表详细说明两个参数的特性:

参数类型说明建议与注意
directoryFile存储缓存条目和 journal 文件的目录必须唯一且可读写;不建议跨进程共享同一目录
maxSizelong缓存占用磁盘的最大字节数建议设为可用存储的 5%~10%;需额外预留 2~3 倍元数据开销(journal)
容量估算实践:若希望缓存 100 MB 有效响应体,建议将 maxSize 设置为 250~300 MB。因为 journal 文件和每个条目的元数据(Headers、状态码等)会额外占用约 1.5~2 倍的空间。

2.2 底层存储引擎:DiskLruCache 工作原理

OkHttp 内部并未直接操作文件流,而是委托给 DiskLruCache。它采用“日志结构”(journaled)保证崩溃一致性:每次对缓存条目的修改(新增、更新、删除)都会先追加记录到 journal 文件,再操作实际数据文件。

journal 文件内容示例如下:

libcore.io.DiskLruCache
1
100
2

DIRTY 148033 1
CLEAN 148033 2 1024 2048
READ 148033
REMOVE 148033
DIRTY 198401 1
CLEAN 198401 1 4096

每一行记录代表一个状态转变:

操作状态说明
DIRTY编辑中正在写入或更新条目,此时文件被锁定
CLEAN已提交写入完成,记录下每个文件的字节数
READ读取中命中缓存,正在读取数据
REMOVE已移除条目被主动驱逐或淘汰
设计要点:日志结构使得操作具有原子性。即使应用在写入过程中崩溃,重启后通过回放 journal 可以恢复到最近一次 CLEAN 或 REMOVE 状态,不会出现半写入的损坏文件。

2.3 缓存目录结构与文件组织

每个缓存目录的内部布局如下:

okhttp_responses/
├── journal
├── 148033.0        # 条目 148033 的元数据(Headers、状态码等)
├── 148033.1        # 条目 148033 的响应体(Body)
├── 198401.0
├── 198401.1
└── ...

每个缓存条目(Entry)对应一个唯一的 32 位整数哈希 Key,并包含两个文件:后缀 .0 存储 Header 与协议元信息;后缀 .1 存储原始响应体字节流。

常见错误:手动在目录中创建、修改或删除 .0 / .1 文件会导致 journal 与实际文件不一致,引发 IOException("unexpected journal line")。所有操作必须通过 DiskLruCache.edit() 和 Snapshot 完成。
OkHttp 缓存组件与读写数据流 OkHttp 缓存内部组件与数据流 OkHttpClient Cache 类 DiskLruCache 缓存目录 journal [key].0 / [key].1 (文件系统) 读取路径 (Request) get() get() read(journal) 写入路径 (Response) put() edit() write(journal) 图例 组件层 读取流程 (Request) 写入流程 (Response)

2.4 缓存写入流程(存储响应)

当服务器返回可缓存的响应(Cache-Control 允许)时,OkHttp 会触发写入流程:

  1. 拦截器判断: CacheInterceptor 确认响应可以被缓存。
  2. 创建 Editor: 调用 DiskLruCache.edit(key),在 journal 中追加 DIRTY 记录,锁定该条目。
  3. 写入元数据: 将响应头、状态码、请求方法等信息序列化写入 [key].0 文件。
  4. 写入响应体: 将原始响应体字节流写入 [key].1 文件。
  5. 提交确认: 调用 Editor.commit(),journal 追加 CLEAN 记录,释放锁并更新总字节计数。
// OkHttp 源码级流程伪代码
DiskLruCache.Editor editor = diskLruCache.edit(urlToKey(request));
try {
  // 写入元数据到 .0 文件
  editor.set(0, MediaType.get(response.header("Content-Type")));
  editor.set(1, response.body().source().readByteString());
  editor.commit(); // 提交成功后写入 CLEAN
} catch (IOException e) {
  editor.abort(); // 异常时写入 REMOVE,回滚
}

2.5 缓存读取流程(获取响应)

当发起符合缓存条件的请求时,读取流程如下:

  1. 查找条目: Cache.get(url) 内部调用 DiskLruCache.get(key),若存在且未被驱逐,则在 journal 中追加 READ 记录。
  2. 获取 Snapshot: 成功获取 Snapshot 对象,内部持有 .0 和 .1 文件的输入流。
  3. 构建缓存响应: 从 .0 读取元数据重建 Headers,从 .1 读取 Body 字节流。
  4. 策略验证: CacheStrategy 判断缓存是否过期。若过期,则发起条件请求(If-None-Match / If-Modified-Since)与服务器进行新鲜度校验。
// 日志输出示例:一次成功的缓存命中
// DIRTY 148033 1
// CLEAN 148033 2 1024 2048
// READ 148033          <-- 读取操作记录
性能关键点: READ 操作不会阻塞写入。DiskLruCache 支持并发读,但同一时刻只允许一个写入线程(DIRTY)。高并发场景下需合理设置 maxSize 以减少因驱逐导致的写入竞争。

2.6 常见错误与稳定性优化

在实际部署中,缓存组件可能因配置不当或环境异常导致功能失效。下表总结了高频问题与优化方案:

分类问题现象根因与解决方案
一致性IOException("unexpected journal line")journal 文件损坏。可在初始化前清空目录或实现定期 cache.evictAll() 重建
容量缓存不生效或频繁驱逐maxSize 设置过小。监控实际磁盘占用并动态调整阈值
并发部分响应无法缓存写入过程中发生异常调用了 abort()。确保 commit() 在 finally 块中或使用 try-with-resources
调试无法确认缓存是否命中开启 OkHttp 日志拦截器,过滤 CacheInterceptor 级别日志;或调用 cache.hitCount() 与 cache.networkCount() 获取统计
稳定性红线:禁止在多个进程中共用同一个 Cache 实例或目录。文件锁在多进程场景下会直接失效,导致 journal 快速损坏并抛出不可恢复的异常。若需跨进程共享数据,应通过 ContentProvider 或文件通道进行封装。