第三章 磁盘缓存实现:文件存储路径规划、MD5命名策略、序列化与反序列化方式

3.1 磁盘缓存整体架构

磁盘缓存是三级缓存体系中的第二层,位于内存缓存之后、网络加载之前。它的核心作用是将图片数据持久化到本地文件系统,避免重复从网络下载,同时为内存缓存逐出时提供后备存储。磁盘缓存的性能直接影响应用启动速度和离线浏览体验。

关键指标:磁盘缓存命中率应达到 85% 以上,单次读取延迟 < 10ms(机械硬盘)或 < 2ms(固态硬盘)。

磁盘缓存的实现需解决以下核心问题:

  • 文件存储路径规划:如何组织目录结构,避免单目录文件过多导致性能下降(ext4 单目录容量建议 < 1 万个文件)。
  • MD5 命名策略:如何将网络 URL 映射为唯一的文件名,同时保证安全性(不暴露源路径)和长度可控。
  • 序列化与反序列化:如何将图片元数据(如 Content-Type、过期时间、原始字节流)高效写入文件,并在读取时正确还原。

3.2 文件存储路径规划

3.2.1 通用目录结构设计

推荐采用两级哈希子目录 + 文件名的结构,避免单个目录文件数量膨胀。典型路径模板:

${cacheRoot}/${prefix}/${subDir1}/${subDir2}/${md5FileName}.tmp

其中:

层级说明示例
cacheRoot应用私有缓存根目录(如 /data/data/com.example.app/cache/images/)/sdcard/Android/data/com.example/cache/
prefix区分图片类型或业务模块(可选)thumb/ 或 full/
subDir1对 MD5 前 2 字符取哈希a3/
subDir2对 MD5 第 3-4 字符取哈希f7/
md5FileName完整 MD5 值(32 字符)a3f7c4d8e1b2f9a0c3d4e5f6a7b8c9d0
扩展名统一使用 .tmp 或根据 MIME 映射.jpg / .png
性能陷阱:如果只使用单层子目录(如 cacheRoot/${md5[:2]}/),每个子目录仍可能包含数千文件。建议使用两层子目录,将文件均匀分散到 256×256=65536 个叶子目录中,保证每个目录文件数不超过几十个。

3.2.2 路径生成代码示例

以下 Java 实现展示了如何根据 URL 计算缓存文件路径:

public class CachePathUtil {
    private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray();

    public static String md5(String input) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
            char[] chars = new char[32];
            for (int i = 0; i < 16; i++) {
                chars[i * 2] = HEX_DIGITS[(digest[i] >> 4) & 0xf];
                chars[i * 2 + 1] = HEX_DIGITS[digest[i] & 0xf];
            }
            return new String(chars);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }

    public static String getCacheFilePath(String cacheRoot, String imageUrl) {
        String md5 = md5(imageUrl);
        String sub1 = md5.substring(0, 2);
        String sub2 = md5.substring(2, 4);
        return cacheRoot + File.separator + sub1 + File.separator + sub2
                + File.separator + md5 + ".tmp";
    }
}

3.3 MD5 命名策略

3.3.1 为什么选择 MD5

  • 唯一性:相同 URL 总是生成相同 MD5,保证缓存命中;不同 URL 碰撞概率极低(约 2-128)。
  • 长度固定:32 字符十六进制,方便文件系统操作。
  • 安全性:不暴露原始 URL 中的路径和参数,防止敏感信息泄漏。
  • 不可逆:无法从文件名反推出 URL,保护隐私。

3.3.2 输入归一化

在计算 MD5 之前,需要对 URL 进行规范化处理,否则同一资源可能因微小差异导致缓存不命中:

  1. 去除默认端口:将 http://example.com:80/pic.jpg 标准化为 http://example.com/pic.jpg。
  2. 小写化:URL 中的协议与域名统一转为小写(路径部分保持原样)。
  3. 移除锚点:删除 #fragment 部分。
  4. 排序查询参数:对于 GET 请求参数,按 key 升序排列,避免 ?a=1&b=2 与 ?b=2&a=1 不同。
public static String normalizeUrl(String url) {
    try {
        URI uri = new URI(url);
        String scheme = uri.getScheme().toLowerCase();
        String host = uri.getHost().toLowerCase();
        int port = uri.getPort();
        if (port == -1 || (scheme.equals("http") && port == 80)
                || (scheme.equals("https") && port == 443)) {
            port = -1;
        }
        String path = uri.getPath();
        String query = uri.getQuery();
        String fragment = null; // 丢弃
        // 构建标准 URI
        URI normalized = new URI(scheme, null, host, port, path, query, null);
        return normalized.toString();
    } catch (URISyntaxException e) {
        return url;
    }
}

3.4 序列化与反序列化方式

3.4.1 存储内容结构

磁盘缓存文件不仅保存图片字节流,还需要记录元数据以便进行有效性检查和格式识别。推荐使用 定长头部 + 可变体 的格式:

偏移字段大小说明
0Magic Number4 字节固定标识 0x4C494D47(即 "LIMG")
4版本号2 字节当前为 1
6MIME 类型长度2 字节无符号短整型,大端
8过期时间戳8 字节Unix 毫秒时间戳,0 表示永不过期
16原始字节长度8 字节图片数据总长度
24MIME 类型字符串变长如 "image/jpeg",以 '\0' 结尾
24+M图片数据变长原始字节流

3.4.2 Java 序列化实现(基于 DataOutputStream/DataInputStream)

public class DiskCacheEntry {
    private static final byte[] MAGIC = "LIMG".getBytes(StandardCharsets.US_ASCII);
    private static final int VERSION = 1;

