JDK 8 → JDK 17 升级踩坑:GZIP 头 OS 字节差异导致 Redis Key 不匹配

项目从 JDK 8 升级到 JDK 17 后,一个"看似无关"的 GZIP 实现细节差异,让所有用 gzip 压缩字符串作为 Redis key 的查询全部失效——HGETALLGET 返回空,业务数据"凭空消失"。这篇记录完整的定位过程、根因与修复方案。

一、现象

升级 JDK 17 后,调用 redisUtils.hgetallMenu("kfc_intelligent_menu_recall_schema", "kfc_preorder_13_2_intelligent_menu_recall") 始终返回空 Map。

但同一个 key,用下面两种方式都能查到数据:

  1. standalone Jedis 工具(JDK 8 编译运行)—— 能查到
  2. redis-cli 直连 —— 能查到

唯独应用自身(JDK 17)查不到。不报错、不抛异常,就是返回空。

二、排查过程

2.1 第一反应:是不是连错集群了?

项目用了 Apollo 配置中心,apollo.bootstrap.enabled=true 会在 Spring 上下文初始化前拉配置覆盖 yml。怀疑 Apollo 把 spring.redis.cluster.nodes 覆盖成了别的集群。

验证:在 ReplicaRedisConfig.createLettuceConnectionFactory 第一行加日志:

log.info("redis cluster nodes: {}", redisProperties.getCluster().getNodes());

输出:

redis cluster nodes: [172.20.1.150:6379, 172.20.1.151:6379, 172.20.1.152:6379]

与 standalone 工具连的完全一致。排除集群节点问题。

2.2 第二反应:是不是序列化器产出的 key 字节不对?

hgetallMenu 用的 gzipRedisTemplate,其 key 序列化器是 GzipHiveRedisSerializer——把字符串 key 先 gzip 压缩再发给 Redis。怀疑 JDK 17 下 gzip 压缩出的字节和 JDK 8 不同,导致 key 不匹配。

验证:在 hgetallMenu 查不到数据时,用原始字节直连 Redis 执行 HGETALL(绕过 Spring 序列化,与 standalone 工具完全一致的方式):

byte[] compressedKey = compressGzip(key.getBytes(StandardCharsets.UTF_8));
Map<byte[], byte[]> rawResult = gzipRedisTemplate.execute((RedisCallback<Map<byte[], byte[]>>) connection -> connection.hGetAll(compressedKey));
log.warn("DEBUG raw: HGETALL result size={}", rawResult == null ? 0 : rawResult.size());

结果:result size=0用原始字节也查不到!

这说明问题不在 Spring 序列化器,而在 gzip 压缩本身——JDK 17 压缩出的 key 字节,和 Redis 里存的(JDK 8 写入的)不一样。

2.3 对比字节:锁定差异

打印 JDK 17 压缩出的 key 字节,和 standalone 工具(JDK 8)压缩出的字节逐字节对比:

JDK 8:  [31, -117, 8, 0, 0, 0, 0, 0, 0,  0, 5, -63, -127, 13, ...]
JDK 17: [31, -117, 8, 0, 0, 0, 0, 0, 0, -1, 5, -63, -127, 13, ...]
                                    ^^
                              index 9: JDK 8 = 0 (0x00), JDK 17 = -1 (0xFF)

只有 index 9 这一个字节不同。

三、根因:GZIP 头的 OS 字节

GZIP 文件格式头共 10 字节,定义如下(RFC 1952):

偏移 长度 字段 说明
0 2 magic 1f 8b
2 1 CM 压缩方法(8 = deflate)
3 1 FLG 标志位
4-7 4 MTIME 修改时间
8 1 XFL 额外标志
9 1 OS 操作系统标识

OS 字节表示压缩时的操作系统类型,取值如:

  • 0x00 — FAT filesystem (MS-DOS, Windows)
  • 0x03 — Unix
  • 0xFF — unknown

JDK 版本差异

java.util.zip.GZIPOutputStream 写入的 OS 字节在不同 JDK 版本下不同:

JDK 版本 OS 字节值
JDK 8 0x00 (0)
JDK 17 0xFF (-1)

实测代码验证(打印 gzipBytes[9]):

JDK 8:  index9 = 0
JDK 17: index9 = -1

这是一个历史遗留差异——JDK 8 的 GZIP 实现将 OS 写为 0x00(FAT/Windows),而更高版本将其改为 0xFF(unknown)或随实现变化。

为什么这一个字节会致命?

GzipHiveRedisSerializer 被用作 RedisTemplatekey 序列化器

@Bean
public RedisTemplate<String, Object> gzipRedisTemplate(RedisConnectionFactory connectionFactory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(connectionFactory);
    template.setKeySerializer(new GzipHiveRedisSerializer<>());      // ← key 压缩
    template.setValueSerializer(new GzipHiveRedisSerializer<>());
    template.setHashKeySerializer(new GzipHiveRedisSerializer<>());
    template.setHashValueSerializer(new GzipHiveRedisSerializer<>());
    ...
}

也就是说,Redis key 是 "kfc_preorder_13_2_intelligent_menu_recall" 经 gzip 压缩后的字节。存量数据由外部 pipeline 用 JDK 8 写入(OS=0x00),应用(JDK 17)查询时压缩出的 key 是 OS=0xFF——key 字节不一致,HGETALL 当然找不到。

反序列化为什么不受影响?

GZIPInputStream 读取时完全忽略 OS 字节,只校验 magic 和 CM。所以用 JDK 17 解压 JDK 8 写入的 gzip 数据完全正常——只有"压缩后用作 key"的场景才会因为字节不一致而失配。

四、修复方案

