缓存写入流程与存储结构:响应写入 DiskLruCache,元数据管理,文件命名与清理

OkHttp 的离线缓存模块并没有自行实现磁盘存储的底层细节,而是直接委托给 Google 的 DiskLruCache 组件。一个 HTTP 缓存条目在磁盘上被拆解为 元数据(.0 文件) 和 响应体(.1 文件) 两部分,通过原子性的写入流程保证数据一致性。本章深入分析响应从网络进入磁盘的完整管道。

核心设计原则: 写入即原子提交。只有在所有数据(元数据 + 主体)都成功写入后,commit() 才会将条目标记为可用;否则通过 abort() 回滚,避免产生残缺的缓存文件。
图4-1 响应写入DiskLruCache流程与文件结构 网络响应 CacheInterceptor diskLruCache.edit() 写入元数据 (.0) 写入主体 (.1) commit() 文件命名映射规则 MD5(url) → 目录索引 .0 文件 → 响应元数据 .1 文件 → 原始响应体 LRU清理 (trimToSize) 超出 maxSize 时触发

写入流程深度解析

当 CacheInterceptor 决定缓存一个来自网络的响应时(状态码 200、GET 请求、非 no-store),它会将数据和元数据提交给 Cache.put() 方法,该方法进一步委托给底层的 DiskLruCache。

  1. 获取编辑器: 调用 diskLruCache.edit(urlMd5) 获取一个 Editor 实例。如果正在执行并发写入,该方法返回 null,当前线程放弃缓存。
  2. 写入元数据: 通过 Editor.newSink(0) 打开索引为 0 的输出流,写入请求行、响应头、Vary 头、TLS 握手信息以及时间戳。
  3. 写入主体: 通过 Editor.newSink(1) 打开索引为 1 的输出流,逐块写入响应体的原始字节。
  4. 提交或中止: 所有数据写入成功后调用 Editor.commit() 原子性提交;若中途发生异常,调用 Editor.abort() 删除已写入的临时文件。
调试命令: 运行时可以通过以下 ADB 命令查看缓存目录的文件分布与大小:
adb shell ls -la /data/data/{your_package}/cache/okhttp_cache/
# 输出示例:
# -rw-rw---- 1 u0_a123 u0_a123  24576 Mar 10 10:00 journal
# -rw-rw---- 1 u0_a123 u0_a123   2048 Mar 10 10:01 5d41402abc4b2a76b9719d911017c592.0
# -rw-rw---- 1 u0_a123 u0_a123  15248 Mar 10 10:01 5d41402abc4b2a76b9719d911017c592.1

磁盘文件命名与存储结构

OkHttp 的 DiskLruCache 在日志文件(journal)中维护索引,而实际数据则分离到不同的磁盘文件中。每个缓存条目对应两个数据文件,文件名由 URL 的 MD5 哈希值生成。

文件路径内容生命周期
journal操作日志文件,记录所有读写、提交、回滚操作。用于重启后重建 LRU 顺序。贯穿缓存实例的整个生命周期
{url_hash}.0元数据文件:包含请求行、响应头、Cache-Control 指令、Vary 头、TLS 连接信息。随条目被清理而删除
{url_hash}.1主体文件:存储 HTTP 响应体的原始二进制数据。随条目被清理而删除

元数据管理:.0 文件内部结构

元数据文件是决定缓存命中与新鲜度校验的核心。OkHttp 按照固定格式将结构化的键值对写入 .0 文件,使得无需读取 .1 文件即可完成缓存决策。

http://example.com/data
GET
0
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=3600
Vary: Accept-Encoding
OkHttp-Received-Millis: 1715360000000
OkHttp-Sent-Millis: 1715360001000

其中 OkHttp-Received-Millis 和 OkHttp-Sent-Millis 是 OkHttp 内部注入的自定义头,用于参与新鲜度计算和条件请求(If-Modified-Since / If-None-Match)的判断。

清理策略与 LRU 淘汰机制

当写入操作导致缓存目录的总大小超过构造时设置的 maxSize,DiskLruCache 会在后台线程中调用 trimToSize() 方法进行清理。清理按照 LRU(最近最少使用) 顺序进行,从 journal 文件中读取访问时间戳,逐条淘汰最久未被读取的条目,直到总容量回落到安全阈值以下。

常见错误:FileNotFoundException
在高并发写入或空间紧张时,一个线程正在读取的缓存文件可能被另一个线程的 LRU 清理删除。此时 OkHttp 会抛出 FileNotFoundException 或 IOException。应用层的 CacheInterceptor 会捕获此类异常并自动降级为网络请求,开发者在实现自定义缓存策略时也应该遵守这一原则:读取缓存时始终准备好回退到网络。
性能与稳定性优化:
  • 原子提交: 始终使用 commit() / abort() 配对,避免产生“部分写入”的脏数据。
  • 控制 journal 文件大小: journal 过大时会触发重组(rebuild),此时 I/O 压力较大。应合理设置 maxSize,避免频繁触发清理阈值。
  • 监控缓存命中率: 通过 Cache.hitCount() 和 Cache.networkCount() 监控写入是否生效,及时调整缓存配置。