    public String mimeType;
    public long expireTimeMillis;  // 0 表示永不过期
    public byte[] data;

    public void writeTo(File file) throws IOException {
        try (DataOutputStream dos = new DataOutputStream(
                new BufferedOutputStream(new FileOutputStream(file)))) {
            // Magic + Version
            dos.write(MAGIC);
            dos.writeShort(VERSION);
            // MIME
            byte[] mimeBytes = mimeType.getBytes(StandardCharsets.UTF_8);
            dos.writeShort(mimeBytes.length);
            dos.writeLong(expireTimeMillis);
            dos.writeLong(data.length);
            dos.write(mimeBytes);
            dos.write(data);
            dos.flush();
        }
    }

    public static DiskCacheEntry readFrom(File file) throws IOException {
        try (DataInputStream dis = new DataInputStream(
                new BufferedInputStream(new FileInputStream(file)))) {
            byte[] magic = new byte[4];
            dis.readFully(magic);
            // 校验 magic
            if (!Arrays.equals(magic, MAGIC)) {
                throw new IOException("Invalid cache file format");
            }
            int version = dis.readUnsignedShort();
            if (version != VERSION) {
                throw new IOException("Unsupported cache version: " + version);
            }
            DiskCacheEntry entry = new DiskCacheEntry();
            int mimeLen = dis.readUnsignedShort();
            entry.expireTimeMillis = dis.readLong();
            long dataLen = dis.readLong();
            // 检查文件剩余长度是否足够
            if (dataLen < 0 || dataLen > 10_000_000) { // 10MB 限制
                throw new IOException("Data length too large: " + dataLen);
            }
            byte[] mimeBytes = new byte[mimeLen];
            dis.readFully(mimeBytes);
            entry.mimeType = new String(mimeBytes, StandardCharsets.UTF_8);
            entry.data = new byte[(int) dataLen];
            dis.readFully(entry.data);
            return entry;
        }
    }
}

3.4.3 序列化验证与调试技巧

  • Magic 校验:读取时首先检查魔数,快速识别非缓存文件。
  • 版本兼容:版本号字段允许未来升级格式时做向后兼容处理。
  • 长度校验:读取长度后进行合理范围检查,防止恶意损坏文件导致 OOM。
  • 内存映射优化:对于大文件(>1MB),可使用 FileChannel.map 减少堆拷贝。
调试命令:在 Linux/Android 模拟器中,可用 xxd -l 32 <cache_file> 查看文件头,验证魔数和版本是否正确。

3.5 磁盘缓存核心流程(内联 SVG)

下图展示了磁盘缓存的写入与读取完整流程:

磁盘缓存核心流程(写入与读取) 网络 URL 归一化 + MD5 生成缓存路径 文件已存在? 是 反序列化读取 否 下载原始图片 序列化写入文件 缓存 Key 归一化 + MD5 生成缓存路径 文件存在? 是 反序列化读取 否 返回 null 写入与读取流程对称使用同一序列化格式

3.6 常见错误与排查

错误现象根因解决方案
同一 URL 下载多次,磁盘缓存未命中URL 未归一化,包含看似不同但指向同一资源的路径(如大小写、默认端口)确保写入和读取使用同一归一化函数
文件无法删除:no space left on device缓存目录未限制总大小,磁盘写满实现 LRU 淘汰策略,设置最大容量
反序列化时 IOException: Invalid cache file format文件头部损坏或写入不完整写入时使用原子操作:先写临时文件,再 rename
读入的图片 bytes 长度为 0 或乱码序列化时 data 长度与实际写入不匹配,或写入过程中断写入完成后立即读取校验(可选)
同一文件被多个线程读写导致数据错乱缺乏并发锁机制使用文件锁(FileLock)或基于 key 的锁分段

3.7 性能与稳定性优化

  • 写缓冲区:使用 BufferedOutputStream 批量写入数据块,避免频繁磁盘 I/O。
  • 延迟写入:小图片(<10KB)可先缓存在内存队列中,达到阈值或定时再批量刷盘。
  • 文件锁:对同一缓存文件写入时,使用 FileChannel.lock() 防止并发冲突。
  • 内存映射:对于大文件(>1MB),使用 FileChannel.map(READ_ONLY, ...) 可减少两次拷贝(内核→用户→应用),降低 GC 压力。
  • 异步 I/O:使用线程池或 CompletableFuture 将磁盘读写与 UI 线程分离。
// 使用 FileChannel 和 MappedByteBuffer 优化大文件读取
public static DiskCacheEntry readWithMmap(File file) throws IOException {
    try (RandomAccessFile raf = new RandomAccessFile(file, "r");
         FileChannel channel = raf.getChannel()) {
        MappedByteBuffer mmap = channel.map(FileChannel.MapMode.READ_ONLY, 0, (int) channel.size());
        // 读取头部
        byte[] magic = new byte[4];
        mmap.get(magic);
        // ... 后续读取逻辑类似,但使用 mmap.get() 方法
    }
}
注意:mmap 映射的文件在垃圾回收前不会释放系统资源,因此建议对大文件读取使用显式的 Cleaner 或限制映射次数。更适合读取一次后缓存到内存的场景。

3.8 部署与测试

  1. 单元测试:覆盖正常写入/读取、部分写入(模拟断点)、过期检测、URL 归一化等场景。
  2. 集成测试:在真实设备上模拟多次网络加载,监控磁盘缓存命中率。
  3. 压力测试:并发 20 线程同时读写同一缓存文件,验证锁机制是否有效。
  4. 清理策略:定期清理过期文件,或者使用 LRU 淘汰,避免缓存文件无限增长。