深入Redis内存模型2:对象
1、对象
Redis构建了自己的类型系统,主要包括:
-
redisObject对象
-
基于redisObject对象的类型检查
-
基于redisObject对象的显示多态函数
-
对redisObject进行分配、共享和销毁的机制
C语言不是面向对象语言,这里将redisObject称呼为对象是为了讲述方便,让里面的内容更容易被理解,redisObject其实是一个结构体。
Redis使用对象来表示数据库中的键和值,每次当我们在redis的数据库中新创建一个键值对时,我们至少会创建两个对象,一个键对象,一个值对象。Redis中的每个对象都由一个redisObject结构表示。
在Redis中,键总是一个字符串对象,而值可以是字符串、列表、集合等对象,所以我们通常说键为字符串键,表示这个键对应的值为字符串对象,我们说一个键为集合键时,表示这个键对应的值为集合对象。
typedef struct redisObject {
// 对象类型
unsigned type:4;
// 编码 对象使用的底层数据结构
unsigned encoding:4;
// 指向对象的底层实现数据结构
void *ptr;
// 对象最后一次被访问的时间
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
// 引用计数--->用于内存回收
int refcount;
} robj;
key对象都是字符串,value对象包含五种数据结构类型。所以,对象内存消耗可以简单理解为 sizeof(keys)+sizeof(values)。
type代表一个value对象具体是何种数据类型
-
type key :判断对象的数据类型
encoding属性和*prt指针
-
prt指针指向对象底层的数据结构,而数据结构由encoding属性来决定
-
每种类型的对象至少使用了两种不同的编码,而这些编码对用户是完全透明的。
1.1、类型检查
Redis中用于操作键的命令基本上可以分为两种类型:
-
一种命令可以对任何类型的键执行,比如说del命令、expire命令、rename命令、type命令、object命令等,
-
另一种命令只能对特定类型的键执行。
在执行一个类型特定命令之前,服务器会通过redisObject结构的type属性来检查输入数据库键的值对象是否为执行命令所需的类型,如果类型不正确,服务器将拒绝执行命令,并向客户端返回一个类型错误。
1.2、命令多态
Redis除了会校验值对象的类型,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令。通过encoding属性来设定对象所使用的编码,使得Redis可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率,极大地提升了redis的灵活性和效率。
1.3、内存回收
因为C语言并不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数技术实现的内存回收机制,通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。每个对象的引用计数信息由redisObject结构的refcount属性记录。对象的整个生命周期可以划分为创建对象、操作对象、释放对象三个阶段,对象的引用计数信息会随着对象的使用状态而不断变化:
-
在创建一个新对象时,引用计数的值会被初始化为1;
-
当对象被一个新程序使用时,它的引用计数值会被增一;
-
当对象不再被一个程序使用时,它的引用计数值会被减一;
-
当对象的引用计数值变为0时,对象所占用的内存会被释放。
1.4、对象共享
除了用于实现引用计数内存回收机制之外, 对象的引用计数属性还带有对象共享的作用。在 Redis 中, 让多个键共享同一个值对象需要执行以下两个步骤:
-
将数据库键的值指针指向一个现有的值对象;
-
将被共享的值对象的引用计数增一。
例如包含整数值100的字符串对象同时被键A和键B共享之后,除了对象的引用计数从之前的1变成了2之外,其他属性都没有变化。
共享对象机制对于节约内存非常有帮助,数据库中保存的相同值对象越多,对象共享机制就能节约越多的内存。
-
Redis会在初始化服务器时,创建一万个字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要用到值为0到9999的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象。
-
可以使用OBJECT REFCOUNT命令查看键A的值对象的引用计数。
-
这些共享对象不单单只有字符串键可以使用,那些在数据结构中嵌套了字符串对象的对象(linkedlist编码的列表对象、hashtable编码的哈希对象、hashtable编码的集合对象,以及zset编码的有序集合对象)都可以使用这些共享对象。
为什么Redis不共享包含字符串的对象?
当服务器考虑将一个共享对象设置为键的值对象时,程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同,只有在共享对象和目标对象完全相同的情况下,程序才会将共享对象用作键的值对象,而一个共享对象保存的值越复杂,验证共享对象和目标对象是否相同所需的复杂度就会越高,消耗的CPU时间也会越多:
* 如果共享对象是保存整数值的字符串对象,那么验证操作的复杂度为O(1);
* 如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为O(N);
* 如果共享对象是包含了多个值(或者对象的)对象,比如列表对象或者哈希对象,那么验证操作的复杂度将会是O(N ^2)。
因此,尽管共享更复杂的对象可以节约更多的内存,但受到CPU时间的限制,Redis只对包含整数值的字符串对象进行共享。
1.5、对象的空转时长
redisObject 结构的 lru 属性记录了对象最后一次被命令程序访问的时间。如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者 allkeys-lru ,那么当服务器占用的内存数超过了 maxmemory 选项所设置的上限值时, 空转时长较高的那部分键会优先被服务器释放, 从而回收内存。
-
OBJECT IDLETIME命令可以打印出键的空转时长。
2、对象编码转换条件
2.1、字符串对象
字符串对象的编码可以是int、embstr、raw,string 是最常用的一种数据类型,普通的key/value存储都可以归结为string类型,value不仅是string,也可以是数字。其他几种数据类型的构成元素也都是字符串,注意Redis规定字符串的长度不能超过512M。
(1)int:如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,会直接将整数值保存在字符串对象结构的ptr属性里(将void*转换成long),并将字符串对象的编码设置为int。
(2)embstr:如果字符串对象保存的是一个长度<=39字节字符串值。
(3)raw:如果字符串对象保存的是一个长度>39字节字符串值,字符串对象将使用一个简单动态字符串SDS来保存这个字符串值,并将对象的编码设置为raw。
embstr与raw区别:
embstr编码和raw编码一样都使用redisObject结构和sdshdr结构来表示字符串对象,但是raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,embstr编码的字符串对象在执行命令时产生的效果和raw编码相同,使用embstr编码的字符串对象来保存短字符串有以下好处:
-
embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次。
-
释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数。
-
因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起raw编码的字符串对象能够更好地利用缓存带来的优势。
embstr编码的字符串对象实际上是只读的,当我们对 embstr 编码的字符串对象执行任何修改命令时, 程序会先将对象的编码从 embstr 转换成 raw ,然后再执行修改命令。因为这个原因,embstr 编码的字符串对象在执行修改命令之后, 总会变成一个 raw 编码的字符串对象。
为什么是39字节:是因为redisObject的长度是16字节,sds的长度是9+字符串长度;因此当字符串长度是39时,embstr的长度正好是16+9+39=64,jemalloc正好可以分配64字节的内存单元。
常用命令:
set/get
-
set:设置key对应的值为string类型的value (多次set name会覆盖)
-
get:获取key对应的值
mset /mget
-
mset 批量设置多个key的值,如果成功表示所有值都被设置,否则返回0表示没有任何值被设置
-
mget批量获取多个key的值,如果不存在则返回null
incr && incrby<原子操作>
-
incr对key对应的值进行加加操作,并返回新的值,incrby加指定的值
decr && decrby<原子操作>
-
decr对key对应的值进行减减操做,并返回新的值,decrby减指定的值
setnx <小小体验一把分布式锁,真香>
-
设置Key对应的值为string类型的值,如果已经存在则返回0
setex
-
设置key对应的值为string类型的value,并设定有效期
setrange/getrange
-
setrange从指定位置替换字符串
-
getrange获取key对应value子字符串
2.2、列表对象
列表对象的编码可以是ziplist或者linkedlist
(1)ziplist
使用压缩列表作为底层实现,每个压缩列表节点保存了一个列表元素,例如执行rpush命令插入三个元素
rpush numbers 1 "three" 5
(2)linkedlist
使用双端链表作为底层实现,每个双端链表节点都保存了一个字符串对象,每个字符串对象都保存了一个列表元素。
linkedlist编码的列表对象在底层的双端链表结构中包含了多个字符串对象,字符串对象是redis五种类型的对象中唯一一种会被其他四中类型对象嵌套对象。
上图StringObject字样的格子表示一个字符串对象,完整字符串对象表示如下:
(3)编码转换
当列表对象可以同时满足以下两个条件时,列表对象使用 ziplist 编码,否则使用linkedlist编码:
-
列表对象保存的所有字符串元素的长度都小于 64 字节;
-
列表对象保存的元素数量小于 512 个;
-
list-max-ziplist-value、list-max-ziplist-entries
ziplist和linkedlist区别:
-
列表对象包含的元素比较少时,Redis使用压缩列表作为底层实现,因为压缩列表比双端链表更节约内存,并且在元素数量较少时,在内存中以连续块方式保存的压缩列表比起双端链表可以更快被载入到缓存中。
-
随着列表对象包含的元素越来越多,使用压缩列表来保存元素的优势逐渐消失时,对象就会将底层实现从压缩列表转向功能更强、更适合保存大量元素的双端链表。
(4)常用命令
-
lpush: 从头部加入元素
-
rpush:从尾部加入元素
-
lpop: 从list的头部删除元素,并返回删除的元素
-
rpop:从list的尾部删除元素,并返回删除的元素
-
rpoplpush: 第一步从尾部删除元素,第二步从首部插入元素 结合着使用
-
linsert :插入方法 linsert listname before [集合的元素] [插入的元素]
-
lindex: 返回list中指定位置的元素
-
llen: 返回list中的元素的个数
2.3、哈希对象
哈希对象的编码可以是ziplist或者hashtable。
(1)ziplist
每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾。因此:
-
保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后
-
先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
(2)hashtable
hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:
-
字典的每个键都是一个字符串对象,对象中保存了键值对的键
-
字典的每个值都是一个字符串对象,对象中保存了键值对的值
(3)编码转换
当哈希对象可以同时满足一下两个条件时,哈希对象使用ziplist编码,不能满足这两个条件的哈希对象需要使用hashtable编码
-
哈希对象保存的所有键值对的键和值字符串长度都小于64字节。
-
哈希对象保存的键值对数量小于512个。
-
hash-max-ziplist-value、hash-max-ziplist-entries
(4) 常用命令
-
hset/hget
-
hmset/hmget
-
hsetnx/hgetnx
-
hincrby/hdecrby
2.4、集合对象
集合对象的编码可以是intset或者hashtable
(1)intset
intset 编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。
(2)hashtable
hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL。
(3)编码转换
当集合对象可以同时满足以下两个条件时,对象使用intset编码,否则使用hashtable编码
-
集合对象保存的所有元素都是整数值。
-
集合对象保存的元素数量不超过512个。
-
set-max-intset-entries
(4)常用命令
-
sadd: 向集合中添加元素 (set不允许元素重复)
-
smembers: 查看集合中的元素
-
srem: 删除集合元素
-
spop: 随机返回删除的key
-
sdiff :返回两个集合的不同元素 (哪个集合在前就以哪个集合为标准)
-
sinter: 返回两个集合的交集
-
sinterstore: 返回交集结果,存入目标集合
-
sunion: 取两个集合的并集
-
sunionstore: 取两个集合的并集,并存入目标集合
-
smove: 将一个集合中的元素移动到另一个集合中
-
scard: 返回集合中的元素个数
-
sismember: 判断某元素是否存在某集合中,0代表否 1代表是
-
srandmember: 随机返回一个元素
2.5、有序集合
有序集合对象的编码可以是ziplist或者skiplist
(1)ziplist
每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),第二个元素保存元素的分值(score)。集合元素按分值从小到大进行排序,分值较小的元素靠近表头,而分值较大的元素靠近表尾。
(2)skiplist
skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表:
typedef struct zset {
zskiplist *zsl;
dict *dict;
} zset;
-
zsl 跳跃表按分值从小到大保存了所有集合元素, 每个跳跃表节点都保存了一个集合元素:跳跃表节点的 object 属性保存了元素的成员,score 属性则保存了元素的分值。 通过这个跳跃表,程序可以对有序集合进行范围型操作,比如 ZRANK 、 ZRANGE 等命令。
-
dict 字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,字典的值则保存了元素的分值。通过这个字典,程序可以用 O(1) 复杂度查找给定成员的分值,如 ZSCORE 命令。
-
有序集合每个元素的成员都是一个字符串对象, 而每个元素的分值都是一个 double 类型的浮点数。
(3)编码转化
当有序集合对象可以同时满足以下条件时,对象使用ziplist编码,否则使用skiplist编码:
-
有序集合保存的元素数量小于128个。
-
有序集合保存的所有元素成员的长度都小于64字节。
-
zset-max-ziplist-entries、zset-max-ziplist-value。
(4)为什么有序集合需要同时使用跳跃表和字典来实现?
-
如果只使用字典来实现有序集合, 那么虽然以 O(1) 复杂度查找成员的分值这一特性会被保留, 但是,因为字典以无序的方式来保存集合元素,所以每次在执行范围型操作 —— 比如 ZRANK 、 ZRANGE 等命令时, 程序都需要对字典保存的所有元素进行排序,完成这种排序需要至少 O(N log N) 时间复杂度,以及额外的 O(N) 内存空间(因为要创建一个数组来保存排序后的元素)。
-
如果只使用跳跃表来实现有序集合,那么跳跃表执行范围型操作的所有优点都会被保留,但因为没有了字典,所以根据成员查找分值这一操作的复杂度将从 O(1) 上升为 O(log N) 。
-
值得一提的是,虽然 zset 结构同时使用跳跃表和字典来保存有序集合元素, 但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存。
(5)常用命令
-
zrem: 删除集合中名称为key的元素member
-
zincrby: 以指定值去自动递增
-
zcard: 查看元素集合的个数
-
zcount: 返回score在给定区间中的数量
-
zrangebyscore: 找到指定区间范围的数据进行返回
3、内存优化
3.1、内存消耗
可通过执行info memory命令来获取内存相关指标。
redis进程内消耗主要包括:自身内存、对象内存、缓冲内存、内存碎片,redis空进程自身内存消耗非常少,通常used_memory_rss在3MB左右,used_memory在800KB左右,一个空的redis进程消耗内存可以忽略不计。
(1)对象内存:redis内存占用最大的一块,存储着用户所有的数据,可以简单理解为sizeof(key)+ sizeof(values)。
(2)缓冲内存:主要包括客户端缓冲、复制积压缓冲区、AOF缓冲区。
-
客户端缓冲,指的是所有接入到redis服务器TCP连接的输入输出缓冲,输入缓冲无法控制,最大空间为1G,如果超过将断开连接,输出缓冲区通过client-output-buffer-limit控制。
-
复制积压缓冲区,redis2.8版本之后提供了一个可重用的固定大小缓冲区用于实现部分复制功能,根据repl-backlog-size参数控制,默认是1M。
-
AOF缓冲区,这部分空间用于在redis重写期间保存最近写入命令,此缓冲区空间消耗用户无法控制。
(3)内存碎片:used_memory_rss - used_memory 多出的部分内存并没有用于数据存储,而是被内存碎片所消耗,如果两者相差很大,说明碎片率严重。
当mem_fragmentation_ratio>1时,说明used_memory_rss - used_memory 多出的部分内存并没有用于数据存储,而是被内存碎片所消耗,如果两者相差很大,说明碎片率严重。
当mem_fragmentation_ratio<1时,这种情况一般出现在操作系统把redis内存交换到磁盘导致,出现这种情况时要格外关注,由于硬盘速度远远慢于内存,redis性能会变得很差,甚至僵死。
3.2、内存管理
(1)设置内存上限:redis的内存上限可以通过 config set maxmemory 进行动态修改,Redis默认无限使用服务器内存,为防止极端情况下导致系统内存耗尽,建议所有的Redis进程都要配置maxmemory。
(2)内存回收策略:
Redis的内存回收机制主要体现在以下两个方面:删除到达过期时间的键对象、内存使用达到maxmemory上限时触发内存溢出控制策略。
1)删除过期键对象:
-
惰性删除:惰性删除用于当客户端读取带有超时属性的键时,如果已经超过键设置的过期时间,会执行删除操作并返回空。但是单独用这种方式存在内存泄露的问题,当过期键一直没有访问将无法得到及时删除,从而导致内存不能及时释放。正因为如此,Redis还提供另一种定时任务删除机制作为惰性删除的补充。
-
定时任务删除:Redis内部维护一个定时任务,默认每秒运行10次。定时任务在每个数据库空间随机检查20个键,当发现过期时删除对应的键。如果超过检查数25%的键过期,循环执行回收逻辑直到不足25%或运行超时为止,慢模式下超时时间为25毫秒。如果之前回收键逻辑超时,则在Redis触发内部事件之前再次以快模式运行回收过期键任务,快模式下超时时间为1毫秒且2秒内只能运行1次。快慢两种模式内部删除逻辑相同,只是执行的超时时间不同。
2)内存溢出控制策略:当Redis所用内存达到maxmemory上限时会触发相应的溢出控制策略,Redis支持6种策略:
-
noeviction:默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息(error)OOM command not allowed when used memory,此时Redis只响应读操作。
-
volatile-lru:根据LRU算法删除设置了超时属性的键,直到腾出足够空间为止。如果没有可删除的键对象,回退到noeviction策略。
-
allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。
-
allkeys-random:随机删除所有键,直到腾出足够空间为止。
-
volatile-random:随机删除过期键,直到腾出足够空间为止。
-
volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略。
3.3、内存优化
-
可以使用scan + object idletime命令批量查询哪些键长时间未被访问,找出长时间不访问的健进行清理,可降低内存占用。
-
高并发写入场景中,在条件允许的情况下,建议字符串长度控制在39字节以内,减少创建redisObject内存分配次数从而提高性能。
-
缩小键值长度,在设计key时,在完整描述业务情况下,键值越短越好。
-
共享对象池,Redis内部维护[0-9999]的整数对象池以节省内存。每个redisObject内部结构至少占16字节,甚至超过了整数自身空间消耗。
但当设置maxmemory并启用LRU相关淘汰策略如:volatile-lru,allkeys-lru时,Redis禁止使用共享对象池,因为对象共享意味着多个引用共享同一个redisObject,这时lru字段也会被共享,导致无法获取每个对象的最后访问时间。
5.防止字符串预分配带来的内存浪费
字符串之所以采用预分配的方式是防止修改操作需要不断重分配内存和字节数据拷贝。但同样也会造成内存的浪费。尽量减少字符串频繁修改操作如append,setrange, 改为直接使用set修改字符串,降低预分配带来的内存浪费和内存碎片化。
-
第一次创建len属性等于数据实际大小,free等于0,不做预分配。
-
修改后如果已有free空间不够且数据小于1M,每次预分配一倍容量。
-
修改后如果已有free空间不够且数据大于1MB,每次预分配1MB数据。
6、字符串重构
不一定把每份数据作为字符串整体存储,像json这样的数据可以使用hash结构,使用二级结构存储也能帮我们节省内存,同时可以使用hmget、hmset命令支持字段的部分读取修改,而不用每次整体存取。第一次默认配置下使用hash类型, 内存消耗不但没有降低反而比字符串存储多出2倍,调整配置后hash类型内部编码方式变为ziplist,相比字符串更省内存且支持属性的部分操作。
7.编码优化
转换在Redis写入数据时自动完成,这个转换过程是不可逆的,转换规则只能从小内存编码向大内存编码转换。
-
ziplist在hash、list、zset内存和速度测试
-
使用ziplist可以分别作为hash,list,zset数据类型实现。
-
使用ziplist编码类型可以大幅降低内存占用。
-
ziplist实现的数据类型相比原生结构,命令操作更加耗时,不同类型耗时排序:list < hash < zset。
针对性能要求较高的场景使用ziplist,建议长度不要超过1000,每个元素大小控制在512字节以内。命令平均耗时使用info Commandstats命令获取,包含每个命令调用次数,总耗时,平均耗时,单位微秒。
-
使用intset编码的集合时,要防止个别大整数触发集合升级操作,产生内存浪费。
intset数据结构插入命令复杂度为O(n),查询命令为O(log(n)),由于整数占用空间非常小,所以在集合长度可控的基础上,写入命令执行速度也会非常快,因此当使用整数集合时尽量使用intset编码。
8.控制键的数量
当使用Redis存储大量数据时,通常会存在大量键,过多的键同样会消耗大量内存。对于存储相同的数据内容利用Redis的数据结构降低外层键的数量,也可以节省大量内存。如通过在客户端预估键规模,把大量键分组映射到多个hash结构中降低键的数量。
hash类型节省内存的原理是使用ziplist编码,所以确保不要超过hash-max-ziplist-value限制。如果使用hashtable编码方式反而会增加内存消耗。ziplist适合存储的小对象,对于大对象不但内存优化效果不明显还会增加命令操作耗时。