核心思路:序列化时强制把 OS 字节归一化为 0x00,与 JDK 8 写入的存量数据保持一致。

4.1 修改 GzipHiveRedisSerializer

@Override
public byte[] serialize(String t) throws SerializationException {
    if (t == null) {
        return new byte[0];
    }
    final byte[] data = t.getBytes(StandardCharsets.UTF_8);
    if (data != null) {
        try {
            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
            try (GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteArrayOutputStream)) {
                gzipOutputStream.write(data);
            }
            final byte[] byteArray = byteArrayOutputStream.toByteArray();
            normalizeGzipOsByte(byteArray);   // ← 新增
            return byteArray;
        } catch (IOException e) {
            throw new SerializationException("Failed to compress serialized data", e);
        }
    }
    return new byte[0];
}

/**
 * 归一化 GZIP 头的 OS 字节(header index 9)为 0x00。
 *
 * <p>背景:JDK 8 的 GZIPOutputStream 写入 OS=0x00,JDK 17 写入 OS=0xFF。
 * 本序列化器用于 Redis key 压缩,OS 字节不同会导致压缩后的 key 字节不一致,
 * 从而在跨 JDK 读写 Redis 时查不到数据。存量数据由 JDK 8 写入,强制归一化为 0x00
 * 以兼容存量数据。反序列化不受影响(GZIPInputStream 忽略 OS 字节)。
 */
private static void normalizeGzipOsByte(byte[] gzipBytes) {
    if (gzipBytes.length > 9) {
        gzipBytes[9] = (byte) 0x00;
    }
}

4.2 同步修改 GzipMenuRedisSerializer

同样在 serialize() 末尾归一化:

try (GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteArrayOutputStream)) {
    gzipOutputStream.write(data);
}
byte[] result = byteArrayOutputStream.toByteArray();
if (result.length > 9) {
    result[9] = (byte) 0x00;   // ← 归一化 OS 字节
}
return result;

4.3 不需要改动的部分

位置 原因
deserialize() 方法 GZIPInputStream 忽略 OS 字节,解压不受影响
RedisUtils.compress() 静态方法 grep 确认无外部调用,且不用于 key 压缩
standalone Jedis 工具 用 JDK 8 运行,天然产出 0x00,与存量数据一致

五、为什么选 0x00 而不是 0xFF

选项 含义 风险
归一化为 0x00 与存量数据(JDK 8 写入)一致 ✅ 无需迁移数据,立即生效
归一化为 0xFF 与 JDK 17 默认值一致 ❌ 存量 key 全部失效,需全量重写

存量 Redis 数据由 JDK 8 写入,OS 字节为 0x00。选择 0x00零数据迁移修复,反之则需要清洗全部 Redis key。对生产系统而言,前者明显更安全。

六、影响范围与验证

受影响的 Template

所有使用 GzipHiveRedisSerializerGzipMenuRedisSerializer 作为 key serializerRedisTemplate

  • gzipRedisTemplate(主库)
  • gzipRedisTemplateReplica(从读)
  • gzipCacheMenuRedisTemplate(主库菜单缓存)
  • gzipHiveRedisTemplate / gzipHiveRedisTemplateReplica(key 是 string,不受影响,但 value 压缩也会归一化)

验证方式

# 应用启动后触发 hgetallMenu 调用
curl -X POST http://localhost:8080/api/intent/recommend/OR/sit/menu -H "Content-Type: application/json" -d '{...}'

# 日志应正常返回菜单数据,不再出现 "data is null"

或直接验证 key 字节:

GzipHiveRedisSerializer<String> s = new GzipHiveRedisSerializer<>();
byte[] bytes = s.serialize("test");
assert bytes[9] == (byte) 0x00;  // JDK 17 下归一化后为 0x00,与存量数据一致

七、经验教训

7.1 GZIP 不是确定性输出

很多人以为"相同输入 + 相同算法 = 相同输出",但 GZIP 头包含 MTIME、OS、XFL 等元数据字段,不同实现/版本可能产出不同字节。当 gzip 输出被用作 key 或哈希值时,必须归一化这些字段。

7.2 升级 JDK 要审计所有"字节敏感"操作

JDK 大版本升级时,除了 API 变更(如 javax.*jakarta.*),还要注意标准库实现的微妙差异:

模块 潜在差异
java.util.zip.GZIP OS 字节、MTIME
java.util.zip.Deflater 压缩级别默认值
java.security.SecureRandom 算法默认值
java.net.URLEncoder 空格编码(+ vs %20

凡是输出会持久化、跨进程传输、用作 key/哈希的,都要回归测试。

7.3 序列化器要可复现

RedisSerializerserialize() 应当是纯函数——相同输入永远产出相同字节。本次 bug 的本质就是 JDK 版本变了导致"纯函数不纯了"。归一化元数据字段是保证可复现性的标准做法。

7.4 排查方法论

遇到"能连上但查不到"的问题,按此顺序排查:

  1. 连的实例对不对(节点/IP/端口/密码)
  2. key 序列化对不对(打印实际发给 Redis 的字节,和存量对比)
  3. 路由对不对(集群模式下的 slot/ReadFrom)

第 2 步最容易被忽略——因为序列化器是"透明"的,出了问题不会报错,只会安静地返回空。

八、相关文件

文件 改动
src/main/java/com/yumchina/recommend/config/GzipHiveRedisSerializer.java serialize() 新增 normalizeGzipOsByte() 归一化 OS 字节
src/main/java/com/yumchina/recommend/config/GzipMenuRedisSerializer.java serialize() 末尾归一化 OS 字节

九、关联文档


修复日期:2026-07-16
JDK 版本:8 → 17.0.15

十、参考资源

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