redis字典和跳表实现

现在开始讨论redis内部的实现。关于源码的学习,有一个建议。首先定一个小的主题,预期要得到的效果,准备测试数据以及调试环境,然后查看流程,把每一个细支流程拷贝出来并在旁边写上注释,最后得出结论。在学习过程中,会不可避免地接触到源码。我们可以利用画图等工具,将源码截图,加上箭头指向下一个流程,加上文本框添加注释,标明代码流程。
另外,学习一些组件的时候,带着一条主线索去学习,效率会较高。比如,学习redis,主线就是单线程如何做到高效;学习nginx,主线就是多进程的原理,多进程下的单线程如何做到高效;学习skynet,主线是如何做到充分的并发。

字典

redis DB KV组织是通过字典来实现的。当hash结构当节点超过 512 个或者单个字符串长度大于64 时,hash结构采用字典实现。

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

typedef struct dictht {
    dictEntry **table;
    unsigned long size;// 数组长度
    unsigned long sizemask; //size-1
    unsigned long used;//当前数组当中包含的元素
} dictht;

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) 用于安全遍历*/
} dict;

这里介绍一下,字符串经过hash函数运算得到64位整数,而整数对2的n次幂取余可以转化为位运算,所以有了sizemask

哈希冲突

负载因子

redis用负载因子描述哈希冲突的严重程度。
负载因子 = used / size
used 是数组存储元素的个数,size 是数组的长度。
负载因子越小,冲突越小;负载因子越大,冲突越大。

扩容

如果负载因子 > 1 ,redis会发生扩容,扩容的规则是将size翻倍。
在这里插入图片描述
如果正在 fork (在rdb、aof复写以及rdb-aof混用情况下)时,会阻止扩容;但是如果此时若负载因子 > 5 ,索引效率大大降低,redis仍然会马上扩容。这里涉及到写时复制原理。写时复制的核心思想是,只有在不得不复制数据内容时才去复制数据内容。

缩容

如果负载因子 < 0.1 ,redis会发生缩容,缩容的规则是恰好包含 used 的 2的整数次幂。举个例子,假如此时数组存储元素个数为9,恰好包含该元素的就是16。
在这里插入图片描述
dict的size至少为4,缩容不会缩到比4小。正常size取质数能够尽可能地避免冲突,这里size不取质数,有两个原因,一是质数取余比位运算慢,二是便于后面的scan操作。

渐进式rehash

当 hashtable 中的元素过多的时候,不能一次性 rehash 到 ht[1]。由于redis是单线程逻辑,这样的话,rehash会长期占用 redis ,其他命令得不到响应,所以需要使用渐进式 rehash 。
rehash步骤:将 ht[0] 中的元素重新经过hash函数生成64位整数,再对 ht[1] 长度进行取余,从而映射到ht[1] 。
渐进式规则:

  1. 分治的思想,将 rehash 分到之后的每步增删改查的操作当中。但是,如果要rehash的元素非常多,比如上千万,而操作次数较少,每次操作又只rehash一个元素,这样永远都完成不了rehash了,所以有了方法2。
  2. 在定时器中,每次rehash 100 个数组槽位,当执行到1ms就退出rehash,等待下次定时器继续rehash。每次执行的间隔由server.hz,默认值为10,即100ms执行一次。
    注意,处于渐进式rehash阶段时,不会发生扩容缩容。
    这里提示一下,在编码的过程中,有两种重要的思维,一是分治,将大问题转化为小问题,二是抽象,这个在之前的设计模式中有提到。

scan间断遍历

scan cursor [MATCH pattern] [COUNT count] [TYPE type]

cursor是游标值,返回的第一个值是下次要遍历的游标值。count不是遍历出来的元素数,而是遍历的槽位数。
scan在一定程度上降低了遍历时redis的工作量,相当于自动的分页查询,也运用到了分治的思想。
游标cursor采用高位进位加法的遍历顺序,主要原因是rehash 后的槽位在遍历顺序上是相邻的,这也是size取2的整数次幂的原因之一。
这里简单介绍一下scan的原理。遍历目标是不重复,不遗漏 。当然,redis在缩容时scan可能会重复,但是不会遗漏。
在这里插入图片描述

扩容

如果scan到10,返回的游标是01,此时扩容,再次scan,访问的是001(高位自动添0),然后返回101,做到了不重不漏。

缩容

如果scan到010,返回110,此时缩容,再次scan,访问的是10(自动忽略高位),这样会重复遍历010。

正在rehash

这种情况较为复杂,不论扩还是缩,遍历size较大的那个dict就行。

expire过期机制

