Redis 对象与数据库

Redis 中有字符串、列表、哈希、集合和有序集合 5 种对象,每种对象都用到了至少一种之前介绍的数据结构。每个 Redis 对象都由一个 redisObject 结构表示,该结构中和保存数据有关的三个属性分别是 type、encoding 和 ptr。

// redis.h
typedef struct redisObject {

// 类型
unsigned type:4;

// 编码
unsigned encoding:4;

// 对象最后一次被访问的时间
unsigned lru:REDIS_LRU_BITS;

// 引用计数
int refcount;

// 指向实际值的指针
void *ptr;

} robj;

#define REDIS_LRU_BITS 24

redisObject 结构体使用了位域(Bit Field)类型,即在成员变量最后添加冒号并指定占用的位数,可以节约内存空间,用 sizeof robj 语句可以看到结构体占用的字节数是 16(64 位系统下指针占 8 字节),在 C 语言中默认的类型是 int,所以 unsigned 其实就是 unsigned int

type 属性即 Redis 的 5 种对象类型,TYPE 命令输出的就是这 5 种类型:

对象类型 对象 type 属性的值 TYPE 命令的输出
字符串 REDIS_STRING string
列表 REDIS_LIST list
哈希 REDIS_HASH hash
集合 REDIS_SET set
有序集合 REDIS_ZSET zset

ptr 指针对象对象的底层实现数据结构,而这些数据结构由 encoding 属性决定,OBJECT ENCODING 命令可以查看底层数据结构的类型:

底层数据结构类型 对象 encoding 属性的值 OBJECT ENCODING
整数 REDIS_ENCODING_INT int
embstr 编码的 SDS REDIS_ENCODING_EMBSTR emstr
SDS REDIS_ENCODING_RAW raw
字典 REDIS_ENCODING_HT hashtable
链表 REDIS_ENCODING_LINKEDLIST linkedlist
压缩列表 REDIS_ENCODING_ZIPLIST ziplist
整数集合 REDIS_ENCODING_INTSET intset
跳跃表 REDIS_ENCODING_SKIPLIST skiplist

字符串对象

字符串对象的编码可以是 int、embstr 或者 raw 。如果一个字符串对象保存的是整数值,且这个整数值可以用 C 语言的 long 类型来表示,直接使用 ptr 指针属性存储这个整数值。如果保存的字符串很短(不大于 44 字节)则使用 embstr 编码,即通过调用一次内存分配函数申请一块连续的内存空间,依次存放 redisObject 和 sdshdr 两个结构,embstr 编码的字符串对象在修改之后总会变成一个 raw 编码的字符串对象。

// object.c

// 创建一个 REDIS_ENCODING_EMBSTR 编码的字符对象
// 这个字符串对象中的 sds 会和字符串对象的 redisObject 结构一起分配
// 因此这个字符也是不可修改的
robj *createEmbeddedStringObject(char *ptr, size_t len) {
robj *o = zmalloc(sizeof(robj)+sizeof(struct sdshdr)+len+1);
struct sdshdr *sh = (void*)(o+1);

o->type = REDIS_STRING;
o->encoding = REDIS_ENCODING_EMBSTR;
o->ptr = sh+1;
o->refcount = 1;
o->lru = LRU_CLOCK();

sh->len = len;
sh->free = 0;
if (ptr) {
memcpy(sh->buf,ptr,len);
sh->buf[len] = '\0';
} else {
memset(sh->buf,0,len+1);
}
return o;
}

列表对象

列表对象的编码可以是 ziplist 或者 linkedlist。ziplist 编码的列表对象使用压缩列表作为底层实现,每个节点(Entry)保存一个列表元素。linkedlist 编码的列表对象使用链表作为底层实现,每个节点(Node)都保存了一个字符串对象(redisOject),这个字符串对象保存了一个列表元素。

当列表对象可以同时满足以下两个条件时(数值可以配置),使用 ziplist 编码,否则使用 linkedlist 编码:

  • 列表对象保存的所有字符串元素的长度都小于 64 字节;
  • 列表对象保存的元素数量小于 512 个。

哈希对象

