Redis[1]-数据对象

238 阅读15分钟

1.Redis对象头

Redis中每个对象都由一个RedisObject的结构表示:

struct RedisObject{
	int4 type;//数据类型
	int4 encoding;//
	int24 lru;//lru信息
	int32 refcount;//引用计数器
	void *ptr;//指向实现当前数据类型的数据结构首地址
}robj

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     /* SDS 字符串 */
define OBJ_ENCODING_INT 1     /* 整数 */
define OBJ_ENCODING_HT 2      /* 字典结构 */
define OBJ_ENCODING_ZIPMAP 3  /* 压缩map,已经废弃 */
define OBJ_ENCODING_LINKEDLIST 4 /* LinkedList 双端链表,废弃了 */
define OBJ_ENCODING_ZIPLIST 5 /* 压缩列表 */
define OBJ_ENCODING_INTSET 6  /* 整数集合 */
define OBJ_ENCODING_SKIPLIST 7  /* 跳跃表 */
define OBJ_ENCODING_EMBSTR 8  /* 短字符串 */
define OBJ_ENCODING_QUICKLIST 9 /* 压缩链表和双向链表组成的快速列表 */

refcount属性是一个引用计数属性,可以用于内存回收和对象共享 lru属性,记录了对象最后一次被命令程序访问的时间,可以计算出某个键的空转时长;

2.字符串对象

字符串:SDS(Simple Dynamic String)动态字符串

struct SDS<T>{
	T capacity;//数组容量
	T len;//实际长度
	byte flags//标志位
	byte[] content;//实际数据
}

obj encoding int

如果字符串为int类型,会将*ptr(对象头指针)转为long型,存储value,继而分配内存.

embstr & raw

embstr和raw都是基于SDS结构来表示,embstr只会调用一次内存分配并分配一块连续的内存空间,raw会调用两次内存分配来创建RedisObject和SDS空间。 字符串比较短是采用embstr形式存储,当超过44字节采用raw形式存储

44字节的原因:jemalloc、tcmalloc等分配内存大小的单位都是2/4/8/16/32/64,RedisObject为16字节,SDS3个字节、结束符1个字节,字符串对象超过64字节就会转为raw的形式,因此一个最大的embstr字符串为64-(16+3+1) = 44字节

EMBSTR是不可修改的,当对EMBSTR编码的字符串append,总会先将其转换成RAW编码再进行修改, int->string 同样转为raw的存储形式 注:Redis 规定了字符串的长度不得超过 512 MB,在字符串长度小于1MB之前,扩容空间采用加倍策略,超过1MB之后,为了避免加倍后的冗余空间过大而导致浪费,每次扩容只会多分配1MB的空间

3.List对象

List对象得编码可以是OBJ_ENCODING_LINKEDLIST和OBJ_ENCODING_ZIPLIST及OBJ_ENCODING_QUICKLIST,其中OBJ_ENCODING_LINKEDLIST基本已经被QUICKLSIT取代了。 使用zipList作为底层结构,对象头ptr指针则会执行一个zipList数据结构,则每个压缩列表节点(entry)保存了一个列表元素。 使用LinkedList作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。

3.1 编码转换

当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:

  • 列表对象保存的所有字符串元素的长度都小于64字节;
  • 列表对象保存的元素数量小于512个;不能满足这两个条件的列表对象需要使用linkedlist编码。当满足一定条件底层数据结构会进行转换,

以上两个条件都可以通过参数配置:

list-max-ziplist-value 512 #对象保存的元素数量512个 list-max-ziplist-entries 64 #对象保存的所有字符串元素的长度都64字节

3.2版本后quickList结构取代了linkedList,将多个linkedList切割成一个zipList加上pre和next组成quickNode,如果zipList元素太小,quickNode节点就会增多,内存碎片就会增多,如果过大连续分配较大内存也会影响效率,Redis提供了参数进行配置zipList的大小:

list-max-ziplist-size -2

参数的含义解释,取正值时表示quicklist节点ziplist包含的数据项。取负值表示按照占用字节来限定quicklist节点ziplist的长度。

-5: 每个quicklist节点上的ziplist大小不能超过64 Kb。
-4: 每个quicklist节点上的ziplist大小不能超过32 Kb。
-3: 每个quicklist节点上的ziplist大小不能超过16 Kb。
-2: 每个quicklist节点上的ziplist大小不能超过8 Kb。(默认值)
-1: 每个quicklist节点上的ziplist大小不能超过4 Kb。

list-compress-depth 0 #压缩深度
0: 是个特殊值,表示都不压缩。这是Redis的默认值。
1: 表示quicklist两端各有1个节点不压缩,中间的节点压缩。
2: 表示quicklist两端各有2个节点不压缩,中间的节点压缩。

