Redis中的数据结构

470 阅读18分钟

Redis对象

Redis的每种对象其实都由对象结构(redisObject)对应编码的数据结构组合而成,而每种对象类型对应若干编码方式,不同的编码方式所对应的底层数据结构是不同的。

Redis通过对象系统构建对不同数据结构的使用、优化。

  • 类型与编码对应,选择适用的底层数据结构
  • 采用引用计数法实现对象回收和共享机制

对象设计

在redis的命令中,用于对键进行处理的命令占了很大一部分,而对于键所保存的值的类型(键的类型),键能执行的命令又各不相同。

Redis 必须让每个键都带有类型信息, 使得程序可以检查键的类型, 并为它:

  • 选择合适的处理方式
  • 根据数据类型的不同编码进行多态处理

为了解决以上问题, Redis 构建了自己的类型系统, 这个系统的主要功能包括:

  • redisObject 对象.
  • 基于 redisObject 对象的类型检查.
  • 基于 redisObject 对象的显式多态函数.
  • 对 redisObject 进行分配、共享和销毁的机制

redisObject数据结构

redisObject 是 Redis 类型系统的核心, 数据库中的每个键、值, 以及 Redis 本身处理的参数, 都表示为这种数据类型.

/*
 * 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;
  
   

下图对应上面的结构

img

其中type、encoding和ptr是最重要的三个属性

  • type记录了对象所保存的值的类型,它的值可能是以下常量中的一个:
/*
* 对象类型
*/
#define OBJ_STRING 0 // 字符串
#define OBJ_LIST 1 // 列表
#define OBJ_SET 2 // 集合
#define OBJ_ZSET 3 // 有序集
#define OBJ_HASH 4 // 哈希表
  
    
    
  • encoding记录了对象所保存的值的编码,它的值可能是以下常量中的一个:
/*
* 对象编码
*/
#define OBJ_ENCODING_RAW 0     /* Raw representation */
#define OBJ_ENCODING_INT 1     /* Encoded as integer */
#define OBJ_ENCODING_HT 2      /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3  /* 注意:版本2.6后不再使用. */
#define OBJ_ENCODING_LINKEDLIST 4 /* 注意:不再使用了,旧版本2.x中String的底层之一. */
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define OBJ_ENCODING_INTSET 6  /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7  /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8  /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */
  
  • ptr是一个指针,指向实际保存值的数据结构,这个数据结构由type和encoding属性决定。举个例子, 如果一个redisObject 的type 属性为OBJ_LIST , encoding 属性为OBJ_ENCODING_QUICKLIST ,那么这个对象就是一个Redis 列表(List),它的值保存在一个QuickList的数据结构内,而ptr 指针就指向quicklist的对象;

下图展示了redisObject 、Redis 所有数据类型、Redis 所有编码方式以及底层数据结构之间的关系:

命令的类型检查和多态

那么Redis是如何处理一条命令的呢?

当执行一个处理数据类型命令的时候,redis执行以下步骤

  • 根据给定的key,在数据库字典中查找和他相对应的redisObject,如果没找到,就返回NULL;
  • 检查redisObject的type属性和执行命令所需的类型是否相符,如果不相符,返回类型错误;
  • 根据redisObject的encoding属性所指定的编码,选择合适的操作函数来处理底层的数据结构;
  • 返回数据结构的操作结果作为命令的返回值。

img

对象共享

redis一般会把一些常见的值放到一个共享对象中,这样可使程序避免了重复分配的麻烦,也节约了一些CPU时间。

redis预分配的值对象如下

  • 各种命令的返回值,比如成功时返回的OK,错误时返回的ERROR,命令入队事务时返回的QUEUE,等等
  • 包括0 在内,小于REDIS_SHARED_INTEGERS的所有整数(REDIS_SHARED_INTEGERS的默认值是10000)

img

注意:共享对象只能被字典和双向链表这类能带有指针的数据结构使用。像整数集合和压缩列表这些只能保存字符串、整数等自勉之的内存数据结构

为什么redis不共享列表对象、哈希对象、集合对象、有序集合对象,只共享字符串对象

  • 列表对象、哈希对象、集合对象、有序集合对象,本身可以包含字符串对象,复杂度较高。
  • 如果共享对象是保存字符串对象,那么验证操作的复杂度为O(1)
  • 如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为O(N)
  • 如果共享对象是包含多个值的对象,其中值本身又是字符串对象,即其它对象中嵌套了字符串对象,比如列表对象、哈希对象,那么验证操作的复杂度将会是O(N的平方)

