Redis 高级篇 Part 1
😉 本文共6413字,阅读时间约12min
Redis 底层数据结构
动态字符串SDS
我们都知道Redis中保存的Key是字符串,value往往是字符串或者字符串的集合。可见字符串是Redis中最常用的一种数据结构。
为什么不使用C语言中字符串?
- 获取字符串长度的需要通过运算
- 非二进制安全
- 不可修改(用字符串常量给其赋值,指针就会指向不可修改的常量区)
SDS结构
-- Redis 会在底层创建两个SDS,key和value各一个
set name 虎哥
-
SDS结构体代码
header(长度 + 申请字节数 + sdshdr头类型)+ 数据(char数组)
-
SDS图例
动态扩容
SDS之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为“hi”的SDS:
- 假如我们要给SDS追加一段字符串“,Amy”,这里首先会申请新内存空间:
- 如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;
- 如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。称为内存预分配。
Intset
IntSet结构
IntSet是Redis中set集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征。
结构如下:
header(编码方式 + 元素个数) + 数据(int数组)
其中的encoding包含三种模式,表示存储的整数大小不同,对应的范围也不同。
升序排列 + 二分查找 + 类型升级
-
为了方便查找,Redis会将intset中所有的整数按照升序依次保存在contents数组中,结构如图:
-
我们向该其中添加一个数字:50000,这个数字超出了int16_t的范围,intset会自动升级编码方式到合适的大小。以当前案例来说流程如下:
- 升级编码到32位,重置数组大小
- 倒序将旧元素以新编码方式拷贝到新位置,新元素大于零,插入数组尾部
-
源码如下:
- 判断当前插入数字是否超出编码范围
- 未超出,二分查找是否有一样的数字
- 有,不插入
- 无,插入并进行数组扩容。移动pos之后元素到pos+1,给新元素腾出空间,重置长度
- 超出
- 升级编码,重置数组大小
- 倒序遍历旧元素以新编码方式拷贝到新位置,新元素<0插入数组头,>0插入数组尾
小总结
Intset可以看做是特殊的整数数组,具备一些特点:
- Redis会确保Intset中的元素唯一、有序
- 具备类型升级机制,可以节省内存空间
- 底层采用二分查找方式来查询
Dict
我们知道Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。
Dict结构
- 类似java的HashTable,底层是数组加链表来解决哈希冲突,没有红黑树
- Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash
添加元素
- Redis首先根据key计算出hash值(h)
- 然后利用 h & sizemask来计算元素应该存储到数组中的哪个索引位置。
- 我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组角标1位置。
- 然后看是否有跟他相等的,则替换
- 没有相等的,头插法
扩容:渐进型hash
- Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。
- Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size) ,满足以下两种情况时会触发哈希表扩容:
- 哈希表LoadFactor >= 1,且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程;
- 哈希表LoadFactor > 5 ;
- 收缩:当LoadFactor小于0.1时,Dict收缩
- 扩容还是找下一个2^n
- 渐进性rahash
- 不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash。
- 过程是这样的:
- 计算新hash表的realSize,值取决于当前要做的是扩容还是收缩:
- 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的2^n
- 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n (不得小于4)
- 按照新的realSize申请内存空间,创建dictht,并赋值给dict.ht[1]
- 设置dict.rehashidx = 0,标示开始rehash
- 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]
- 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存
- 将rehashidx赋值为-1,代表rehash结束
- 计算新hash表的realSize,值取决于当前要做的是扩容还是收缩:
- rehash过程,rehash时ht[0]只减不增,新增操作只在ht[1]执行,其它操作在两个哈希表。
整个过程可以描述成:
感觉这个和一些JVM的垃圾收集算法中的新生代的survivor0、survivor1的工作流程有一点类似
如何体现渐进性?
如果一次性把哈希表1的大量数据拷贝到哈希表2,那么会造成Redis线程阻塞。
渐进式,顾名思义,就是不一次性将所有的数据进行转移,而是一步一步的进行转移,直到最后全部完成。避免了一次性大批量的在两个全局hash表之间拷贝数据造成线程阻塞。
每次访问Dict时执行一次rehash:在上述第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。
ZipList
ZipList结构
ZipList 压缩列表是一种特殊的“双端链表” ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)。
极致压缩,redis设计中想方设法省内存的一种体现(之前体现就是sds,intset分了好多种类型)。
总字节数 + 尾偏移量 + 节点个数 + entry顺序存储 + 结束标识
ZipList Entry的结构
列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低
ZipList 中的Entry并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存。而是采用了下面的结构:
- previous_entry_length:前一节点的长度,占1个或5个字节。
- 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
- 如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
- encoding:编码属性,记录content的数据类型(字符串还是整数)以及本节点长度,占用1个、2个或5个字节
- contents:负责保存节点的数据,可以是字符串或整数
问题
- 如果列表数据过多,导致链表过长,可能影响查询性能
- 增或删较大数据时有可能发生连续更新问题
- ZipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低
ZipList的连锁更新问题
连锁更新是由于previous_entry_length造成的。
现在考虑这样一种情况:在一个压缩列表中,有多个连续的,长度介于250字节到253字节之间的节点e1-eN,如下图:
因为e1到eN的长度都介于250字节到253字节之间,所以previous_entry__length均为1字节。
现在将一个长度大于254字节的节点插入到e1前面,如下图:
因为newE的长度大于254字节,所以他后面的e1的previous_entry__length长度需要从1字节变为5字节。但是由于e1原本的长度为250字节到253字节之间,这样子扩展后,长度必然超过254字节,就会导致后面的e2的previous_entrylength也需要改变…以此类推
后面的节点previous_entry__length都需要扩展。Redis将这种特殊情况下产生的连续多次空间扩展操作称之为"连锁更新"。
除了插入节点会发生"连锁更新"。删除节点也会,如下图:
假设small及后面的节点的长度都介于250-253字节,big长度大于254字节,现在把samll删除了,导致e1的previous_entry__length需要扩展,后面依此类推,这样就会发生"连锁更新"。
因为连锁更新在最坏的情况下需要对压缩列表执行N次空间重分配,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2).
造成连锁更新的概率是很低的,因为压缩列表恰好有多个连续的、长度介于250~253之间的节点,连续更新才有可能被触发,实际情况下出现的概率很低其次,出现连锁更新时,只要被更新的节点数量不多,也不会造成性能问题
QuickList
引入原因
问题1:ZipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低。怎么办?
答:为了缓解这个问题,我们必须限制ZipList的长度和entry大小。
问题2:但是我们要存储大量数据,超出了ZipList最佳的上限该怎么办?
答:我们可以创建多个ZipList来分片存储数据。
问题3:数据拆分后比较分散,不方便管理和查找,这多个ZipList如何建立联系?
答:Redis在3.2版本引入了新的数据结构QuickList,它是一个双端链表,只不过链表中的每个节点都是一个ZipList。
为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制。
如果值为正,则代表ZipList的允许的entry个数的最大值
如果值为负,则代表ZipList的最大内存大小
QuickList结构
双向链表,只不过每一个节点都保存了一个ziplist的头指针
特点
- 是一个节点为ZipList的双端链表
- 节点采用ZipList,解决了传统链表的内存占用问题
- 控制了ZipList大小,解决连续内存空间申请效率问题
- 中间节点可以压缩,进一步节省了内存,因为频繁访问的经常只是头节点和尾节点
SkipList
跳表结构
- SkipList(跳表)首先是双向链表,但与传统链表相比有几点差异:
- 元素按照升序排列存储
- 节点可能包含多个指针,指针跨度不同。
- 前一个节点指针只有跨度为1的一个
- 后一个是一个数组,保存多个下一个节点指针和指针跨度
特点
- 跳跃表是一个双向链表,每个节点都包含score和element值
- 节点按照score值排序,score值一样则按照element字典排序
- 每个节点都可以包含多层指针,层数是1到32之间的随机数
- 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
- 增删改查效率与红黑树基本一致,实现却更简单
- 随着我们层数的增加,查询的效率接近二分查找的O(logn)
设计跳表
Redis Object
Redis中的任意数据类型的键和值都会被封装为一个RedisObject,也叫做Redis对象
出现原因
从Redis的使用者的角度来看,⼀个Redis节点包含多个database(非cluster模式下默认是16个,cluster模式下只能是1个),而一个database维护了从key space到object space的映射关系。
- 这个database存了两个dict
- 一个是key-value,用来存数据的。
- 这个映射关系的key是string类型,⽽value可以是多种数据类型,比如:string, list, hash、set、sorted set等。我们可以看到,key的类型固定是string,而value可能的类型是多个。
- 还有一个是key-ttl,用来内存回收的。
- 一个是key-value,用来存数据的。
⽽从Redis内部实现的⾓度来看,database内的这个映射关系是用⼀个dict来维护的。dict的key固定用⼀种数据结构来表达就够了,这就是动态字符串sds。而value则比较复杂,为了在同⼀个dict内能够存储不同类型的value,这就需要⼀个通⽤的数据结构,这个通用的数据结构就是robj,全名是redisObject。
type表示应用层数据结构
encoding表示用来实现的编码,对应底层不同的数据结构
LRU表示最后一次访问时间,回收用
refcount表示对象引用计数器,为0表示无人引用,可以回收
ptr指向存放实际数据的空间
五种应用层数据结构对应编码实现
Redis中会根据存储的数据类型不同,选择不同的编码方式。每种数据类型的使用的编码方式如下:
数据类型 | 编码方式 |
---|---|
OBJ_STRING | int、embstr、raw |
OBJ_LIST | LinkedList和ZipList(3.2以前)、QuickList(3.2以后) |
OBJ_SET | intset、HT |
OBJ_ZSET | ZipList、HT、SkipList |
OBJ_HASH | ZipList、HT |
Redis应用层数据结构
String
raw+embstr+int
robj+ptr+sds
如何实现
- String在Redis中是一个Redis Object,其encoding可能有三种:
- raw,当字符串大于44字节时,存储上限为512mb
- SDS的数据量变多变大了,SDS和RedisObject布局分家各自过
- raw类型将会调用两次内存分配函数,分配内存空间,一块用于包含redisObject结构,而另一块用于包含sdshdr结构。
- embstr,小于44字节,则会采用EMBSTR编码
- 只分配一块连续的内存空间,空间中依次包含的redisObject与sdshdr两个数据结构。
- 只需要调用一次内存分配函数,效率更高。让元数据、指针和SDS是一块连续的内存区域,这样就可以避免内存碎片。足够小,可以找到这样的连续内存。
- int,如果存储的字符串是整数值,并且大小在LONG_MAX范围内,则会采用INT编码。直接将数据保存在RedisObject的ptr指针位置(刚好8字节),不再需要SDS了,省内存。
- raw,当字符串大于44字节时,存储上限为512mb
- raw和embstr都是用sds存储,int直接把string存成了long类型,保存在robj的指针位置
图例
- raw
- embstr
- int
List
双端列表,Redis的List类型可以从首、尾操作列表中的元素:
如何实现
哪一个数据结构能满足上述特征?
- LinkedList :普通链表,可以从双端访问,内存占用较高,内存碎片较多
- ZipList :压缩列表,可以从双端访问,内存占用低,存储上限低
- QuickList:LinkedList + ZipList,可以从双端访问,内存占用较低,包含多个ZipList,存储上限高
Redis的List结构类似一个双端链表,可以从首、尾操作列表中的元素:
在3.2版本之前,Redis采用ZipList和LinkedList来实现List,当元素数量小于512并且元素大小小于64字节时采用ZipList编码,超过则采用LinkedList编码(找不到那么大内存了)。
在3.2版本之后,Redis统一采用QuickList来实现List:
Set
Set是Redis中的单列集合,满足下列特点:
- 不保证有序性
- 保证元素唯一
- 求交集、并集、差集
如何实现
可以看出,Set对查询元素的效率要求非常高,思考一下,什么样的数据结构可以满足?
- Dict,key用来存储元素,value统一为null。
- IntSet,当存储的所有数据都是整数,并且元素数量不超过set-max-intset-entries时,Set会采用IntSet编码,以节省内存。
结构如下:
Hash
如何实现?
- 数据量较少时,用压缩列表ziplist进行存储数据。
- 默认采用ZipList编码,用以节省内存。
- ZipList中相邻的两个entry 分别保存field和value。
- 数据量较多或任意entry太大时,转为dict存储
为什么不完全用ziplist?
- ziplist数据量过多时,需要遍历,查找效率低
- 数据量过多时,插入删除更大概率造成连锁更新
Zset
很像hash,(member,score),member唯一,能根据member查询分数
但要能按score排序。
如何实现
数据量大
- SkipList:可以排序,并且可以同时存储score和ele值(member)
- Dict:可以键值存储,并且可以根据key找value
各存一份。。。。
数据量小
- ZipList,当元素数量不多时,HT和SkipList的优势不明显,而且更耗内存。因此zset还会采用ZipList结构来节省内存。同样元素数量和最大元素大小有要求,否则转为上面两个。
- 连续内存,因此score和element是紧挨在一起的两个entry, element在前,score在后
- score越小越接近队首,score越大越接近队尾,按照score值升序排列
Redis 内存回收
当内存使用达到上限时,就无法存储更多数据了。为了解决这个问题,Redis提供了一些策略实现内存回收。
内存过期策略(过期Key处理)
expire k1 5,当k1的TTL到期以后,再次访问k1返回的是nil,说明这个key已经不存在了。
但这部分内存何时回收?
Redis之所以性能强,最主要的原因就是基于内存存储。然而单节点的Redis其内存大小不宜过大,会影响持久化或主从同步性能。我们可以通过修改配置文件来设置Redis的最大内存:maxmemory 1gb
- Redis database结构体中,有两个Dict:一个用来记录key-value;另一个用来记录key-TTL。
惰性删除
惰性删除:并不是在TTL到期后就立刻删除,而是在访问一个key的时候,检查该key的存活时间,如果已经过期才执行删除。
周期删除
通过一个定时任务,周期性的抽样部分过期的key,然后执行删除。
-
执行周期有两种:
- Slow模式:Redis服务初始化函数initServer()中设置定时任务serverCron(),按照server.hz的频率来执行过期key清理,模式为SLOW。低频高时长。
- Fast模式:Redis的每个事件循环前会调用beforeSleep()函数,执行过期key清理,模式为FAST。高频低时长,避免阻塞线程。
-
SLOW模式规则:
- 执行频率受server.hz影响,默认为10,即每秒执行10次,每个执行周期100ms。
- 执行清理耗时不超过一次执行周期的25%.默认slow模式耗时不超过25ms
- 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期。如果没达到时间上限(25ms)并且过期key比例大于10%,再进行一次抽样,这是一个循环,否则结束
-
FAST模式规则(过期key比例小于10%不执行 ):
- 执行频率受beforeSleep()调用频率影响,但两次FAST模式间隔不低于2ms(不同点)
- 执行清理耗时不超过1ms(不同点)
- 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期。如果没达到时间上限(1ms)并且过期key比例大于10%,再进行一次抽样,否则结束
总结
-
RedisKey的TTL记录方式:在RedisDB中通过一个Dict记录每个Key的TTL时间
-
过期key的删除策略:
- 惰性清理:每次查找key时判断是否过期,如果过期则删除
- 定期清理:定期抽样部分key,判断是否过期,如果过期则删除。
-
定期清理的两种模式:
- SLOW模式执行频率默认为10,每次不超过25ms
- FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms
内存淘汰策略
什么是内存淘汰?
内存淘汰:当Redis内存使用达到设置的上限时,主动挑选部分key删除以释放更多内存的流程。
Redis会在处理客户端命令的方法processCommand()中尝试做内存淘汰:
8种淘汰策略
- noeviction: 不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。
- volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
- allkeys-random:对全体key ,随机进行淘汰。也就是直接从db->dict中随机挑选
- volatile-random:对设置了TTL的key ,随机进行淘汰。也就是从db->expires中随机挑选。
- allkeys-lru: 对全体key,基于LRU算法进行淘汰
- volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰
- allkeys-lfu: 对全体key,基于LFU算法进行淘汰
- volatile-lfu: 对设置了TTL的key,基于LFI算法进行淘汰
- volatile针对设置ttl的,allkeys针对全体key
- random随机、lru、lfu、ttl
- noeviction 不淘汰策略
比较容易混淆的有两个:
- LRU(Least Recently Used),最少最近使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
- LFU(Least Frequently Used),最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。
Redis的数据都会被封装为RedisObject结构:
LFU的访问次数之所以叫做逻辑访问次数,是因为并不是每次key被访问都计数,而是通过运算:
- 生成0~1之间的随机数R
- 计算 (旧次数 * lfu_log_factor + 1),记录为P
- 如果 R < P ,则计数器 + 1,且最大不超过255
- 访问次数会随时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟,计数器 -1
最后用一副图来描述当前的这个流程吧