Redis Hash与Ziplist优化

Redis Hash与Ziplist优化

Redis 的五种数据结构各有特色,用对了才能发挥它的优势。很多人只用到了 String 和 Hash,却不知道 List、Set、ZSet 在特定场景下更合适。本文从应用场景出发,讲什么时候用什么类型。

一、Hash的编码方式

Redis Hash有两种底层编码:

编码 结构 适用场景
ziplist 压缩列表 字段少、值小的Hash
hashtable 哈希表 字段多或值大的Hash

#

1.1 编码转换条件

默认配置(redis.conf):
hash-max-ziplist-entries 512 # ziplist最多512个entry
hash-max-ziplist-value 64 # 每个value最大64字节

转换规则:

  • Hash满足条件:使用ziplist
  • 任一条件不满足:转换为hashtable
  • 转换是单向的:ziplist → hashtable后不会回退

二、Ziplist详解

#

2.1 Ziplist结构

Ziplist是紧凑的连续内存结构:

整体布局:
┌─────────┬─────────┬─────────────────────┬─────────┐
│ zlbytes │ zltail │ zllen │ entries │ zlend │
│ 4字节 │ 4字节 │ 2字节 │ 变长 │ 1字节 │
└─────────┴─────────┴─────────┴───────────┴─────────┘

各字段说明:
- zlbytes: 整个ziplist占用的字节数
- zltail: 到最后一个entry的偏移量(方便快速定位尾部)
- zllen: entry数量(超过65535时需遍历统计)
- entries: 实际的entry列表
- zlend: 结束标记,固定为0xFF

#

2.2 Entry结构

每个Entry的格式:
┌─────────────────┬─────────────────┬─────────────────┐
│ prevlen │ encoding │ content │
│ 1或5字节 │ 1或2或5字节 │ 变长 │
└─────────────────┴─────────────────┴─────────────────┘

prevlen: 前一个entry的长度(方便倒序遍历)
encoding: 编码信息,包含content类型和长度
content: 实际数据(整数或字符串)

prevlen的连锁更新问题

当前entry的prevlen是1字节(记录前entry长度<254)
如果前entry增大到>=254字节
→ 当前entry的prevlen需从1字节扩展到5字节
→ 可能导致当前entry也变长
→ 下一个entry的prevlen可能也要扩展
→ 连锁反应!

这是ziplist的已知缺陷,在大量entry且值大小波动时可能触发。

#

2.3 Ziplist的优缺点

优点

  • 内存紧凑,无额外指针开销
  • 适合小数据量场景
  • CPU缓存友好(连续内存)

缺点

  • 插入/删除可能需要重新分配内存
  • 连锁更新问题
  • 查找特定字段O(n)(线性查找)

三、Hashtable详解

#

3.1 字典结构

typedef struct dict {
dictType *type; // 类型特定函数
void *privdata; // 私有数据
dictht ht[2]; // 两个哈希表(用于渐进式rehash)
int rehashidx; // rehash进度(-1表示不在rehash)
} dict;

typedef struct dictht {
dictEntry **table; // 哈希表数组
unsigned long size; // 表大小
unsigned long sizemask; // 掩码 = size - 1
unsigned long used; // 已有节点数
} dictht;

typedef struct dictEntry {
void *key; // 键(sds)
union {
void *val;
uint64_t u64;
int64_t s64;
} v; // 值
struct dictEntry *next; // 拉链法解决冲突
} dictEntry;

#

3.2 哈希算法

// MurmurHash2算法
uint64_t dictGenHashFunction(const void *key, int len) {
// ... MurmurHash2实现
}

// 计算索引
idx = hash(key) & d->ht[0].sizemask;

#

3.3 渐进式Rehash

当哈希表负载因子超过阈值时,需要扩容:

触发条件:
- 没有执行BGSAVE/BGREWRITEAOF时,负载因子 >= 1
- 执行BGSAVE/BGREWRITEAOF时,负载因子 >= 5

负载因子 = used / size

渐进式Rehash流程

初始状态:
ht[0]: 大小4,已用3
ht[1]: 空
rehashidx: -1

