redis之字典

本文阅读 16 分钟
首页 代码,Java 正文

字典又称散列表,是用来存储键值对的一种数据结构,在很多高级语言中都有实现。但是C语言没有这种数据结构,Redis是K-V型数据库,这个数据库使用字典来存储的,对redis数据库的增、删、改、查,实际就是对字典中的数据进行增、删、改、查。

根绝redis数据库的特点,可知字典有如下特征:

  • 可以存储海量数据,键值对是映射关系,可以根据键以O(1)的时间复杂度取出或插入关联值。
  • 键值对中键的类型可以是字符串、整型、浮点型等,且键是唯一的。
  • 键值对中值的类型可为String,hash,list,set,SortedSet。 我们知道Redis底层是C语言编写的,所以redis中的字典也至少满足以上三个基本特点。当然redis是基于工程的应用,需要考虑的因素会更多,所以其数据结构要比一般的字典要复杂的多。

Redis字典实现依赖的数据结构主要包含了三部分:字典、hash表、hash表节点。字典中嵌入了两个hash表,hash表中的table字段存放着hash表节点,hash表节点对应存储的是键值对。

Hash表

在Redis源码中取名为Hash表,其数据结构如下:

typedef struct dictht {
    dictEntry **table;                        /*指针数组,用于存储键值对*/
    unsigned long size;                        /*table数组的大小*/
    unsigned long sizemask;                    /*掩码=size-1*/
    unsigned long used;                        /*table数组已存元素个数,包含next单链表的数据*/
} dictht;

Hash表的结构体整体占用32字节,其中table字段是数组,作用是存储键值对,该数组中的元素指向的是dictEntry的结构体,每个dictEntry里面存有键值对。size表示able数组的总大小。used字段记录着table数组已存键值对个数。 sizemask字段用来结算键的索引值,sizemask的值恒等于size-1。redis为提高性能对索引值的传统计算进行了优化:

  • Hash表的数组容量初始值为4,随着键值对存储量的增加,就需要对hash表扩容,新扩容的容量大小设定为当前容量大小的一倍,也就是说,hash表的容量大小只能是4,8,16,32…。而sizemask掩码的值只能为3,7,15,31…,对应的二进制为11,111,1111…,因此掩码值的二进制肯定是每一位都为1。
  • 索引值=Hash值&掩码值,而redis源码为,idx=hash&d->ht[table].sizemask,其计算结果等同于键的hash值与hash表容量取余,而计算机的位运算比取余运算快的多。

Hash表节点

Hash表中的元素是用dictEntry结构体来封装的,主要作用是存储键值对。

typedef struct dictEntry {
    void *key;                            /*存储键*/
    union {
        void *val;                        /*db.dict中的val*/
        uint64_t u64;
        int64_t s64;                    /*db.expires中存储过期时间*/
        double d;
    } v;                                /*值,是个联合体*/
    struct dictEntry *next;                /*当Hash冲突时,指向冲突的元素,形成单链表*/
} dictEntry;

Hash表中元素结构体,整体占用24字节,key字段存储的是键值对中的键。v字段是个联合体,存储的是键值对中的值,在不同场景下使用不同的字段。例如,用字典存储整个redis数据库所有键值对时,用的是*val字段,可以指向不同类型的值。再比如,字典被用作记录键的过期时间时,用的是s64字段存储;当出现了hash冲突时,next字段用来指向冲突的元素,通过插头法,形成单链表。 img

字典

Redis字典实现除了包含前面介绍的两个结构体Hash表和Hash表节点外,还在最外层封装了一个叫字典的数据结构,其主要作用是对散列表再进行一层封装,当字典需要进行一些特殊操作时要用到里面的辅助字段。

typedef struct dict {
    dictType *type;                /*该字典对应的特定操作函数*/
    void *privdata;                /*该字典依赖的数据*/
    dictht ht[2];                /*Hash表,键值对存储在此,注意这里表示两个hash表*/
    long rehashidx;             /*rehash标识。默认值-1,代表没进行rehash操作;不为-1时,代表正进行rehash操作,存储的值                        表示hash表ht[0]的rehash操作进行到了哪个索引值*/
    int iterators;                 /* 当前运行的迭代器数*/
} dict;

