受邀写点关于数据结构和算法的内容,借机就整理了一部分Redis里面的数据结构的实际应用,讲的也不是特别多,后续再补充吧。
Redis自己实现的数据结构包括:字符串,链表,字典,跳表,压缩列表,整数集合,对象和GC,正所谓工欲善其事必先利其器,相信Redis的作者在实现诸多数据结构的时候,也是用尽心力了,为Redis的快速存储打下了基础,让我们一起看一下吧。
字符串
作为一个C语言实现的框架,底层字符串存储肯定使用的是char[]
,虽然说数组分配的内存空间是固定的,但是大量的字符串拼接操作会产生很多垃圾内存,所以Redis的字符串实现是利用动态扩容的机制实现的,而且最根本的是C语言的数组是没有长度的,所有语言在实现数组的时候,也都是在这个基础上,维护了一个长度的变量,来让数组有长度。简单结构如下:
struct adshdr{
int len;
int free;
char buf[]
}
len表示字符串的长度,而free表示剩余长度,所以占据内存的大小是len + free + 1,至于为什么+1,大家自己回去思考吧。
这么一来,每次就不要从头遍历数组,判断多少空间有值,多少空间是空的,将时间复杂度由O(n)变成了O(1),而维护了两个int的长度值,所以额外的空间复杂度是 2 * sizeof(int).
很多编程语言的实现基本都类似,但是Redis由于是单线程,不会存在线程争抢的过程,而其他编程语言都会有多线程的概念,这里会产生一个问题就是“size”不准的问题,比如说讨论比较多的HashMap中的size不准的问题,就是一个例子,有兴趣的朋友回去搜索一下吧。
链表
有头没尾的链表不方便“回放”,所以Redis采用的是双向链表,每个数据节点维护了向前和向后的指针:
typedef struct listNode{
struct listNode *prev;
struct listNode *next;
void * value;
}
而链表的长度同样是需要从头到尾遍历的,不方便计算长度,所以Redis同样也采用了维护长度的方式处理链表:
typedef struct list{
listNode *head;
listNode *tail;
unsigned long len;
……
}list;
有趣的是他还维护了尾节点,这样的话也就方便我们从最后往前“倒放”,这种从后往前的倒放操作是否让你想起来了LRU算法呢?Redis同样也是支持LRU淘汰算法的。
字典
字典大家比较常见,这里挑重点的说了,毕竟我们的篇幅有限。
- Redis 是通过链表解决hash冲突的,这种思路跟很多编程语言里面的字典结构没太大差异,这是一种典型的分治策略,如果对Redis集群有了解的话,应该还知道Redis集群模式中的也用了一个slot的概念,同样也是分治策略,每个机器只管一部分“Key”,该我管的我管,不该我管的,重定向给其他机器,这样每个机器的负载就从n变成了n/k, k代表你机器的数量。这部分内容有兴趣的小伙伴可以自行查阅Redis官网。
- 渐进式Rehash的过程。Redis不是一个小服务,而是一个动辄上千万条数据的服务,所以字典的扩容操作不可能像我们编程语言里面的字典那么简单。所以Redis采用的就是时间换空间的思路,按照Key的hash捅的顺序复制,维护了一个index,来标记复制到哪个捅了,每完成一个key的捅,就释放一个捅,所有新进的操作,都会在两个桶上面同时处理,保证了程序的正常运转。以上只是一部分思路,细节还有很多,有兴趣的朋友可以查阅官网或者源代码。
跳表
跳表主要应用在Redis的有序集合里面,平均时间复杂度O(logN),最坏是O(N),(我也是背的,哈哈),具体的内容我们课上也讲过了,我这里就不重复了,需要的小伙伴自行在查阅课件吧。
很多人对这块的内容都有过讨论,为什么没有采用大家比较常见的平衡树。之前查阅过原作者自己的回复,他从三个点给出了结论,我直接翻译成中文跟大家说了:
- 内存占用比B树更少,因为平衡树需要有两个指针,而跳表需要1/(1-p),
- 对于有序集合的查询,在Redis经常会进行一些范围查询,这个时候,对于跳表只需要找到最小节点,之后在最底层向后遍历即可,而平衡树需要在找到最小节点之后,通过中序遍历获取结果,对于Redis这种数据量很大的服务,不可能采用递归的方式的,所以代码也很复杂。
- 实现方式上,跳表确实会比平衡二叉树简单一些。
希望上面的对比,能让大家对二叉树和跳表理解的更加透彻。
其他部分的内容,我们日常开发中遇到的都不太多,再加上时间有限,就不在介绍了,有兴趣的小伙伴自己在查阅相关资料吧。