前言
实验内容源自极客时间 Redis 核心技术和实践:P11。
1. 实验内容
业务假设,需要保存 100w 个 K-V 值。其中 K 是一个 long 类型,V 是一个 String 类型。
- 使用普通的 K-V 存储,观察内存占用及效率;
- 使用 Hash 结构存储,观察内存占用及效率;
- 调整 Redis 参数,重复实验,观察内存占用;
2. 实验过程
2.1 python + redis 往 Redis 写入 K-V 字符串 100w 条
def write100wkv_with_pipline():
print("begin write 100w")
pip = client.pipeline(transaction=False)
before = getRedisUsedMemory()
for k in range(0, 1000000):
pip.set(k, 'v' + str(k))
pip.execute()
after = getRedisUsedMemory()
print("end write 100w, using: " + str(after - before))
在这个过程中,必须开启 pipeline 去进行通信。
begin write 100w
before: used_memory_human:1.87M
before: used_memory_human:70.90M
end write 100w, using: 72380608
100w 行的 k-v 数据,使用了约 70m 的存储空间。
我尝试实用 keys 对 Redis 的 keys 进行一次遍历,结果需要查询 9s。
....
999998) "301947"
999999) "869004"
1000000) "524681"
(9.03s)
2.2 Redis 的 String 存储原理分析
按照上述统计,一个 KV 大概所消耗的内存为 72 字节。那存储的部分都是哪些内容呢?
除了记录实际数据,String 类型还需要额外的内存空间记录数据长度、空间使用等信息,这些信息也叫作元数据。 —— 《极客时间 Redis实践 11 讲》
Redis 在存储 String 的时候,使用的是 动态字符串 SDS 结构,一个对象大概需要如下三个部分:
- len,4字节,已使用的长度;
- alloc,4字节,实际分配长度;
- buf,实际数据存储,结尾"\0";
除了 SDS 的空间使用外,还需要占用一个 RedisObject 对象。
// redis 源码:server.h:620
typedef struct redisObject {
unsigned type:4; 4个 bit
unsigned encoding:4; 4个 bit
unsigned lru:LRU_BITS; /* lru time (relative to server.lruclock) */ 24个 bit
int refcount; 32 bit
void *ptr; 64 bit
} robj
这个对象已经需要占用 16 字节。示意图如下
一方面,当保存的是 Long 类型整数时,RedisObject 中的指针就直接赋值为整数数据了,这样就不用额外的指针再指向整数了,节省了指针的空间开销。另一方面,当保存的是字符串数据,并且字符串小于等于 44 字节时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片。
小结一下,我们已经计算来 SDS(动态字符串) + redisObject 两个对象的数据,先假设大约为 40 Byte,我们剩下计算 32 bit 的数据。
2.3 Redis 的全局 Hash 表
Redis 本质上也是一个大的 Hash 表,也就是说每增加一个对象,就需要增加一个如红箭头标记的一个对象。Redis 源码将其命名为: dictEntry
typedef struct dictEntry {
void *key;
void *val;
struct dictEntry *next;
} dictEntry
从源码上看,一个有三个指针,因为使用的是 jemalloc 内存分配库,所以其会分配 32 字节,而不是 24 字节。
至此,就可以比较完整地统计好数据存储的位置了。
2.4 优化分析A 底层存储优化
从此可见,有效的 KV 信息其实并不长,假如是两个 Long 类型的数据,有效的信息只有 16 字节。Redis 设计来一种新的底层存储结构,压缩列表(ziplist),本质上就是将随机指针地址转换为连续地址,通过偏移量进行识别,完成存储。随机寻址和连续寻址也是计算机中最底层的两种技术选型。
使用连续寻址,就可以将多个 KV,共同存储在一个 dictEntry 上。
2.5 优化分析B 数据结构结合业务优化
假如存储的数据就是 K-V 值,K 做一次拆分。譬如每 1000 个为个 Hash 集合,采用 Hash 数据进行存储。本质上,这是一个分治思想,分级存储。
def write100wkv_with_pipline_hash():
print("begin write 100w in hashway")
pip = client.pipeline(transaction=False)
before = getRedisUsedMemory()
for k in range(0, 1000000):
pip.hset(int(k/1000), k % 1000, 'v' + str(k))
pip.execute()
after = getRedisUsedMemory()
print("end write 100w, using: " + str(after - before)
begin write 100w in hashway
used_memory_human:865.74K
used_memory_human:53.82M
end write 100w, using: 55544192
在默认配置下,hash 存储这批数据,需要 53M,比 72M 优化来一部分。由于切分过程中,可以保证每个 Hash 的 KV 数量为1000,且 KV 值大小是一致的。所以可以增加以下配置,保证 Hash 一直使用 ziplist 做底层数据存储结构。
# 表示用压缩列表保存时哈希集合中的最大元素个数。
hash-max-ziplist-entries 1000
# 表示用压缩列表保存时哈希集合中单个元素的最大长度
hash-max-ziplist-value 64
这两个配置可以保证本次存储一致保持在 ziplist 存储。这样空间利用率是最高效的。存储如下,大概比第一版的存储节省5倍的空间。
begin write 100w in hashway
used_memory_human:865.13K
used_memory_human:14.37M
end write 100w, using: 14185344
3. 实验结论
3.1 Redis 的新认知
这是第一次针对 Redis 实践。结合 Redis 的源码,分析了 Redis 的数据对象存储情况。Redis 的难点并不在业务,而是在于高效,Redis 的接口是简单的。
3.2 Redis 以后用法
这次实验说明了,假如认真对业务分析,再结合 Redis 的数据结构和参数进行定制,是可以获得量级优化的。
4. 引用
5. 源码
- 本次实验的测试源码: write_100w_redis.py