Redis数据结构之字典

1、说明

当我们使用 Redis 的 Hash 操作时,底层的实现就是字典。

在介绍字典之后,我们先回忆一下 Redis 中的 Hash 操作。最常用的就是 HSET 和 HGET 了

127.0.0.1:6379> HSET user name sherlock(integer) 1127.0.0.1:6379> HSET user age 20(integer) 1127.0.0.1:6379> HGET user name"sherlock"127.0.0.1:6379> HGET user age"20"127.0.0.1:6379>

除了 HSET 和 HGET 外的常见指令还有:HDEL、HEXISTS、HGETALL、HMGET 等等,这里就不一一列举了,Redis 的 Hash 操作一般都是以 H 开头的。

我们可以看到,Hash 操作可以保存很多组键值对,其底层的视线就是字典

2、dict

字典的定义在源码目录下 src/dict.h 文件中,为了便于理解,我们从最基本的结构往上介绍

2.1、dictEntry

首先是 dictEntry,它表示字典中的一组键值对,声明如下:

typedef struct dictEntry {    void *key;//键    union {        void *val;        uint64_t u64;        int64_t s64;        double d;    } v;//值    struct dictEntry *next;//指向下个键值对} dictEntry;
  • key 表示的键值对的键;

  • v 表示键值对的值,v 是一个共同体,表示这里的值类型可以是指针、uint64_t、int64_t 和 double 其中之一,用共同体可以节约内存;

  • dictEntrynext 指向下一组键值对,这里是链表,当需要存储的键值对最后计算得到的存储的位置索引出现重复的时候,就使用链表,将多个键值对存在一个数组元素中,而且,Redis 中,新数据会存储在链表的最前面

2.2、dictht

dictht 即为 Redis 操作时的值结构,用于保存多组的键值对,声明如下:

typedef struct dictht {    dictEntry **table;//键值对数组,数组的元素是个链表    unsigned long size;//键值对数组的大小    unsigned long sizemask;//掩码,用于计算索引    unsigned long used;//键值对数量} dictht;
  • table 是一个 dictEntry 类型的数组指针,它的每个元素都是指针,都指向一个 dictEntry 类型;

  • size 表示键值对数组的大小;

  • sizemask 为掩码,用于计算键值对插入时的数组索引,它总是 size - 1,后面会再次说到;

  • used 表示当前哈希表存储的键值对的数量;

下图是一个 dictht 的存储结构,k0和k1的键值计算的索引相同,所以放在一个数组元素中:

2.3、dict

dict 是最终的字典的数据结构,声明如下:

typedef struct dictType {    unsigned int (*hashFunction)(const void *key);    void *(*keyDup)(void *privdata, const void *key);    void *(*valDup)(void *privdata, const void *obj);    int (*keyCompare)(void *privdata, const void *key1, const void *key2);    void (*keyDestructor)(void *privdata, void *key);    void (*valDestructor)(void *privdata, void *obj);} dictType;typedef struct dict {    dictType *type;    void *privdata;    dictht ht[2];    long rehashidx; /* rehashing not in progress if rehashidx == -1 */    int iterators; /* number of iterators currently running */} dict;
  • dictType 是一组操作函数指针,用于操作特定类型的键值对,Redis 会为不同类型的键值对设置不同类型的函数;

  • privdata 表示需要传递给操作函数的特定私有数据;

  • ht 是一个 dictht 类型的数组,有两个元素,之所以两个,是因为需要rehash,后面会再次说到;

  • rehashidx 表示 rehash 进度,-1表示当前并没有进行 rehash;

  • iterators 表示当前运行的迭代器数量,本次不做特别说明;

下图表示一个没有在rehash的字典

3、字典的存储方法

Redis 中的哈希操作,顾名思义,存储方法肯定和哈希算法有关,这里先简单介绍一下哈希算法。

3.1、哈希算法

哈希算法又称之为散列函数,它把任意长度的输入,通过一系列的算法, 变换成固定长度的输出。

Redis 底层使用的哈希算法是 MurmurHash 算法,最初由 Austin Appleby 在2008 年发明,其优势在于,无论输入是否有规律,输出都是随机分布的,而且速度很快。

其实了解哈希的同学都能够明白,用有限的hash值来表示无限的数据,肯定会出现不同的数据得出重复的哈希值的问题,当然,专业的说法不叫重复,叫 碰撞。

3.2、存储过程和键冲突

更多可以访问https://www.xiaoyuani.com/,回到正题,现在当一个键值对添加到字典中,会先计算出当前键值对需要存储在 table 中的 index,计算分两步,先根据键计算hash值,再根据hash值和掩码计算index

hash = dict->type->hashFunction(key);//根据键计算其hash值//根据hash值和掩码计算索引,ht[x]可能会是h[0],也可能是h[1],根据rehash来定index = hash & dict->ht[x].sizemask;

首先,对于不同的key,这里计算的hash值可能相同,其次,不同的hash值经过和掩码取&,会出现相同的index,这也是使用链表的原因,在 Redis 中称之为键发生了冲突(collision)

我们前面说到,sizemask 总是 size - 1,即数组的最大索引,hash & sizemask 就保证得出的 index一定是一个小于等于 sizemask 的值,即一定在数组内

计算错 index 之后,就把该键值对保存到 table 对应的位置,table 的元素都是链表,新插入的键值对,会保存在链表的最前端,这样效率最高,时间复杂度为 O(1),如果保存在最后端,那么时间复杂度为O(N)。反正放在最前端和最后端都一样,就取一个插入最快的吧。

这样存储完成之后,我们取数据也就是要在数组和链表中取,时间复杂度也就是 O(1) + O(N),这里的N越小越好,即链表越小越好,最好没有键冲突,那么时间复杂度就是O(1)

3.3、rehash

rehash 即对hash表进行扩展和收缩。

这个操作是非常又必要的,可以想象一下,假设一开始创建的 dictEntry 数组的大小只有100个,结果随着时间的推移,保存的键值对慢慢变多,变成五六百个,那么,键冲突的概率就会成倍地增加,最后就会导致个别数组内的链表元素有多个,这样就大大地增加了读取的效率,此时就很有必要对原先只有100个元素的数组进行拓展,比如扩展成五六百个,尽量保证,链表节点的数量最小。同理,当保存的键值对删减之后,缩小数组可以节约内存,反正空着也是空着,不如释放了。

提到hash,就不得不提负载因子(load factor)的概念,它是保存的键值对和数组大小的一个比值,使用扩展和收缩的手段,把它控制在一个合理的范围之内,可以避免内存的浪费和读取的低效

负载因子的计算公式如下:

load_factor = ht[0].used / ht[0].size

used 是保存的的键值对的数目,size 是数组的大小

可见,如果负载因子太大,表示数组中的元素的链表元素会多,即键冲突的概率会变大;

而如果负载因子太小,表示数组中可能有些元素没有使用,即有些内存浪费了;

哈希表的收缩和扩展:

Redis 中,当满足下面两个条件之一时,会自动进行扩展操作:

  1. 当服务器没有执行 BGSAVE 和 BGREWRITEAOF 命令,并且负载因子大于等于1的时候;

  2. 当服务器正在执行 BGSAVE 和 BGREWRITEAOF 命令,并且负载因子大于等于5的时候;

这是因为这两个命令在执行过程中,Redis 需要创建子进程,而大多数的操作系统都采用写时复制的技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高触发扩展所需的负载因子,尽可能避免在子进程存在期间进行扩展操作,最大限度地节约内存。

当 Redis 进行 rehash 这种操作的时候,客户端还在使用,要兼顾 rehash 和 客户端,就要保证原数据和新数据同时存储和查询。这就是 dict 结构中 ht 大小为2的原因。

在没有进行 rehash 的时候,只使用 ht[0],rehash 会将新旧数据都重新散列,存入 ht[1] 中。rehash 完成之后,将 ht[1] 变成 ht[0],原来的 ht[0] 释放掉,再新建一个 ht[1]

3.4、渐进式rehash

当数据量很大的时候,rehash 操作如果一次性将h[0]数据转到ht[1],会导致服务宕机,这是不能接受的。因此,Redis 的 rehash 操作并不是一次性、集中式地完成,而是分多次、渐进式地完成的。

大致步骤如下:

  1. 为 ht[1] 分配空间,,让字典同时持有 ht[0] 和 ht[1] 两个哈希表;

  2. 将 rehashindex 值设置为0,表示开始 rehash;

  3. 在 rehash 期间,每次对字典执行增删查改的操作时,程序还会顺带将 ht[0] 中哈希表在 rehashindex 上的所有键值对 rehash 到 ht[1] 上,完成只有,rehashindex 递增;

  4. 随着字典的操作的不断执行,最终在某个时间点上,ht[0] 的所有键值对都会被rehash至 ht[1],这时候,rehash 全部完成,将 rehashindex 设置为-1;

渐进式 rehash 的好处在于,其采用分治的方式,将 rehash 键值对的工作量均摊到每次对字典的增删查改上,避免了集中式 rehash 带来的庞大的计算量

渐进式 rehash 执行期间的哈希表操作:

rehash 的时候,ht[0] 和 ht[1] 同时使用,字典的增删查改等操作会先后在 ht[0] 和 ht[1] 上执行,比如查找时,先在 ht[0] 上查找,如果没有找到,则在 ht[1] 上查找。

rehash 的时候,ht[0] 只删改查,不会进行添加操作,添加会直接添加到 ht[1] 中,保证了 ht[0] 中的键值对数量只减不增,直到 rehash 完成之后,ht[0] 变成空表。

4、总结

  1. 字典广泛用于实现 Redis 的各种功能,包括数据库和哈希键;

  2. 字典底层使用哈希表实现,每个字典带有两个哈希表,一个平时使用,一个 rehash 的时候使用;

  3. 哈希表使用单项链表来解决键冲突;

  4. 哈希表扩展和收缩时,Redis 会将一个哈希表 rehash 到另一个哈希表上,这个操作是渐进式的,不是一次完成的。

(0)

相关推荐

  • 最全总结 | 聊聊 Python 数据处理全家桶(Redis篇)

    最全总结 | 聊聊 Python 数据处理全家桶(Redis篇)

  • 一文读懂 Redis 常见对象类型的底层数据结构

    (给数据分析与开发加星标,提升数据技能) 转自:伍陆七, 链接:cnblogs.com/chentianming/p/13838347.htm Redis 是一个基于内存中的数据结构存储系统,可以用作 ...

  • Redis 核心篇:唯快不破的秘密

    " 天下武功,无坚不摧,唯快不破! " 学习一个技术,通常只接触了零散的技术点,没有在脑海里建立一个完整的知识框架和架构体系,没有系统观.这样会很吃力,而且会出现一看好像自己会,过 ...

  • Redis源码剖析之字典(dict)

    Dict在redis中是最为核心的一个数据结构,因为它承载了redis里的所有数据,你可以简单粗暴的认为redis就是一个大的dict,里面存储的所有的key-value. redis中dict的本质 ...

  • 为什么 Redis 这么火?单线程的Redis有哪些慢动作?

    前言 现在一提到Redis的第一反应就是快.单线程,但是Redis真的快吗?真的是单线程吗? 你有没有深入了解一下Redis,看看它的底层有哪些"慢动作"呢? 为什么 Redis ...

  • Redis 数据结构

    一.Redis简介 Redis是一款基于key-value的高性能NoSQL数据库,开源免费,遵守BSD协议.支持string(字符串) . hash(哈希) .list(列表) . set(集合) ...

  • 数据结构基础1-列表、元组、字典、集合知识点总结

    AI研习图书馆,发现不一样的世界 数据 结构 Python数据结构基础知识点总结 1. 列表 序列是Python中最基本的数据结构.序列中的每个元素都分配一个数字- 它的位置,或索引,第一个索引是0, ...

  • Redis核心数据结构与高性能原理

    作者公众号:一角钱技术(org_yijiaoqian) 五种常用数据结构 String 结构 字符串常用操作 SET key value  //存入字符串键值对MSET key value [key  ...

  • Redis 5大数据结构

    一.字符串 字符串类型是redis最基础的数据结构,首先键是字符串类型,而且其他几种结构都是在字符串类型基础上构建的,所以字符串类型能为其他四种数据结构的学习尊定基础.字符串类型实际上可以是字符串(简 ...

  • 复杂嵌套字典数据结构处理库-glom

    经常遇到复杂嵌套字典数据,我们都是这么写的 data = {'a': {'b': {'c': 'd'}}} print(data['a']['b']['c'])'d' 然后经常遇到这个bug data ...

  • Redis分布式锁升级版RedLock及SpringBoot实现

    分布式锁概览 在多线程的环境下,为了保证一个代码块在同一时间只能由一个线程访问,Java中我们一般可以使用synchronized语法和ReetrantLock去保证,这实际上是本地锁的方式.但是现在 ...

  • 为什么Redis集群要使用反向代理?

    为什么要使用反向代理? 如果没有反向代理,一台Redis可能需要跟很多个客户端连接: 看着是不是很慌?看没关系,主要是连接需要消耗线程资源,没有代理的话,Redis要将很大一部分的资源用在与客户端建立 ...