第2章 缓存数据结构设计:对象模型、序列化方式、版本兼容性
离线缓存方案的核心竞争力在于其数据结构设计的健壮性与演进能力。本章围绕三个关键维度展开:对象模型定义缓存实体的边界与关系;序列化方式决定数据的存储效率与跨语言互通能力;版本兼容性确保缓存数据在SDK升级过程中不失效、不崩溃。三者共同构成缓存数据层的基石。
2.1 缓存对象模型
缓存对象模型采用分层结构,将数据、元数据、索引和版本控制分离,降低耦合度,提升扩展性。核心实体包括以下五类:
| 实体 | 职责 | 关键字段 |
|---|---|---|
| CacheEntry | 存储单条缓存数据的载体 | key, value, createdAt, ttl, version |
| Metadata | 描述缓存的属性与校验信息 | contentType, size, checksum, schemaVersion |
| IndexEntry | 加快查询的键索引 | keyHash, storageOffset, length, expireAt |
| SchemaVersion | 记录数据结构的版本号 | major, minor, patch, compatibleMinVersion |
| CompatibilityRule | 定义字段级别的向前/向后兼容策略 | fieldName, sinceVersion, deprecatedVersion, default value |
设计原则:CacheEntry 不直接持有 Metadata 指针,而是通过 schemaVersion 字段间接引用 SchemaVersion 实体,便于版本独立演进。
2.2 序列化方式对比与选型
序列化方式直接影响缓存写入/读取性能、存储空间占用以及版本演进的灵活性。以下对三种主流方案进行对比:
| 维度 | JSON (Gson) | Protocol Buffers | FlatBuffers |
|---|---|---|---|
| 序列化后大小 (1KB 对象) | ~1.8KB | ~0.6KB | ~1.1KB |
| 反序列化速度 (10万次) | ~320ms | ~90ms | ~40ms (零拷贝) |
| schema 定义 | 无 (注解驱动) | .proto 文件 | .fbs 文件 |
| 字段版本兼容 | 手动 @Since 注解 | 内置字段编号 + 降级规则 | 需手动处理偏移量 |
| 跨语言支持 | 广泛 | 官方多语言 | C++/Java/TS |
| 内存映射支持 | 不支持 | 不支持 | 原生支持 mmap |
| Android 包体积增量 | ~200KB | ~1.2MB (含 proto runtime) | ~400KB |
选型建议:对写入性能和包体积敏感的场景优先使用 FlatBuffers;对跨团队协作和字段版本演进频繁的场景使用 Protocol Buffers;对快速原型或调试友好场景使用 JSON。本课程后续代码以 Protocol Buffers 为主,兼顾 FlatBuffers 对比。
2.3 版本兼容性策略
版本兼容性通过 字段编号锁定 和 SchemaVersion 注册表 实现。核心规则如下:
- 向前兼容(Forward Compat):新版本SDK可以读取旧版本写入的缓存数据——只允许追加字段,不得删除或修改已有字段编号。
- 向后兼容(Backward Compat):旧版本SDK可以读取新版本写入的缓存数据——新版本中废弃的字段需保留默认值占位,并通过
deprecatedVersion标记。 - 破坏性变更隔离:当必须删除或重命名字段时,将 SchemaVersion.major 递增,并清空所有受影响的缓存分区。
Protocol Buffers 中通过 field number 和 reserved 关键字实现锁定:
// cache_entry.proto (v1)
syntax = "proto3";
package cache;
message CacheEntry {
string key = 1;
bytes value = 2;
int64 created_at = 3;
int64 ttl = 4;
int32 schema_version = 5;
// 预留给未来扩展 (1~15 占1字节, 16~2047 占2字节)
reserved 6 to 10;
}
// cache_entry.proto (v2) — 向前兼容追加字段
message CacheEntry {
string key = 1;
bytes value = 2;
int64 created_at = 3;
int64 ttl = 4;
int32 schema_version = 5;
string etag = 6; // 新增字段,编号6
map<string,string> tags = 7; // 新增字段,编号7
reserved 8 to 10;
}
2.4 实现流程:序列化与版本兼容处理
以下实现序列化器,支持多版本反序列化与自动降级:
// CacheSerializer.java
public class CacheSerializer {
private static final int LATEST_SCHEMA_VERSION = 2;
private final Map<Integer, ProtoDeserializer<?>> deserializerMap = new HashMap<>();
public CacheSerializer() {
// 注册每个版本的 deserializer
deserializerMap.put(1, new V1Deserializer());
deserializerMap.put(2, new V2Deserializer());
}
public byte[] serialize(CacheEntry entry) {
CacheEntryProto.CacheEntry.Builder builder = CacheEntryProto.CacheEntry.newBuilder()
.setKey(entry.getKey())
.setValue(ByteString.copyFrom(entry.getValue()))
.setCreatedAt(entry.getCreatedAt())
.setTtl(entry.getTtl())
.setSchemaVersion(LATEST_SCHEMA_VERSION);
if (entry.getEtag() != null) {
builder.setEtag(entry.getEtag());
}
return builder.build().toByteArray();
}
public CacheEntry deserialize(byte[] data, int storedVersion) {
// 获取对应版本的 deserializer,若不存在则向上兼容
ProtoDeserializer<?> deserializer = deserializerMap.get(storedVersion);
if (deserializer == null) {
// 尝试找最近的低版本 deserializer
Integer fallback = findNearestCompatible(storedVersion);
if (fallback == null) {
throw new IncompatibleCacheVersionException("No deserializer for v" + storedVersion);
}
deserializer = deserializerMap.get(fallback);
Log.w("CacheSerializer", "Falling back from v" + storedVersion + " to v" + fallback);
}
return deserializer.deserialize(data);
}
private Integer findNearestCompatible(int version) {
for (int v = version - 1; v >= 1; v--) {
if (deserializerMap.containsKey(v)) return v;
}
return null;
}
}
2.5 调试与日志:版本兼容性检查
在缓存加载时加入版本校验日志,快速定位数据损坏或版本不匹配问题:
// 调试日志示例:加载缓存时的版本检查
public CacheEntry loadFromDisk(String key) {
byte[] raw = storage.read(key);
if (raw == null || raw.length < 4) {
Log.w("CacheLoader", "Corrupt data for key=" + key + ", length=" + (raw == null ? -1 : raw.length));
return null;
}
int storedVersion = extractVersion(raw);
int currentVersion = CacheSerializer.LATEST_SCHEMA_VERSION;
if (storedVersion > currentVersion) {
Log.e("CacheLoader", "Future version detected: stored=" + storedVersion
+ ", current=" + currentVersion + ". Try fallback deserializer.");
} else if (storedVersion < currentVersion - 1) {
Log.i("CacheLoader", "Legacy data v" + storedVersion + ", upgrading on read.");
}
return serializer.deserialize(raw, storedVersion);
}
常见错误:未在序列化时写入 schemaVersion 字段,或反序列化时直接使用最新 schema 解析旧数据,导致字段缺失时抛 NullPointerException。务必在 CacheEntry 顶层保留 version 字段,并在反序列化前校验。
2.6 部署配置:序列化方式切换
在应用启动时通过配置选择序列化后端,便于灰度切换或 A/B 测试:
// CacheConfig.java
public class CacheConfig {
public enum SerializerBackend { JSON, PROTOBUF, FLATBUFFERS }
private SerializerBackend backend = SerializerBackend.PROTOBUF;
public void setSerializerBackend(SerializerBackend backend) {
this.backend = backend;
Log.i("CacheConfig", "Serializer switched to " + backend);
// 切换后需清空旧序列化格式的缓存分区,防止数据错乱
CacheManager.getInstance().invalidatePartition("default");
}
public ISerializer createSerializer() {
switch (backend) {
case JSON: return new JsonCacheSerializer();
case PROTOBUF: return new ProtoCacheSerializer();
case FLATBUFFERS: return new FlatBuffersCacheSerializer();
default: return new ProtoCacheSerializer();
}
}
}
2.7 性能对比数据(基于 Android 12 / Pixel 6)
| 操作 | JSON (Gson) | Protocol Buffers | FlatBuffers |
|---|---|---|---|
| 序列化 1000 条 (ms) | 48.2 | 12.7 | 9.3 |
| 反序列化 1000 条 (ms) | 62.5 | 15.1 | 6.8 |
| 内存分配 (MB / 1000条) | 4.8 | 1.2 | 0.6 |
| GC 触发次数 | 12 | 3 | 0 (零拷贝) |
稳定性优化要点:
1. 序列化器实例建议复用(单例模式),避免反复创建 ProtoBuffer Builder 导致的临时对象抖动。
2. 使用
3. 反序列化时捕获
1. 序列化器实例建议复用(单例模式),避免反复创建 ProtoBuffer Builder 导致的临时对象抖动。
2. 使用
CodedInputStream 的 setSizeLimit() 防止恶意超大缓存撑爆堆内存:codedInputStream.setSizeLimit(4 * 1024 * 1024);3. 反序列化时捕获
InvalidProtocolBufferException,将损坏数据移至 corrupt/ 目录并上报,不阻塞主流程。
2.8 常见错误与修复
| 错误现象 | 根因 | 修复方案 |
|---|---|---|
反序列化抛出 OutOfMemoryError | 缓存 value 字段无大小限制 | 写入时截断至 2MB,读取时校验 length |
| 升级 SDK 后缓存全部失效 | schemaVersion 未写入或默认值为 0 | Serializer 构造函数中强制写入 LATEST_SCHEMA_VERSION |
| 旧版本 SDK 读新缓存崩溃 | 新字段在旧 proto 定义中为 unknown | 使用 mergeFrom() 并忽略未知字段 |
| 缓存数据跨版本后字段值错乱 | field number 被复用或重排 | 使用 reserved 锁定已废弃编号 |