哈希对象的编码可以是 ziplist 或者 hashtable。ziplist 编码的哈希对象使用压缩列表作为底层实现,每当有心的键值对要加入到哈希对象时,先将保存了键的压缩列表节点推入表尾,然后再将保存了值的节点推入到表尾。hashtable 编码的哈希对象使用字典作为底层实现,字典的键值都是字符串对象,分别保存实际的键和值。

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

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;
  • 哈市对象保存的键值对数量小于 512 个。

集合对象

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

当集合对象可以同时满足以下两个条件时,对象使用 intset 编码,否则使用 hashtable 编码:

  • 集合对象保存的所有元素都是整数值;
  • 集合对象保存的元素数量不超过 512 个。

有序集合对象

有序集合对象的编码可以是 ziplist 或者 skiplist。ziplist 编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨着的压缩列表节点来保存,第一个节点保存元素的成员,第二个节点保存元素的分值;压缩列表内的集合元素按分值从小到大排列。skiplist 编码的有序集合对象使用 zset 结构作为底层实现,一个 zset 结构同时包含一个字典和一个跳跃表。

// redis.h
typedef struct zset {

// 字典,键为成员,值为分值
// 用于支持 O(1) 复杂度的按成员取分值操作
dict *dict;

// 跳跃表,按分值排序成员
// 用于支持平均复杂度为 O(log N) 的按分值定位成员操作
// 以及范围操作
zskiplist *zsl;

} zset;

zset 结构中的 zsl 跳跃表按照分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:object 属性保存了元素的成员(一个字符串对象),score 属性保存了元素的分值(一个 double 类型的浮点数),可以支持按照分值进行范围查询。dict 字典创建了一个从成员到分值的映射,支持 O(1) 复杂度查找给定成员的分值。值得一提的是,zsl 和 dict 这两种数据结构会通过指针来共享相同元素的成员和分值,所以并不会浪费额外的内存。

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

  • 有序集合保存的所有元素成员的长度都小于 64 字节;
  • 有序集合保存的元素数量小于 128 个。

数据库

Redis 服务器将所有数据库都保存在 redisServer 结构的 redisDb 数组中,每个 redisDb 结构代表一个数据库,redisDb 结构的 dict 字典保存了数据库中的所有键值对,这个字典被称为键空间(Key Space)。

// redis.h
struct redisServer {
...
// 一个数组,保存着服务器中的所有数据库
redisDb *db;

// 服务器的数据库数量
int dbnum;

// serverCron() 每秒调用的次数
int hz;
...
};

typedef struct redisDb {
...
// 数据库键空间,保存着数据库中的所有键值对
dict *dict;

// 键的过期时间,字典的键为键,字典的值为过期事件 UNIX 时间戳
dict *expires;

// 数据库号码
int id;
...
} redisDb;

图 1:数据库键空间例子

Redis 有 4 种不同的命令可以用于设置键的生存时间(Time To Live, TTL):EXPIRE、PEXPIRE、EXPIREAT、PEXPIREAT,最终前 3 个命令都会转换成 PEXPIREAT 命令,即将键的过期时间设置为指定的毫秒数时间戳。redisDb 结构的 expires 字典保存了数据库中所有键的过期时间,这个字典称为过期字典:过期字典的键是一个指针,指向键空间中的某个键对象;过期字典的值是一个毫秒精度的 UNIX 时间戳(long long 类型)即过期时间。PERSISIT 命令可以移除一个键的过期时间,是 PEXPIREAT 的反操作,它会在过期字典中删除对应的键值对。

Redis 对过期键使用惰性删除策略,所有读写数据库的 Redis 命令在执行之前都会调用 db.c 文件中的 expireIfNeeded 函数判断键是否过期,如果过期则从数据库中删除。

Redis 也会定期清理过期键,redis.c 中的 serverCron 函数是服务器的周期性任务(每秒运行 hz 次),这个函数中会调用 activeExpireCycle 函数对过期键进行清理:它在规定时间内,分多次遍历服务器中的各个数据库,从数据库的 expires 字典中随机检查一部分键的过期时间,并删除其中的过期键。

0%