Redis 数据结构底层与 Hash 优于 JSON 的工程实践
一、Redis 对象模型与编码机制
- Redis 对外提供 5 种数据类型 :String、List、Hash、Set、Sorted Set 。每个键值对在内部由一个 redisObject 表示,包含 type(类型) 与 encoding(编码) 两个关键字段;同一类型在不同场景下可切换不同编码,从而在性能 与内存 之间取得平衡。常见编码有:RAW/INT/EMBSTR(String) 、LISTPACK/HASH(Hash) 、INTSET/HASH(Set) 、LISTPACK/SKIPLIST(Sorted Set) 、QUICKLIST(List) 。例如:String 在值为整数且较短时可用 INT/EMBSTR ,否则用 RAW ;Hash/Set/ZSet 在小数据量时可用紧凑编码(LISTPACK/INTSET),大数据量时转为 HASH/SKIPLIST 。这种设计让 Redis 能在不同数据规模下自动优化存储与访问路径。Redis 7 之后用 LISTPACK 替代了 ZIPLIST,进一步解决级联更新问题并提升稳定性。
二、各数据类型的底层实现与差异
| 数据类型 | 常见底层编码 | 关键特性 | 典型触发条件与阈值 |
|---|---|---|---|
| String | RAW / INT / EMBSTR | O(1) 取长;二进制安全;EMBSTR 与对象头一次分配更省时 | INT:值为可解析的 long;EMBSTR:短字符串(如 ≤44 字节,版本差异);否则 RAW |
| List | QUICKLIST(Redis 3.2+) | 双向链表 + 分段 ziplist,兼顾内存与随机访问 | 历史:ziplist 小对象;现由 quicklist 统一承载 |
| Hash | LISTPACK / HASH | 小对象紧凑;大对象随机访问快 | 同时满足:字段数 ≤ hash-max-listpack-entries(默认 512) 且 字段/值长度 ≤ hash-max-listpack-value(默认 64 字节) 时用 LISTPACK,否则转 HASH |
| Set | INTSET / HASH | 整数集合更省内存;通用元素用 HASH | 同时满足:元素全为整数且数量 ≤ set-max-intset-entries(默认 512) 用 INTSET,否则转 HASH |
| Sorted Set | LISTPACK / SKIPLIST | 跳跃表支持 O(logN) 范围/排名操作 | 同时满足:元素数 ≤ zset-max-listpack-entries(默认 128) 且 成员长度 ≤ zset-max-listpack-value(默认 64 字节) 用 LISTPACK,否则转 SKIPLIST |
- 关键实现要点
- SDS(简单动态字符串) :记录 len/free ,实现 O(1) 取长、二进制安全、预分配与惰性释放,避免缓冲区溢出。
- LISTPACK:连续内存、字段紧凑,适合小对象;Redis 7 后替代 ZIPLIST,避免级联更新。
- HASH/INTSET/SKIPLIST/QUICKLIST:标准哈希表、整数集合、跳跃表、分块链表,分别面向通用、整数、排序、列表场景优化。
三、为什么经常说用 Hash 比用 JSON 好
- 语义与操作粒度更匹配对象
- 对象通常具有多个属性(如 name、age、loginCount )。用 Hash 可按字段独立 HGET/HSET/HINCRBY ,无需反序列化整个对象;而 JSON 字符串 要更新某个字段必须读-改-写整串,既繁琐又易产生并发写覆盖问题。
- 性能与网络开销
- 字段级读写避免了序列化/反序列化 与整串拷贝 ,CPU 与网络字节量都更低;对计数器、状态位等高频更新尤其明显(如 HINCRBY 原子自增)。
- 内存与编码优化空间更大
- 小对象时 Hash 可用 LISTPACK 紧凑存储,节省内存;当数据变大自动转为 HASH,保持访问性能。JSON 始终是字符串,缺少这种"小对象省内存"的弹性。
- 部分更新与局部读取
- 业务常只需读取/修改少数字段(如展示层只要 nickname、avatar )。用 Hash 可 HMGET 只取所需字段;JSON 往往被迫 GET 整串再解析,浪费带宽与 CPU。
- 原子性与并发控制
- Hash 提供字段级原子操作(如 HSETNX/HINCRBY ),更易编写无锁/少锁的并发逻辑;JSON 在 Redis 层面缺少字段级原子指令,通常需要 Lua 脚本才能保证一致性。
四、代码示例对比:JSON 与 Hash 的读写与更新
- 场景:维护用户资料与计数器(昵称、年龄、登录次数、余额)
-
- JSON 方式(String 存序列化对象)
java
// 写
User u = new User("Alice", 25, 0, new BigDecimal("99.50"));
jedis.set("user:1001", new ObjectMapper().writeValueAsString(u));
// 读
String json = jedis.get("user:1001");
User u2 = new ObjectMapper().readValue(json, User.class);
// 更新(读改写,存在并发覆盖风险)
u2.setLoginCount(u2.getLoginCount() + 1);
u2.setBalance(u2.getBalance().add(new BigDecimal("10.00")));
jedis.set("user:1001", new ObjectMapper().writeValueAsString(u2));
-
- Hash 方式(字段级存取)
java
// 写(可一次设置多个字段)
jedis.hset("user:1001", "name", "Alice");
jedis.hset("user:1001", "age", "25");
jedis.hincrBy("user:1001", "loginCount", 1);
jedis.hincrByFloat("user:1001", "balance", 10.00);
// 读(只取需要的字段)
String name = jedis.hget("user:1001", "name");
Long loginCount = Long.valueOf(jedis.hget("user:1001", "loginCount"));
// 批量取
List<String> fields = Arrays.asList("name", "age", "balance");
Map<String, String> profile = jedis.hmget("user:1001", fields.toArray(new String[0]));
-
- 并发安全更新(Hash 原子指令)
java
// 仅当余额充足时扣款(原子性由 Redis 保证)
String lua =
"local bal = tonumber(redis.call('HGET', KEYS[1], 'balance')) " +
"if bal >= tonumber(ARGV[1]) then " +
" redis.call('HINCRBYFLOAT', KEYS[1], 'balance', -tonumber(ARGV[1])) " +
" return 1 " +
"else " +
" return 0 " +
"end";
Long ok = (Long) jedis.eval(lua, 1, "user:1001", "5.00");
-
- 大对象遍历(避免阻塞)
java
// 使用 HSCAN 分批遍历,避免一次性 HGETALL 造成阻塞
ScanResult<Map.Entry<String, String>> scan;
String cursor = "0";
do {
scan = jedis.hscan("user:1001", cursor, new ScanParams().count(50));
for (Map.Entry<String, String> e : scan.getResult()) {
// 处理字段
}
cursor = scan.getCursor();
} while (!"0".equals(cursor));
-
- 何时仍用 JSON(或 RedisJSON)
bash
# 需要路径查询/局部更新且希望服务端完成解析与合并
JSON.SET user:1001 $ '{"name":"Alice","profile":{"age":25}}'
JSON.GET user:1001 $.profile.age
JSON.SET user:1001 $.profile.age 26
- 说明:Redis 7 起 Hash 使用 LISTPACK 替代 ZIPLIST;小对象更省内存,大对象自动转为 HASH。JSON 适合整对象快照或需要 JSONPath 的场景;若对象字段多且频繁局部更新,优先考虑 Hash 或 RedisJSON。
五、工程实践与避坑清单
- 合理控制 Hash 字段数量与单字段大小 :避免把"大对象"塞进一个 Hash;大对象可拆分为多个子 Hash(如 user:{uid}:base 、user:{uid}:ext)。
- 小对象争取命中 LISTPACK :理解并合理设置 hash-max-listpack-entries/value ,在内存与性能间取平衡;数据增长后自动转 HASH 无需人工干预。
- 避免对大 Hash 使用 HGETALL :改用 HSCAN 分批遍历,降低阻塞与网络抖动风险。
- 需要过期控制时记住:EXPIRE 作用于 key,不能对单个 field 设置 TTL;若业务需要字段级过期,考虑拆分 key 或用 RedisJSON 的过期策略。
- 高并发更新同一对象时,优先使用 Hash 的原子指令 (如 HINCRBY/HSETNX );若用 JSON,请配合 Lua 脚本保证读改写一致性。
- 大 Key 与热 Key 治理:字段过多考虑 Hash 分片 ;热点数据结合 本地缓存(Caffeine)+ Redis + MQ 失效广播 ;必要时用 Bloom Filter 防穿透。