Redis数据结构底层:ziplist到listpack的内存优化演进
Redis数据结构底层:ziplist到listpack的内存优化演进
适读人群:Java后端开发、对Redis底层感兴趣的工程师 | 阅读时长:约22分钟
开篇故事
2021年,我们的一台Redis服务器内存使用率突然从40%飙升到95%,触发告警。
查了半天,发现是一个Hash类型的业务数据,原来每个Hash有几百个field,但近期业务变更后,每个Hash的field数量涨到了1万个。
Redis的内存突然翻了3倍。
这让我开始深入研究Redis数据结构的底层实现:为什么小Hash比大Hash更省内存?什么时候会触发底层结构的转换?Redis 7.0的listpack相比ziplist改进了什么?
今天把Redis底层数据结构的演进完整讲一遍。
一、Redis数据类型的底层实现映射
Redis的5种基本数据类型,每种在底层可能使用不同的编码(encoding):
String:
int → 整数(直接存在redisObject中,不额外分配内存)
embstr → 短字符串(≤44字节,redisObject和sds连续内存)
raw → 长字符串(>44字节,单独分配sds内存)
List:
listpack → 小列表(Redis 7.0+,元素数量少且值小)
quicklist → 大列表(实际上是ziplist/listpack链表)
Hash:
listpack → 小Hash(Redis 7.0+,field数量≤128且值长度≤64)
hashtable → 大Hash(超过阈值后转换)
Set:
listpack → 小整数集合(Redis 7.0+,元素全是整数且数量≤128)
intset → 纯整数集合(元素全是整数且数量≤512)
hashtable → 大集合
ZSet(Sorted Set):
listpack → 小有序集合(Redis 7.0+,元素数量≤128且值长度≤64)
skiplist → 大有序集合(跳表 + 字典)二、底层原理:ziplist与listpack的结构对比
2.1 ziplist的结构(Redis 3.x-6.x)
ziplist内存布局:
+--------+--------+-------+------+------+------+-----+-----+
| zlbytes| zltail | zllen | e1 | e2 | e3 | ... | 0xFF|
+--------+--------+-------+------+------+------+-----+-----+
4字节 4字节 2字节 entry entry entry 结束符
每个entry的结构:
+-------------------+----------+--------+
| prevlen(上一entry | encoding | content|
| 的长度) |(编码类型) | (数据) |
+-------------------+----------+--------+
1或5字节 1或2字节 N字节ziplist的严重缺陷:连锁更新(Cascade Update)
场景:所有entry的prevlen都是1字节(值<254),
此时向头部插入一个长度>254字节的entry
1. 新entry的长度 = 255字节(>254),需要5字节的prevlen
2. 但紧随其后的e1原本用1字节存prevlen,现在要存5字节
→ e1需要扩容,长度从X变成X+4(>254)
3. e1扩容后,e2的prevlen也要扩容
4. ...依此类推,最坏情况下所有entry都要重新分配内存
最坏时间复杂度:O(N²),对于大ziplist是灾难性的2.2 listpack的结构(Redis 7.0+)
Redis 7.0将ziplist完全替换为listpack,根本上解决了连锁更新问题:
listpack内存布局:
+--------+--------+------+------+------+-----+-----+
| total | num | e1 | e2 | e3 | ... | 0xFF|
| bytes | elements end |
+--------+--------+------+------+------+-----+-----+
4字节 2字节 entry entry entry 结束符
每个entry的结构(与ziplist的关键区别):
+----------+--------+-------------------+
| encoding | content| backlen(当前entry |
|(编码类型) | (数据) | 的总长度) |
+----------+--------+-------------------+
1-9字节 N字节 1-5字节(VarInt编码)关键改变:
- ziplist每个entry存
prevlen(前一个entry的长度) - listpack每个entry存
backlen(当前entry自己的长度)
修改某个entry时,只需更新该entry自身的backlen,不影响其他entry,彻底消除了连锁更新。
正向遍历:从头到尾,用encoding+content_size跳过每个entry 反向遍历:用backlen从尾到头跳回去
2.3 ziplist/listpack vs hashtable的内存对比
存储100个field-value对(field和value都是短字符串):
ziplist/listpack编码:
每个entry约25字节(encoding 1B + field 10B + backlen 1B + encoding 1B + value 10B + backlen 1B + 各种标记)
100个field × 2个entry(field + value) = 200个entry
内存:约200 × 25 = 5000 bytes = 5KB
hashtable编码:
每个dictEntry:48字节(key指针8B + value指针8B + next指针8B + ...)
加上实际的key和value的sds对象
内存:约100 × (48 + 25 + 25) = 9800 bytes = 9.8KB
listpack比hashtable节省约50%内存!但listpack查找是O(N)线性扫描,hashtable查找是O(1),所以超过阈值后需要转换。
2.4 Hash编码转换的触发条件
Redis配置(可调整):
hash-max-listpack-entries 128 # 元素数量阈值(Redis 7.0+)
hash-max-listpack-value 64 # 单个值的字节长度阈值
当任一条件超过阈值时,Hash从listpack转换为hashtable:
情况1:HSET mykey field_129 value (第129个field)
情况2:HSET mykey field "一个超过64字节的长字符串..."
转换是不可逆的:一旦转为hashtable,即使删除元素减少到128以下,也不会退回listpack三、完整解决方案与代码
3.1 验证编码和内存使用
import redis.clients.jedis.Jedis;
/**
* Redis内存优化验证工具
*/
public class RedisEncodingVerifier {
public static void main(String[] args) {
try (Jedis jedis = new Jedis("localhost", 6379)) {
// 测试Hash编码转换
testHashEncoding(jedis);
// 测试ZSet编码转换
testZSetEncoding(jedis);
}
}
static void testHashEncoding(Jedis jedis) {
String key = "test:hash";
jedis.del(key);
// 插入128个field(在阈值内,使用listpack)
for (int i = 1; i <= 128; i++) {
jedis.hset(key, "field" + i, "value" + i);
}
String encoding1 = jedis.objectEncoding(key);
long memory1 = jedis.memoryUsage(key);
System.out.printf("128 fields: encoding=%s, memory=%d bytes%n",
encoding1, memory1);
// 输出:128 fields: encoding=listpack, memory=5242 bytes
// 插入第129个field(超过阈值,转换为hashtable)
jedis.hset(key, "field129", "value129");
String encoding2 = jedis.objectEncoding(key);
long memory2 = jedis.memoryUsage(key);
System.out.printf("129 fields: encoding=%s, memory=%d bytes%n",
encoding2, memory2);
// 输出:129 fields: encoding=hashtable, memory=12288 bytes
// 内存翻了约2.3倍!
System.out.printf("转换后内存增加:%.1f倍%n", (double) memory2 / memory1);
}
static void testZSetEncoding(Jedis jedis) {
String key = "test:zset";
jedis.del(key);
// 128个元素(listpack编码)
for (int i = 1; i <= 128; i++) {
jedis.zadd(key, i, "member" + i);
}
System.out.printf("ZSet 128 members: encoding=%s, memory=%d%n",
jedis.objectEncoding(key), jedis.memoryUsage(key));
// 第129个元素(转换为skiplist)
jedis.zadd(key, 129, "member129");
System.out.printf("ZSet 129 members: encoding=%s, memory=%d%n",
jedis.objectEncoding(key), jedis.memoryUsage(key));
}
}3.2 设计合理的数据结构规避转换
/**
* 优化Redis Hash使用的最佳实践
*/
@Service
public class UserCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 错误做法:用单个Hash存所有用户数据
* 如果用户数量很多,这个Hash会超过listpack阈值,内存爆炸
*/
public void badPattern(Long userId, Map<String, Object> userData) {
// HSET users:all userId userData ← 最终这个Hash会有百万个field
redisTemplate.opsForHash().putAll("users:all", Map.of(userId.toString(), userData));
// 问题:一个Key的Hash有百万field → 必然转为hashtable → 内存巨大
}
/**
* 正确做法:每个用户一个Hash,利用listpack的内存优势
*/
public void goodPattern(Long userId, UserInfo user) {
String key = "user:" + userId;
Map<String, Object> fields = new HashMap<>();
fields.put("name", user.getName());
fields.put("age", user.getAge());
fields.put("email", user.getEmail());
// ... 最多不超过128个field
redisTemplate.opsForHash().putAll(key, fields);
// 每个key只有几十个field → 保持listpack编码 → 内存效率高
}
/**
* 分组存储:当一个对象有很多属性时,按类别分组
*/
public void groupedStorage(Long userId, UserProfile profile) {
String baseKey = "user:" + userId;
// 基础信息(最常用,放一起)
redisTemplate.opsForHash().putAll(baseKey + ":basic", Map.of(
"name", profile.getName(),
"age", profile.getAge(),
"gender", profile.getGender()
));
// 扩展信息(偶尔用)
redisTemplate.opsForHash().putAll(baseKey + ":ext", Map.of(
"bio", profile.getBio(),
"website", profile.getWebsite()
));
// 每个Hash都在listpack阈值内,内存效率最高
}
}3.3 内存优化监控脚本
/**
* Redis内存使用分析工具
* 找出占用内存最多的key和编码异常的key
*/
@Component
public class RedisMemoryAnalyzer {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 找出超过阈值仍使用低效编码的Hash
*/
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点分析
public void analyzeHashEncoding() {
// 扫描所有Hash类型的key(生产环境用SCAN,不用KEYS)
ScanOptions options = ScanOptions.scanOptions()
.match("*")
.count(1000)
.build();
Cursor<byte[]> cursor = redisTemplate.getConnectionFactory()
.getConnection()
.scan(options);
List<String> oversizedHashKeys = new ArrayList<>();
while (cursor.hasNext()) {
String key = new String(cursor.next());
try {
DataType type = redisTemplate.type(key);
if (type == DataType.HASH) {
Long size = redisTemplate.opsForHash().size(key);
if (size != null && size > 128) {
Long memory = (Long) redisTemplate.execute(
(RedisCallback<Long>) conn ->
(Long) conn.execute("MEMORY", "USAGE",
key.getBytes())
);
oversizedHashKeys.add(String.format(
"key=%s, fields=%d, memory=%dKB", key, size, memory / 1024));
}
}
} catch (Exception e) {
// 忽略单个key的异常
}
}
if (!oversizedHashKeys.isEmpty()) {
log.warn("发现{}个超大Hash,建议优化:", oversizedHashKeys.size());
oversizedHashKeys.forEach(k -> log.warn(" {}", k));
}
}
}四、踩坑实录
坑1:Hash field数量控制不当,内存暴涨
开篇的故事就是这个问题。修复方案:
// 修复前:把所有用户的某个属性存在一个Hash里
// HSET user_scores userId score → 随用户增长,该Hash无限膨胀
// 修复后:合理拆分
// 按用户ID分段存储
public void storeUserScore(Long userId, Double score) {
// 按userId/1000分组,每组最多1000个field
long group = userId / 1000;
String key = "user_scores:group:" + group;
redisTemplate.opsForHash().put(key, userId.toString(), score);
}
// 每个Hash最多1000个field,超过listpack阈值但可控
// 或者直接每人一个String key坑2:ZSet元素value超过64字节,内存放大
// 错误:存长字符串作为ZSet成员
jedis.zadd("hot_products", 100.0, JSON.toJSONString(productDetail));
// productDetail的JSON可能有几百字节 → 触发skiplist编码 → 内存大
// 正确:ZSet只存ID,通过ID再查完整数据
jedis.zadd("hot_products", 100.0, productId.toString()); // 只存ID,短字符串
// 保持listpack编码,内存效率高
// 需要完整数据时:ZRANGE hot_products 0 9 → 获取ID列表 → 批量查Redis或DB坑3:频繁修改ZSet成员导致内存碎片
现象:Redis内存使用量持续增长,但实际存储的数据量没有变化
MEMORY DOCTOR: "High memory fragmentation (>1.5)"
原因:频繁的ZADD+ZREM操作产生内存碎片
skiplist在删除操作后不立即归还内存解决方案:
# 方案1:主动内存碎片整理(Redis 4.0+)
redis-cli CONFIG SET activedefrag yes
redis-cli CONFIG SET active-defrag-threshold-lower 10 # 碎片率>10%开始整理
redis-cli CONFIG SET active-defrag-threshold-upper 100 # 碎片率>100%全力整理
# 方案2:手动重启(成本高)
# 重启后Redis重建数据结构,碎片消除
# 方案3:OBJECT ENCODING 监控
redis-cli OBJECT ENCODING mykey
# 确认编码是否符合预期五、总结与延伸
Redis数据结构的内存优化核心原则:
小而紧凑是关键:Redis对小数据用listpack(原ziplist)编码,比hashtable/skiplist节省50%以上内存。保持每个Key的元素数量在阈值内是内存优化的第一原则。
listpack改进了ziplist的核心缺陷:将prevlen改为backlen,消除了连锁更新的O(N²)问题,代价是反向遍历稍复杂。
编码转换不可逆:超过阈值后转为hashtable/skiplist,即使后来缩减元素数量也不会回退,设计时要预估数据量。
Redis 7.0是分水岭:从7.0起,Hash/ZSet/List都用listpack替代ziplist,性能和稳定性都有提升。生产环境建议升级到7.0+。