4.Hash对象

哈希对象的编码可以是ziplist或者hashtable。 ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对插入,程序先把由key组成的zipList的节点插入到压缩列表的队尾,然后再把value组成zipList节点插入到压缩列表的队尾,所以具有如下特点:

hash键值对总是存储两个zipList节点挨着存储;
zipList列表总是按时间先后顺序依次存储hash的键值对;

hashTable编码的哈希对象使用字典作为底层实现,每个键值对都是用dict键值对存储;

字典的每个键值对都是一个字符串对象结构;

4.1 编码转换

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

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

不能满足这两个条件的哈希对象需要使用hashtable编码,以上两个条件都可以通过参数配置:

hash-max-ziplist-value 64 #所有键值对的键和值的字符串长度都64字节; hash-max-ziplist-entries 512 #的键值对数量512个

5.Set对象

集合对象的编码可以是intset或者hashtable。 当Set对象存储的全是整数,会使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。 hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL。

5.1 编码转换

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

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

不能满足这两个条件的集合对象需要使用hashtable编码,可通过参数设置整数据集合元素数量:

set-max-intset-entries 512 #Intset集合保存的元素数量超过512,转成hashTable编码。

6.ZSet对象

有序集合的编码可以是ziplist或者skiplist。 ziplist编码的哈希对象使用压缩列表作为底层实现,当插入zset元素是,会生成元素成员(member)和score两个zipListNode节点紧挨着存储; 压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。 skiplist编码的哈希对象使用跳跃列表作为底层实现,skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表:

struct zset{
    skipList *zsl;
    dict *dict
}zset

zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。通过这个跳跃表,程序可以对有序集合进行范围型操作,比如ZRANK、ZRANGE等命令就是基于跳跃表API来实现的。

zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程序可以用O(1)复杂度查找给定成员的分值,ZSCORE命令就是根据这一特性实现的,而很多其他有序集合命令都在实现的内部用到了这一特性。

6.1 编码转换

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

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

以上两个条件都可以通过参数配置:

zset-max-ziplist-entries 128 #集合保存的元素数量小于128个
zset-max-ziplist-value 64 #所有元素成员的长度都小于64字节

7.键类型检查

Redis的指令一般分为两类:

针对所有类型的键执行的指令,如expire 、del等
针对特定类型的键执行的指令,如lpush、set、hset等

类型特定命令所进行的类型检查是通过redisObject结构的type属性来实现的,在执行一个键值对的插入时候,先会检查键对象的type类型是否是执行命令的类型,如果是的话执行指令,否则返回错误类型提示客户端。 除了会检查对象的类型,同时还会根据type选择执行对应的方法,如del指令,如果是zipList的编码,怎会执行zipist编码对应的删除实现。

8.内存回收

8.1 引用计数

Redis在自己的对象系统中构建了一个引用计数(reference counting)技术实现的内存回收机制,通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。 对象的引用计数信息会随着对象的使用状态而不断变化:

  • 在创建一个新对象时,引用计数的值会被初始化为1;
  • 当对象被一个新程序使用时,它的引用计数值会被增一;
  • 当对象不再被一个程序使用时,它的引用计数值会被减一;
  • 当对象的引用计数值变为0时,对象所占用的内存会被释放。

8.2 LRU & LFU

在Redis当内存使用达到限制时,系统默认会根据一定的规则自动清理旧数据,Redis默认采用近似LRU(Least Recently Used)算法进行缓存淘汰。

8.2.1 最大内存设置

maxmemory 配置选项使用来配置 Redis 的存储数据所能使用的最大内存限制。可以通过在内置文件redis.conf中配置,也可在Redis运行时通过命令CONFIG SET来配置

maxmemory 0 #表示没有内存限制

当存储数据达到限制时,Redis 会根据情形选择不同策略,或者返回errors(这样会导致浪费更多的内存),或者清除一些旧数据回收内存来添加新数据。

8.2.2 内存回收策略

当内存达到限制时,Redis 具体的回收策略是通过 maxmemory-policy 配置项配置的:

  • noenviction #不清除数据,只是返回错误,这样会导致浪费掉更多的内存,对大多数写命令(DEL 命令和其他的少数命令例外)默认
  • allkeys-lru #从所有的数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰,以供新数据使用
  • volatile-lru #从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰,以供新数据使用
  • allkeys-random #从所有数据集(server.db[i].dict)中任意选择数据淘汰,以供新数据使用
  • volatile-random #从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰,以供新数据使用
  • volatile-ttl #从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰,以供新数据使用