如果对复杂度较高的对象创建共享对象,需要消耗很大的CPU,用这种消耗去换取内存空间,是不合适的

引用计数以及对象的消毁

redisObject中有refcount属性,是对象的引用计数,显然计数0那么就是可以回收。

  • 每个redisObject结构都带有一个refcount属性,指示这个对象被引用了多少次;
  • 当新创建一个对象时,它的refcount属性被设置为1;
  • 当对一个对象进行共享时,redis将这个对象的refcount加一;
  • 当使用完一个对象后,或者消除对一个对象的引用之后,程序将对象的refcount减一;
  • 当对象的refcount降至0 时,这个RedisObject结构,以及它引用的数据结构的内存都会被释放。

String

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

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

图例

下图是一个String类型的实例,其中键为hello,值为world

img

命令使用

命令简述使用
GET获取存储在给定键中的值GET name
SET设置存储在给定键中的值SET name value
DEL删除存储在给定键中的值DEL name
INCR将键存储的值加1INCR key
DECR将键存储的值减1DECR key
INCRBY将键存储的值加上整数INCRBY key amount
DECRBY将键存储的值减去整数DECRBY key amount

底层结构

Redis对象的字符串对象结构如下:

type表明为字符串对象

encoding表示编码方式

Redis String底层由动态字符串SDS实现,C语言字符串只用在日志等不能修改字符的功能。

当调用SET name value时,Redis会创建两个字符串对象,而字符串对象对应的底层实现就是SDS

编码

字符串对象有三种编码方式,分别为embstr,raw, int。对于不同的数据会采用合适的编码 如果是整数且可以用long储存就使用int编码,字符串转为long值保存。 如果为字符,小于32字节使用embstr编码;大于使用raw编码。

embstr

专门用于保存短字符串的一种优化编码方式。一次内存分配给redisObject和sdshdr。

  • 相比raw分配函数调用次数更少,释放也更少
  • 连续的内存空间,能更好的利用缓存,比raw编码少一次内存寻址

其中元数据占⽤了20字节,剩下44字节用于保存数据,超过44字节会使用raw编码

SDS的结构

结构体如下:

struct sdshdr{
    int len;
    int free;
    char buf[];
}
  • len表示字符串的长度
  • free表示还未使用空间的长度
  • buf是实际储存char的数组

SDS的特性

由于SDS的结构实现可以知道,SDS使用char数组保存字符串,且SDS的内存空间可以大于实际储存的字符,因此SDS具有以下特性

  • 常数时间获取字符串长度,len记录了实际字符串长度

  • 自动扩容,杜绝了缓冲区溢出

  • 空间预分配,基于字符串的长度

    • 小于1MB额外分配与字符串相同的空间即free = len
    • 大于1MB,free = 1MB
  • 惰性空间释放,缩短字符串时不会立刻减少char数组空间,由Redis自己绝对缩短的时机,也提供指令,立刻回收空间

  • 二进制安全,不需要用/0表示字符串结束

使用注意事项

批量读写

过期时间

用于计数,注意计数范围,超出signed long最值会报错

String的最大长度为512MB

尽量使用embstr编码,即String长度应该在44字节之内,20字节用于保存元数据

Set

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

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

图例

img

命令使用

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

底层结构

集合对象的编码可以是intset或hashtable,当使用hashTable进行保存时,

当集合对象保存的所有元素都是整数值且数量不超过512个,会使用inset编码

inset

typedef struct inset{
    uint32_t encoding;
    uint32_t length;
    
    int8_t contents[];
}

整数集合中的每个元素由contents数组保存,并按值得大小从小到大有序排列,且不包含重复项。

encoding

决定了contents中保存的类型

包括int16、int32、int64

length

记录了contents数组的长度

升级

当需要插入一个值,这个值的类型比contents类型大,此时会进行升级

即重新分配数组空间,将原来的元素转换类型并插入到合适的位置,最后将当前值进行插入

在升级后集合不会再降级

hashtable

即字典

image.png

字典结构

