JDK 8 → JDK 17 升级踩坑:GZIP 头 OS 字节差异导致 Redis Key 不匹配
JDK 8 → JDK 17 升级踩坑:GZIP 头 OS 字节差异导致 Redis Key 不匹配
项目从 JDK 8 升级到 JDK 17 后,一个"看似无关"的 GZIP 实现细节差异,让所有用 gzip 压缩字符串作为 Redis key 的查询全部失效——
HGETALL、GET返回空,业务数据"凭空消失"。这篇记录完整的定位过程、根因与修复方案。
一、现象
升级 JDK 17 后,调用 redisUtils.hgetallMenu("kfc_intelligent_menu_recall_schema", "kfc_preorder_13_2_intelligent_menu_recall") 始终返回空 Map。
但同一个 key,用下面两种方式都能查到数据:
- standalone Jedis 工具(JDK 8 编译运行)—— 能查到
- 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— Unix0xFF— 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 被用作 RedisTemplate 的 key 序列化器:
@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
所有使用 GzipHiveRedisSerializer 或 GzipMenuRedisSerializer 作为 key serializer 的 RedisTemplate:
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 序列化器要可复现
RedisSerializer 的 serialize() 应当是纯函数——相同输入永远产出相同字节。本次 bug 的本质就是 JDK 版本变了导致"纯函数不纯了"。归一化元数据字段是保证可复现性的标准做法。
7.4 排查方法论
遇到"能连上但查不到"的问题,按此顺序排查:
- 连的实例对不对(节点/IP/端口/密码)
- key 序列化对不对(打印实际发给 Redis 的字节,和存量对比)
- 路由对不对(集群模式下的 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 字节 |
九、关联文档
- Redis 连接池静默 Bug 修复记录 —— 本次 GZIP 问题在排查连接池 bug 时暴露,两者相互独立但排查过程交织
修复日期:2026-07-16
JDK 版本:8 → 17.0.15
十、参考资源
- RFC 1952 — GZIP file format specification —— GZIP 格式的官方定义,包含 OS 字节字段说明
- JDK-8170153: GZIPOutputStream writes OS=0x00 on all platforms —— JDK 9 起 GZIP OS 字节行为变更的跟踪 issue
- JDK-8223770: GZIPOutputStream should set OS flag to unknown (0xFF) —— 后续关于 OS 字节默认值的讨论
- Spring Data Redis Reference — Serializers —— RedisSerializer 接口规范与最佳实践
- GZIP 格式详解(RFC 1952 中文翻译) —— 便于快速查阅 GZIP 头部各字段含义
更多推荐




所有评论(0)