redis底层数据结构系列 - 压缩列表

259 阅读6分钟

青春因磨砺而精彩,人生因奋斗而升华

ziplist简介

ziplist: 是一个经过特殊编码的双向链表,设计的目标就是为了提高存储效率。可以用来存储整数或字符串,其中整数是按照真正的二进制进行编码的,而不是编码成字符串序列。能以O(1)的时间复杂度在表的两端提供push和pop操作;

优点:

ziplist充分体现了redis对于存储效率的追求,一个普通的双向链表,链表中每一项都占用独立的一块内存,各项之间用地址指针连接起来。这种方式会带来大量的内存碎片,而且地址指针也会占用额外的内存。ziplist却是将表中每一项都存放在前后连续的地址空间内,一个ziplist整体占用一大块内存

数据结构

内部宏观结构布局

<zlbytes> <zltail> <zllen> <entry>  ... <entry> <zlend>

字段解析:

  • : 4byte,表示ziplist占用的字节总数;在对压缩列表进行内存重分配,或计算zlend的位置时使用

  • : 4byte,ziplist最后一项entry在ziplist中偏移的字节数;通过这个偏移量,可以很方便找到最后一项 (zlbytes+zltail=最后一个entry) ,且不需要遍历;从而可以很快的执行push或pop操作;

  • : 2byte, 表示ziplist中数据项entry的个数;因为只有2byte,当值超过2^16 - 2时,此值设置为2^16 - 1, 此时的值不再表示数据项个数,想知道ziplist中数据项总数,必须对其进行遍历;

  • : 真正存储数据的数据项,长度不定。

  • : 1byte,是一个结束标记;标记压缩列表的末端;

entry数据项结构

<prevlen> <encoding> <entry-data>

节点解析:

  • prevlen:存储上一个entry的长度,以字节为单位,能够从后向前遍历列表。只需要偏移prevlen字节就能找到前一项;

  • encoding:entry的编码。

  • entry-data:存储entry表示的数据

prevlen解析

  • 当前一个 entry 长度 小于254 的时候,prevlen只使用 1byte, 这个字节的值就是前一个entry占用的字节数

    <prevlen from 0 to 253>

  • 当前一个entry 长度 >=254,那么prevlen用 5byte 表示,第一个字节设置为254(0xFE),仅作为一个标记,后面4个字节组成一个整型值,用来真正存储前一个entry的占用字节数;

    0xFE <4 bytes unsigned little endian prevlen>

由于prevlen记录前一个数据项(entry)的长度,所以可以根据当前节点的起始地址计算出前一个节点的起始地址(prev = 地址指针 - prevlen属性值);压缩列表从表尾向表头遍历操作就是使用这一原理实现的;只要拥有某一节点的起始指针就可以一直向表头遍历;

encoding

记录了节点属性所保存数据的类型及长度;encoding的长度和值根据保存的是int或string,还有数据的长度而定;
前两位表示类型:11: 表示存储的是int类型;其他表示string;

当存储的是string时:

  • |00pppppp| - 1byte: 第1个字节最高2bit是00;字段占用1字节;剩余的6bit用来表示长度值,最高可表示 63;

  • |01pppppp|qqqqqqqq| - 2byte: 第1byte最高2bit是01, 占用2byte,剩余14bit来表示长度值;

  • |10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt - 5byte: 第1byte最高2bit是10,字段占5byte,总共使用32bit表示长度值(第1字节的后6bit舍弃不用);

当存储整数时:

  • |11000000| - 3byte: 占用1byte,值为: 0xC0; 占用2byte,存储int16类型

  • |11010000| - 5byte: 占用1byte,值为: 0xD0; 占用4byte,存储int32类型
  • |11100000| - 9byte: 占用1byte,值为: 0xE0; 占用8byte,存储int64类型
  • |11110000| - 4byte: 占用1byte,值为: 0xF0; 占用3byte,存储int24类型
  • |11111110| - 2byte: 占用1byte,值为: 0xFE; 占用1byte,存储int8类型
  • |1111xxxx| - (xxxx的值在0001 - 1101之间): 这是一种特殊情况,xxxx从1到13一共13个值,就用这13个值来表示真正的数据(不是数据长度);即这种情况下,后面不需要一个单独的字段来表示真正的数据,而是和合二为一。另外由于xxxx只能去0001 - 1101这13个值(其他可能的值和其它情况冲突,eg: 0000 和 1110分别同其前面情况出现冲突),而小数值应该从0开始,因此这13个值分别表示 0 - 12,即xxxx的值减去1才是它所要表示的那个整数值; 

zlentry压缩列表结构:

typedef struct zlentry {
    // 前一节点长度信息的长度;因为压缩列表可以能需要倒叙遍历;所以可定位上一个entry
    unsigned int prevrawlensize;
    // 前一节点长度;
    unsigned int prevrawlen;     
    // 当前节点长度信息长度
    unsigned int lensize;        
    // 当前节点长度
    unsigned int len;            
    // 当前节点头部信息长度
    unsigned int headersize;  
    // 当前节点数据编码;
    unsigned char encoding;   
    // 指向开头指针
    unsigned char *p;       
} zlentry;

ziplist具体结构

  • ziplist包含27byte,本例以16进制表示;

  • zlbytes用4byte,1B=27byte;

  • 然后用4byte表示zltail,0x16=22,表示最后一项数据项在字节数组的第22位置

  • 再用2byte,值为0x03=3,表示ziplist里一共存有3项数据项(entry)。

  • 第一个entry: 用6byte保存数据项;其中prevlen=0;因为它前面没有数据项;encoding=4,表示后面4byte按照字符串存储,数据的值为”name”

  • 第二个entry: 用 5byte 保存数据项;其中prevlen=06,即前一项的长度6byte, encoding表示当前entry-data为3byte, 值为:why;

  • 第三个entry: 用5byte保存数据;encoding=FE; 所以存储的为1byte的整数,值为:0x14=20;

  • 最后1byte, 表示zlend,固定值0xFF;

思考:

连锁更新:

添加新节点或者删除节点,都有可能发生连锁更新的想象;

  • 当对ziplist进行插入操作时,由于prevlen记录了前一个节点的长度,假设ziplist现有数据项的长度都 <254字节,那么prevlen只需1byte;如果我们插入的数据项长度 >= 254, 由于新数据项后面节点的prevlen仅1byte,无法保存新数据项长度,所以会对压缩列表执行空间重分配操作,并将e1的prevlen扩容至5byte, 但是,由于e1的原长度<254, 扩容之后,e1后面的数据项又无法保存e1的长度,为了让其能够记录下e1的长度,程序需要再次执行空间重分配操作,并将e2的prevlen扩容到5byte, 以此类推,从而发生连锁更新现象;

  • 当对ziplist进行删除操作,如果ziplist中存在一个big节点,且其后面的节点e1-en的长度<254时, 如果删除big节点后e1数据项时,e2的prevlen为1byte, 不足以记录big的长度,程序就会扩展e1的空间,以此类推就会发生连锁更新

为什么没有255

因为255已经定义了ziplist的的值。在ziplist的很多操作的实现中,都会根据数据项的第1个字节(的第1字节)是不能够取255的,否则就冲突了

为什么元素较少时使用ziplist?

ziplist本身是一块连续的内存块,从底层的磁盘读写来说,顺序I/O的效率肯定高于随机I/O。但是,由于ziplist是连续内存,如果元素数量太多,意味着当创建和扩展的时候需要操作更多的内存,所以ziplist针对元素少的时候才能提升效率