Redis的底层数据结构

要搞懂redis,首先得了解他的常用的数据类型,以及对应的底层的存储结构,看看它到底是如何实现如此高效且多样的数据结构,今天就用着一篇文章来揭开redis的面纱。

字典表

redis单机服务端有16个数据库,每个数据库都有一个字典结构,这个字典里存着两个hash表(为了之后的扩缩容),而这个hash表里有一个dictEntry 组成的数组,里面存放的就是所有的键值对。这个dictEntry还有指向下一个节点的指针,就是为了在hash冲突的情况,采用拉链法扩展出一个链表。

/*
 * 字典
 */
typedef struct dict {

    // 类型特定函数
    dictType *type;

    // 私有数据
    void *privdata;

    // 哈希表
    dictht ht[2];

    // rehash 索引
    // 当 rehash 不在进行时,值为 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */

    // 目前正在运行的安全迭代器的数量
    int iterators; /* number of iterators currently running */

} dict;
/*
 * 哈希表
 * 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
 */
typedef struct dictht {
    
    // 哈希表数组
    dictEntry **table;

    // 哈希表大小
    unsigned long size;
    
    // 哈希表大小掩码,用于计算索引值
    // 总是等于 size - 1
    unsigned long sizemask;

    // 该哈希表已有节点的数量
    unsigned long used;

} dictht;
/*
 * 哈希表节点
 */
typedef struct dictEntry {
    
    // 键
    void *key;

    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;

    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;

} dictEntry;

img

向字典表再添加一个元素 set name abin我们会先对key做散列运算,将得到的值再对哈希表的大小4做一个取余,假设得到的值是3,那么这个key就会落在3的位置,比如:

img

渐进式rehash

当我们哈希表中存的数据越来越多,哈希冲突的概率就会越来越大。这样所有的键值对冲突后会形成一个链表,查询的效率就由原先的O(1)变成了O(n),所以我们要有一个评估的标准,用来判断是否需要扩缩容。

负载因子=used / size

扩容:

  1. 程序没有执行BGSAVE命令或者BGREWRITEAOF(AOF重写)命令,并且哈希表的负载因子大于等于1
  2. 如果程序正在执行BGSAVE或者BGREWRITEAOF(AOF重写)命令并且哈希表的负载因子大于等于5。在执行RDB或者AOF重写操作时,redis会创建当前服务器的子进程执行相应操作,为了避免在子进程存在期间对哈希表进行扩展操作,将扩展因子提高。可以避RDB或者AOF重写时不必要的内存写入操作,最大限度的节约内存。

缩容:当负载因子小于0.1

为什么需要渐进式rehash

然而redis并不像我画的那样,只有一两个key。一个生产使用的redis可以达到几百上千万个。而redis的核心计算是单线程的,一次性重新散列这么多的key会造成长时间的服务不可用,因此需要采用渐进式的rehash。

具体步骤

  1. 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
  2. 在字典中维持-一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。
  3. 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增-一。
  4. 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。
  5. 最后将h[1]的地址设置给h[0],并将h[1]设置为null,也就是将新哈希表替换旧hash表。

渐进式rehash的好处在于它采取分而治之,的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量。

共存的策略

因为在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间:

  • 所有增删改查都会先访问ht[0],再访问ht[1].比如查询会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找,诸如此类
  • rehash期间所有新增的键值对都会添加到h[1]里,保证ht [0]的键值对数量会只减不增,最终会变成空表

scan-高位进位扫描

为了高效地匹配出数据库中所有符合给定模式的Key,Redis提供了Scan命令。该命令会在每次调用的时候返回符合规则的部分Key以及一个游标值Cursor(初始值使用0),使用每次返回Cursor不断迭代,直到Cursor的返回值为0代表遍历结束。

Redis官方定义Scan特点如下:

  1. 整个遍历从开始到结束期间, 一直存在于Redis数据集内的且符合匹配模式的所有Key都会被返回;
  2. 如果发生了rehash,同一个元素可能会被返回多次,遍历过程中新增或者删除的Key可能会被返回,也可能不会。

扫描的方式有两种,一种是顺序扫描,一种是高位进位的方式扫描,对应如下两种情况。而scan采用高位进位扫描法,尽可能的减少重复的概率

img

假设某时刻,哈希表的长度由h[4]扩展到h[8]。看看高位进位法的扫描结果