对LRF新增以下两个配置:

  • volatile-lfu #从所有配置了过期时间的键中驱逐使用频率最少的键
  • allkeys-lfu #从所有键中驱逐使用频率最少的键
8.2.3 空转时长

Redis对象头存储了Lru字段,初始值为server.lrulock,server.lrulock默认是Unix时间戳对2的 24次方进行取模,server.lrulock在定时任务serverCron里主动设置,每100ms更新一次,server.lrulock与lru的差就是空转时长,每次对象更新则会更新lru为server.lrulock。 OBJECT IDLETIME命令可以打印出给定键的空转时长;

8.2.4 近似LRU算法

Redis中的LRU与常规的LRU实现并不相同,常规LRU会准确的淘汰掉队头的元素,但是Redis的LRU并不维护队列,只是根据配置的策略要么从所有的key中随机选择N个(N可以配置)要么从所有的设置了过期时间的key中选出N个键,然后再从这N个键中选出空转时间最长的一个key进行淘汰。 随机个数设置:

maxmemory-samples 5 #默认5个元素

Redis3.0之后又改善了算法的性能,会提供一个待淘汰候选key的pool,里面默认有maxmemory-samples个key,按照空闲时间排好序。更新时从Redis键空间随机选择N个key,分别计算它们的空闲时间idle,key只会在pool不满或者空闲时间大于pool里最小的时,才会进入pool,然后从pool中选择空闲时间最大的key淘汰掉。

8.2.5 LFU算法

在4.0之后支持配置缓存淘汰使用LFU(Least Frequently Used, 最少使用算法) Redis中实现LFU算法的时候,有这个两个重要的可配置参数:

lfu-log-factor 10 #能够影响计数的量级范围;
lfu-decay-time 1 #控制LFU计数衰减的参数,衰减因子

在RedisObject对象头中还是采用lru字段来记录LFU存储的两个值 ldt和logc:

logc:用前8位存储访问计数,初始计数值会直接就是LFU_INIT_VAL(5),8个bit位最大为255,从Redis通过一个复杂的公式,通过配置lfu_log_factor和server.lfu_decay_time两个参数来调整数据的递增速度,不同的lfu_log_factor的值能够控制计数代表的量级的范围,当factor为100时,能够最大代表10M,也就是千万级别的命中数。 ldt:16个bit存储上次访问时间,因为只有16个bit,只精确到分钟,通过该参数可计算出空转时间

计数衰减

logc的更新:在缓存被访问时,会更新数据的访问计数,更新的步骤是:先在现有数据的计数上进行计数衰减,再对完成衰减后的计数进行增加。
ldt的更新:在Redis的淘汰逻辑进行时进行更新,淘汰逻辑只会是在内存达到maxmemory设置时才触发,和LRU一样,随机挑选n个key,淘汰热度最低的key,然后更新ldt值为空转时间/lfu-decay-time,在更新ldt时,同样也会对logc进行衰减,logc-空转时间/lfu-decay-time

8.2 过期缓存淘汰

redisDb结构的expires字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典

struct redisDb{
    dict* expires;
}

过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也即是某个数据库键),过期字典的值是一个long类型的整数,这个整数保存了键所指向的数据库键的过期时间——一个毫秒精度的UNIX时间戳。 Redis采用以下两种方式进行过期数据淘汰:

  • 惰性删除:在每个访问数据时,会进行过期时间判断,若到期则删除,同时向用户返回nil;
  • 定时删除:Redis配置项hz定义了serverCron任务的执行周期,默认为10,即CPU空闲时每秒执行10次,每次过期key清理的时间不超过CPU时间的25%,即若hz=1,则一次清理时间最大为250ms,若hz=10,则一次清理时间最大为25ms,每次随机取20个key判断过期,是则清除,如果超过5个key过期则重复随机选取key淘汰;

由于算法采用的随机取key判断是否过期的方式,故几乎不可能清理完所有的过期Key; 调高hz参数可以提升清理的频率,过期key可以更及时的被删除,但hz太高会增加CPU时间的消耗,为了保证不会循环过度,导致卡顿,扫描时间上限默认不超过25ms。 系统中应避免大量的key同时过期,给要过期的key设置一个随机范围,避免最大清除时间;

9.总结

  • Redis数据库中的每个键值对的键和值都是一个对象。
  • Redis共有字符串、列表、哈希、集合、有序集合五种类型的对象,每种类型的对象至少都有两种或以上的编码方式,不同的编码可以在不同的使用场景上优化对象的使用效率。
  • 服务器在执行某些命令之前,会先检查给定键的类型能否执行指定的命令,而检查一个键的类型就是检查键的值对象的类型
  • 在每个对象头都会有引用计数及Lru信息,用于回收内存