Redis原理---数据结构篇
引言
redis是一种内存型的NoSql数据库,常用于缓存、分布式session、排行榜、消息队列等等功能实现。作为内存型的数据库,redis是怎么充分利用内存?如何实现高性能?又是怎样支撑如此多的功能呢?也许可以从redis的数据结构设计中寻找答案。
字符串 sds
redis的场景决定了字符串的高度频繁读取,所以字符串主要以解决性能问题为主,安全性、功能性为辅。c语言的字符串不满足这些场景,于是redis自定义了一种SDS的简单动态字符串。
SDS的定义如下:
struct sdshdr{
//已使用的字节数
int len;
//未使用的字节数
int free;
//字节数组
char buff[];
}
我们从定义和api等方面看看sds是如何实现高性能、安全性、功能性的。
1、高性能
c语言在获取字符长度时是通过遍历字符数组来获取的,复杂度是O(n)。而SDS是直接获取len即可,复杂度是O(1)。这方面,可以说是SDS完爆C字符串吧。
在扩缩容字符串时,c字符串是每次都重新申请/清理内存,有时会涉及系统调用。不频繁修改,性能差距还不明显。但redis的场景要求必然是会频繁修改字符串,所以redis也内存分配上做了优化。再重新申请时,会多分配一部分内存。小于1M,则翻倍。大于1M,则多分配1M。清理时也不是直接释放内存,而是修改free,下次可以复用多余的内存。
2、安全性
c字符串需要手动申请内存,如果未申请,则会导致覆盖后面字符串的数据。造成字符溢出。SDS则是自动帮忙申请,安全可靠。
3、功能性
c字符串是以空格为结尾的ASCII码字符组成,格式限制高且不能存储空格字符,只能存储文本。SDS则可以存储任意的二进制字符,功能灵活。SDS还兼容部分c字符的函数。
链表 linkedlist
由于c没有专门的链表结构,所以redis自定义了一个带首尾节点的双端链表。可以实现顺序访问、范围访问。结构和Java中的LinkList差不多。不细表。
字典 dict
redis是个KV型数据库,所以KV数据结构必不可少。而redis的字典数据结构就是KV的具体实现。
字典dict定义如下:
typedef struct dict{ //类型函数,不讨论 dictType *type; //私有数据,不讨论 void *private; //哈希表 分为h0和h1区 dictht ht[2]; //渐进式hash索引 int trehashidx; } typedef struct dictht{ //hash数组 dictEntry **table; //节点大小 unsigned long size; //哈希表掩码 unsigned long sizemask; //已有节点 unsigned long used; } typedef struct dictEntry{ //键 void *key; union{ void *val; unit64_tu64; int64_ts64; } v; //哈希表掩码 unsigned long sizemask; //下一个hash节点 struct dictEntry *next;
整体上和Java中的HashMap差不多,都是数组+链表形式。真正不同的地方在于rehash。众所周知,如果字典结构如果数据量很大,那么一次完整的rehash是很耗时的一件事。而redis是单线程的,一次耗时长的rehash造成阻塞几乎是件必然的事。而这是redis不能承受的。所以redis发明了一个渐进式rehash操作。
渐进式rehash,意思就是不是一次性全部rehash完成,而是分批的rehash。字典中的两个数组和trehashidx字段就是为了实现渐进式rehash。在普通状态下,读写都在数组h0中,h1不做任何操作。但在渐进式rehash中,是先给h1分配双倍的空间,然后每次读取、写入时从h0中的一个元素rehash到h1。这样每次rehash数组的一个元素,避免了阻塞,trehashidx记录了已经rehash的数量,全部完成后再将h1赋值给h0。渐进式rehash期间,读取和写入都是先访问h0,再访问h1。
综上,特征如下
1、数组+链表结构
2、链表头插法
3、渐进式hash
跳表 skiplist
跳表是一种有序的数据结构,支持顺序查找、倒序查找、范围查找。平均O(logn),最差O(n)的复杂度。查询速度可以和B树、hashMap媲美,实现也相对简单。是redis的有序集合键的底层实现之一。
跳表的整体结跳表是在双端链表的基础上,加上了一个forward数组。forward数组记录着对应层高的下一个节点,也即可以通过forward[level]快速跳跃到下一个节点,达到快速查询的效果。forward[0]层则是完整的数据链表了,加上backward前指针,可以完美的实现范围查找、顺序查找、倒序查找。这也是命令zrange和zresrange的底层支持了。跳表的Java实现代码在文末,有兴趣的同学可以看一看。
综上,特征如下:
1、查询速度快
2、内存碎片不少:每个节点都有若干个其他节点的指针。
3、顺逆序遍历
4、范围遍历
压缩列表 ziplist
为了进一步压缩内存,redis开发了一种压缩列表的数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或一个整数
每一个entry都会用一个字节(或两个/五个字节)记录前节点的长度,以便通过(当前位置-前节点的长度)来快速找到前节点,类似"前指针",而且没有任何内存碎片,达到压缩内存的效果。也因为这个结构,所以也只能从尾遍历,不能顺序遍历。该结构也有缺陷,比如前节点的长度从63变成64+,当前节点原本用一个字节变成了用两个字节来记录前节点的长度,也即当前节点的长度会+1,如果恰巧也是63变成64,那么后节点也会跟着变长。从而产生"连锁更新"反应,影响到性能。
综上,特征如下:
1、内存紧凑:没有多余碎片,但更新时,需要申请或回收内存。
2、逆序遍历:从尾节点遍历,尾插法。
3、连锁更新:一个节点的更新,可能导致后面的若干节点都更新
快速链表 quicklist
早期版本中,list有两种底层实现:
1、列表较短或者元素占用少,则采用ziplist存储。
2、列表较长或者元素占用多,则采用linkedlist存储
但其实两者都有优缺点:
1、ziplist
优点:内存紧凑,
缺点:更新效率低
2、linkedlist
优点:更新很快
缺点:节点多时,会有碎片。
为了兼容二者优缺点,高版本redis实现了一种快速列表quicklist。quicklist是一种以ziplist为节点的linkedlist的结构。结构参考如下
该结构虽然讲二者优点都兼容了,但在某些情况下也是会水土不服的。当然,redis设计者还是很贴心的,都有各自的解决方案。
场景一:
ziplist分配元素过多,那么ziplist会出现更新慢的问题
ziplist分配过少,那么会退化成linkedlist。内存碎片多。
解决方案:配置文件提供list-max-ziplist-size参数。为正数时表示,每个ziplist数据项的最大值。为负数(只能是[-1,-5]区间的数)时,表示ziplist大小级别。使用者可以根据场景自定义该参数。
场景二:
当链表很长时,最频繁访问的是两端的数据,中间访问频率会较低。比如排行榜,通常也就是访问最高/最低的那几个元素。那其实中间的数据其实可以进一步压缩的。同样的,redis的配置文件也提供了配置参数list-compress-depth,表示双端各有多少个节点被压缩,为0时表示不压缩,这也是默认值。
整数集合 intset
整数集合是集合对象的底层实现,当集合对象元素全部是整数时,就会使用intset来存储。
typedef struct intset { uint32_t encoding; uint32_t length; int8_t contents[]; }
intset的真正数据是存放在contents数组中,且该数组是从小到大排序的。元素类型由encoding属性决定,一般有int8_t、int16_t、int32_t。
intset的升级
如果contents中的元素全部都是int8_t,而新加入的整数是int16_t时,那么就会触发升级。具体的操作是为contents重新分配空间。其次是将原有整数类型转换成int16_t,排序插入到新数组。最后插入新的整数。ps:因为插入的数是高级别的类型,所以要么比原整数都大,要么比原整数组都小,所以要么是插入数组头,要么是数组尾,复杂度为O(1);当然这里是不会有降级操作的,这辈子都不可能降级。
综上,intset的特征如下:
1、可兼容多种整数类型
2、会升级,但不会降级
对象 redisObject
虽然上述的数据结构已经非常丰富完善,但redis并不直接使用上述数据结构。而是引入redisObject。
typedef struct redisObject { //编码 unfigned encoding; //类型 unfigned type; void *ptr; //引用计数 int refcount //最近访问时间 unsigned lru; }
通过type和encoding的方式灵活决定使用哪些数据结构。此外还有引用计数来实现内存回收和对象复用,lru记录最近访问时间,以便淘汰过期的key。对象主要有五类:字符串对象、哈希对象、列表对象、集合对象、有序集合对象。
字符串对象
字符对象一般是int、raw(sds)、embstr三种。embstr是一种redisObject+sds,专门保存短字符串(<=32字节)。raw是需要分配redisObject和sds两次内存,但embstr只需要分配一次内存就好了,同时释放也只需要释放一次。不过embstr是只读的,只要修改一次(append命令),哪怕长度<=32字节就会变成raw。使用object命令可以观察到这个这变化。
127.0.0.1:6379> set str value1 OK 127.0.0.1:6379> object encoding str "embstr" 127.0.0.1:6379> set str nlakjsdnflaksjdhflaskdjfhlaksdfhaisduhflaisudfhlaisduf\ OK 127.0.0.1:6379> object encoding str "raw" 127.0.0.1:6379> set str2 a OK 127.0.0.1:6379> object encoding str2 "embstr" 127.0.0.1:6379> append str2 b (integer) 2 127.0.0.1:6379> object encoding str2 "raw"
字符串对象也是很多其他对象的基础实现之一。
列表对象
列表对象一般是ziplist和linkedlist两种。高版本是quicklist。
编码转换:
当满足1、列表元素长度都小于64字节 2、列表长度小于512时才会使用ziplist。只要不满足其中之一就会转换成linkedlist.这两个数值可以通过list-max-ziplist-value和list-max-ziplist-entries配置
哈希对象
哈希对象一般是ziplist和hashtale两种。ziplist是如何实现哈希功能呢?ziplist用两个entry保存key和value,然后相邻地插入到ziplist。满足两个条件1、所有键值对长度都小于64字节2、键值对数量小鱼512才会使用ziplist
只要不满足其中之一,就会转化成hashtable。这两个数值可以通过hash-max-ziplist-value和hash-max-ziplist-entries配置
集合对象
集合对象一般是intset和hashtale两种。intset好理解,hashtable怎么做集合对象呢?很简单,key是字符串对象,value是null即可。同时满足两个条件,1、所有值都是整数2、数量小于512。才会使用intset只要不满足其中之一,就会转化成hashtable。第一个条件没法改,但第二个数值可以通过set-max-intset-entries配置
有序集合对象
有序集合对象一般是ziplist和skiplist两种。ziplist怎么实现有序集合对象呢?用两个entry,一个存分值,一个存value,同时且有序地插入ziplist。分值小的靠近表头,大的靠近表尾。和其他对象不同的是,有序集合对象还存有一个hashtable,保存<分值,value>的映射。方便用O(1)的速度快速查找分值对应的value。同时满足两个条件,1、所有元素长度小于64字节2、数量小于128才会使用ziplist。只要不满足其中之一,就会转化成skiplist.这两个数值可以通过zset-max-ziplist-value和zset-max-ziplist-entries配置
引用计数
通过refcount记录引用总数,方便回收内存。不过应该和JVM的引用缺陷相同,相互引用的情况下,会造成无法回收的情况。
共享对象
redis启动时会生成一批对象,方便共享。比如1-9999之类的。
总结
redis的数据结构最大的特点是节约内存,因为纯内存操作本身已经是非常高的性能,而如果能节约内存则就能获取最大的性能收益了。因此其次才是性能方面。同时还兼顾了功能性,以便能实现各种各种的功能,比如排行榜、消息队列等等。
节约内存
1、emstr和redisObject连续存储
2、ziplist的极致压缩
3、intset的升级机制
4、对象引用回收和复用
5、quickList的中间节点压缩
高性能:
1、SDS的预分配、惰性释放、len
2、skiplist的高效查询
3、hashtable
4、emstr只申请一次内存
5、有序集合中的hashtable结构
功能性:
1、sds的保存二进制数组,支持图片等
2、skipList的顺逆序查询、范围查询
3、intset的兼容
4、hashtable的渐进式rehash防阻塞
参考引用
1、《redis的设计与实现第二版》
2、https://www.html.cn/softprog/database/176951.html
参考代码
public class SkipList<T extends Comparable<? super T>> { private static final int LEVEL_MAX = 32; private Node<T> head = new Node<>(LEVEL_MAX, null); private Node<T> tail; private int levelCount = 0; private static double SKIP_P = 0.5; private class Node<T> { Node<T> backword; Node<T>[] forward; int levelMax; T data; Node(int levelMax) { this.levelMax = levelMax; forward = initForward(this.levelMax); } Node(int levelMax, T t) { this.levelMax = levelMax; forward = initForward(this.levelMax); data = t; } public T getData() { return data; } public void setData(T data) { this.data = data; } // @Override // public String toString() { // return "Node{" + // "pre=" + pre + // ", pro=" + pro + // ", forward=" + Arrays.toString(forward) + // ", levelMax=" + levelMax + // ", data=" + data + // '}'; // } } public Node<T> get(T t) { Node<T> p = head; for (int i = head.levelMax - 1; i >= 0; i--) { while (p.forward[i] != null && p.forward[i].getData().compareTo(t) < 0) { p = p.forward[i]; } } if (p.forward[0].getData().equals(t)) { return p.forward[0]; } return null; } public void put(T t) { int level = randomLevel(); Node<T> newNode = new Node<>(level, t); Node<T>[] trace = initForward(level); levelCount = Math.max(level, levelCount); Node<T> p = head; for (int i = level - 1; i >= 0; i--) { while (p.forward[i] != null && p.forward[i].getData().compareTo(t) < 0) { p = p.forward[i]; } trace[i] = p; } for (int i = level - 1; i >= 0; i--) { newNode.forward[i] = trace[i].forward[i]; trace[i].forward[i] = newNode; } //维护生成节点的前指针 if (trace[0] == head) { newNode.backword = null; } else { newNode.backword = trace[0]; } //维护生成节点的后指针 if (newNode.forward[0] != null) { newNode.forward[0].backword = newNode; } else { tail = newNode; } } public void delete(T t) { Node<T>[] trace = new Node[levelCount]; Node<T> p = head; for (int i = levelCount - 1; i >= 0; i--) { while (p.forward[i] != null && p.forward[i].getData().compareTo(t) < 0) { p = p.forward[i]; } trace[i] = p; } for (int i = 0; i < levelCount; i++) { //去除重复的值 while (trace[i] != null && trace[i].forward[i] != null && trace[i].forward[i].data.equals(t)) { Node<T> targetNode = trace[i].forward[i]; if (targetNode.equals(tail)){ tail = trace[i]; } trace[i].forward[i] = targetNode.forward[i]; if (targetNode.forward[i] != null) { targetNode.forward[i].backword = trace[i]; } targetNode.forward[i] = null; targetNode.backword = null; } } } static int randomLevel() { int level = 1; //层数概率会影响插入和查询效率 while (Math.random() < SKIP_P && level < LEVEL_MAX) { level++; } return level; // return (int) (Math.random() * (LEVEL_MAX - 1)) + 1; } private List<T> range(int start, int end) throws Exception { if (start < 0) { throw new Exception("start is err"); } List<T> result = new ArrayList<>(); Node<T> p = head.forward[0]; int i = 0; while (p != null) { if (start <= i && i <= end) { result.add(p.getData()); } if (i > end) break; p = p.forward[0]; i++; } return result; } private List<T> resRange(int start, int end) throws Exception { if (start < 0) { throw new Exception("start is err"); } List<T> result = new ArrayList<>(); Node<T> p = tail; int i = 0; while (p != null) { if (start <= i && i <= end) { result.add(p.getData()); } if (i > end) break; p = p.backword; i++; } return result; } private Node[] initForward(int level) { Node[] forward = new Node[level]; return forward; } private void printAll() { for (int i = LEVEL_MAX - 1; i >= 0; i--) { Node<T> p = head.forward[i]; StringBuilder str = new StringBuilder("第").append(i).append("层"); while (p != null) { str.append(p.data.toString()).append("->"); p = p.forward[i]; } System.out.println(str.append("null").toString()); } } public static void main(String[] args) throws Exception { SkipList<Integer> skipList = new SkipList<>(); for (int i = 0; i < 5; i++) { skipList.put(i); } skipList.printAll(); skipList.delete(0); skipList.printAll(); List<Integer> range = skipList.range(0, 1); System.out.println(JSON.toJSONString(range)); List<Integer> resRange = skipList.resRange(0, 1); System.out.println(JSON.toJSONString(resRange)); // timeTest(); } public static void timeTest() { SkipList<Integer> skipList = new SkipList<>(); int count = 100000; long time1 = System.currentTimeMillis(); for (int i = 0; i < count; i++) { skipList.put(i); } long time2 = System.currentTimeMillis(); System.out.println(" insert " + (time2 - time1)); for (int i = 0; i < count; i++) { skipList.get(i); } long time3 = System.currentTimeMillis(); System.out.println("get " + (time3 - time2)); HashMap<String, Integer> hashMap = new HashMap<>(); long htime1 = System.currentTimeMillis(); for (int i = 0; i < count; i++) { hashMap.put(i + "", i); } long htime2 = System.currentTimeMillis(); System.out.println("hashMap insert " + (htime2 - htime1)); for (int i = 0; i < count; i++) { hashMap.get(i + ""); } long htime3 = System.currentTimeMillis(); System.out.println("hashMap get " + (htime3 - time2)); } }

浙公网安备 33010602011771号