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) 注入即可生效。下表详细说明两个参数的特性:
| 参数 | 类型 | 说明 | 建议与注意 |
|---|---|---|---|
directory | File | 存储缓存条目和 journal 文件的目录 | 必须唯一且可读写;不建议跨进程共享同一目录 |
maxSize | long | 缓存占用磁盘的最大字节数 | 建议设为可用存储的 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 完成。
2.4 缓存写入流程(存储响应)
当服务器返回可缓存的响应(Cache-Control 允许)时,OkHttp 会触发写入流程:
- 拦截器判断:
CacheInterceptor确认响应可以被缓存。 - 创建 Editor: 调用
DiskLruCache.edit(key),在 journal 中追加DIRTY记录,锁定该条目。 - 写入元数据: 将响应头、状态码、请求方法等信息序列化写入
[key].0文件。 - 写入响应体: 将原始响应体字节流写入
[key].1文件。 - 提交确认: 调用
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 缓存读取流程(获取响应)
当发起符合缓存条件的请求时,读取流程如下:
- 查找条目:
Cache.get(url)内部调用DiskLruCache.get(key),若存在且未被驱逐,则在 journal 中追加READ记录。 - 获取 Snapshot: 成功获取
Snapshot对象,内部持有.0和.1文件的输入流。 - 构建缓存响应: 从
.0读取元数据重建 Headers,从.1读取 Body 字节流。 - 策略验证:
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 或文件通道进行封装。