不一样的Redis(二)
上次的文章,发布当天我的室友旭哥就津津有味的看了一遍,然后就意犹未尽,然后就一直催我,让我赶紧更新,被逼无奈,只能赶紧更新一下了。(PS:今天会上代码哦!!!!)
上次看完了我的不一样的Redis第一篇之后,我室友旭哥就迫不及待的问了,那3.2之后是如何实现的呢?
那么好,这里我给大家解析一下Redis3.2之后是如何实现的?
SDS
第一章还有一些知识点,忘记写了,这一章做一下补充。
为什么要用柔型数组
用柔性数组放字符串,是因为柔性数组的地址和结构体是连续的,这样查找内存更快(因为不需要额外通过指针找到字符串的位置)可以很方便地通过柔性数组的首地址偏移得到结构体首地址,进而能很方便地获取其余变量。
就是SDS并不是每一次都会成倍的扩容,而是有一个大小,当长度超过1M(1024*1024)的时候,这个时候就不会成倍的扩容了,而是每一次加1M的空间,这个地方后面做优化的话需要考虑一下。
static int checkStringLength(client *c, long long size) {
// 超出了512M,就直接报错
if (size > 512*1024*1024) {
addReplyError(c,"string exceeds maximum allowed size (512MB)");
return C_ERR;
}
return C_OK;
}
第一章,研究完了SDS3.2之前版本的实现,现在看一下,3.2之后的Redis是如何实现的?
#ifndef __SDS_H
#define __SDS_H
#define SDS_MAX_PREALLOC (1024*1024)
extern const char *SDS_NOINIT;
#include <sys/types.h>
#include <stdarg.h>
#include <stdint.h>
typedef char *sds;
/* Note: sdshdr5 is never used, we just access the flags byte directly.
* However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 { //这里表示0 - 2的5次方-1 字符串长度大小
unsigned char flags; /* 低3位存储类型,高5位存储长度 */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {//如果上面的不满足要去,走这个,表示0 - 3的8次方-1
uint8_t len; /* 目前字符创建的长度 */
uint8_t alloc; /* 已经分配的总长度,这里用alloc-len表示free */
unsigned char flags; /* 3个字节表示类型,5个字节还没使用 */
char buf[]; /* 柔性数组,这里都是以\0结尾的 */
};
struct __attribute__ ((__packed__)) sdshdr16 {//以此类推
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
这里发现,跟3.2之前的实现有一些不一样,为什么要多这些sdshdr5,sdshdr8,sdshdr16。。。呢?
原来是新版本根据长度不同的字符串做了优化,选取uint8_t,uint16_t,uint32_t等来表示长度,一共申请字节的大小等。
_attribute_((_packed_))是用来字节对齐。gcc的语法,一般情况下,结构体会按照所有变量大小的最小公倍数做字节对齐,而用packed修饰后,结构体则变为按1字节对齐。以sdshdr32为例,修饰前按4字节对齐大小为12(4*3)字节;修饰后按1字节对齐。这样做的好处:节省内存,例如sdshdr32可节省3个字节
SDS返回给上层的不是结构体首地址,而是指向内容的buf指针。因为此时按1字节对齐,故SDS创建成功后,无论是sdshdr16还是32,都能通过(char*)sh+hdrlen得到buf指针地址(其中hdrlen是结构体长度,通过sizeof计算得到)。修饰后,无论是sdshdr8,16还是32都能提供buf[-1]找到flags,因此此时按1字节对齐。如果没有packed的修饰,还需要对不同结构进行处理,实现更复杂。
创建字符串
sds sdsnewlen(const void *init, size_t initlen) {
void *sh;
sds s;
char type = sdsReqType(initlen);//根据字符串长度选择不同的类型
/* Empty strings are usually created in order to append. Use type 8
* since type 5 is not good at this. */
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;//SDS_TYPE_5强制转化为SDS_TYPE_8
int hdrlen = sdsHdrSize(type);//计算不同头部所需的长度
unsigned char *fp; /*指向flags的指针 */
sh = s_malloc(hdrlen+initlen+1);//+1是为了结束符\0
if (init==SDS_NOINIT)
init = NULL;
else if (!init)
memset(sh, 0, hdrlen+initlen+1);
if (sh == NULL) return NULL;
s = (char*)sh+hdrlen;//s是指向buf的指针
fp = ((unsigned char*)s)-1;//s是柔型数组buf的指针,-1是指向flags
//进行判断看type的类型
switch(type) {
case SDS_TYPE_5: {
*fp = type | (initlen << SDS_TYPE_BITS);
break;
}
case SDS_TYPE_8: {
SDS_HDR_VAR(8,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_16: {
SDS_HDR_VAR(16,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_32: {
SDS_HDR_VAR(32,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
case SDS_TYPE_64: {
SDS_HDR_VAR(64,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
}
if (initlen && init)
memcpy(s, init, initlen);
s[initlen] = '\0';//添加末尾的结束符
return s;
}
这里简单说明一下内存对齐的好处:
数据机构尤其是栈应该尽可能在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要做两次访问内存,而对齐的内存访问只需要一次即可。
缺点的话:就是存在效率问题,这是一种以空间换时间的做法。
释放字符串
void sdsfree(sds s) {
if (s == NULL) return;
s_free((char*)s-sdsHdrSize(s[-1]));//此处直接释放内存
}
为了优化性能(减少申请内存的开销),SDS提供了不直接释放内存,而是通过重置统计值达到清空的目的的方法--sdsclear。该方法仅将SDS的len归零,此处已存在的buf并没有真正被清除,新的数据可以覆盖写,而不用重新申请内存
void sdsclear(sds s) {
sdssetlen(s, 0);//统计值len归零
s[0] = '\0';//清空buf
}
拼接字符串
sds sdscatsds(sds s, const sds t) {
return sdscatlen(s, t, sdslen(t)); }
sdscatsds是暴露给上层的方法,其最终调用的是sdscatlen。由于其中可能涉及到SDS的扩容。sdscatlen中调用了sdsMakeRoomFor对带拼接的字符串s容量做检查。若无需扩容则直接返回s。若需要扩容,则返回扩容好的新字符串s,若需要扩容,则返回扩容好的新字符串s。函数中的len,curlen等长度值是不含结束符的。而拼接时用memcpy将两个字符串拼接在一起,指定相关长度,故该过程保证了二进制的安全。最后需要加上结束符
/* 将指针t的内容和指针s的内容拼接在一起,该操作是二进制安全的*/
sds sdscatlen(sds s, const void *t, size_t len) {
size_t curlen = sdslen(s); s = sdsMakeRoomFor(s,len); if (s == NULL) return NULL; memcpy(s+curlen, t, len);//直接拼接,保证了二进制安全
sdssetlen(s, curlen+len); s[curlen+len] = '\0';//加上结束符
return s; }
这里研究完string的底层实现了,这时候我室友旭哥灰头灰脸的来找我了,他说,大哥大哥,我刚才面试被怼了!!!
我:这能忍??!谁怼你的?怎么怼的?!!!!
旭哥:刚看完你写的Redis就迫不及待的去面试了一波,结果被面试官问到ZSet是如何实现的,我就一辆懵逼了,慌慌张张的说了一个链表,面试官冷笑了一声,让我回家深造一下,这可咋办啊???这ZSet到底是个啥?底层,大哥快救救我
跳跃表
跳跃表是有序集合ZSet的底层实现,效率高,实现简单
在了解跳跃表之前,我们先了解一下什么是有序链表?
有序链表是所有元素以递增或递减方式有序排列的数据结构,其中每个节点都有指向下一个节点next指针,最后一个节点的next指向null。递增有序链表
如果要查询值为52的元素,需要从1,11,22,41依次开始找起,那么,这个有序链表的时间复杂度就是O(N)。有序链表的插入和删除操作都需要找到合适的位置再修改next指针,修改操作基本不消耗时间,所以插入,删除,修改有序链表的耗时主要是查找元素上。
这里面,1叫11的前驱结点,11也是1的后继节点
那么,跳跃表又是什么呢?
首先我们思考一个问题
一个有序链表搜索,添加,删除的平均时间复杂度是多少?
答案:O(n)
还是上面那个图,如果我这里有个45要插入的话,需要怎么做呢?
这里肯定是要遍历链表,从1开始一直遍历到52,发现52比45大了,之后插入到41的位置,所以这里时间复杂度是O(n)
那么我们再思考一下,这里我们能否利用二分搜索优化有序链表,将搜索,添加,删除的平均时间复杂度降低到O(logn)呢?
还记得有序数组中有个遍历的方法叫二分查找么?
假如这里是一个数组,我们如果要查找60的位置,一般我们应该是从10开始遍历,但是如果是二分查找的话,我们可以从40开始遍历,找六十的位置,这样是不是就把时间复杂度降低了?
为什么数组可以二分查找呢?因为数组支持随机访问,比如我们要从中间开始,这里数组的长度是8,我们可以取下标是3的来访问
int arr[] = {10 , 20 , 30 , 40 , 50 , 60 , 70 , 80}
int middle = arr[3];
那么,大家在想一下,链表是不支持随即访问的,那么这里需要怎么才能让他进行二分查找呢?
还是这个链表,我们是不是可以让它有一个捷径呢?比如从1让它直接到22这样呢?这样就跳过了11。
我们是不是可以这样,通过冗余的方式,让1直接到22,这就跟坐地铁差不多,你坐车的话,从1号线换到2号线,再到3号线再到四号线,这样来回换,最后换十号线,那地铁可能不能这么玩,对吧,那坐车的人真的就做吐了,各种换成,那地铁中肯定是,比如1号线直接倒5号线,5号线再倒十号线就ok了。这上面其实和坐地铁是一个道理
这里如果我们要查找41的话,需要怎么做,可以从1直接跳跃到22,然后再往后走发现52比41大,那么就走到最下一层,往下走,找到41,这样是不是就减少了一半的路了
手写跳跃表
搞过链表的同学应该都知道,我们需要一个内部类Node。
/**
* 跳跃表的实现
* @author Five在努力
* 2020.11.15
*/
public class SkipList<K , V> {
/**
* 写过链表都知道需要封装一个node
* @param <K>
* @param <V>
*/
private static class Node<K , V>{
K key;
V value;
Node<K ,V>[] nexts;
}
}
Node中封装了key和value,链表中,也需要一个next来指向下一个Node,但是这里为什么是next数组呢?
大家想一下,看一下咱们上面画的图1是三层,11是一层,22是两层,这样的话,说明节点的个数是不确定的,所以需要用一个数组来装这些节点
跳跃表的搜索方式
从顶层链表的首元素开始,从左往右搜索,直到找到一个大于或等于目标的元素,或者到达当前层链表的尾部
如果该元素等于目标元素,则表明该元素已被找到
如果该元素大于目标元素或已到达链表的尾部,则退回到当前层的前一个元素,然后转入下一层进行搜索
public class SkipList<K, V> {
//这里需要定义一下跳跃表最大的层级,32层,因为不可能无限递增层数
private static final int MAX_LEVEL = 32;
private static final double P = 0.25;
private int size;
//这里用做比较
private Comparator<K> comparator;
/**
* 有效层数
*/
private int level;
/**
* 不存放任何K-V
*/
private Node<K, V> first;
public SkipList(Comparator<K> comparator) {
this.comparator = comparator;
first = new Node<>(null, null, MAX_LEVEL);
}
public SkipList() {
this(null);
}
public int size() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
public V get(K key) {
keyCheck(key);
Node<K, V> node = first;
//从最顶层开始循环,先循环竖层
for (int i = level - 1; i >= 0; i--) {
int cmp = -1;
//这里循环横层,往回查找链表
while (node.nexts[i] != null
&& (cmp = compare(key, node.nexts[i].key)) > 0) {
node = node.nexts[i];
}
if (cmp == 0) return node.nexts[i].value;
}
return null;
}
public V put(K key, V value) {
keyCheck(key);
Node<K, V> node = first;
Node<K, V>[] prevs = new Node[level];
for (int i = level - 1; i >= 0; i--) {
int cmp = -1;
while (node.nexts[i] != null
&& (cmp = compare(key, node.nexts[i].key)) > 0) {
node = node.nexts[i];
}
if (cmp == 0) { // 节点是存在的
V oldV = node.nexts[i].value;
node.nexts[i].value = value;
return oldV;
}
prevs[i] = node;
}
// 新节点的层数
int newLevel = randomLevel();
// 添加新节点
Node<K, V> newNode = new Node<>(key, value, newLevel);
// 设置前驱和后继
for (int i = 0; i < newLevel; i++) {
if (i >= level) {
first.nexts[i] = newNode;
} else {
newNode.nexts[i] = prevs[i].nexts[i];
prevs[i].nexts[i] = newNode;
}
}
// 节点数量增加
size++;
// 计算跳表的最终层数
level = Math.max(level, newLevel);
return null;
}
public V remove(K key) {
keyCheck(key);
Node<K, V> node = first;
Node<K, V>[] prevs = new Node[level];
boolean exist = false;
for (int i = level - 1; i >= 0; i--) {
int cmp = -1;
while (node.nexts[i] != null
&& (cmp = compare(key, node.nexts[i].key)) > 0) {
node = node.nexts[i];
}
prevs[i] = node;
if (cmp == 0) exist = true;
}
if (!exist) return null;
// 需要被删除的节点
Node<K, V> removedNode = node.nexts[0];
// 数量减少
size--;
// 设置后继
for (int i = 0; i < removedNode.nexts.length; i++) {
prevs[i].nexts[i] = removedNode.nexts[i];
}
// 更新跳表的层数
int newLevel = level;
while (--newLevel >= 0 && first.nexts[newLevel] == null) {
level = newLevel;
}
return removedNode.value;
}
private int randomLevel() {
int level = 1;
while (Math.random() < P && level < MAX_LEVEL) {
level++;
}
return level;
}
private void keyCheck(K key) {
if (key == null) {
throw new IllegalArgumentException("key must not be null.");
}
}
private int compare(K k1, K k2) {
return comparator != null
? comparator.compare(k1, k2)
: ((Comparable<K>)k1).compareTo(k2);
}
//搞过链表的铜须都知道需要在内部封装一个Node,用来存放键值对,指向下一个链表的指针,但是这里因为是跳跃表,所以是一个数组,用来指向下一个数组
private static class Node<K, V> {
K key;
V value;
Node<K, V>[] nexts;
public Node(K key, V value, int level) {
this.key = key;
this.value = value;
nexts = new Node[level];
}
@Override
public String toString() {
return key + ":" + value + "_" + nexts.length;
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("一共" + level + "层").append("\n");
for (int i = level - 1; i >= 0; i--) {
Node<K, V> node = first;
while (node.nexts[i] != null) {
sb.append(node.nexts[i]);
sb.append(" ");
node = node.nexts[i];
}
sb.append("\n");
}
return sb.toString();
}
}
跳表已经写完了,下一章详细讲一下Redis中跳跃表的实现和这个跳跃表是如何实现的,大家敬请期待