现在开始讨论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] 。
渐进式规则:
- 分治的思想,将 rehash 分到之后的每步增删改查的操作当中。但是,如果要rehash的元素非常多,比如上千万,而操作次数较少,每次操作又只rehash一个元素,这样永远都完成不了rehash了,所以有了方法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