数据结构
- 简单动态字符串(SDS)
- 优点:O(1)获取长度;空间预分配,惰性空间释放
- 链表
- 字典
- 渐进式rehash
- 跳跃表
- 整数集合
- 升级
- 压缩列表
- 连锁更新带来的问题
1. SDS(Simple Dynamic String)
3.2版本之前一种结构,3.2开始5种数据结构
//3.0版本
struct sdshdr {
// 记录buf数组中已使用字节的数量,即SDS所保存字符串的长度
unsigned int len;
// 记录buf数据中未使用的字节数量
unsigned int free;
// 字节数组,用于保存字符串
char buf[];
};
//3.2版本
sdshdr5:32位 4B
sdshdr8:256位 32B
sdshdr16:64kB
sdshdr32:4G
以sdshdr8为例:
len:记录当前已使用的字节数(不包括'\0'),获取SDS长度的复杂度为O(1)
alloc:记录当前字节数组总共分配的字节数量(不包括'\0')
flags:标记当前字节数组的属性,是sdshdr8还是sdshdr16等,flags值的定义可以看下面代码
buf:字节数组,用于保存字符串,包括结尾空白字符'\0'
// flags值定义
#define SDS_TYPE_5 0
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
(1)优点
- 常数复杂度O(1)获取字符串长度:C语言需要遍历buf获取长度
- 杜绝缓冲区溢出:SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题
- 减少了修改字符串时带来的内存重分配次数
- 对于C语言,修改C字符串,需要对C字符串数组进行一次内存重分配操作。而Redis使用场景,需要频繁修改字符串,C的方式会造成性能影响
- 为避免C的缺陷,在SDS中,buf长度不一定就是字符数量加一,数组里面可以包含未使用的字节,记录在free属性中
- 通过free属性,SDS实现了空间预分配和惰性空间释放两种优化空间的分配策略
空间欲分配 | 当SDS需要被修改增长时,程序会提前分配额外的空间,为之后扩展作预留
|
惰性空间释放 | 当SDS需要被修改缩短时,不会立刻释放内存空间。而是先用free属性记录起来,等待将来使用 |
- SDS的API是二进制安全的
- 由于C语言中空字符代表会被认为是字符串结尾,因此C字符串只能保存文本,不能保存图片、音频、视频这样的二进制数据
- 而SDS的API都会以处理二进制的方式来处理存放在
buf
数组里的数据,不会对里面的数据做任何的限制。SDS使用len
属性的值来判断字符串是否结束,而不是空字符。
- 兼容部分C字符串函数
2.链表
- 链表(List):包括 链表头指针head,链表尾指针tail,链表长度len,其他三个临时值用于实现函数(dup,free,match)
- 节点(ListNode):listNode中包括(前节点指针,后节点指针,节点值)
//链表中的节点定义
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点值
void *value;
} listNode;
//链表定义
typedef struct list {
// 链表头节点
listNode *head;
// 链表尾节点
listNode *tail;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
// 链表所包含的节点数量
unsigned long len;
} list;
3.字典
由于C语言并没有内置字典的数据结构,因此Redis构建了自己的字典实现
(1)数据结构
一共三种数据结构(字典,哈希表,哈希节点),结构类似Hashmap,采用数组+链表
- dictEntry:哈希节点(类似JAVA中Entry)
- key:节点键
- value:节点值
- 下个节点指针:指向下个节点
- dictht:哈希表
- size:哈希表大小,即table数组的大小
- used:已有节点(key-value对)的数量
- sizemask:总是等于size-1
- dict:字典结构
- type:指向dictType(存储与字典操作有关的函数)的指针,为创建多态字典而设置
- privdata:用于传递dictType的参数args,可选参数
- ht[2]:包含了两个dictht的数组,指向两个dictht用于扩容
- 一般情况下,只使用ht[0],ht[1]在h[0]进行rehash时使用
- rehashidx:记录rehash目前的进度
- 如果目前没有进行rehash,rehashidx=-1;正在进行时rehashidx>=0
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值,等于size-1
unsigned long sizemask;
// 哈希表已有节点的数量
unsigned long used;
} dictht;
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
typedef struct dict {
// 和类型相关的处理函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引,当rehash不再进行时,值为-1
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 迭代器数量
unsigned long iterators; /* number of iterators currently running */
} dict;
(2)哈希算法
- 索引计算:先通过dict->type->hashFunction()计算哈希值,再与dictht中的sizemask作&操作得到数组下标
- 插入方法:拉链头插法
- 渐进式rehash
- rehash条件
- 服务器目前没有执行bgsave或bgrewriteof:负载因子>=1时触发
- 服务器目前正在执行bgsave或bgrewriteof:负载因子>=5时触发
- 当负载因子<0.1时,自动开始收缩
- 负载因子:比如数组table长度为32,但是一共使用了48个Entry,则负载因子是:48/32 = 1.5
- rehash扩容大小
- 自动扩展到>size的2的n次方。eg.used=17时候,扩展到32。
- 渐进式rehash
- 步骤:rehash并不是一次性完成的,而是多次完成。
- 为ht[1]分配空间
- 将rehashidx设置为0,表示rehash开始
- 每次对字典的增删改查都在ht[1]上进行,不在更改原来的ht[0]
- 增删改查的同时,顺带将ht[0]在rehashidx索引上的所有键值对rehash到ht[1]。每次结束后rehashidx++
- 全部完成后,rehashidx再置为-1
- 如果这期间,有查询操作,先从ht[0]查找,找不到再去ht[1]中查找
- 优缺点
- 优点:如果键值对有无限个,一次性rehash()会导致数据库停止服务,避免了redis阻塞
- 缺点:由于需要新分配ht[1],如果原本就很大,会导致内存量暴增,导致大量key被驱逐
- 步骤:rehash并不是一次性完成的,而是多次完成。
- rehash条件
4.整数集合
整数集合是redis用于保存整数值的数据结构
(1)数据结构
intset结构体:
- length:元素数量
- encoding:表示数组里面的类型int16/32/64
- contents:从小到大排好序的数组,且数组中不包含任何重复项
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
升级:如果此时encoding=int16,插入了一个int32类型的数,就会对所有数进行升级,都变成int32。
具体方法:计算出需要增加的空间大小,从后往前移到新位置
不支持降级
5.压缩列表
(1)数据结构:ziplist和entry组成
- ziplist:一个压缩列表包含多个节点entry,每个节点可以保存一个字节数组或者一个整数值
zlbytes
:记录整个压缩列表占用的内存字节数。zltail
:第一个entry1到最后一个entry的偏移量。eg.如果知道第一个entry的指针,可以计算出末节点的地址zllen
:包含的entry节点数量,但该属性值小于UINT16_MAX(65535)时,该值就是压缩列表的节点数量,否则需要遍历整个压缩列表才能计算出真实的节点数量zlend
:特殊值0xFF(十进制255),用于标记压缩列表的末端
- entry
previous_entry_length
:记录压缩列表前一个字节的长度(1字节或者5字节),可以根据这个计算出前一个entry的地址- 如果前一个entry<254字节,则用1个字节保存前一个entry长度
- 如果前一个entry>=254字节,则用5个字节保存前一个entry长度,最高位是0xFE
encoding
:节点的encoding保存的是节点的content的内容类型- 最高位是00,01,10表示数组
- 最高位是11表示整数
content
:content区域用于保存节点的内容,节点内容类型和长度由encoding决定
(2)连锁更新问题
- 大致意思是:
previous_entry_length可能是1字节,也可能是5字节,取决于大小是否<254字节
- 如果存在多个连续的,长度介于250字节到254字节的节点。就会引起连锁更新
- 最坏情况下要对压缩链表执行N次空间重分配操作
6.跳跃表
最初由William Pugh的论文提出,是一种可以媲美平衡树的链表结构
与红黑树的区别在于:高并发时红黑树的rebalance可能涉及整棵树操作;而跳跃表只涉及局部
在复杂度相同的情况下,跳跃表实现更简单
(1)原始的跳跃表
正常来说链表的查找复杂度是O(n)。跳跃表的复杂度是O(log2n)
对于链表的每层节点,每两个节点都往上加一层索引。假设第一层n个索引,第二层n/2,第三层n/4,以此类推。
下图展示了寻找数字8的搜索过程
(2)redis改进的跳跃表
原始的存在的问题:维护麻烦,每插入一个新节点,需要维护后面节点这种上下相邻两层链表上节点个数严格的 2:1 的对应关系。复杂度变回log(n)
redis的改进:对于每个节点,根据幂次定律(类似反比例函数的图像)随机生成一个介于1到32之间的值作为层level的大小
eg. 50%概览level=1, 25%概览level=2,12.5%概览level=3.....
(3)redis结构
zSkipListNode:节点 | double score:记录值 sds:记录value *backward:指向前一个node的指针 level[n]:span表示跨度记录节点之间的距离,*forward记录下一个 |
zSkipList:跳表 | *header, *tail记录头尾指针 length记录节点数量 |
注:sds必须唯一,但是score可以相同。
eg. 按照score进行排序,相同score的按照sds字典序排序
zadd myzset 15 banana
zadd myzset 10 apple
zadd myzset 20 apple //(apple,10)更新为(apple,20)
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
// 成员对象 (robj *obj;)
sds ele;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
// 跨度实际上是用来计算元素排名(rank)的,在查找某个节点的过程中,将沿途访过的所有层的跨度累积起来,得到的结果就是目标节点在跳跃表中的排位
unsigned long span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
(4)实现
插入删除操作:类似链表,先找到该节点,难点在于维护好各个指针
更新:如果value存在,调整score的值。如果value不存在,变成插入
rank操作:沿着搜索路径,把所有经过节点的跨度span 值进行累加就可以算出当前元素的最终 rank 值。
Reference
java - Redis(2)——跳跃表 - 我没有三颗心脏 - SegmentFault 思否
《Redis设计与实现》