img

  1. 先在h[4]中扫描到00
  2. 在扫描到10时发生了rehash
  3. 于是在h[8]中扫描010-110-001-101以此类推

再来看看如果按照顺序扫描会发生什么样的情况

img

  1. 先在h[4]中扫描到00-01
  2. 在扫描10的时候发生rehash
  3. 于是在h[8]中扫描010-011以此类推,这样会重新扫描到100,101等位置的key,造成大量重复

RedisObject

redis数据库中的每个键值对的键和值都是一个对象

  • 每个对象都有相应的类型,这些类型决定了你能对他们操作的指令,比如string类型的对象只能用set命令设置。
  • 每种类型的对象又有两种以上的编码,不同编码可以在不同场景上优化使用效率
/*
 * Redis 对象
 */
typedef struct redisObject {

    // 类型
    unsigned type:4;

    // 编码方式
    unsigned encoding:4;

    // LRU - 24位, 记录最末一次访问时间(相对于lru_clock); 或者 LFU(最少使用的数据:8位频率,16位访问时间)
    unsigned lru:LRU_BITS; // LRU_BITS: 24

    // 引用计数
    int refcount;

    // 指向底层数据结构实例
    void *ptr;

} robj;

这里的每个字段都很重要,比如类型和编码,有一个基于6.0的关系图

img

比如我们执行一个命令 hset user age 25在字典上的数据结构大概是这样,为了方便,string类型的对象就简画成了stringobject。

img

核心就是搞明白,无论是key还是value,都是一个redisObject即可。

LRU

lru字段只对键对象起作用,存放的是这个键的最后访问时间戳,根据这个时间戳就可以了解这个对象多久没被访问过了

img

并且在内存淘汰开启了volatile-lru或allkeys-lru时,会将更久没被访问的那部分键优先释放,这部分下章会介绍。

对象共享

refcount存放的是这个对象的被引用次数,当这个值为0后才能真正释放内存。对象共享可以极大的改善redis的内存占用,特别是使用频率高的string对象。目前redis在初始化服务器时就会初始化1w个对象分别是0-9999的字符串对象。

数据类型

String字符串

String是redis中最基本的数据类型,一个key对应一个value。

String类型是二进制安全的,意思是 redis 的 string 可以包含任何数据。如数字,字符串,jpg图片或者序列化的对象。

编码方式

字符串对象的编码可以是intraw或者embstr

1)如果一个字符串对象保存的是整数值,并且这个整数值可以用lon类型表示,那么字符串对象将整数值保存在字符串对象结构的ptr属性里面(将void*转换成long),并将字符串对象的编码方式设置为int。

2)如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串保存这个值,并将对象的编码设置为raw。

3)如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。

编码的转换

int编码和embstr编码的字符串对象在满足条件的情况下,会转换为raw编码的字符串对象。

1)int编码转为raw编码:原对象保存的值不再是整数值,而是一个字符串值,那么会发生编码从int变为raw

2)redis没有为embstr编码的字符串对象编写任何相应的修改程序(只有int转为raw),所以,embstr编码字符串实际上是只读的,当对embstr编码的字符从执行修改命令时,

程序会先将对象的embstr转换成raw,然后再执行修改命令。(embstr编码的字符串对象执行APPEND命令后,对象的编码会从embstr变为raw)。

  • 命令使用
命令简述使用
GET获取存储在给定键中的值GET name
SET设置存储在给定键中的值SET name value
DEL删除存储在给定键中的值DEL name
INCR将键存储的值加1INCR key
DECR将键存储的值减1DECR key
INCRBY将键存储的值加上整数INCRBY key amount
DECRBY将键存储的值减去整数DECRBY key amount
  • 命令执行
127.0.0.1:6379> set hello world
OK
127.0.0.1:6379> get hello
"world"
127.0.0.1:6379> del hello
(integer) 1
127.0.0.1:6379> get hello
(nil)
127.0.0.1:6379> set counter 2
OK
127.0.0.1:6379> get counter
"2"
127.0.0.1:6379> incr counter
(integer) 3
127.0.0.1:6379> get counter
"3"
127.0.0.1:6379> incrby counter 100
(integer) 103
127.0.0.1:6379> get counter
"103"
127.0.0.1:6379> decr counter
(integer) 102
127.0.0.1:6379> get counter
"102"
  • 实战场景

    • 缓存: 经典使用场景,把常用信息,字符串,图片或者视频等信息放到redis中,redis作为缓存层,mysql做持久化层,降低mysql的读写压力。
    • 计数器:redis是单线程模型,一个命令执行完才会执行下一个,同时数据可以一步落地到其他的数据源。
    • session:常见方案spring session + redis实现session共享,