typedef struct dict{
    dictType *type;
    void *privdata;
    dictht ht[2];
    int rehashidx;
}

type和privdata针对不同类型的键值对,实现了多态字典

type是指向dictType的指针,每个dictType都保存了对应类型的函数

privdata保存了需要传给特定类型函数的可选参数

ht是保存了两个dictht哈希表的数组,一般情况下只会使用ht[0],ht[1]用于协助进行rehash

rehashidx用于记录rehash的进度,

dictht哈希表
typedef struct dictht{
    dictEntry **table;
    
    unsigned long size;
    
    unsigned long sizemask;
    
    unsigned long used;
}

**table是以dictEntry为元素的数组

size保存了数组大小

sizemask哈希表掩码用于计算索引值,总是等于size - 1

used表示已有节点数量

dictEntry哈希节点
typedef struct dictEntry{
    void *key;
    union{
        void * val;
        uint64_tu64;
        int64_ts64;
    } v;
    
    struct dictEntry *next;
}dictEntry;

key保存键,v保存值,值可以为指针、uint64_t或int64_t的值。

next保存了指向像一个节点的指针,使用拉链法解决哈希冲突

hash算法

使用Murmur算法作为hash函数,使用拉链法解决hash冲突

rehash

当负载因子超出合理范围之后,哈希表保存的键值对数量太多或太少,需要对哈希表的大小进行调整。

rehash时机

当以下条件中任意一个被满足时,会自动对哈希表进行扩容

  • 目前没有执行bgsave命令或者bgrewriteAOF命令,且负载因子大于等于1
  • 正在执行bgsave命令或者bgrewriteAOF命令,负载因子大于等于5

当负载因子小于0.1时,会对哈希表进行收缩

rehash过程
  • 为ht[1]分配合适的空间,空间大小取决于操作和used
  • 在每次进行更新或查询操作时,会顺带将ht[0] [rehashidx]上的键值对
  • 重新计算hash值和索引值,并迁移到ht[1]的对应位置上,rehashidx + 1
  • 直到rehash == ht[0].size,说明完成迁移,rehashidx 设置为-1
  • 迁移结束后将ht[1]设置为ht[0],再创建一个新的ht[1]

rehash期间查找,首先下在ht[0]进行,没有找到再去ht[1]上查询

List

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

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

图例

img

命令使用

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

使用列表的技巧

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

底层结构

列表的redisObject结构如下:

image.png

type指明该object的类型

encoding是具体数据的储存的编码格式有ziplist和linkedlist两种

当保存的所有字符串都小于64字节,且列表的保存的元素数量少于512个时,会使用ziplist

ziplist

为节约内存而存在,是由一系列特殊编码的连序数据结构。

一个ziplist可以保存任意个节点,每个节点可以保存一个字节数组或者一个整数值

组成

image.png

Entry

每个压缩列表节点都可以保存一个字节数组或一个整数值

image.png

previous_entry_length

以字节为单位,记录了压缩列表中前一个节点的长度,长度可以为一个字节或5个字节

由长度是否超过254个字节

encoding

记录了节点的content保存的数据结构和长度

  • 1字节、2字节或5字节,值的高位为00, 01,10表示是字节数组编码,其他位记录字节数组的长度,具体为多少字节由字节数组的长度决定
  • 一字节长,值的最高位是11,表示整数编码,其他未记录整数的长度和类型。11之后两位00、01、10、分别表示int16、int32、int64类型。11110000、11111110分别表示24为有符号整数和8位有符合整数。
content

具体储存节点值,值的长度和类型由encoding决定。

连锁更新

当存在大量长度接近254字节的节点时,添加节点可能会导致的出现上一个节点长度超过254,previous_entry_length重新分配为5字节,此时当前节点也超过254字节,下一节点也需要重新分配空间,以此类推导致连续更新。

linkedlist

typedef struct list{
    listNode *head;
    lsitNode *tail;
    
    unsigned long len;
    
    void *(*dup) (void *ptr);
    
    void (*free) (void *ptr);
    
    int (*match) (void *ptr, void key)
        
        
}

image.png

特性
  • 双向链表
  • 无环
  • 有头尾节点
  • len记录链表长度
  • 多态

QuickList

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

