同学们好,上节课我们详细介绍了Redis字符串的结构原理,在学习数据结构之前我们先了解一下Redis底层的数据存储结构
Redis 底层的设计思路
目前能够存储海量数据的数据库,底层数据的存储结构一般有:
- 数组,时间复杂度O(1)
- 索引,比如有tree(红黑树、AVL 树), 时间复杂度logN
- 链表:时间复杂度O(N)
对比发现,数组查询数据的时间复杂度最优,所以 Redis 底层的结构就是采用数组,
那么既然采用数组,则redis的key就对应的是数组的索引
我们知道Redis中key的结构很丰富,可以是 String、int、float等,为了让key存入到数组中,我们一般利用hash,hash函数可以将任意字符串转化为一个非常大的自然数,但是如果真的存这么大的数据在数组中,会很浪费空间,所以在转化为自然数后利用求模以及截取前面的长度来保证key的长度
所以key的映射过程,通常使用 hash 算法实现,因此也称映射过程为哈希化,存放记录的数组叫做散列表、或hash表,Redis中如何做hash的稍候我们详细介绍
哈希化之后难免会产生一个问题,那就是对不同的关键字,可能得到同一个散列地址,即hash冲突?解决冲突最常用的方法就是链地址法,就是在冲突的下标处,维护一个链表,所有映射到该下标的记录,都添加到该链表上。
以上就是Redis大致的一个底层数据的设计思路,接下来我们详细看一下其内部原理。
Redis 底层数据存储原理
redisDb
redis 中以redisDb作为整个缓存存储的核心,保存着我们客户端需要的缓存数据
「redisDb」结构如下:
typedef struct redisDb {
dict *dict; /* The keyspace for this DB *///保持数据的dict
dict *expires; /* Timeout of keys with a timeout set *///保持key的过期信息
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*///一些同步的keys
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */Redis默认有16个数据库
long long avg_ttl; /* Average TTL, just for stats */
} redisDb;
- dict和expires是redisDB中最主要的两个属性,分别保存了对象数据键值对和key的过期时间,其底层数据结构都是dict字典。之所以分开存储,由于过期时间并不是数据的固有属性,虽然分开存储需要两次查找,但是却能节省内存开销。
- blocking_keys和ready_keys主要为了实现BLPOP等阻塞命令
- watched_keys用于实现watch命令,记录正在被watch的一些key
- eviction_pool是记录可能被lru淘汰的一些备选key
- id为当前数据库的id,redis支持单个服务多数据库,默认有16个
dict
可以看到dict保存着数据库所有的键值对,再详细看一下「dict」 的结构
/*
* 字典
*
* 每个字典使用两个哈希表,用于实现渐进式 rehash
*/
typedef struct dict {
// 特定于类型的处理函数
dictType *type;
// 类型处理函数的私有数据
void *privdata;
// 哈希表(2 个)
dictht ht[2];
// 记录 rehash 进度的标志,值为 -1 表示 rehash 未进行
int rehashidx;
// 当前正在运作的安全迭代器数量
int iterators;
} dict;
dict各属性含义:
- type存储了hash函数,key和value的复制,比较以及销毁函数,而privdata则保存了一些私有数据,其和type共同决定了当前dictType结构中所保存的函数的指向,从而实现多态的目的,比如redis提供的hash函数就有两种,一种是Murmurhash函数,另一种是djbhash函数
- *ht[2]*是一个长度为2的数组,正常情况下回使用ht[0]来存储数据,当进行rehash操作时,redis会使用ht[1]来配合进行渐进式rehash操作。而在平常情况下,只有 ht[0]有效,ht[1]里面没有任何数据。具体rehash过程后面详细讲解
- rehashidx是一个整数值,如果当前没有进行rehash操作,则其值为-1,如果正在进行rehash操作,那么其值为当前rehash操作正在执行到的ht[1]中dictEntry数组的索引
- iterators当前正在进行遍历的 iterator 的个数。如果其值不为0,那么是不允许对hash表进行rehash操作的。
typedef struct dictType {
// hash函数
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;
dictht
上述的 dictht 就是我们说的 hashTable (dictht 是字典 dict 哈希表的缩写,即 dict hash table)
/*
* 哈希表
*/
typedef struct dictht {
// 哈希表节点指针数组(俗称桶,bucket)
dictEntry **table;
// 指针数组的大小
unsigned long size;
// 指针数组的长度掩码,用于计算索引值,其实永远都是size-1
unsigned long sizemask;
// 哈希表现有的节点数量
unsigned long used;
} dictht;
dictht 定义一个哈希表的结构,包括以下部分:
- dictEntry 指针数组(table)。key 的哈希值最终映射到这个数组的某个位置上(对应一个 bucket)。如果多个 key 映射到同一个位置,就发生了冲突,那么就拉出一个 dictEntry 链表。
- size:标识 dictEntry 指针数组的长度。它总是 2 的指数次幂。
之所以设置为2的指数次幂是因为经常要对hash值与size(tables数组长度)取模运算,因为取模运算为了转化为 哈希值 % size = 哈希值 & sizemask,其中sizemask=size-1,为了可以使用&运算提高计算性能,则要求为2的指数次幂,算是求模的优化
sizemask:用于将哈希值映射到 table 的位置索引。它的值等于(size-1),比如 7, 15, 31, 63,等等,也就是用二进制表示的各个 bit 全 1 的数字。每个 key 先经过 hashFunction 计算得到一个哈希值,然后计算(哈希值 & sizemask)得到在 table 上的位置。相当于计算取余(哈希值 % size)。
used:记录 dict 中现有的数据个数。它与 size 的比值就是装载因子。这个比值越大,哈希值冲突概率越高,当比值[默认]超过 5,会强制进行 rehash(扩容),这里就涉及到负载因子的概念,后面详细说明一下。
dictEntry
/*
* 哈希表节点
*/
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 链往后继节点
struct dictEntry *next;
} dictEntry;
key是 void 指针,这意味着它可以指向任何类型。
value 是个 union(联合体),当它的值是 uint64_t、int64_t 或 double 类型时,就不再需要额外的存储,这有利于减少内存碎片。当然,v 也可以是 void 指针,以便能存储任何类型的数据。
next 指向另一个 dictEntry 结构, 多个 dictEntry 可以通过 next 指针串连成链表, 从这里可以看出, dictht 使用链地址法来处理键碰撞: 当多个不同的键拥有相同的哈希值时,哈希表用一个链表将这些键连接起来。
下图展示了一个由 dictht 和数个 dictEntry 组成的哈希表例子:
如果再加上之前列出的 dict 类型,那么整个字典结构可以表示如下:
整体存储结构
下图就是整个主题的数据结构
redisObject
根据上面的主体结构图中可以看到 dictEntry 中的 value 都是指向一个叫做 redisObject的对象,我们看一下其结构
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 对象最后一次被访问的时间
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
// 引用计数
int refcount;
// 指向实际值的指针
void *ptr;
} robj;
- type 记录了对象的类型,string、set 等,根据该类型才可以确定是哪种结构,方便使用什么样的 API 操作
- encoding 表示 ptr 指向的具体数据结构,即这个对象使用了什么数据结构作为底层实现。
- lru 表示对象最后一次被命令程序访问的时间,内存淘汰的时候使用的
- refcount 表示引用计数,由于 C 语言并不具备内存回收功能,所以 Redis 在自己的对象系统中添加了这个属性,当一个对象的引用计数为 0 时,则表示该对象已经不被任何对象引用,则可以进行垃圾回收了。
上面可能会出现循环引用的问题,Java有考虑,Redis 有没有考虑循环引用的问题呢?这个问题我们下来再详细说明一下
- ptr 指针:指向对象的底层实现数据结构
接下来我们通过命令看一下数据的存储是什么样的,通过 object encoding key 命令来查看具体的存储结构
上图可以看到不同的字符串其内部的结构不一样一个是embstr、另外一个是raw, 为什么不同的 key ,ptr指向的结构不一样呢?
下一节来我们将详细学习每种数据类型底层的数据结构。