List列表

Redis中的List其实就是链表(Redis用双端链表实现List)。

使用List结构,我们可以轻松地实现最新消息排队功能(比如新浪微博的TimeLine)。List的另一个应用就是消息队列,可以利用List的 PUSH 操作,将任务存放在List中,然后工作线程再用 POP 操作将任务取出进行执行。

对象的编码:

列表对象的编码在在3.2前是ziplist或者linkedlist,3.2后采用quicklist

ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点保存一个列表节点。

linkedlist编码的列表对象使用双端链表作为底层

quicklist采用两者的结合

实现。每个双端链表节点(node)都保存一个字符串对象,而每个字符串对象都保存了一个列表元素。

编码的转换

**当列表对象可以同时满足一下两个条件时,列表对象使用ziplist编码,**不能满足这两个条件的列表对象需要使用linkedlist编码。

1)列表对象保存的所有字符串元素的长度都小于64字节

2)列表对象保存的元素数量小于512个,

  • 命令使用
命令简述使用
RPUSH将给定值推入到列表右端RPUSH key value
LPUSH将给定值推入到列表左端LPUSH key value
RPOP从列表的右端弹出一个值,并返回被弹出的值RPOP key
LPOP从列表的左端弹出一个值,并返回被弹出的值LPOP key
LRANGE获取列表在给定范围上的所有值LRANGE key 0 -1
LINDEX通过索引获取列表中的元素。你也可以使用负数下标,以 -1 表示列表的最后一个元素, -2 表示列表的倒数第二个元素,以此类推。LINDEX key index
  • 使用列表的技巧

    • lpush+lpop=Stack(栈)
    • lpush+rpop=Queue(队列)
    • lpush+ltrim=Capped Collection(有限集合)
    • lpush+brpop=Message Queue(消息队列)
  • 命令执行

127.0.0.1:6379> lpush mylist 1 2 ll ls mem
(integer) 5
127.0.0.1:6379> lrange mylist 0 -1
1) "mem"
2) "ls"
3) "ll"
4) "2"
5) "1"
127.0.0.1:6379> lindex mylist -1
"1"
127.0.0.1:6379> lindex mylist 10        # index不在 mylist 的区间范围内
(nil)
  • 实战场景

    • 微博TimeLine: 有人发布微博,用lpush加入时间轴,展示新的列表信息。
    • 消息队列

Set集合

Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。

Redis 中集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。

编码:

集合对象的编码可以时intset或者hashtable

1)intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。

2)hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象都包含一个集合元素,而字典的值则被全部设置为NULL。

编码的转换

同时满足两个条件使用intset,否则使用hashtable

1)集合对象保存的所有值都是整数值

2)集合对象保存的元素数量不超过512个

  • 命令使用
命令简述使用
SADD向集合添加一个或多个成员SADD key value
SCARD获取集合的成员数SCARD key
SMEMBERS返回集合中的所有成员SMEMBERS key member
SISMEMBER判断 member 元素是否是集合 key 的成员SISMEMBER key member

其它一些集合操作,请参考这里https://www.runoob.com/redis/redis-sets.html

  • 命令执行
127.0.0.1:6379> sadd myset hao hao1 xiaohao hao
(integer) 3
127.0.0.1:6379> smembers myset
1) "xiaohao"
2) "hao1"
3) "hao"
127.0.0.1:6379> sismember myset hao
(integer) 1
  • 实战场景

    • 标签(tag),给用户添加标签,或者用户给消息添加标签,这样有同一标签或者类似标签的可以给推荐关注的事或者关注的人。
    • 点赞,或点踩,收藏等,可以放到set中实现

Hash散列

Redis hash 是一个 string 类型的 field(字段) 和 value(值) 的映射表,hash 特别适合用于存储对象。

哈希对象的编码:

哈希对象的编码可以是ziplist或者hashtable

  1. ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入压缩列表表尾。因此

    1. 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;
    2. 先添加到哈希对象中的键值对会放在压缩列表的表头方向,而后添加的哈希对象中的键指对会被放在压缩列表的链表方向。
  2. hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存

    1. 字典的每个键都是一个字符串对象,对象中保存了键值对的键。
    2. 字典中每个值都是一个字符串对象,对象中保存了键值对的值。

