[Redis]压缩列表详解
Redis 为了节约内存空间使用,zset
和 hash
容器对象在元素个数较少的时候,采用压缩列表(ziplist)进行存储。压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙。
>zadd programmings 1.0 go 2.0 python 3.0 java
(integer)3
>debug object programmings
Value at:0x7fec2de00020 refcount:1 encoding:ziplist serializedlength:36 lru:6022374 lru_seconds_idle:6
>hmset books go fast python slow java fast
OK
>debug object books
Value at:0x7fec2de000c0 refcount:1 encoding:ziplist serializedlength:48 lru:6022478 lru_seconds_idle:1
这里,注意观察 debug object
输出的 encoding 字段都是 ziplist
,这就表示内部采用压缩列表结构进行存储。
图 5-6 所示是压缩列表的内部结构示意图。
struct ziplist<T>
{
int32 zlbytes; //整个压缩列表占用字节数
int32 zltail_offset;//最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
int16 zllength; // 元素个数
T[] entries; //元素内容列表,依次紧凑存储
int8 zlend; //标志压缩列表的结束,值恒为0xFE
}
压缩列表为了支持双向遍历
,所以才会有 ztail_offset 这个字段,用来快速定位到最后一个元素,然后倒着遍历
。
entry块随着容纳的元素类型不同,也会有不一样的结构。
struct entry
{
int<var> prevlen;//前一个 entry 的字节长度
int<var> encoding;//元素类型编码
optional byte[] content;//元素内容
}
如图 5-7 所示是压缩列表内部具体元素的结构示意图。它的 prevlen 字段表示前一个 entry 的字节长度,当压缩列表倒着遍历时,需要通过这个字段来快速定位到下一个元素的位置。它是一个变长的整数,当字符串长度小于254(即0xFE)
时,使用1个字节表示;如果达到或超出
254 时,就使用5个字节来表示。第一个字节是0xFE,剩余四个字节表示字符串长度
。你可能会觉得,用5个字节来表示字符串长度是不是太浪费了,我们可以算一下,当字符串长度比较长的时候,其实5个字节也只占用了不到 2% 的空间。
len_size = 5 #存储字符串长度占用字节数
str_size >= 254 #存储字符串内容占用字节数
#字符串长度存储占用的空间百分比
len_size_ratio = lensize/(lensize+strsize) <= 5/(5+254)=1.93%
encoding 字段存储了元素内容的编码类型信息 ziplist 通过这个字段来决定后面content 的形式。
Redis 为了节约存储空间 ,对 encoding 字段进行了相当复杂的设计。 Redis 通过这个字段的前缀位来识别具体存储的数据形式。下面我们来看看 Redis 是如何根据 encoding 的前缀位来区分内容的。
- 00xxxxxx 是最大长度位数为 63 的短字符串,后面的 6 个位存储字符串的位数,剩余的字节就是字符串的内容。
- 01xxxxxx xxxxxxxx 是中等长度的字符串 后面 14 个位来表示字符串的长度,剩余的字节就是字符串的内容。
- 10000000 aaaaaaaa bbbbbbbb cccccccc dddddddd 是特大字符串,需要使用额外4个字节来表示长度。第-个字节前缀是 10 ,剩余 位没有使用,统一置为零。后面跟着字符串内容。不过这样的大字符串是没有机会使用的,压缩列表通常只是用来存储小数据的。
- 11000000 表示 int16 ,后跟两个字节表示整数
- 11010000 表示 int32 ,后跟四个字节表示整数
- 11100000 表示 int64 ,后跟八个字节表示整数。
- 11110000 表示 int24 ,后跟三个字节表示整数
- 11111110 表示 int8 ,后跟一个字节表示整数
- 11111111 表示 ziplist 的结束,也就是 zlend 的值 OxFF
- 1111 xxxx 表示极小整数, xxxx 的范围只能是( 0001~1101) ,也就是 1-13,因为 0000 1110 1111 都被占用了。读取到的 value 需要将 xxxx 减一 ,也就是说整0-12 就是最终的 value
注意 content 字段在结构体中定义为 optional 类型,表示这个字段是可选的,对于很小的整数而言,它的内容已经内联到 encoding 字段的尾部了。
增加元素#
因为 ziplist 都是紧凑存储,没有冗余空间(对比一下 Redis 的字符串结构),意味着插入一个新的元素就需要调用 realloc 扩展内存。取决于内存分配器算法和当前的ziplist 内存大小,realloc 可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也有可能在原有的地址上进行扩展,这时就不需要进行旧内容的内存拷贝。如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗
,所以ziplist 不适合存储大型字符串,存储的元素也不宜过多。
级联更新#
/* 压缩列表级联主新*
unsigned char *一 ziplistCascadeUpdate(unsigned char *zl, unsigned
char *p) {
size t curlen =工 trev32ifbe (ZIPLIST BYTES (zl)), rawlen,
rawlensize;
S 工 ze t offset, noffset, extra;
uns gned char *np;
zlentry cur, next ;
while (p[O] != ZIP END) {
zipEntry(p, &cur);
rawlen = cur.headers ze + cur.len;
rawlensize = zipStorePrevEntryLength(NULL,rawlen) ;
/* Abort if there i 口o next entry . */
if (p[rawlen] == ZIP END ) break;
zipEntry(p+rawlen , &next) ;
/* Abort when ” prevlen ” has not changed 女/
II prevlen 的长度没有交,中断级联更新
if (口 ext.prevrawlen == rawlen) break;
(next.prevrawlensize < rawlensize) {
I* The “ prevlen” field of “ next” needs more bytes to
the raw length of cur ”. 女/
//级联扩展
offset = p - zl ;
extra = rawlensize -口 ext prevrawlensize
//扩大内存
zl = ziplistResize(zl,curlen+extra);
p = zl+offset;
I* Current pointer and offset for next element. *I
np = p+rawlen ;
noffset =口p-zl
I* Update tail offset when next element is not the
tail element. *I
//史新 zltail offset 指针
i f ( (zl+intrev32ifbe (ZIPLIST TAIL OFFSET (zl))) != np) {
ZIPLIST TAIL OFFSET(zl ) =
intrev32ifbe(intrev32ifbe(ZIPLIST TAIL
OFFSET(zl))+extra);
I* Move the tail to the back .女/
//移动内存
memmove(np+rawlens ze
np+next . prevrawlensize ,
curlen-noffset-next.prevrawlensize- 1) ;
zipStorePrevEntryLength(np,rawlen);
/*Advance the cursor*/
p += rawlen;
curlen +=extra
}else
if(next.prevrawlensize>rawlensize){/*This would result in shrinking,which we want
to avoid.*So,set "rawlen" in the available bytes.*///级联收缩,不过这里可以不用收缩了,因为5个字节也是可以存储 1 个字节的内容的
//虽然有点浪费,但是级联更新实在是太可怕了,所以浪费就浪费
zipStorePrevEntryLengthLarge(p+rawlen,rawlen);}else {
//大小没变,改个长度值就完事了
zipStorePrevEntryLength(p+rawlen,rawlen);
/央Stop here,as the raw length of“next”has not
changed.*/
break;
return zl;
}
前面提到每个 entry 都会有一个 prevlen 字段存储前一个 entry 的长度。如果内容小于 254 字节, prevlen 就用一个字节存储,否则就用五个字节存储。这意昧着如果某个entry 经过了修改操作从 253 字节变成了254字节,那么它的下一个entry的prevlen 字段就要更新,从一个字节扩展到5个字节;如果后面这个 entry 的长度本来也是 253 字节,那么再后面entry prevlen 字段还得继续更新。
如果 ziplist 里面的每个 entry 恰好都存储了 253 字节的内容,那么第1个 entry内容的修改就会导致后续所有 entry的级联更新,这就是1个比较耗费计算资源的操作。
删除中间的某个节点也可能会导致级联更新,读者可以思考一下为什么?
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?