Redis的KV存储结构
Redis内存数据库,最底层是一个redisDb;
redisDb 整体使用 dict字典 来存储键值对KV;
字典中的每一项,使用dictEntry ,代表KV键值;类似于HashMap中的键值对Entry。
why dict/map?
dict是一种用于维护key和value映射关系的数据结构,与很多编程语言中的Map类似。为什么dict/map 这么受欢迎呢?
因为dict/map实现了key和value的映射,通过key查询value是效率非常高的操作,时间复杂度是O(C),C是常数,在没有冲突/碰撞的情况下,可以达到O(1)。
dict本质上是为了解决算法中的查找问题(Searching),一般查找问题的解法分为两个大类:一个是基于各种平衡树,一个是基于哈希表。
- 平衡树,如二叉搜索树、红黑树,使用的是“二分思想”;
如果需要实现排序,则可使用平衡树,如:用大顶堆实现TreeMap; - 哈希表,如Java中的Map,Python中的字典dict,使用的是“映射思想”;
我们平常使用的各种Map或dict,大都是基于哈希表实现的。在不要求数据有序存储,且能保持较低的哈希值冲突概率的前提下,基于哈希表的查找性能能做到非常高效,接近O(1),而且容易实现。
Redis dict的应用
字典dict 在 Redis 中的应用广泛, 使用频率可以说和 SDS 以及双端链表不相上下, 基本上各个功能模块都有用到字典的地方。
其中, 字典dict的主要用途有以下两个:
- 实现数据库键空间(key space);
- 用作 hash 键的底层实现之一;
以下两个小节分别介绍这两种用途。
Redis数据库键空间(key space)
Redis 是一个键值对数据库服务器,服务器中每个数据库都由 redisDB 结构表示(默认16个库)。其中,redisDB 结构的 dict 字典保存了数据库中所有的键值对,这个字典被称为键空间(key space)。
可以认为,Redis默认16个库,这16个库在各自的键空间(key space)中;其实就通过键空间(key space)实现了隔离。而键空间(key space)底层是dict实现的。
键空间(key space)除了实现了16个库的隔离,还能基于键空间通知(Keyspace Notifications) 实现某些事件的订阅通知,如某个key过期的时间,某个key的value变更事件。
键空间通知(Keyspace Notifications),是因为键空间(key space)实现了16个库的隔离,而我们执行Redis命令最终都是落在其中一个库上,当有事件发生在某个库上时,该库对应的键空间(key space)就能基于pub/sub发布订阅,实现事件“广播”。
dict 用作 hash 键的底层实现
Redis 的 hash 键使用以下两种数据结构作为底层实现:
- 压缩列表ziplist ;
- 字典dict;
因为压缩列表 比字典更节省内存,所以程序在创建新 Hash 键时,默认使用压缩列表作为底层实现, 当有需要时,才会将底层实现从压缩列表转换到字典。
压缩链表转成字典(ziplist->dict)的条件
同时满足以下两个条件,hash 键才会使用ziplist:
1、key和value 长度都小于64
2、键值对数小于512
该配置 在redis.conf
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
如何实现字典dict/映射
dict,又称字典(dictionary)或映射(map),是集合的一种;这种集合中每个元素都是KV键值对。
它是根据关键字值(key)而直接进行访问KV键值对的数据结构。也就是说,它通过把关键字值映射到一个位置来访问记录,以加快查找的速度。这个映射函数称为哈希函数(也称为散列函数)。
因此通常我们称字典dict,也叫哈希表。
映射过程,通常使用hash算法实现,因此也称映射过程为哈希化,存放记录的数组叫做散列表、或hash表。
哈希化之后难免会产生一个问题,那就是对不同的关键字,可能得到同一个散列地址,即不同的key散列到同一个数组下标,这种现象称为冲突
,那么我们该如何去处理冲突呢?
最常用的就是链地址法,也常被称为拉链法,就是在冲突的下标处,维护一个链表,所有映射到该下标的记录,都添加到该链表上。
Redis字典dict如何实现的?
Redis字典dict,也是采用哈希表,本质就是数组+链表。
也是众多编程语言实现Map的首选方式,如Java中的HashMap。
Redis字典dict 的底层实现,其实和Java中的ConcurrentHashMap思想非常相似。
就是用数组+链表实现了分布式哈希表。当不同的关键字、散列到数组相同的位置,就拉链,用链表维护冲突的记录。当冲突记录越来越多、链表越来越长,遍历列表的效率就会降低,此时需要考虑将链表的长度变短
。
将链表的长度变短
,一个最直接有效的方式就是扩容数组。将数组+链表结构中的数组扩容,数组变长、对应数组下标就增多了;将原数组中所有非空的索引下标、搬运到扩容后的新数组,经过重新散列,自然就把冲突的链表变短了。
如果你对Java的HashMap或ConcurrentHashMap 底层实现原理比较了解,那么对Redis字典dict的底层实现,也能很快上手。
dict.h 给出了这个字典dict的定义:
/*
* 字典
*
* 每个字典使用两个哈希表,用于实现渐进式 rehash
*/
typedef struct dict {
// 特定于类型的处理函数
dictType *type;
// 类型处理函数的私有数据
void *privdata;
// 哈希表(2 个)
dictht ht[2];
// 记录 rehash 进度的标志,值为 -1 表示 rehash 未进行
int rehashidx;
// 当前正在运作的安全迭代器数量
int iterators;
} 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;
结合上面的代码,可以很清楚地看出dict的结构。一个dict由如下若干项组成:
dictType *type;
一个指向dictType结构的指针(type)。它通过自定义的方式使得dict的key和value能够存储任何类型的数据。void *privdata;
一个私有数据指针(privdata)。由调用者在创建dict的时候传进来。dictht ht[2];
两个哈希表(ht[2])。只有在rehash的过程中,ht[0]和ht[1]才都有效。而在平常情况下,只有ht[0]有效,ht[1]里面没有任何数据。上图表示的就是rehash进行到中间某一步时的情况。int rehashidx;
当前rehash索引(rehashidx)。如果rehashidx = -1,表示当前没有在rehash过程中;否则,表示当前正在进行rehash,且它的值记录了当前rehash进行到哪一步了。int iterators;
当前正在进行遍历的iterator的个数。这不是我们现在讨论的重点,暂时忽略。
dictType结构包含若干函数指针,用于dict的调用者对涉及key和value的各种操作进行自定义。这些操作包含:
- hashFunction,对key进行哈希值计算的哈希算法。
- keyDup和valDup,分别定义key和value的拷贝函数,用于在需要的时候对key和value进行深拷贝,而不仅仅是传递对象指针。
- keyCompare,定义两个key的比较操作,在根据key进行查找时会用到。
- keyDestructor和valDestructor,分别定义对key和value的析构函数。
私有数据指针(privdata)就是在dictType的某些操作被调用时会传回给调用者。
dictht(dict hash table)哈希表
dictht 是字典 dict 哈希表的缩写,即dict hash table。
dict.h/dictht 类型定义:
/*
* 哈希表
*/
typedef struct dictht {
// 哈希表节点指针数组(俗称桶,bucket)
dictEntry **table;
// 指针数组的大小
unsigned long size;
// 指针数组的长度掩码,用于计算索引值
unsigned long sizemask;
// 哈希表现有的节点数量
unsigned long used;
} dictht;
/*
* 哈希表节点
*/
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 链往后继节点
struct dictEntry *next;
} dictEntry;
dictht 定义一个哈希表的结构,包括以下部分:
- 一个dictEntry指针数组(table)。key的哈希值最终映射到这个数组的某个位置上(对应一个bucket)。如果多个key映射到同一个位置,就发生了冲突,那么就拉出一个dictEntry链表。
- size:标识dictEntry指针数组的长度。它总是2的指数次幂。
- sizemask:用于将哈希值映射到table的位置索引。它的值等于(size-1),比如7, 15, 31, 63,等等,也就是用二进制表示的各个bit全1的数字。每个key先经过hashFunction计算得到一个哈希值,然后计算(哈希值 & sizemask)得到在table上的位置。相当于计算取余(哈希值 % size)。
- used:记录dict中现有的数据个数。它与size的比值就是装载因子。这个比值越大,哈希值冲突概率越高。
Redis dictht的负载因子
我们知道当HashMap中由于Hash冲突(负载因子)超过某个阈值时,出于链表性能的考虑、会进行扩容,Redis dict也是一样。
一个dictht 哈希表里,核心就是一个dictEntry数组,同时用size记录了数组大小,用used记录了所有记录数。
dictht的负载因子,就是used与size的比值,也称装载因子(load factor)。这个比值越大,哈希值冲突概率越高。当比值[默认]超过5,会强制进行rehash。
dictEntry
结构中包含k, v和指向链表下一项的next指针。k是void指针,这意味着它可以指向任何类型。v是个union,当它的值是uint64_t、int64_t或double类型时,就不再需要额外的存储,这有利于减少内存碎片。当然,v也可以是void指针,以便能存储任何类型的数据。
next 指向另一个 dictEntry 结构, 多个 dictEntry 可以通过 next 指针串连成链表, 从这里可以看出, dictht 使用链地址法来处理键碰撞: 当多个不同的键拥有相同的哈希值时,哈希表用一个链表将这些键连接起来。
渐进式rehash:
扩展或收缩哈希表需要将 ht[0]
里面的所有键值对 rehash 到 ht[1]
里面, 但是, 这个 rehash 动作并不是一次性、集中式地完成的, 而是分多次、渐进式地完成的。
这样做的原因在于, 如果 ht[0]
里只保存着四个键值对, 那么服务器可以在瞬间就将这些键值对全部 rehash 到 ht[1]
; 但是, 如果哈希表里保存的键值对数量不是四个, 而是四百万、四千万甚至四亿个键值对, 那么要一次性将这些键值对全部 rehash 到 ht[1]
的话, 庞大的计算量可能会导致服务器在一段时间内停止服务。
因此, 为了避免 rehash 对服务器性能造成影响, 服务器不是一次性将 ht[0]
里面的所有键值对全部 rehash 到 ht[1]
, 而是分多次、渐进式地将 ht[0]
里面的键值对慢慢地 rehash 到 ht[1]
。
以下是哈希表渐进式 rehash 的详细步骤:
- 为
ht[1]
分配空间, 让字典同时持有ht[0]
和ht[1]
两个哈希表。 - 在字典中维持一个索引计数器变量
rehashidx
, 并将它的值设置为0
, 表示 rehash 工作正式开始。 - 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将
ht[0]
哈希表在rehashidx
索引上的所有键值对 rehash 到ht[1]
, 当 rehash 工作完成之后, 程序将rehashidx
属性的值增一。 - 随着字典操作的不断执行, 最终在某个时间点上,
ht[0]
的所有键值对都会被 rehash 至ht[1]
, 这时程序将rehashidx
属性的值设为-1
, 表示 rehash 操作已完成。
渐进式 rehash 的好处在于它采取分而治之的方式, 将 rehash 键值对所需的计算工作均滩到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。
图 4-12 至图 4-17 展示了一次完整的渐进式 rehash 过程, 注意观察在整个 rehash 过程中, 字典的 rehashidx
属性是如何变化的。
因为在进行渐进式 rehash 的过程中, 字典会同时使用 ht[0]
和 ht[1]
两个哈希表, 所以在渐进式 rehash 进行期间, 字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行: 比如说, 要在字典里面查找一个键的话, 程序会先在 ht[0]
里面进行查找, 如果没找到的话, 就会继续到 ht[1]
里面进行查找, 诸如此类。
另外, 在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1]
里面, 而 ht[0]
则不再进行任何添加操作: 这一措施保证了 ht[0]
包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。