Redis存储结构介绍
一、什么是Redis?
Redis全称是Remote Dictionary Server(远程字典服务),是一个开源的、使用C语言编写、可基于内存也可以持久化的Key-Value数据库。
二、Redis数据结构
Redis有以下这五种基本类型:
- String(字符串)
- Hash(哈希)
- List(列表)
- Set(集合)
- zset(有序集合)
它还有四种特殊的数据结构类型(不作详细解释):
-
Geospatial( 一个球面数据结构,可直接计算两个经纬度之间的距离等等)
-
Hyperloglog(提供了—种不太精确的基数统计方法,用来统计—个集合中不重复的元素个数,比如统计网站的UV,或者应用的日活、 月活,存在—定的误差。在 Redis 中实现的 Hyperloglog, 只需要12K内存就能统计2A 64个数据)
-
Bitmap( Bitmaps是在字符串类型上面定义的位操作。 —个字节由8个二进制位组成)
-
Streams( 5.0 推出的数据类型。支持多播的可持久化的消息队列,用于实现发布订阅功能, 借鉴了kafka的设计。)
三、Redis存储结构(数组+链表)
Redis采用了哈希表作为最底层的数据存储结构, 在Redis中,hashtable被称为字典(dictionary) 。的。而在Hashtable中,又对RedisObject(存储实际值的对象)进行了多层的封装。
关系结构图
RedisObject对象
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 对象最后一次被访问的时间
unsigned lru:
// 引用计数
int refcount;
// 指向实际值的指针
void *ptr;
} robj;
(1)type属性
type主要存储当前value对象的数据类型,如:
-
String(字符串)
-
Hash(哈希)
-
List(列表)
-
Set(集合)
-
zset(有序集合)
(2)enconding属性
存储当前值对象底层编码的实现方式,不同type对象对应不同的编码。
编码转换:
(3)lru属性
lru记录此对象最后一次访问的时间。
当redis内存回收算法设置为volatile-lru或者allkeys-lru时候redis会优先释放最久没有被访问的数据。
(4)refcount属性
用于共享计数,类似于jvm的引用计数垃圾回收算法,当refcount为0时,表示没有其它对象引用,可以进行释放此对象。
(5)ptr 指针属性
ptr 指针是指向对象的底层实现数据结构
哈希算法定位索引:
假如我们现在模拟将 hash值从0到5的哈希表节点 放入 size为4的哈希表数组 中,也就是将包含键值对的哈希表节点放在哈希表数组的指定索引上。
索引
index = hash & ht[x].sizemask, 哈希表大小掩码sizemark属性的值总是等于size-1,size是hash表大小
比如上面 hash值为0的节点,0 % 4 = 0,所以放在索引0的位置上,
hash值为1的节点,1 % 4 = 1,所以放在索引1的位置上,
hash值为5的节点,5 % 4 = 1,也等于1,也会被分配在索引1的位置上,并且因为dictEntry节点组成的链表没有指向链表表尾的指针,所以会将新节点添加在链表的表头位置,排在已有节点的前面。
我们把上面索引相同从而形成链表的情况叫键冲突,而且因为形成了链表!那么就意味着查找等操作的复杂度变高了!
例如你要查找hash=1的节点,你就只能先根据hash值找到索引为1的位置,然后找到hash=5的节点,再通过next指针才能找到最后的结果,也就意味着键冲突发生得越多,查找等操作花费的时间也就更多。
如果解决键冲突?
其实rehash操作很好理解,可以简单地理解为哈希表数组扩容或收缩操作,即将原数组的内容重新hash放在新的数组里。
比如还是上面的数据,我们这次把它们放在 size等于8的哈希表数组 里。
如下图,此时size = 8,hash为5的键值对,重新计算索引:5 % 8 = 5,所以这次会放在索引5的位置上。
那么假如我们还要找hash=1的节点,因为没有键冲突,自然也没有链表,我们可以直接通过索引来找到对应节点。
可以看到,因为rehash操作数组扩容的缘故,键冲突的情况少了,进而我们可以更高效地进行查找等操作。
rehash
typedef struct dict {
//类型特定函数
//是一个指向dictType结构的指针,可以使dict的key和value能够存储任何类型的数据
dictType *type;
//私有数据
//私有数据指针,不是讨论的重点,暂忽略
void *privdata;
//哈希表
dictht ht[2];
//rehash 索引
//当 rehash 不在进行时,值为 -1
int rehashidx;}
重点关注两个属性就可以:
- ht 属性:
可以看到ht属性是一个 size为2 的 dictht哈希表数组,在平常情况下,字典只用到 ht[0],ht[1] 只会在对 ht[0] 哈希表进行rehash时才会用到。
- rehashidx 属性:
它记录了rehash目前的进度,如果现在没有进行rehash,那么它的值为-1,可以理解为rehash状态的标识。 触发rehash操作的条件:
首先我们先引入一个参数,叫做负载因子(load_factor),它是一个会动态变化的参数,等于哈希表的 used属性值/size属性值,也就是 实际节点数/哈希表数组大小。
假如一个size为4的哈希表有4个哈希节点,那么此时它的负载因子就是1;size为8的哈希表有4个哈希节点,那么此时它的负载因子就是0.5。
满足下面任一条件,程序就会对哈希表进行rehash操作:
- 扩容操作条件:
-
- 服务器目前**没有执行 **BGSAVE 或者 BGREWRITEAOF 命令,负载因子大于等于1。
- 服务器目前正在执行 BGSAVE 或者 BGREWRITEAOF 命令,负载因子大于等于5。
- 收缩操作条件:
-
- 负载因子小于0.1时。(2/30<0.1)
BGSAVE 和 BGREWRITEAOF 命令可以统一理解为redis的实现持久化的操作。
-
BGSAVE 表示通过fork一个子进程,让其创建RDB文件,父进程继续处理命令请求。
-
BGREWRITEAOF 类似,不过是进行AOF文件重写。
渐进式rehash:
首先我们知道redis是单线程,并且对性能的要求很高,但是rehash操作假如碰到了数量多的情况,比如需要迁移百万、千万的键值对,庞大的计算量可能会导致服务器在一段时间里挂掉!
为了避免rehash对服务器性能造成影响,redis会分多次、渐进式地进行rehash,即渐进式rehash。
对哈希表进行渐进式rehash的步骤如下:
-
首先为 ht[1] 哈希表分配空间,size的大小取决于要执行的操作,以及 ht[0] 当前的节点数量(即ht[0]的used属性值):
-
- 扩展操作,ht[1]的size值为第一个大于等于ht[0].used属性值乘以2的 2^n(如果 ht[0].used 当前的值为 4 , 4 * 2 = 8 , 而 8 (2^3)恰好是第一个大于等于 8 的 2 的 n 次方, 所以程序会将 ht[1] 哈希表的大小设置为 8 。)
- 收缩操作,ht[1]的size值为第一个小于ht[0].used属性值的 2^n(如果 ht[0].used 当前的值为 8, 而 4(2^3)恰好是第一个小于8 的 2 的 n 次方, 所以程序会将 ht[1] 哈希表的大小设置为 4。)
-
将哈希表的rehashidx值从-1置为0,表示rehash工作开始。
-
节点转移,重新计算键的hash值和索引值,再将节点放置到ht[1]哈希表的对应索引位置上。
-
每次rehash工作完成后,程序会将rehashidx值加一。
注:这里的每次rehash就指渐进式rehash
- 当ht[0]的所有节点都转移到ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白的hash表,等待下次rehash再用到。(其实就是数据转移到ht[1]后,再恢复为 ht[0]储存实际数据,ht[1]为空白表的状态)
- 最后程序会将rehashidx的值重置为-1,代表rehash操作已结束。