编码的转换

当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码,否则使用hashtable编码

1)哈希对象保存的所有键值对的键和值的字符串长度都小于64个字节

2)哈希对象保存的键值对数量小于512个。

  • 命令使用
命令简述使用
HSET添加键值对HSET hash-key sub-key1 value1
HGET获取指定散列键的值HGET hash-key key1
HGETALL获取散列中包含的所有键值对HGETALL hash-key
HDEL如果给定键存在于散列中,那么就移除这个键HDEL hash-key sub-key1
  • 命令执行
127.0.0.1:6379> hset user name1 hao
(integer) 1
127.0.0.1:6379> hset user email1 hao@163.com
(integer) 1
127.0.0.1:6379> hgetall user
1) "name1"
2) "hao"
3) "email1"
4) "hao@163.com"
127.0.0.1:6379> hget user user
(nil)
127.0.0.1:6379> hget user name1
"hao"
127.0.0.1:6379> hset user name2 xiaohao
(integer) 1
127.0.0.1:6379> hset user email2 xiaohao@163.com
(integer) 1
127.0.0.1:6379> hgetall user
1) "name1"
2) "hao"
3) "email1"
4) "hao@163.com"
5) "name2"
6) "xiaohao"
7) "email2"
8) "xiaohao@163.com"
  • 实战场景

    • 缓存: 能直观,相比string更节省空间,的维护缓存信息,如用户信息,视频信息等。

Zset有序集合

Redis 有序集合和集合一样也是 string 类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。

有序集合的成员是唯一的, 但分数(score)却可以重复。有序集合是通过两种数据结构实现:

  1. 压缩列表(ziplist): ziplist是为了提高存储效率而设计的一种特殊编码的双向链表。它可以存储字符串或者整数,存储整数时是采用整数的二进制而不是字符串形式存储。它能在O(1)的时间复杂度下完成list两端的push和pop操作。但是因为每次操作都需要重新分配ziplist的内存,所以实际复杂度和ziplist的内存使用量相关
  2. 跳跃表(zSkiplist): 跳跃表的性能可以保证在查找,删除,添加等操作的时候在对数期望时间内完成,这个性能是可以和平衡树来相比较的,而且在实现方面比平衡树要优雅,这是采用跳跃表的主要原因。跳跃表的复杂度是O(log(n))。
  • 命令使用
命令简述使用
ZADD将一个带有给定分值的成员添加到有序集合里面ZADD zset-key 178 member1
ZRANGE根据元素在有序集合中所处的位置,从有序集合中获取多个元素ZRANGE zset-key 0-1 withccores
ZREM如果给定元素成员存在于有序集合中,那么就移除这个元素ZREM zset-key member1

更多命令请参考这里 https://www.runoob.com/redis/redis-sorted-sets.html

  • 命令执行
127.0.0.1:6379> zadd myscoreset 100 hao 90 xiaohao
(integer) 2
127.0.0.1:6379> ZRANGE myscoreset 0 -1
1) "xiaohao"
2) "hao"
127.0.0.1:6379> ZSCORE myscoreset hao
"100"
  • 实战场景

    • 排行榜:有序集合经典使用场景。例如小说视频等网站需要对用户上传的小说视频做排行榜,榜单可以按照用户关注数,更新时间,字数等打分,做排行。

数据结构

简单动态字符串 - sds

Redis 是用 C 语言写的,但是对于Redis的字符串,却不是 C 语言中的字符串(即以空字符’\0’结尾的字符数组),它是自己构建了一种名为 简单动态字符串(simple dynamic string,SDS)的抽象类型,并将 SDS 作为 Redis的默认字符串表示。

struct sdshdr {
    uint8_t len; /* 当前字符串大小(单位字节)*/
    uint8_t alloc; /* 内存分配大小(单位字节) */
    unsigned char flags; /* 头类型分配8,16,32,64字节四种类型(其中5字节这种类型被弃用) */
    char buf[]; /* 字符串数组 */
};

sds有四种类型uint8, uint16, uint32, uint64,分别表示len以及alloc字段的长度。如果是uint8类型,那么len和alloc字段能存的数据最多为2的8次方,也就是256字节。这样在分配的内存很少的情况下,也能尽可能的节省字段的长度。

