换 Redis 序列化器不是改一行配置:从对象信任到可回滚迁移
工程笔记
发布 / DavidHLPL
把 Redis serializer 换掉,表面上可能只需要修改一个 Bean。但 Redis 中保存的是字节,不是 Java 对象。
如果旧缓存使用 JDK serializer 或 GenericJackson2JsonRedisSerializer,新 serializer 使用另一种 envelope,那么直接切换会产生两个独立问题:
- 新代码能否安全地解释 Redis 中的类型信息?
- 已存在的旧字节如何迁移、回滚,以及如何避免覆盖并发写入?
反序列化的危险发生在对象创建之前
Jackson 多态类型数据通常会携带类型标识,例如:
{
"version": 2,
"payload": {
"@class": "com.example.User"
}
}
如果类型标识来自 Redis,而 Redis 中的内容又可能被其他应用、旧程序或攻击者写入,那么“先让 Jackson 按类型创建对象,再在业务层检查”已经太晚了。
真正需要保证的是:
读取字节
-> 检查类型标识是否允许
-> 通过后才让 Jackson 实例化对象
这是一条信任边界,不是普通的 DTO 转换。
ResiCache 的 serializer 做了三层约束
当前 SecureJacksonRedisSerializer 有三个相关机制。
版本化 envelope
非空缓存值会被包在:
{
"version": 2,
"payload": "..."
}
这里的 version 是序列化格式版本。它不是业务对象版本,也不是缓存值的 CAS token。
白名单策略
默认允许的业务包前缀是:
io.github.davidhlp
同时,java.lang、部分 java.time、java.math 类型和明确枚举的集合类型可以通过。
白名单不是简单的“开启或关闭多态”。它必须回答:
这个具体的全限定类名是否允许出现在缓存字节中?
当前实现有一个配置边界值得特别注意:
com.example.* -> 按包边界匹配
com.example -> 按 startsWith 匹配
因此,com.example 可能匹配 com.exampleX.SomeType。如果需要严格的包边界,应使用带 .* 的形式,或者配置足够精确的前缀。
streaming preflight
serializer 在真正读取 envelope 之前,会通过流式 parser 检查:
- 配置的 type property;
@class;- wrapper-array 形式的类型 id;
- 多态 payload 中出现的类型标识。
只有检查通过后,才进入 EnvelopeCodec.read。
当前源码的核心流程可以概括为:
try (JsonParser parser = objectMapper.createParser(bytes)) {
validateTypeIdsStreaming(parser);
}
Object envelope = EnvelopeCodec.read(objectMapper, bytes);
if (EnvelopeCodec.version(envelope)
!= EnvelopeCodec.currentVersion()) {
// failOnUnknownType=true 时拒绝
}
这不是完整的反序列化安全审计,但它把最关键的类型检查放到了对象实例化之前。
源码见 SecureJacksonRedisSerializer.java 和 WhitelistPolicy.java。
为什么还要支持旧缓存迁移
即使新的 serializer 已经安全,旧缓存仍然可能不是当前 envelope。
当前迁移配置明确区分四个阶段:
| 阶段 | 行为 | 是否修改源 key |
|---|---|---|
SHADOW_READ |
解码并验证旧值 | 否 |
DUAL_WRITE |
保留旧值,把新 envelope 写入 sidecar | 否 |
CUTOVER |
备份旧值,再替换源值 | 是 |
ROLLBACK |
从备份恢复旧值 | 是 |
默认阶段是 SHADOW_READ,迁移不会在应用启动时自动执行。
SHADOW_READ:先知道存量数据是什么
SHADOW_READ 做的事情很保守:
扫描候选 key
-> 识别是否已经是当前 envelope
-> 尝试按配置的 legacy serializer 解码
-> 记录成功或失败
-> 不写 Redis
它解决的是“迁移前的可见性”问题。
如果不先知道旧数据的格式和可解码比例,直接 cutover 很容易把兼容性问题伪装成普通缓存 miss。
DUAL_WRITE:先写旁路数据
DUAL_WRITE 保留旧 source key,并将新 envelope 写入 sidecar:
user:42
user:42:__resicache_envelope
sidecar 会沿用源 key 的 TTL。再次执行迁移时,如果 sidecar 内容已经相同,就不会重复写入。
这提供了两个好处:
- 旧消费者仍然可以读旧 source key;
- 新格式可以先被验证,而不需要立即替换主数据。
CUTOVER:备份、比较、替换
cutover 的顺序是:
读取旧 source
-> 写入 legacy backup sidecar
-> 原子比较 source 是否仍等于旧字节
-> 相等才替换为新 envelope
-> 使用 KEEPTTL 保留生命周期
替换逻辑的关键 Lua 语义是:
if redis.call('get', KEYS[1]) == ARGV[1] then
redis.call('set', KEYS[1], ARGV[2], 'KEEPTTL')
return 1
else
return 0
end
如果迁移读取旧值后,业务线程已经写入了新值,比较就会失败:
迁移读取 old
业务写入 new
迁移尝试替换 old -> envelope(old)
比较失败,拒绝覆盖 new
这不是为了保证迁移一定完成,而是为了避免迁移工具成为新的数据覆盖者。
ROLLBACK 也不能盲目覆盖
回滚时,系统不会简单地把 backup 写回 source。
它会先判断当前 source 是否仍然是预期的 envelope:
当前值仍是本次 cutover 产生的值
-> 允许恢复旧值
当前值已经被业务重新写入
-> 拒绝回滚,保护新写入
因此,rollback 也是一种带条件的恢复,而不是无条件覆盖。
为什么不直接清空 Redis 再切换
如果缓存完全可丢弃,并且业务能够接受一次全量回源,那么清空旧缓存后切换 serializer 可能是更简单的方案。
但这需要明确满足几个前提:
- 缓存确实只是缓存,不包含不能丢失的状态;
- 全量回源压力可接受;
- 所有实例能够同时切换;
- 没有旧消费者继续写入旧格式;
- 不需要灰度、旁路验证或回滚。
当前迁移引擎适合的是不能简单清空、需要观察存量格式、或需要保护并发写入的场景。它付出的代价是:
- 多个迁移阶段;
- sidecar 和 backup key;
- operator 控制;
- legacy decoder;
- 迁移报告和失败处理;
- 对 Redis wire format 的额外维护。
这不是“更高级”,而是用操作复杂度换取迁移期间的可控性。
类型安全和迁移安全必须同时成立
迁移旧数据时,legacy decoder 也不能绕过白名单。
测试中的恶意旧 JSON:
{
"@class": "com.attacker.Gadget"
}
在迁移阶段同样会被拒绝,而不是因为它来自旧格式就自动信任。
这说明迁移流程不能被视为安全边界之外的临时脚本。它读取的是同一批不可信或不确定来源的数据,因此必须复用类型约束。
测试验证了哪些边界
当前 serializer 测试覆盖了:
@class白名单外类型拒绝;- wrapper-array 类型标识拒绝;
- 自定义 type property 拒绝;
- 白名单内类型 round-trip;
- serializer factory 正确传递配置;
- 旧
CachedValue缺少刷新元数据时仍可读取。
真实 Redis 集成测试中,SerializationMigrationIntegrationTest 共 8 个测试全部通过,覆盖:
SHADOW_READ不写源 key;DUAL_WRITE保留旧值并可幂等执行;- sidecar 保留 TTL;
CUTOVER备份旧字节;ROLLBACK恢复旧值;- 并发业务写入后拒绝覆盖;
- 混合数据集中拒绝非白名单类型。
测试代码见 SerializationMigrationIntegrationTest.java。
成本和失效边界
这套设计的边界同样需要写清楚。
- envelope 的
version只是格式版本,不会自动迁移任意历史格式。 - 当前 legacy decoder 明确支持的格式有限;其他 serializer 需要新增解码器。
VersionEnvelope.payload的@JsonTypeInfo使用固定的@class,如果修改 type property,注解和配置必须同步。- 默认白名单偏向项目自身包名,自定义业务类型必须显式配置。
- pre-flight 只扫描样本,不能证明整个 Redis 中没有旧格式。
- 迁移扫描受
maxKeys、pattern 和 batch size 限制,不是一次性全库事务。 dryRun可以降低风险,但不能替代真实 cutover 测试。- 白名单和 streaming preflight 不能等同于完整供应链安全审计。
- 本轮没有测量序列化 CPU、GC、Redis 网络开销,因此不能推出性能提升比例。
可迁移的判断方法
更换持久化格式时,至少要先确认:
- 新格式如何识别?
- 旧格式有哪些,能否逐类解码?
- 不可信类型在哪里被拒绝?
- 是否需要保留旧消费者的读取能力?
- cutover 如何避免覆盖并发写入?
- TTL、过期时间和 sidecar 是否保持一致?
- 回滚时如何判断当前值仍然属于本次迁移?
- 迁移失败是停止全流程,还是按 key 隔离并继续?
如果这些问题没有答案,修改 serializer 配置并不等于完成迁移。
来源与关联文档
研究记录
- 原始文档: src/content/note/zh-cn/resicache-serialization-migration.md
- 来源版本: f49953fb0d4bd52a39bf81167b0c4b7dd7d78a64
- 证据状态: 暂定