开始rehash:
1. 为ht[1]分配大小为8的空间
2. rehashidx = 0

增删查时渐进迁移:
- 每次操作将ht[0]中rehashidx位置的桶迁移到ht[1]
- rehashidx++

完成时:
- ht[0] = ht[1]
- ht[1] 清空
- rehashidx = -1

优势

  • 避免一次性rehash的长时间阻塞
  • 分摊到每次操作中

四、Hash命令的实现

#

4.1 HSET的实现

void hsetCommand(client *c) {
robj *o;

// 查找或创建Hash对象
if ((o = hashTypeLookupWriteOrCreate(c, c->argv[1])) == NULL) return;

// 检查是否需要从ziplist转换为hashtable
hashTypeTryConversion(o, c->argv, 2, 3);

// 设置字段
hashTypeSet(o, c->argv[2]->ptr, c->argv[3]->ptr, HASH_SET_COPY);

addReply(c, shared.ok);
}

#

4.2 编码转换检查

void hashTypeTryConversion(robj *o, robj **argv, int start, int end) {
if (o->encoding != OBJ_ENCODING_ZIPLIST) return;

for (int i = start; i <= end; i++) {
// 检查字段或值是否超过64字节
if (sdslen(argv[i]->ptr) > server.hash_max_ziplist_value) {
// 转换为hashtable
hashTypeConvert(o, OBJ_ENCODING_HT);
break;
}
}
}

#

4.3 HGETALL的实现

void hgetallCommand(client *c) {
robj *o = lookupKeyReadOrReply(c, c->argv[1], shared.null[c->resp]);
if (o == NULL || checkType(c, o, OBJ_HASH)) return;

// 扩容回复数组
addReplyArrayLen(c, hashTypeLength(o) * 2);

// 遍历所有字段
hashTypeIterator *hi = hashTypeInitIterator(o);
while (hashTypeNext(hi) != C_ERR) {
// 回复field
addReplyBulkCBuffer(c, hi->zk, hi->klen);
// 回复value
addReplyBulkCBuffer(c, hi->zv, hi->vlen);
}
hashTypeReleaseIterator(hi);
}

五、内存优化实践

#

5.1 合理设置ziplist阈值

# redis.conf

# 如果Hash字段多但值小,可以增大entries限制
hash-max-ziplist-entries 1000
hash-max-ziplist-value 128

# 如果Hash字段少但值大,保持默认值或减小

#

5.2 控制Hash的字段数量

// 不好的做法:单个Hash存储大量字段
for (int i = 0; i < 100000; i++) {
redis.hset("big:hash", "field" + i, "value" + i);
}
// 最终会转换为hashtable,且单个key过大

// 好的做法:分片存储
int shardCount = 100;
for (int i = 0; i < 100000; i++) {
int shard = i % shardCount;
redis.hset("hash:shard:" + shard, "field" + i, "value" + i);
}
// 每个Hash最多1000个字段,保持ziplist编码

#

5.3 使用Hash替代String存储对象

// String方式(每个对象一个JSON)
// key: user:1001, value: {"name":"Alice","age":25,"city":"Beijing"}

// Hash方式
// key: user:1001, field-value pairs
// 内存对比(假设100万用户):
// String: 约200MB
// Hash: 约120MB(ziplist节省大量元数据)

#

5.4 查看编码信息

# 查看Hash的编码方式
OBJECT ENCODING user:1001
# 输出: "ziplist" 或 "hashtable"

# 查看内存占用
MEMORY USAGE user:1001
# 输出: (integer) 128

# 查看详细信息
DEBUG OBJECT user:1001
# 输出: value at:... encoding:ziplist serializedlength:86 ...

六、性能测试对比

#

6.1 Ziplist vs Hashtable

# 测试小Hash(ziplist)
redis-benchmark -n 100000 HSET small:hash field value
# 结果:约50000 ops/sec

# 测试大Hash(hashtable)
redis-benchmark -n 100000 HSET big:hash field1000 value1000
# 先构造1000个字段使其转换为hashtable
# 结果:约40000 ops/sec

