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));

    }

} 
posted @ 2021-12-03 10:21  程序实验室  阅读(193)  评论(0)    收藏  举报