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 实体 |
journal | DiskLruCache 操作日志 | 崩溃恢复与 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 发起请求到数据写入磁盘的完整路径:
数据流的关键步骤:
- 拦截决策:
CacheInterceptor调用Cache.get(cacheStrategy),CacheStrategy根据请求头与缓存元数据决定是否使用网络。 - 磁盘读取:如果缓存有效,
Cache通过DiskLruCache.get(key)获取Snapshot,解析{key}.0和{key}.1组装成Response。 - 磁盘写入:如果缓存未命中或需要更新,
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% 时,需要检查缓存头配置是否合理。 |