# 小Hash查询更快(CPU缓存友好)
redis-benchmark -n 100000 HGET small:hash field
# 结果:约60000 ops/sec

#

6.2 编码转换的影响

# 监控编码转换(通过INFO命令)
INFO COMMANDSTATS
# 查看HSET命令的耗时变化

七、Hash应用优化

#

7.1 购物车优化

@Service
public class CartService {

private static final int CART_EXPIRE_DAYS = 7;

public void addToCart(Long userId, Long skuId, Integer quantity) {
String key = "cart:" + userId;
String field = String.valueOf(skuId);

// 使用hincrby实现数量累加
Long newQty = redis.hincrBy(key, field, quantity);

if (newQty <= 0) {
redis.hdel(key, field);
}

// 设置过期时间
redis.expire(key, CART_EXPIRE_DAYS * 86400);
}

public Map<Long, Integer> getCart(Long userId) {
String key = "cart:" + userId;
Map<String, String> entries = redis.hgetAll(key);

Map<Long, Integer> cart = new HashMap<>();
entries.forEach((k, v) -> cart.put(Long.valueOf(k), Integer.valueOf(v)));
return cart;
}

public void clearCart(Long userId) {
redis.del("cart:" + userId);
}
}

#

7.2 配置中心优化

@Component
public class ConfigManager {

private static final String CONFIG_KEY = "app:config";

@PostConstruct
public void init() {
// 加载配置到Redis
Map<String, String> configs = loadFromDB();
redis.hsetAll(CONFIG_KEY, configs);
redis.expire(CONFIG_KEY, 3600);
}

public String getConfig(String key) {
String value = redis.hget(CONFIG_KEY, key);
if (value == null) {
// 回查数据库
value = db.queryConfig(key);
if (value != null) {
redis.hset(CONFIG_KEY, key, value);
}
}
return value;
}

public void updateConfig(String key, String value) {
db.updateConfig(key, value);
redis.hset(CONFIG_KEY, key, value);
}
}

八、常见问题

#

8.1 Hash的字段数量限制

理论限制:约40亿个字段(2^32 - 1)
实际限制:内存大小

建议:单个Hash不超过1000个字段(保持ziplist编码)

#

8.2 HGETALL的性能问题

// 大Hash使用HGETALL会阻塞Redis
Map<String, String> all = redis.hgetAll("big:hash"); // 危险!

// 替代方案:使用HSCAN渐进遍历
Cursor<Map.Entry<String, String>> cursor = redis.opsForHash()
.scan("big:hash", ScanOptions.scanOptions().count(100).build());

while (cursor.hasNext()) {
Map.Entry<String, String> entry = cursor.next();
processEntry(entry);
}

#

8.3 Hash过期问题

# Hash只能对整个key设置过期,不能对单个字段设置
EXPIRE user:1001 3600 # 整个Hash过期

# 如需字段级过期,需要额外设计
# 方案一:使用ZSet存储过期时间
# 方案二:应用层判断过期

九、总结

特性 Ziplist Hashtable
内存占用
读写性能 小数据量优 大数据量优
查找复杂度 O(n) O(1)
插入开销 可能需要内存重分配 可能需要rehash
适用场景 字段少、值小 字段多、值大

Hash优化核心要点:

  1. 保持ziplist编码:控制字段数量和值大小
  2. 避免大Hash:单个Hash不超过1000字段
  3. 分片存储:大数据量分多个Hash
  4. 使用HSCAN:避免HGETALL大Hash
  5. 合理设置阈值:根据实际数据调整hash-max-ziplist-*

核心要点

  1. String:简单的键值对,适合缓存、计数器

  2. Hash:存储对象属性,适合用户信息、配置

  3. List:有序列表,适合消息队列、最新列表

  4. Set:无序去重,适合共同好友、抽奖

  5. ZSet:有序集合,适合排行榜、积分系统

总结

选择合适的数据结构是使用 Redis 的关键。在实际项目中,根据业务需求选择合适的类型,可以提升性能和开发效率。


   转载规则


《Redis Hash与Ziplist优化》 小乐 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录