字典这个结构体整体占用96字节,其中type字段,指向dictType结构体,里面包含了对该字典操作的函数指针。 redis字典这个数据结构,除了主数据库的K-V数据存储外,还有很多其他地方会用到。例如Redis的哨兵模式,就用字典存储管理所有master节点及Slave节点;再如,数据库中键值对的值为hash类型时,存储这个hash类型的值也是用的字典。在不同的应用中,字典中的键值对形态都可能不同,而dictType结构体,则是为了实现各种形态的字典而抽象出来的一组操作函数。 一个完整的Redis字典的数据结构如图: img

字典初始化

在redis-server启动中,整个数据库会先初始化一个空的字典用于存储整个数据库的键值对。

字典添加元素

redis-server启动完后,再启动redis-cli连上server,添加元素需要经历一下几步:

  • 因为字典中键必须是唯一的,所以需要先调dictFind函数查询键是否存在,存在则调用dbOverwrite函数修改键值对,否则调用dbAdd函数添加元素
  • dbAdd最终调用dict.h文件中的dictAdd函数插入键值对 dictAdd函数的主要执行步骤
    - 调用dictAddRaw函数,添加键,字典中已存在则返回NULL,否则添加键至Hash表中,并返回新加的Hash节点。 - 给返回的新节点设置值,即更新其val字段。 - 如果通过dictAddRaw函数发现键已存在dictAdd函数中直接返回错误状态码,如果键不存在,则插入值,然后返回成功状态码。 dictAddRaw函数主要作用是添加或查找键,添加成功返回新节点entry,查询成功返回NULL并把老节点存入existing字段。 从dictAddRaw函数源码中可以看到拿到键的索引值后则可直接定位“键值对”要存入的位置,新创建一个节点存入即可。

字典查找元素

查找元素过程:

  • 根据键调用Hash函数取得其hash值
  • 根据hash值得到索引值
  • 遍历字典的两个hash表,读取索引对应的元素
  • 遍历该元素单链表,如找到了与自身匹配的键,则返回该元素
  • 找不到返回null

字典修改元素

修改元素过程:

  • 调用dictFind查找键是否存在
  • 不存在则中断执行
  • 修改节点键值对中的值为新值
  • 释放旧值内存

字典删除元素

删除元素过程:

  • 查找改键是否存在于该字典中
  • 存在则把该节点从单链表中剔除
  • 释放该节点对应键占用的内存、值占用的内存,以及本身占用的内存
  • 给对应的Hash表的used字典减1操作

字典遍历元素

遍历redis数据库主要有两种方式:

  • 全遍历:一次命令执行就遍历完整个数据库
  • 间断遍历:每次命令执行只取部分数据,分多次遍历

迭代器遍历数据分为两类:

  • 普通迭代器:只遍历数据 普通迭代器迭代字典中数据时,会对迭代器中fingerprint字段(它是一个64位的整数,标识在给定时间内字典的状态。之所以成为字典的指纹,是因为它的值为字典中所有字段值组合在一起生成的Hash值)的值做严格的校验,来保证迭代过程中字典结构不发生任何变化,确保读取出的数据不出现重复。 普通迭代器通过遍历开始前生成的指纹和遍历后生成的指纹进行对比,来限制整个迭代过程中只能进行迭代操作,即迭代过程中字典数据的修改、添加、删除、查找等操作都不能进行,只能调用dictNext函数迭代整个字典,否则就报异常,由此来保证迭代器取出数据的准确性。 例如:sort命令
  • 安全迭代器:遍历的同时删除数据 安全迭代器确保读取数据的准确性,不是通过限制字典的部分操作来实现的,而是通过限制rehash的进行来确保数据的准确性,因此迭代过程中可以对字典进行增删改查等操作。 例如:keys命令 但是无论普通迭代器还是安全迭代器,如果redis中有海量数据时,这种全遍历操作很容易导致redis数据短暂不可用,所以redis2.8.0之后新增了scan操作,也就是“间断遍历”。而dictScan是“间断遍历”中的一种实现,主要在迭代字典中数据时使用,例如:hscan命令迭代整个数据库中的key,以及Zscan命令迭代有序集合所有成员与值时,都是通过dictScan函数来实现的字典遍历。dictScan遍历字典过程中可以进行rehash操作的,通过算法来保证所有的数据能被准确遍历到

