第三章 磁盘缓存实现:文件存储路径规划、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 进行规范化处理,否则同一资源可能因微小差异导致缓存不命中:
- 去除默认端口:将
http://example.com:80/pic.jpg标准化为http://example.com/pic.jpg。 - 小写化:URL 中的协议与域名统一转为小写(路径部分保持原样)。
- 移除锚点:删除
#fragment部分。 - 排序查询参数:对于 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 存储内容结构
磁盘缓存文件不仅保存图片字节流,还需要记录元数据以便进行有效性检查和格式识别。推荐使用 定长头部 + 可变体 的格式:
| 偏移 | 字段 | 大小 | 说明 |
|---|---|---|---|
| 0 | Magic Number | 4 字节 | 固定标识 0x4C494D47(即 "LIMG") |
| 4 | 版本号 | 2 字节 | 当前为 1 |
| 6 | MIME 类型长度 | 2 字节 | 无符号短整型,大端 |
| 8 | 过期时间戳 | 8 字节 | Unix 毫秒时间戳,0 表示永不过期 |
| 16 | 原始字节长度 | 8 字节 | 图片数据总长度 |
| 24 | MIME 类型字符串 | 变长 | 如 "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)
下图展示了磁盘缓存的写入与读取完整流程:
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 部署与测试
- 单元测试:覆盖正常写入/读取、部分写入(模拟断点)、过期检测、URL 归一化等场景。
- 集成测试:在真实设备上模拟多次网络加载,监控磁盘缓存命中率。
- 压力测试:并发 20 线程同时读写同一缓存文件,验证锁机制是否有效。
- 清理策略:定期清理过期文件,或者使用 LRU 淘汰,避免缓存文件无限增长。