SDS比起C语言的字符串实现有啥优势?

  • 常数复杂度获取字符串长度

由于 len 属性的存在,我们获取 SDS 字符串的长度只需要读取 len 属性,时间复杂度为 O(1)。而对于 C 语言,获取字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。通过 strlen key 命令可以获取 key 的字符串长度。

  • 杜绝缓冲区溢出

我们知道在 C 语言中使用 strcat 函数来进行两个字符串的拼接,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。而对于 SDS 数据类型,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。

  • 减少修改字符串的内存重新分配次数

C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。

而对于SDS,由于len属性和alloc属性的存在,对于修改字符串SDS实现了空间预分配惰性空间释放两种策略:

1、空间预分配:对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。

2、惰性空间释放:对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 alloc 属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间。)

  • 二进制安全

因为C字符串以“\0”字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括“\0”,因此C字符串无法正确存取;而所有 SDS 的API 都是以处理二进制的方式来处理 buf 里面的元素,并且 SDS 不是以“\0”来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束。

  • 兼容部分 C 字符串函数

虽然 SDS 是二进制安全的,但是一样遵从每个字符串都是以“\0”结尾的惯例,这样可以重用 C 语言库<string.h> 中的一部分函数。

sds主要用于redis的string类型的底层结构

字典/哈希表 - Dict

字典本质上也是hash表,这里的哈希表和上文说的字典是一模一样的

img

哈希表主要有两个作用:

1、作为我们数据库的字典的底层实现,他每个字典有两个哈希表,一个在rehash时使用

2、作为redis的hash类型的底层实现

整数集 - IntSet

整数集合(intset)是集合类型的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现。

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;
encoding `表示编码方式,的取值有三个:`INTSET_ENC_INT16`, `INTSET_ENC_INT32`, `INTSET_ENC_INT64

length 代表其中存储的整数的个数

contents 指向实际存储数值的连续内存区域, 就是一个数组;整数集合的每个元素都是 contents 数组的一个数组项(item),各个项在数组中按值得大小从小到大有序排序,且数组中不包含任何重复项。(虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组,但实际上 contents 数组并不保存任何 int8_t 类型的值,contents 数组的真正类型取决于 encoding 属性的值)

img

整数级的升级

当在一个int16类型的整数集合中插入一个int32类型的值,整个集合的所有元素都会转换成32类型。 整个过程有三步:

  • 根据新元素的类型(比如int32),扩展整数集合底层数组的空间大小,并为新元素分配空间。
  • 将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变。
  • 最后改变encoding的值,length+1。

相反,redis并不会考虑降级,主要出于效率的考虑。

整数级主要作为redis的set类型的底层实现

双向链表-linkedlist

链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。

typedef struct list {//链表
    listNode *head;//链表头
    listNode *tail;//链表尾
    void *(*dup)(void *ptr); //复制函数指针
    void (*free)(void *ptr); //释放内存函数指针
    int (*match)(void *ptr, void *key); //比较函数指针
    unsigned long len; //链表长度
} list;
typedef struct listNode {
    struct listNode *prev;//前驱指针
    struct listNode *next;//后继指针
    void *value; //节点的值
} listNode;

list 结构为链表提供了表头指针 head、表尾指针 tail,以及链表长度计数器 len,而 dup、free 和 match 成员则是用于实现多态链表所需的类型特定函数:

1、dup 函数用于复制链表节点所保存的值;

2、free 函数用于释放链表节点所保存的值;

3、match 函数则用于对比链表节点所保存的值和另一个输入值是否相等。

图像如图所示:

img

双端链表 linkedlist 主要有两个作用:

1、3.2版本以前作为 Redis 的 list 数据类型底层实现方法之一;

2、作为通用数据结构可以被其他功能模块使用;

压缩列表 - ZipList

ziplist是为了提高存储效率而设计的一种特殊编码的双向链表。它可以存储字符串或者整数,存储整数时是采用整数的二进制而不是字符串形式存储。它能在O(1)的时间复杂度下完成list两端的push和pop操作。但是因为每次操作都需要重新分配ziplist的内存,所以实际复杂度和ziplist的内存使用量相关。

