不一样的Redis(二)

1,029 阅读12分钟

不一样的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中跳跃表的实现和这个跳跃表是如何实现的,大家敬请期待