第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 实体,便于版本独立演进。
缓存数据结构对象模型 CacheDatabase CacheEntry MetadataIndex VersionController CacheKey CacheValue TTL + Priority SchemaRegistry CompatMapper MigrationLog 版本兼容策略:向前兼容(字段新增) + 向后兼容(字段废弃保留默认值) 通过 SchemaVersion + CompatMapper 实现无损升级

2.2 序列化方式对比与选型

序列化方式直接影响缓存写入/读取性能、存储空间占用以及版本演进的灵活性。以下对三种主流方案进行对比:

维度JSON (Gson)Protocol BuffersFlatBuffers
序列化后大小 (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 注册表 实现。核心规则如下:

  1. 向前兼容(Forward Compat):新版本SDK可以读取旧版本写入的缓存数据——只允许追加字段,不得删除或修改已有字段编号。
  2. 向后兼容(Backward Compat):旧版本SDK可以读取新版本写入的缓存数据——新版本中废弃的字段需保留默认值占位,并通过 deprecatedVersion 标记。
  3. 破坏性变更隔离:当必须删除或重命名字段时,将 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 BuffersFlatBuffers
序列化 1000 条 (ms)48.212.79.3
反序列化 1000 条 (ms)62.515.16.8
内存分配 (MB / 1000条)4.81.20.6
GC 触发次数1230 (零拷贝)
稳定性优化要点:
1. 序列化器实例建议复用(单例模式),避免反复创建 ProtoBuffer Builder 导致的临时对象抖动。
2. 使用 CodedInputStream 的 setSizeLimit() 防止恶意超大缓存撑爆堆内存:codedInputStream.setSizeLimit(4 * 1024 * 1024);
3. 反序列化时捕获 InvalidProtocolBufferException,将损坏数据移至 corrupt/ 目录并上报,不阻塞主流程。

2.8 常见错误与修复

错误现象根因修复方案
反序列化抛出 OutOfMemoryError缓存 value 字段无大小限制写入时截断至 2MB,读取时校验 length
升级 SDK 后缓存全部失效schemaVersion 未写入或默认值为 0Serializer 构造函数中强制写入 LATEST_SCHEMA_VERSION
旧版本 SDK 读新缓存崩溃新字段在旧 proto 定义中为 unknown使用 mergeFrom() 并忽略未知字段
缓存数据跨版本后字段值错乱field number 被复用或重排使用 reserved 锁定已废弃编号