组成
typedef struct quicklistNode{
    quicklistNode *prev;
    quicklistNode *next; //下一个node
    unsigned char *zl;            //保存的数据 压缩前ziplist 压缩后压缩的数据
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
  • prev: 指向链表前一个节点的指针。
  • next: 指向链表后一个节点的指针。
  • zl: 数据指针。如果当前节点的数据没有压缩,那么它指向一个ziplist结构;否则,它指向一个quicklistLZF结构。
  • sz: 表示zl指向的ziplist的总大小(包括zlbyteszltailzllenzlend和各个数据项)。需要注意的是:如果ziplist被压缩了,那么这个sz的值仍然是压缩前的ziplist大小。
  • count: 表示ziplist里面包含的数据项个数。这个字段只有16bit。稍后我们会一起计算一下这16bit是否够用。
  • encoding: 表示ziplist是否压缩了(以及用了哪个压缩算法)。目前只有两种取值:2表示被压缩了(而且用的是LZF压缩算法),1表示没有压缩。
  • container: 是一个预留字段。本来设计是用来表明一个quicklist节点下面是直接存数据,还是使用ziplist存数据,或者用其它的结构来存数据(用作一个数据容器,所以叫container)。但是,在目前的实现中,这个值是一个固定的值2,表示使用ziplist作为数据容器。
  • recompress: 当我们使用类似lindex这样的命令查看了某一项本来压缩的数据时,需要把数据暂时解压,这时就设置recompress=1做一个标记,等有机会再把数据重新压缩。
  • attempted_compress: 这个值只对Redis的自动化测试程序有用。我们不用管它。
  • extra: 其它扩展字段。目前Redis的实现里也没用上
typedef struct quicklistLZF {
    unsigned int sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;

quicklistLZF结构表示一个被压缩过的ziplist。其中:

  • sz: 表示压缩后的ziplist大小。
  • compressed: 是个柔性数组(flexible array member),存放压缩后的ziplist字节数组。
Copy
typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned long len;          /* number of quicklistNodes */
    int fill : QL_FILL_BITS;              /* fill factor for individual nodes */
    unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;
  • head: 指向头节点(左侧第一个节点)的指针。
  • tail: 指向尾节点(右侧第一个节点)的指针。
  • count: 所有ziplist数据项的个数总和。
  • len: quicklist节点的个数。
  • fill: 16bit,ziplist大小设置,存放list-max-ziplist-size参数的值。
  • compress: 16bit,节点压缩深度设置,存放list-compress-depth参数的值。

注意事项

Hash

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

图例

img

命令使用

命令简述使用
HSET添加键值对HSET hash-key sub-key1 value1
HGET获取指定散列键的值HGET hash-key key1
HGETALL获取散列中包含的所有键值对HGETALL hash-key
HDEL如果给定键存在于散列中,那么就移除这个键HDEL hash-key sub-key1

底层结构

哈希对象的编码可以是ziplist和hashtable,使用ziplist底层的哈希对象会将键值作为两个节点连续插入队尾。

当哈希对象同时满足以下两个条件,会使用ziplist:

  • 哈希对象保存的所有键和值都小于64字节
  • 哈希对象保存的键值对数量小于512个

Zset

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

有序集合的成员是唯一的,但分数(score)却可以重复。集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。

图例

img

命令使用

命令简述使用
ZADD将一个带有给定分值的成员添加到哦有序集合里面ZADD zset-key 178 member1
ZRANGE根据元素在有序集合中所处的位置,从有序集合中获取多个元素ZRANGE zset-key 0-1 withccores
ZREM如果给定元素成员存在于有序集合中,那么就移除这个元素ZREM zset-key member1

底层结构

有序集合的编码可以是ziplist或者skiplist

使用ziplist编码会将元素和对应的分值相邻保存,并从小到大排序

使用skiplist编码,对象中会包含一个字典和一个跳表。

  • 编码转换

当有序集合对象同时满足以下两个条件时,对象使用 ziplist 编码:

1、保存的元素数量小于128;

2、保存的所有元素长度都小于64字节。

不能满足上面两个条件的使用 skiplist 编码。以上两个条件也可以通过Redis配置文件zset-max-ziplist-entries 选项和 zset-max-ziplist-value 进行修改。

注意事项

Zset score 存储数据结构为浮点型(float64),如果超过 17 位时会出现精度问题。

可以采取Key 不同范围数字来表征不同的维度分值