redis添加元素时,在插入新节点之前会判断字典容量是否充足,达到上限,此时就需要对字典的Hash表进行扩容。扩容过程如下:

  • 申请一块新内存,初次申请默认容量大小为4个dictEntry;非初次申请时,申请内存的大小则为当前Hash表容量的一倍
  • 把新申请的内存地址赋值给h[1],并把字典的rehashidx标识由-1改为0,表示之后需要进行rehash操作

什么是rehash

所谓rehash就是将老的hash表h[0]中的数据重新计算索引值后全部迁移插入到新的hash表(ht[1])中的过程。

rehash的过程

redis进行rehash时不是一次性操作,而是采用渐进式rehash,主要是防止当redis数据库中数据量比较大时,一次性操作可能会导致redis暂时不可用,毕竟redis是单线程的。所谓渐进式rehash就是将rehash的过程分多次进行。 需要注意的是rehash过程除了扩容时会触发缩容时也会触发,并且rehash的过程是针对字典而言的,SDS的扩容是没有rehash可言的!

具体rehash过程如下:

  • 给hash表ht[1]申请足够的空间;扩容时空间为当前容量*2;当使用量不到总空间10%时,则进行缩容。缩容时空间大小则为能恰好包含ht[0]表中used个节点的2的N次方整数,并把字典的rehashidx标识为0。
  • 进行rehash操作调用的函数是dictRehash函数,重新计算ht[0]表中每个键的hash值与索引值(重新计算就叫rehash),依次添加到新的hash表ht[1],并把老hash表中该键值对删除。把字典中字段rehashidx字段修改为hash表ht[0]中正在进行rehash操作节点的索引值。
  • rehash操作后,清空ht[0],然后对调一下ht[1]与ht[0]的值,并把字典中的rehashidx字段标识为-1

上面已经说过redis的rehash过程不是一次完成,而是利用分而治之的思想进行的渐进式rehash,大致步骤如下:执行插入、删除、查找、修改等操作前,都先判断当前字典rehash操作是否在进行中,进行中则进行rehash操作(每次只对一个节点进行rehash操作,共执行一次)。除这些操作外,当服务空闲时,如果当前字典也需要进行rehash操作这会进行批量rehash操作(每次对100个节点进行rehash操作,共执行1毫秒)。在经历N次rehash操作后,整个ht[0]的数据都会迁移到ht[1]中。这样做的好处是本应集中处理的时间分散到几万甚至上百万的操作中,所以耗时可忽略不计。 需要注意的是在rehash过程中,新添加的键值对都往新Hash表中存储;而修改、删除、查找操作需要在ht[0]、ht[1]中进行检查,然后再决定去对哪个Hash表操作。

整个缩容的步骤大致为:每次删除元素时,判断当前的容量是否达到最低阈值,即used/size<10%,达到了则调用dictResize函数进行缩容,缩容后的函数容量实质为used的最小2的N次方整数。

redis之详解

本文为互联网自动采集或经作者授权后发布,本文观点不代表立场,若侵权下架请联系我们删帖处理!文章出自:https://blog.csdn.net/qq_38571892/article/details/115485529
-- 展开阅读全文 --
Web安全—逻辑越权漏洞(BAC)
« 上一篇 03-13
Redis底层数据结构--简单动态字符串
下一篇 » 04-10

发表评论

成为第一个评论的人

热门文章

标签TAG

最近回复