struct ziplist<T>{
    int32 zlbytes;           //整个压缩列表占用的字节数
    int32 zltail;     //最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
    int16 zllen;          //元素个数
    T[] entries;             //元素内容列表,紧密存储
    int8 zlend;              //标志压缩列表的结束,值恒为0XFF
}
struct entry {
    int<var> prevlen;         //前一个元素长度,用于快速定位到下一个元素的位置
    int<var> encoding;          //元素类型编码
    optional byte[] content;  //内容
}

ziplist:

  • zlbytes字段的类型是uint32_t, 这个字段中存储的是整个ziplist所占用的内存的字节数
  • zltail字段的类型是uint32_t, 它指的是ziplist中最后一个entry的偏移量. 用于快速定位最后一个entry, 以快速完成pop等操作
  • zllen字段的类型是uint16_t, 它指的是整个ziplit中entry的数量. 这个值只占2bytes(16位): 如果ziplist中entry的数目小于65535(2的16次方), 那么该字段中存储的就是实际entry的值. 若等于或超过65535, 那么该字段的值固定为65535, 但实际数量需要一个个entry的去遍历所有entry才能得到.
  • zlend是一个终止字节, 其值为全F, 即0xff. ziplist保证任何情况下, 一个entry的首字节都不会是255

entry:

prevlen:前一个entry的大小,字段长度有多个等级;

encoding:不同的情况下值不同,用于表示当前entry的类型和长度;

entry-data:真是用于存储entry表示的数据;

img

大概内存结构长这样,支持快速定位到尾部,同时还支持双向遍历。

通过entry,我们了解到,每个节点都存了上一个节点的长度,由此才能双向遍历,通过本节点指针P-prelen=上个节点位置。

级联更新

并且prelen为了最大程度减小内存,有多个长度的等级,如果每个节点都是254个字节,第一个节点突然变成255字节,那么为了能存下这么长的长度,下一个节点需要扩充prevlen字段长度,导致下一个节点长度也超过255,最终导致级联更新。所以后面还出了listpack来实现该功能,listpack最大的区别在于保存的是本字段的长度,不会产生级联效果

在hash类型下的ziplist存储结构

img

在zset类型下的ziplist存储结构

img

本质上都是用了两个entry,第一个键,第二个值,紧密相连。

压缩列表ziplist主要用于hash和zset类型的某些简单场景的底层结构

3.2以前,还作为list的底层结构

快表 - QuickList

quicklist这个结构是Redis在3.2版本后新加的, 之前的版本是list(即linkedlist), 用于String数据类型中。

它是一种以ziplist为结点的双端链表结构. 宏观上, quicklist是一个链表, 微观上, 链表中的每个结点都是一个ziplist。

早起的版本list的实现元素少用ziplist,元素多用linkedlist。后面改成了quicklist。

img

看图就能知道,是ziplist和linkedlist的结合。 考虑到链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节 (64bit 系统的 指针是 8 个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管 理效率。后续版本对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist。

跳表 - ZSkipList

跳跃表结构在 Redis 中的运用场景只有一个,那就是作为有序列表 (Zset) 的使用。跳跃表的性能可以保证在查找,删除,添加等操作的时候在对数期望时间内完成,这个性能是可以和平衡树来相比较的,而且在实现方面比平衡树要优雅,这就是跳跃表的长处。跳跃表的缺点就是需要的存储空间比较大,属于利用空间来换取时间的数据结构。

// server.h
// 最高层数为64层
#define ZSKIPLIST_MAXLEVEL 64 /* Should be enough for 2^64 elements */
// 大概每隔4个节点向上抽象一层
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */

typedef struct zskiplistNode {
	// 字符串类型的member值
    sds ele;
    // 分值
    double score;
    // 后向指针
    struct zskiplistNode *backward;
    struct zskiplistLevel {
    	// 前向指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned long span;
    } level[];
} zskiplistNode;

img

跳表的层数并没有严格的要求,否则每次删除节点,都有可能要降低别的节点的层数,复杂度就高了。

所以跳表的层数用的是随机算法,每次插入一个节点,每层是否生成都采用的随机算法计算。

可以思考两个问题,答案在八股文处解答:

  • redis为啥用跳表,不用平衡二叉树?
  • mysql为啥用平衡二叉树不用跳表?

参考资料

《redis设计与实现》

美团针对Redis Rehash机制的探索和实践

Redis进阶 - 数据结构:对象机制详解

Redis五种数据结构

redis基础数据结构及编码方式

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值