Redis原理篇之数据结构
Posted 大忽悠爱忽悠
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis原理篇之数据结构相关的知识,希望对你有一定的参考价值。
Redis原理篇之数据结构
Redis原理
Redis源码可以去官网下载,也可以从我下面提供的这个链接进行下载:
数据结构
动态字符串SDS
redis中保存的Key是字符串,value大多也是字符串或字符串集合,因此字符串是Redis中最常使用的一种数据结构。
不过Redis没有直接使用C语言中的字符串,因为C语言字符串存在很多问题:
- 获取字符串长度需要的复杂度为O(N)
- 非二进制安全,C语言使用空字符’\\0’作为字符串结尾的标记,如果保存的字符串本身含义该标记,那么会造成读取被截断,获取的数据不完整
- 不可修改
- 容易造成缓冲区溢出,例如字符串拼接时,超过原本的空间大小,可能会覆盖掉相邻变量的内存空间
而SDS就是对c字符串的封装,以此来解决上述的问题。
SDS结构
SDS是C语言实现的一个结构体:
一个简单的例子如下:
动态扩容
在c语言中,如果要对字符串操作:
- 拼接–>先进行内存重分配来扩展底层数组大小,如果忘记了这一步,会导致缓冲区溢出
- 缩短–>需要通过内存重分配来释放字符串不再使用的那部分空间,如果忘记了会导致内存泄露
因为内存重分配需要执行系统调用,并且系统实现内存重分配算法也非常复杂,所以这通过是一个比较耗时的操作
-
因此通过内存预分配可以减少内存重分配的次数,进而提高整体执行效率
-
并且SDS还提供了惰性空间释放的功能,即对字符串缩短操作而言,不会立刻使用内存重分配算法来回收多出来的字节,而是通过一个free属性进行记录,当后面需要进行字符串增长时,就会用到
小结
SDS优点如下:
- O(1)复杂度获取字符串长度
- 杜绝缓冲区溢出
- 减少修改字符串长度时所需的内存重分配次数
- 二进制安全
- 兼容部分C字符串函数(因此SDS遵循了以’\\0’结尾的惯例)
整数集合IntSet
IntSet是vlaue集合的底层实现之一,当一个集合只包含整数值元素,并且这个集合元素数量不多的情况下,Redis就会使用IntSet作为该value集合的底层实现。
IntSet是Redis用于保存整数值集合抽象数据结构,它可以保存类型为int16_t,int32_t,int64_t的整数值,并且保证集合中不会出现重复元素。
IntSet结构如下:
typedef struct intset
//编码方式,支持存放16位,32位,64位整数
uint32_t encoding;
//元素个数
uint32_t length;
//整数数组,保存集合数据
int8_t contents[];
intset;
contents是整数数组底层实现,用来存储元素,并且各个项在数组中的值按从小到大有序排列,并且数组中不包含重复元素。
其中的encoding包含三种模式,表示存储的整数大小不同:
/* Note that these encodings are ordered, so:
* INTSET_ENC_INT16 < INTSET_ENC_INT32 < INTSET_ENC_INT64. */
/* 2字节整数,范围类似java的short */
#define INTSET_ENC_INT16 (sizeof(int16_t))
/* 4字节整数,范围类似java的int */
#define INTSET_ENC_INT32 (sizeof(int32_t))
/* 8字节整数,范围类似java的long */
#define INTSET_ENC_INT64 (sizeof(int64_t))
为了方便查找,Redis会将intset中所有的整数按照升序依次保存在contents数组中,结构如图:
现在,数组中每个数字都在int16_t的范围内,因此采用的编码方式是INSET_ENC_INT16,每部分占用的字节大小为:
- encoding: 4字节
- length: 4字节
- contents: 2字节*3=6字节
上图中给出的公式是计算每个数组元素起始地址,从这里也能看出为什么很多语言中,数组元素下标都从0开始
因为,如果从1开始,那么公式就变成了: startPtr+(sizeof(int16)*(index-1))
还要额外计算一次减法操作,这会浪费额外的cpu资源
- startPtr: 数组首元素起始地址
- sizeof(int16): 数组中每个元素的大小,数组中每个元素大小一致,便于按照下标寻址
- sizeof(int16)*(index): index下标元素举例起始地址多远,即index元素的起始地址
IntSet升級
- 升级编码为INTSET_ENC_INT32,每个整数占4字节,并按照新的编码方式及元素个数扩容数组
- 倒序依次将数组中的元素拷贝到扩容后的正确位置
正序挨个拷贝,会导致前面的元素扩容后覆盖后面的元素,而倒序可以避免这种情况。
c语言写数组插入元素的算法时,也是将元素挨个后移,然后腾出位置,插入新元素。
- 将待添加的元素放入数组末尾
- 最后,将intset的encoding属性改为INTSET_ENC_INT32,将length属性改为4
升级源码分析
- insetAdd–插入元素
/* Insert an integer in the intset */
intset *intsetAdd(
//需要插入的intset
intset *is,
//需要插入的新元素
int64_t value,
//是否插入成功
uint8_t *success)
//获取当前值编码
uint8_t valenc = _intsetValueEncoding(value);
//要插入的位置
uint32_t pos;
if (success) *success = 1;
/* Upgrade encoding if necessary. If we need to upgrade, we know that
* this value should be either appended (if > 0) or prepended (if < 0),
* because it lies outside the range of existing values. */
//判断编码是不是超过了当前intset的编码
if (valenc > intrev32ifbe(is->encoding))
/* This always succeeds, so we don't need to curry *success. */
//超出编码,需要升级
return intsetUpgradeAndAdd(is,value);
else
//不需要进行数组编码升级,只需要将元素插入到指定位置即可
/* Abort if the value is already present in the set.
* This call will populate "pos" with the right position to insert
* the value when it cannot be found. */
//在当前intset中查找值与value一样的元素的角标--使用二分查找法
//如果找到了,说明元素已经存在,无需再次插入,那么pos就是该元素的位置
//否则pos指向比value大的前一个元素
if (intsetSearch(is,value,&pos))
//如果找到了,则无需插入,直接结束并返回
if (success) *success = 0;
return is;
//数组扩容
is = intsetResize(is,intrev32ifbe(is->length)+1);
//移动数组中pos之后的元素到pos+1,给新元素腾出空间
if (pos < intrev32ifbe(is->length)) intsetMoveTail(is,pos,pos+1);
//插入新元素
_intsetSet(is,pos,value);
//重置元素长度
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
return is;
- intsetUpgradeAndAdd–升级数组编码
/* Upgrades the intset to a larger encoding and inserts the given integer. */
/* 插入的元素比当前数组编码要大,因此数组需要进行扩容,但是这个新元素具体是插入头部还是尾部不确定
* 因为该元素可能是一个负数!!!
* */
static intset *intsetUpgradeAndAdd(intset *is, int64_t value)
//获取当intset编码
uint8_t curenc = intrev32ifbe(is->encoding);
//获取新编码
uint8_t newenc = _intsetValueEncoding(value);
//获取元素个数
int length = intrev32ifbe(is->length);
//判断新元素是大于0还是小于0,小于0插入队列头部,大于0插入队尾
int prepend = value < 0 ? 1 : 0;
/* First set new encoding and resize */
//重置编码为新编码
is->encoding = intrev32ifbe(newenc);
//重置数组大小--扩容
is = intsetResize(is,intrev32ifbe(is->length)+1);
/* Upgrade back-to-front so we don't overwrite values.
* Note that the "prepend" variable is used to make sure we have an empty
* space at either the beginning or the end of the intset. */
//倒序遍历,逐个搬运元素到新的位置,_intsetGetEncoded按照旧编码方式查找旧元素
while(length--)
//_intsetSet按照新编码方式将取出的旧元素插入到数组中
//length+prepend: 如果新元素为负数,那么prepend为1,即旧元素后移的过程中,还会在数组头部腾出一个新位置
_intsetSet(is,length+prepend,_intsetGetEncoded(is,length,curenc));
/* Set the value at the beginning or the end. */
//插入新元素,prepend决定是数组头部还是尾部
if (prepend)
_intsetSet(is,0,value);
else
_intsetSet(is,intrev32ifbe(is->length),value);
//修改数组长度
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
return is;
- intsetSearch–二分查找元素
/* Search for the position of "value". Return 1 when the value was found and
* sets "pos" to the position of the value within the intset. Return 0 when
* the value is not present in the intset and sets "pos" to the position
* where "value" can be inserted. */
//返回1表示元素存在,我们不需要进行任何操作
//如果返回0,表示元素还不存在
static uint8_t intsetSearch(intset *is, int64_t value, uint32_t *pos)
//初始化二分查找需要的min,max,mid
int min = 0, max = intrev32ifbe(is->length)-1, mid = -1;
//mid对应的值
int64_t cur = -1;
/* The value can never be found when the set is empty */
//如果数组为空则不用找了
if (intrev32ifbe(is->length) == 0)
if (pos) *pos = 0;
return 0;
else
/* Check for the case where we know we cannot find the value,
* but do know the insert position. */
//数组不为空,判断value是否大于最大值,小于最小值
if (value > _intsetGet(is,max))
//大于最大值,插入队尾
if (pos) *pos = intrev32ifbe(is->length);
return 0;
else if (value < _intsetGet(is,0))
//小于最小值,插入队尾
if (pos) *pos = 0;
return 0;
//二分查找
while(max >= min)
mid = ((unsigned int)min + (unsigned int)max) >> 1;
cur = _intsetGet(is,mid);
if (value > cur)
min = mid+1;
else if (value < cur)
max = mid-1;
else
break;
if (value == cur)
if (pos) *pos = mid;
return 1;
else
if (pos) *pos = min;
return 0;
整数集合升级策略有两个好处:
- 提升整数集合的灵活性
- 尽可能节约内存
降级
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。
内存都是连续存放的,就算进行了降级,也会产生很多内存碎片,如果还要花时间去整理这些碎片更浪费时间。
当然,有小伙伴会说,可以参考SDS的做法,使用free属性来标记空闲空间大小—>当然应该存在更好的做法,大家可以尝试去思考更好的解法
小结
intset具备以下特点:
- Redis会确保intset中的元素唯一,有序
- 具备类型升级机制,可以节约内存空间
- 底层采用二分查找方式来查询
字典(DICT)
Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查,而键与值的映射关系正是通过Dict实现的。
Dict由三部分组成,分别是: 哈希表(DictHashTable),哈希节点(DictEntry).字典(Dict)
//哈希节点
typedef struct dictEntry
//键
void *key;
//值
union
void *val;
uint64_t u64;
int64_t s64;
double d;
v;
//下一个entry的指针
struct dictEntry *next;
dictEntry;
/* This is our hash table structure. Every dictionary has two of this as we
* implement incremental rehashing, for the old to the new table. */
//哈希表
typedef struct dictht
//entry数组,数组中保存的是指向entry的指针
dictEntry **table;
//哈希表的大小
unsigned long size;
//哈希表大小的掩码,总是等于size-1
unsigned long sizemask;
//entry的个数
unsigned long used;
dictht;
当出现hash碰撞的时候,会采用链表形式将碰撞的元素连接起来,然后链表的新元素采用头插法
//字典
typedef struct dict
//dict类型,内置不同的hash函数
dictType *type;
//私有数据,在做特殊运算时使用
void *privdata;
//一个Dict包含两个哈希表,其中一个是当前数据,另一个一般为空,rehash时使用
dictht ht[2];
//rehash的进度,-1表示未开始
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
//rehash是否暂停,1则暂停,0则继续
int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
dict;
扩容
/* Expand the hash table if needed */
//如果需要的话就进行扩容
static int _dictExpandIfNeeded(dict *d)
/* Incremental rehashing already in progress. Return. */
//如果正在rehash,则返回ok
if (dictIsRehashing(d)) return DICT_OK;
/* If the hash table is empty expand it to the initial size. */
//如果哈希表为空,则初始哈希表为默认大小4
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. */
//d->ht[0].used >= d->ht[0].size: 说明哈希节点数量已经大于数组长度了,这个条件要满足
//下面两个条件满足其中一个:
//1.dict_can_resize: 当服务器执行BGSAVE或者BGREWRITERAO时,该值为假
//2.d->ht[0].used/d->ht[0].size计算出来的就是负载因子
//当负载因子大于5时,不管是否正在执行BGSAVE或者BGREWRITERAO,都会进行扩容
//如果dict type 有expandAllowed函数,则会调用判断是否能够进行扩容
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize ||
d->ht[0].used/d->ht[0].size > dict_force_resize_ratio) &&
dictTypeExpandAllowed(d))
//扩容带下为used+1,底层会对扩容大小进行判断,实际上找的是第一个大于等于used+1的2^n
return dictExpand(d, d->ht[0].used + 1);
return DICT_OK;
收缩
Dict除了扩容以外,每次删除元素时,也会对负载因子做检查,当LoadFactory<0.1时,会做哈希表收缩:
- 删除元素源码
/* Delete an element from a hash.
* Return 1 on deleted and 0 on not found. */
//从hash中删除一个元素,删除成功返回1,没找到返回0
int hashTypeDelete(robj *o, sds field)
int deleted = 0;
//底层采用压缩链表实现,这个暂时不管
if (o->encoding == OBJ_ENCODING_ZIPLIST)
unsigned char *zl, *fptr;
zl = o->ptr;
fptr = ziplistIndex(zl, ZIPLIST_HEAD);
if (fptr != NULL)
fptr = ziplistFind(zl, fptr, (unsigned char*)field, sdslen(field), 1);
if (fptr != NULL)
zl = ziplistDelete(zl,&fptr); /* Delete the key. */
zl = ziplistDelete(zl,&fptr); /* Delete the value. */
o->ptr = zl;
deleted = 1;
//底层采用hash实现
else if (o->encoding == OBJ_ENCODING_HT)
//删除成功返回C_OK
if (dictDelete((dict*)o->ptr, field) == C_OK)
deleted = 1;
/* Always check if the dictionary needs a resize after a delete. */
//删除成功后,检查是否需要重置DICT大小,如果需要则调用dictResize重置
if (htNeedsResize(o->ptr)) dictResize(o->ptr);
else
serverPanic("Unknown hash encoding");
return deleted;
- htNeedsResize–判断是否需要重置Dict大小
htNeedsResize(dict *dict)
long long size, used;
//哈希表大小--槽的数量就是数组长度
size = dictSlots(dict);
//entry数量
used = dictSize(dict);
//当哈希表大小大于4并且负载因子低于0.1,表示需要进行收缩
return (size > DICT_HT_INITIAL_SIZE &&
(used*100/size < HASHTABLE_MIN_FILL));
- dictSize–真正进行收缩的源码
/* Resize the table to the minimal size that contains all the elements,
* but with the invariant of a USED/BUCKETS ratio near to <= 1 */
int dictResize(dict *d)
unsigned long minimal;
//如果正在做bgsave或bgrewriteof或rehash,则返回错误
if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
//获取entry个数
minimal = d->ht[0].used;
//如果entry小于4,则重置为4
if (minimal < DICT_HT_INITIAL_SIZE)
minimal = DICT_HT_INITIAL_SIZE;
//重置大小为minimal,其实是第一个大于等于minimal的2^n
return dictExpand(d, minimal);
rehash源码分析
- _dictExpand函数是真正完成扩容的方法,下面来看看这个方法干了啥
/* Expand or create the hash table,
* when malloc_failed is non-NULL, it'll avoid panic if malloc fails (in which case it'll be set to 1).
* Returns DICT_OK if expand was performed, and DICT_ERR if skipped. */
int _dictExpand(dict *d, unsigned long size, int* malloc_failed)
if (malloc_failed) *malloc_failed = 0;
/* the size is invalid if it is smaller than the number of
* elements already inside the hash table */
//如果当前entry数量超过了要申请的size大小,或者正在rehash,直接报错
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
//声明新的hash table
dictht n; /* the new hash table */
//扩容后的数组实际大小,第一个大于等于size的2^n次方
unsigned long realsize = _dictNextPower(size);
/* Rehashing to the same table size is not useful. */
//计算得到的新数组大小与旧数组大小一致,返回错误信息
if (realsize == d->ht[0].size) return DICT_ERR;
/* Allocate the new hash table and initialize all pointers to NULL */
//设置新的hash table的大小和掩码
n.size = realsize;
n.sizemask = realsize-1;
if (malloc_failed)
n.table = ztrycalloc(realsize*sizeof(dictEntry*));
*malloc_failed = n.table == NULL;
if (*malloc_failed)
return DICT_ERR;
else//为新的hash table分配内存: size*entrySize
n.table = zcalloc(realsize*sizeof(dictEntry*));
//新的hash table的used为0
n.used = 0;
/* Is this the first initialization? If so it's not really a rehashing
* we just set the first hash table so that it can accept keys. */
//如果是第一次来,即进行哈希表的初始化,那么直接将
//上面新创建的n赋值给ht[0]即可
if (d->ht[0].table == NULL)
d->ht[0] = n;
return DICT_OK;
/* Prepare a second hash table for incremental rehashing */
//否则,需要rehash,此处需要把rehashidx设置为0
//表示当前rehash的进度
//在每次增删改查时都会触发rehash(渐进式hash下面会讲)
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
rehash流程分析
- 插入新元素,导致rehash产生 以上是关于Redis原理篇之数据结构的主要内容,如果未能解决你的问题,请参考以下文章
高级程序员必须精通的Redis,第三篇之——hash(散列)