# 只支持对最外层key过期
expire key seconds
pexpire key milliseconds
ttl key
pttl key

过期机制中的删除操作一般有三种方式,redis采用后两种。
1、定时删除。为每个key做个定时器,到时就删。
2、惰性删除。分布在每一个命令操作时检查 key 是否过期,若过期删除 key ,再进行命令操作。这一操作由函数expireIfNeeded()完成。
3、定期删除。在定时器中检查库中指定个数个key。这一操作由函数activeExpireCycle()完成。每次执行间隔同样由server.hz决定。事实上,这个函数和刚才讲到的rehash,是在同一个函数中调用,具体代码就不贴了。

大key

在redis实例中有可能会形成很大的对象,比如一个很大的hash或很大的zset,这样的对象在扩容的时候,会一次性申请更大的一块内存,这会导致卡顿;如果这个大key被删除,内存会一次性回收,卡顿现象会再次产生。这也是所谓的“双卡顿”。如果观察到redis的内存大起大落,极有可能因为大key导致的。
对于“双卡顿”的解决办法,是找到大key,重新设计数据结构,把它分散下来。

# -i是执行间隔,如每隔0.1秒 执行100条scan命令
redis-cli -h 127.0.0.1 --bigkeys -i 0.1

执行命令后,会遍历所有key,所以如果key太多,建议加-i。分析出不同数据类型的大key后,然后进行优化即可。
删除大key是由专门开启的一个线程bio_lazy_free来负责的,但是如果没有设置或是redis版本较低(4.0),删除大key没有用这个线程,依然会卡顿。

内存异常之另一种情况

大key造成的“双卡顿”是redis内存异常的一种情况。此外,再介绍一种异常情况。
redis中key过多,如果此时又在fork过程中,又对key有操作,根据写时复制的原理,内存会翻倍,有可能会造成内存溢出。redis中有maxmemory配置能占用的最大内存数,不要设置超过物理内存的一半。当然,最好也不要刚好设置一半,因为还有操作系统等占用一些空间。

跳表

跳表(多层级有序链表)结构用来实现有序集合。鉴于redis需要实现 zrange 以及 zrevrange功能,需要节点间最好能直接相连并且增删改操作后结构依然有序。考虑B+树和跳表。
B+树时间复杂度为O(log n),鉴于B+复杂的节点分裂操作。考虑其他O(1)或是O(log n)的数据结构,这里考虑跳表。

理想跳表

每隔一个节点生成一个层级节点,用以模拟二叉树结构,以此达到搜索时间复杂度为O(log n)。
如果对理想跳表结构进行删除增加操作,很有可能改变跳表结构。如果重构理想结构,将是巨大的运算。考虑用概率的方法来进行优化,从每一个节点出发,每增加一个节点都有1/2的概率增加一个层级,1/4的概率增加两个层级,1/8的概率增加3个层级,以此类推,经过证明,当数据量足够大(256)时,通过概率构造的跳表趋向于理想跳表,并且此时如果删除节点,无需重构跳表结构,此时依然趋向于理想跳表,此时时间复杂度为O(log n)。

redis跳表

从节约内存的角度出发,redis考虑牺牲一点时间复杂度让跳表结构更加变扁平,就像二叉堆改成四叉堆结构,并且redis 还限制了跳表的最高层级为 32,节点数量大于 128 或者有一个字符串长度大于 64 ,则使用跳表( skiplist )。

实现

#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^64 elements */
#define ZSKIPLIST_P 0.25 /* Skiplist P = 1/4 */
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    sds ele;
    double score; // WRN: score 只能是浮点数
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span; // 用于 zrank
    } level[];
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length; // zcard
    int level; // 本跳表目前的最高层数
} zskiplist;

typedef struct zset {
    dict *dict; // 帮助快速索引到节点
    zskiplist *zsl;
} zset;

下面是redis跳表结构图
在这里插入图片描述

应用

这里还是以延时队列为例。
之前实现的延时队列有很大的局限性:

1、时间基准问题:两个应用程序如何保证时间一致。
2、异步轮询的问题:耗性能,大量无意义的数据请求。
3、原子性的问题:需要引入lua脚本来执行。
解决:
1、以redis的时间为基准:redis为数据中心且为单点,时间准确性得到大幅提升。
2、通过阻塞接口来实现,避免轮询。
3、修改源码,直接实现原子接口。

# 在redis当中 zset结构 score = time + 5
dq_add "delay_queue" 5 msg
# 如果没有消息pop,则将此连接加入阻塞队列,然后在redis的定时器中比较 zset 最小的值与当前time的大小 如果满足条件,通知阻塞队列中的连接
dq_bpop "delay_queue" timeout 60
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值