Redis的基本数据结构
Redis是一种key-value的数据库存储系统,简称为k-v。key-value形式的存储结构,一般会使用红黑树或者hash表来存储。红黑树的时间复杂度为O(logn),hash表则是O(1)。Redis内部是有维护一个hash表的,说到hash表,肯定会第一时间想到有相应的hash算法,Redis中的key如果是字符串的话,一定需要通过hash算法将字符串hash为整数,然后再放到确定的槽位。Redis中使用的hash算法是siphash算法,siphash就可以将字符串hash为64位的 整数,并且能够很好的处理有特征的key。
Redis中key的数据类型可以是string、hash、set、zset、list。并且需要注意的是,如果数据类型为double、int、long、string,作为key的话,存储的类型都会变为string。value可以存储的内容包括:string、hash、stream、set、zset、list。Redis中key的特征是有规律,具有强随机分布性,就是保证key的随机分布。
Redis中的Hash表应用
Redis中的hash算法是将无限个数的字符串转换为有限个整数的,那么必然会在hash的时候产生hash冲突(抽屉原理可以证明:n+1个苹果放在n个抽屉,至少一个抽屉有重叠),这时候就需要进行rehash来保证 解决hash冲突;还有个问题就是整数hash出来之后是一个64位的整数,64位的整数范围是0~264-1,如果hash表采用一个264次方大小的数组存储数据的话,是很占用内存的,因此Redis初始时将数据存放在一个长度为4的数组中,大数变小数的第一可行方法就是取余,但是取余的话也会产生冲突。
那么如何解决hash冲突呢?(1)使用数组+链表的方式解决,冲突的数据使用链表链接在一起,Redis也是使用这种方法解决的(2)再hash法,就是另取一个hash函数(3)开址法,可以使用两个数组,一个用于存放没有冲突的元素,一个用于存放冲突的元素。因此在Redis中需要链表去存放冲突的数据,并且由于需要存放最新的数据,因此为了取数据时候不用遍历一次链表,链表插入数据的时候使用头插法。
还有个就是初始值只有长度为4,肯定是不够的,因此需要适当的进行扩容或者在数据量减少的时候需要进行缩容。具体的扩容的方法就是将存放元素的数组的size翻倍,但是需要注意的是在进行持久化操作的时候,比如rdb、aof操作时,这时候是不能申请内存的,因此需要等到操作结束之后进行扩容。缩容的话是当数据量小于数组长度的10%(也就是负载因子ratio <0.1)的时候进行,因为需要防止,反复缩容扩容的情况。
接下来来看一下Redis内部hash表的结构,Redis的字典内部使用了hash表,代码如下:
// hash表
typedef struct dictht {
dictEntry **table; // 一个数组,数组中的每个元素都指向一个dictEntry
unsigned long size; // hash表的长度
unsigned long sizemask; // size - 1,用于计算hash表中元素应该落到哪个位置
unsigned long used; // hash表中已经被使用的数量,一般小于size,做持久化操作时可能会大于size
} dictht;
// hash节点
typedef struct dictEntry {
// 键
void *key;
// 值
union { // union可以节省内存空间
void *val; // 指向不同的类型
uint64_t u64; // 用于redis集群,哨兵模式,选举算法
int64_t s64; // 记录过期时间
double d;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
// 字典的结构
typedef struct dict {
// 字典的类型,Redis会为⽤途不同的字典设置不同的类型特定函数
dictType *type;
void *privdata; // 字典的上下文,保存了需要传给那些类型特定函数的可选参数
dictht ht[2]; // 哈希表,⼆维的,默认使⽤ht[0],当需要进⾏rehash的时候,会利⽤ht[1]进⾏
// rehash的索引,当没有进⾏rehash时其值为-1
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
// hash表的迭代器,⼀般⽤于rehash和主从复制等等
unsigned long iterators; /* number of iterators currently running */
} dict;
字典示意图
hash table示意图
上图就是Redis中hash表的图,table指向一个数组,长度为4,数组中的指针是一个链表的头,链表中的节点为dictEntry。
由于Redis使用c++实现的,内部进行取余操作时如果直接使用%运算的话会比较消耗性能,因此使用位运算可以节省性能,因此sizemask的用处就在这,size-1正好可以用于&原来的数,比如 x % 4 = x & 3。
Redis中取余代码:
// 使⽤字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
// 使⽤哈希表的 sizemask 属性和哈希值,计算出索引值
// 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;
之前所说的Redis的扩容和缩容以及何时进行操作,都涉及到Redis对于rehash的处理。hash表的缩容和扩容时根据hash表的负载因子决定的。看如下代码:
// 当负载因此小于0.1的时候需要对其进行缩容
/* If the percentage of used slots in the HT reaches HASHTABLE_MIN_FILL• we resize the hash table to save memory */
void tryResizeHashTables(int dbid) {
if (htNeedsResize(server.db[dbid].dict))
dictResize(server.db[dbid].dict);
if (htNeedsResize(server.db[dbid].expires))
dictResize(server.db[dbid].expires);
}
int htNeedsResize(dict *dict) {
long long size, used;
size = dictSlots(dict);
used = dictSize(dict);
return (size > DICT_HT_INITIAL_SIZE &&
(used*100/size < HASHTABLE_MIN_FILL));
}
// 其实上⽂说的扩容为ht[0].uesd*2 是不严谨的,实际上是⼀个刚好⼤于该书的2的N次⽅
static int _dictExpandIfNeeded(dict d)
{
/ Incremental rehashing already in progress. Return. */
if (dictIsRehashing(d)) return DICT_OK;
/* If the hash table is empty expand it to the initial size. */
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
/* If we reached the 1:1 ratio, and we are allowed to resize the hash
* table (global setting) or we should avoid it but the ratio between
* elements/buckets is over the "safe" threshold, we resize doubling
* the number of buckets. */
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize ||
d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
{
return dictExpand(d, d->ht[0].used*2);
}
return DICT_OK;
}
// 服务器⽬前没有在执⾏ BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因⼦⼤于等于1,则扩容hash表,扩容⼤⼩为当前ht[0].used2
// 服务器⽬前正在执⾏ BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因⼦⼤于等 于 5,则扩容hash表,并且扩容⼤⼩为当前ht[0].used2
/* Our hash table capability is a power of two */
static unsigned long _dictNextPower(unsigned long size)
{
unsigned long i = DICT_HT_INITIAL_SIZE;
if (size >= LONG_MAX) return LONG_MAX + 1LU;
while(1) {
if (i >= size)
return i;
i *= 2;
}
}
扩容的步骤如下: 1、为字典ht[1]哈希表分配合适的空间; 2、将ht[0]中所有的键值对rehash到ht[1]:rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到 ht[1] 哈希表的指定位置上; 3、当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后 (ht[0] 变为空表), 释放 ht[0] , 将 ht[1] 设置 为 ht[0] , 并在 ht[1] 新创建⼀个空⽩哈希表, 为下⼀次 rehash 做准备。
关于rehash还需要思考一个问题,当有数组中有大量数据的时候,如果还需要对数组进行扩容的话,这个时候如果正常操作rehash会非常耗时,因此使用渐进式rehash,详细的步骤为:1. 为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。 2. 在字典中维持⼀个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash ⼯作正式开始。 3. 在 rehash 进⾏期间, 每次对字典执⾏添加、删除、查找或者更新操作时, 程序除了执⾏指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash ⼯作完成之后, 程序将 rehashidx 属性的值增⼀。如果没有持久化操作的话就定时进行rehash,具体实现是1ms内操作多轮100次。如果有持久化操作的话就是执行一次rehash,一次rehash一个数组中元素下面挂的一个链表。 4. 随着字典操作的不断执⾏, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash ⾄ ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。
渐进式 rehash 的好处在于它采取分⽽治之的⽅式, 将 rehash 键值对所需的计算⼯作均滩到对字典的每个添加、删除、查找和更新操作上,甚⾄是后台启动⼀个定时器,每次时间循环时只⼯作⼀毫秒, 从⽽避免了集中式 rehash ⽽带来的庞⼤计算量。
/* This function handles ‘background’ operations we are required to do
• incrementally in Redis databases, such as active key expiring, resizing,
• rehashing. /
void databasesCron(void) {
/ Expire keys by random sampling. Not required for slaves
• as master will synthesize DELs for us. */
if (server.active_expire_enabled) {
if (iAmMaster()) {
activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
} else {
expireSlaveKeys();
}
}/* Defrag keys gradually. */
activeDefragCycle();/* Perform hash tables rehashing if needed, but only if there are no
• other processes saving the DB on disk. Otherwise rehashing is bad
• as will cause a lot of copy-on-write of memory pages. /
if (!hasActiveChildProcess()) {
/ We use global counters so if we stop the computation at a given
• DB we’ll be able to start from the successive in the next
• cron loop iteration. */
static unsigned int resize_db = 0;
static unsigned int rehash_db = 0;
int dbs_per_call = CRON_DBS_PER_CALL;
int j;/* Don’t test more DBs than we have. */
if (dbs_per_call > server.dbnum) dbs_per_call = server.dbnum;/* Resize */
for (j = 0; j < dbs_per_call; j++) {
tryResizeHashTables(resize_db % server.dbnum);
resize_db++;
}/* Rehash /
if (server.activerehashing) {
for (j = 0; j < dbs_per_call; j++) {
int work_done = incrementallyRehash(rehash_db);
if (work_done) {
/ If the function did some work, stop here, we’ll do
* more at the next cron loop. /
break;
} else {
/ If this db didn’t need rehash, we’ll try the next one. */
rehash_db++;
rehash_db %= server.dbnum;
}
}
}
}
}
// 控制时间
/* Our hash table implementation performs rehashing incrementally while• we write/read from the hash table. Still if the server is idle, the hash
• table will use two tables for a long time. So we try to use 1 millisecond
• of CPU time at every call of this function to perform some rehahsing.
•
• The function returns 1 if some rehashing was performed, otherwise 0
• is returned. /
int incrementallyRehash(int dbid) {
/ Keys dictionary /
if (dictIsRehashing(server.db[dbid].dict)) {
dictRehashMilliseconds(server.db[dbid].dict,1);
return 1; / already used our millisecond for this loop… /
}
/ Expires /
if (dictIsRehashing(server.db[dbid].expires)) {
dictRehashMilliseconds(server.db[dbid].expires,1);
return 1; / already used our millisecond for this loop… */
}
return 0;
}// 后台定时执行
int dictRehashMilliseconds(dict *d, int ms) {
long long start = timeInMilliseconds();
int rehashes = 0;while(dictRehash(d,100)) {
rehashes += 100;
if (timeInMilliseconds()-start > ms) break;
}
return rehashes;
while(dictRehash(d,100)) {
rehashes += 100;
if (timeInMilliseconds()-start > ms) break;
}
return rehashes;
}
// 一次rehash的操作
/* Performs N steps of incremental rehashing. Returns 1 if there are still• keys to move from the old to the new hash table, otherwise 0 is returned.
•
• Note that a rehashing step consists in moving a bucket (that may have more
• than one key as we use chaining) from the old to the new hash table, however
• since part of the hash table may be composed of empty spaces, it is not
• guaranteed that this function will rehash even a single bucket, since it
• will visit at max N*10 empty buckets in total, otherwise the amount of
• work it does would be unbound and the function may block for a long time. /
int dictRehash(dict d, int n) {
int empty_visits = n10; / Max number of empty buckets to visit. */
if (!dictIsRehashing(d)) return 0;
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
/* Note that rehashidx can't overflow as we are sure there are more
* elements because ht[0].used != 0 */
assert(d->ht[0].size > (unsigned long)d->rehashidx);
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
de = d->ht[0].table[d->rehashidx];
/* Move all the keys in this bucket from the old to the new hash HT */
while(de) {
uint64_t h;
nextde = de->next;
/* Get the index in the new hash table */
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
/* Check if we already rehashed the whole table… */
if (d->ht[0].used == 0) {
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
d->rehashidx = -1;
return 0;
}/* More to rehash… */
return 1;
}
Redis的遍历
字典中的hash表遍历的方式有以下几种:keys、hkeys、scan、hscan、zscan。keys和hkeys返回的是hash表中的所有的key,如果当前的hash表很大的话,会比较耗时,这样的话就会阻塞其他操作,因此运行环境中返回keys和hkeys这两种方式是不推荐的。使用scan是分布式遍历的,是分步进行返回的,这样就不会阻塞其他的操作。
scan和hscan的实现原理:通过一系列位运算可以直接将遍历中的任意数找到001的位置,这样的话,遍历就不会丢掉其他的数据。无论是扩容,缩容还是进行rehash都能够全部遍历到数据。但是有一个例外就是进行连续两次缩容的时候可能会产生冗余的数据。下图就是scan操作进行运算的一个模板。
scan遍历操作的过程图
unsigned long dictScan(dict *d,
unsigned long v,
dictScanFunction fn,
dictScanBucketFunction bucketfn,
void *privdata)
{
dictht *t0, *t1;
const dictEntry *de, *next;
unsigned long m0, m1;
if (dictSize(d) == 0) return 0;
/* Having a safe iterator means no rehashing can happen, see _dictRehashStep.
* This is needed in case the scan callback tries to do dictFind or alike. */
d->iterators++;
if (!dictIsRehashing(d)) {
t0 = &(d->ht[0]);
m0 = t0->sizemask;
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);
de = t0->table[v & m0];
while (de) {
next = de->next;
fn(privdata, de);
de = next;
}
// 通过下述运算,直接可以从遍历的任何一个位置开始找到起点,就不会错过其他的数据
/* Set unmasked bits so incrementing the reversed cursor
* operates on the masked bits */
v |= ~m0;
/* Increment the reverse cursor */
v = rev(v);
v++;
v = rev(v);
} else {
t0 = &d->ht[0];
t1 = &d->ht[1];
/* Make sure t0 is the smaller and t1 is the bigger table */
if (t0->size > t1->size) {
t0 = &d->ht[1];
t1 = &d->ht[0];
}
m0 = t0->sizemask;
m1 = t1->sizemask;
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t0->table[v & m0]);
de = t0->table[v & m0];
while (de) {
next = de->next;
fn(privdata, de);
de = next;
}
/* Iterate over indices in larger table that are the expansion
* of the index pointed to by the cursor in the smaller table */
do {
/* Emit entries at cursor */
if (bucketfn) bucketfn(privdata, &t1->table[v & m1]);
de = t1->table[v & m1];
while (de) {
next = de->next;
fn(privdata, de);
de = next;
}
/* Increment the reverse cursor not covered by the smaller mask.*/
v |= ~m1;
v = rev(v);
v++;
v = rev(v);
/* Continue while bits covered by mask difference is non-zero */
} while (v & (m0 ^ m1));
}
/* undo the ++ at the top */
d->iterators--;
return v;
}
跳表
跳表主要是实现有序集合的,比如zset,跳表是一个动态搜索结构。其实红黑树也是一个动态搜索结构,时间复杂度为O(logn),而跳表是一个大概率的O(logn)(1-N ^(1/C))。跳表的特征就是可以实现搜索区间段的排序,比如搜索20-50内的数据。
跳表的原理:实际就是一个多层的单链表。单层的单链表的话,复杂度是O(n),这样的话效率还是比较低的,因此我们想到的是多加一层链表,新加的一层链表是中间相隔一个链表连接的。使用增加一条快线的方法,增加一个直接连接需要的节点的链表实现直接的查找,降低了复杂度,但是增加了内存。如下图:
单层链表的话,走完356线路需要一格格往前走,而加上一条快链表的话就可以直接跳转到4,然后再到56,提高了效率。
如果有很多节点的话,查找就需要更多的快线协助,也就是多加几层链表。那么怎样去控制链表的层数以及每层链表节点之间的间隔呢?根据推算,理想的跳表结构就是均匀分布的,接近二分查找,时间复杂度为O(logn)。
理想跳表图示
理想的跳表如上图,用概率的方法计算的话,也就是每个节点都有50%的概率有第二层的结点,只需要模拟出这种理想的跳表,就可以实现高效的查找。因此跳表实际上不适合少量的数据,redis中的set在数据量小于128的时候使用就是普通的(压缩列表)字节数组,大于128的时候才会使用跳表来操作。
redis的存储结构就总结到这,后续还会继续跟进代码。