Redis-请慎用String类型
慎用 String?
开篇之前先给出一个组对比:
1. 这是执行 flushdb 后的干净的 Redis 内存使用信息。
127.0.0.1:6379> info memory
# Memory
used_memory:502272
used_memory_human:490.50K
used_memory_rss:7901184
used_memory_peak:119628904
used_memory_peak_human:114.09M
used_memory_lua:33792
mem_fragmentation_ratio:15.73
mem_allocator:jemalloc-3.6.0
2. 执行100000次(一百万)循环执行 set 操作。
// 代码仅作参考
$redisHandle = Redis::connection('intranet_log_redis');
for ($i= 0; $i < 1000000; $i++) {
$redisHandle->set($i, 10000000);
}
// 执行步骤2后的内存信息。
127.0.0.1:6379> info memory
# Memory
used_memory:72891064
used_memory_human:69.51M
used_memory_rss:80601088
used_memory_peak:134217744
used_memory_peak_human:128.00M
used_memory_lua:33792
mem_fragmentation_ratio:1.11
mem_allocator:jemalloc-3.6.0
// 由上信息可知除去本身redis占用的内存,一百万个键值对使用了69M的内存。
3. 再次执行 flushdb 后,改为hash类型存储,再次执行1000000(一百万)次循环。
// 代码仅供参考
$redisHandle = Redis::connection('intranet_log_redis');
for ($i= 0; $i < 1000000; $i++) {
$redisHandle->hset('testKey', $i, 10000000);
}
// 执行步骤3后查看内存信息
127.0.0.1:6379> info memory
# Memory
used_memory:72883104
used_memory_human:3.43M
used_memory_rss:82509824
used_memory_peak:134217744
used_memory_peak_human:128.00M
used_memory_lua:33792
mem_fragmentation_ratio:1.13
mem_allocator:jemalloc-3.6.0
当当当! 使用了hash存储,内存使用减少了 20 倍!!!这是为什么尼???
String 类型存储结构
为什么存储同样数据量的相同数据,string 会比 hash 大了 20 倍?带着这个疑问然后学习下 string 的具体实现方式。
上面的例子中 value 部分都是 1000000 ,可用用1个8字节的 Long 类型存储。而key 则是 0~1000000 同样可以用 Long 存储。理论上存储只需要几M的内存,为什么用到了69M?
这就设计到 string 的编码和结构体了,比如说64位系统中保存一个整数。 redis 会用一个 8 字节的 Long 类型存储,这就是 int 编码方式。
而字符串则不太一样,采用 SDS(Simple Dynamic String) 简单动态字符串结构体存储。
结构 | 大小 | 描述 |
---|---|---|
len | 4B | 4个字节,表示 buf 已用长度 |
alloc | 4B | 4个字节,表示 buf 的实际分配长度,通常大于 len |
buf | 数组,保持实际数据。 | 自动在数组后面连接一个 “\0”,标志数据的结尾。占1个字节 |
从上表格中可以看出 SDS 会有额外的 len 和 alloc 的存储开销,这个有点类似 Mogodb 。当然对于 string 类型来说除了 SDS 的开销外还有 RedisObject 结构体。它的作用在于用来记录数据的元数据同时指向这些数据,
RedisObject 包含一个8字节的元数据和一个8字节的指针(真实数据位置)。当然了为了节省内存空间,Redis 还对 Long 类型整数和 SDS 的内存布局做了专门的设计。
比如说:当保存的是 Long 类型整数时,RedisObject 中的指针就直接赋值为整数数据了,这样就不用额外的指针再指向整数了,节省了指针的空间开销。
当保存的是字符串数据,并且字符串小于等于 44 字节时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片。这种布局方式也被称为 embstr 编码方式。
当字符串大于 44 字节时,SDS 的数据量就开始变多了,Redis 就不再把 SDS 和 RedisObject 布局在一起了,而是会给 SDS 分配独立的空间,并用指针指向 SDS 结构。这种布局方式被称为 raw 编码模式。
上面列举的是 string 结构体和 RedisObject 带来的额外存储开销,但是不仅仅只有这一点。Redis 会使用一个全局哈希表保存所有键值对,哈希表的每一项是一个 dictEntry 的结构体,用来指向一个键值对。dictEntry 结构中有三个 8 字节的指针,分别指向 key、value 以及下一个 dictEntry,三个指针共 24 字节,如下图所示:
上面的三个指针共用了24字节,但是实际上 Redis 会给他分配32个字节,这个和 Redis 使用的内存分配 jemalloc 有关。jemalloc 在分配内存时,会根据我们申请的字节数 N,找一个比 N 大,但是最接近 N 的 2 的幂次数作为分配的空间,这样可以减少频繁分配的次数。所以接近24且比24大的只有32。
所以上面两个原因就是主要导致存储一百万个数据是使用了 69M,因为有很多地方带来了额外的内存开销。那为什么用 hash 就小很多尼?
哈希存储
Redis 有一种底层数据结构,叫压缩列表(ziplist),这是一种非常节省内存的结构。压缩列表 表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量,以及列表中的 entry 个数。压缩列表尾还有一个 zlend,表示列表结束。如图:
压缩列表之所以能节省内存,就在于它是用一系列连续的 entry 保存数据。不用额外的指针进行连接,所以节省空间。 每个 entry 的元数据包括下面几部分。
结构 | 描述 |
---|---|
prev_len | ,表示前一个 entry 的长度。prev_len 有两种取值情况:1 字节或 5 字节。取值 1 字节时,表示上一个 entry 的长度小于 254 字节。虽然 1 字节的值能表示的数值范围是 0 到 255,但是压缩列表中 zlend 的取值默认是 255,因此,就默认用 255 表示整个压缩列表的结束,其他表示长度的地方就不能再用 255 这个值了。所以,当上一个 entry 长度小于 254 字节时,prev_len 取值为 1 字节,否则,就取值为 5 字节。 |
len | 自身长度 4字节 |
encoding | 编码方式 1字节 |
content | 存储实际数据 |
当然了Hash 类型的两种底层实现结构,分别是压缩列表和哈希表。由下面两组参数控制合适使用哈希表或压缩列表。
hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。默认512。
hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。默认60。
当 Hash 集合中写入的元素个数超过了 hash-max-ziplist-entries,或者写入的单个元素大小超过了 hash-max-ziplist-value,Redis 就会自动把 Hash 类型的实现结构由压缩列表转为哈希表。一旦从压缩列表转为了哈希表,Hash 类型就会一直用哈希表进行保存,而不会再转回压缩列表了。在节省内存空间方面,哈希表就没有压缩列表那么高效了。
所在文章前面做实验时候我把 hash-max-ziplist-entries 调到了一百万。所以但存储大量的键值对时候需要谨慎些,多想想。比如可以用 Hash 类型的二级编码方式。