Redis面试热点之底层实现篇

经过本文你将了解到如下内容:html

  • Redis的做者、发展演进和江湖地位
  • Redis面试问题的概况
  • Redis底层实现相关的问题包括:
    经常使用数据类型底层实现、SDS的原理和优点、字典的实现原理、跳表和有序集合的原理、Redis的线程模式和服务模型

舒适提示:内容并不难,就怕你不看。git

看不懂能够先收藏先Mark,等到深刻研究的时间再翻出来看看,你就发现真是24K干货呀!中止吹嘘,写点不同的文字吧!github

1.Redis往事

Redis是一个使用ANSI C编写的开源、支持网络、基于内存、可选持久化的高性能键值对数据库。Redis的之父是来自意大利的西西里岛的Salvatore Sanfilippo,Github网名antirez,笔者找了做者的一些简要信息并翻译了一下,如图:面试

从2009年第一个版本起Redis已经走过了10个年头,目前Redis仍然是最流行的key-value型内存数据库的之一。redis

优秀的开源项目离不开大公司的支持,在2013年5月以前,其开发由VMware赞助,而2013年5月至2015年6月期间,其开发由毕威拓赞助,从2015年6月开始,Redis的开发由Redis Labs赞助。算法

笔者也使用过一些其余的NoSQL,有的支持的value类型很是单一,所以不少操做都必须在客户端实现,好比value是一个结构化的数据,须要修改其中某个字段就须要总体读出来修改再总体写入,显得很笨重,可是Redis的value支持多种类型,实现了不少操做在服务端就能够完成了,这个对客户端而言很是方便。数据库

固然Redis因为是内存型的数据库,数据量存储量有限并且分布式集群成本也会很是高,所以有不少公司开发了基于SSD的类Redis系统,好比360开发的SSDB、Pika等数据库,可是笔者认为从0到1的难度是大于从1到2的难度的,毋庸置疑Redis是NoSQL中浓墨重彩的一笔,值得咱们去深刻研究和使用。api

2.Redis的江湖地位

Redis提供了Java、C/C++、C#、 PHP 、JavaScript、 Perl 、Object-C、Python、Ruby、Erlang、Golang等多种主流语言的客户端,所以不管使用者是什么语言栈总会找到属于本身的那款客户端,受众很是广。数组

笔者查了网站看了下Redis和MySQL的最新市场份额和排名对比以及全球Top站点的部署量对比(网站数据更新到写做当日2019.12.11):缓存

能够看到Redis整体份额排名第9而且在全球Top100站点中部署数量与MySQL基本持平,因此Redis仍是有必定的江湖地位的。

3.聊聊实战

目前Redis发布的稳定版本已经到了5.x,功能也愈来愈强大,从国内外互联网公司来看Redis几乎是标配了。做为开发人员在平常笔试面试和工做中遇到Redis相关问题的几率很是大,掌握Redis的相关知识点都十分有必要。

学习和梳理一个复杂的东西确定不能胡子眉毛一把抓,每一个人都有本身的认知思路,笔者认为要从充分掌握Redis须要从底向上、从外到内去理解Redis。

Redis的实战知识点能够简单分为三个层次:

  • 底层实现:主要是从Redis的源码中提炼的问题,包括但不限于底层数据结构、服务模型、算法设计等。
  • 基础架构:可用概况为Redis总体对外的功能点和表现,包括但不限于单机版主从架构实现、主从数据同步、哨兵机制、集群实现、分布式一致性、故障迁移等。
  • 实际应用:实战中Redis可用帮你作什么,包括但不限于单机缓存、分布式缓存、分布式锁、一些应用。

深刻理解和熟练使用Redis须要时间锤炼,要作到信手拈来着实不易,想在短期内突破只能从热点题目入手,虽然这样感受有些功利,不过也算无可厚非吧,为了吃饭咱们仍是倾向于原谅懒惰的本身,要否则吃土喝风?

4.底层实现热点题目

底层实现篇的题目主要是与Redis的源码和设计相关,能够说是Redis功能的基石,了解底层实现可让咱们更好地掌握功能,因为底层代码不少,在后续的基础架构篇中仍然会穿插源码来分析,所以本篇只列举一些热点的问题。

Q1: Redis经常使用五种数据类型是如何实现的?
Redis支持的经常使用5种数据类型指的是value类型,分别为:字符串String、列表List、哈希Hash、集合Set、有序集合Zset,可是Redis后续又丰富了几种数据类型分别是Bitmaps、HyperLogLogs、GEO。

因为Redis是基于标准C写的,只有最基础的数据类型,所以Redis为了知足对外使用的5种数据类型,开发了属于本身独有的一套基础数据结构,使用这些数据结构来实现5种数据类型。

Redis底层的数据结构包括:简单动态数组SDS、链表、哈希表、跳跃链表、整数集合、压缩列表、对象。

Redis为了平衡空间和时间效率,针对value的具体类型在底层会采用不一样的数据结构来实现,其中哈希表和压缩列表是复用比较多的数据结构,以下图展现了对外数据类型和底层数据结构之间的映射关系:

从图中能够看到ziplist压缩列表能够做为Zset、Set、List三种数据类型的底层实现,看来很强大,压缩列表是一种为了节约内存而开发的且通过特殊编码以后的连续内存块顺序型数据结构,底层结构仍是比较复杂的。

 

Q2: Redis的SDS和C中字符串相比有什么优点?
在C语言中使用N+1长度的字符数组来表示字符串,尾部使用'\0'做为结尾标志,对于此种实现没法知足Redis对于安全性、效率、丰富的功能的要求,所以Redis单独封装了SDS简单动态字符串结构。

在理解SDS的优点以前须要先看下SDS的实现细节,找了github最新的src/sds.h的定义看下:

typedef char *sds;
/*这个用不到 忽略便可*/
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
/*不一样长度的header 8 16 32 64共4种 都给出了四个成员
len:当前使用的空间大小;alloc去掉header和结尾空字符的最大空间大小
flags:8位的标记 下面关于SDS_TYPE_x的宏定义只有5种 3bit足够了 5bit没有用
buf:这个跟C语言中的字符数组是同样的,从typedef char* sds能够知道就是这样的。
buf的最大长度是2^n 其中n为sdshdr的类型,如当选择sdshdr16,buf_max=2^16。
*/
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

#define SDS_TYPE_5  0
#define SDS_TYPE_8  1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
#define SDS_TYPE_MASK 7
#define SDS_TYPE_BITS 3

看了前面的定义,笔者画了个图:

从图中能够知道sds本质分为三部分:header、buf、null结尾符,其中header能够认为是整个sds的指引部分,给定了使用的空间大小、最大分配大小等信息,再用一张网上的图来清晰看下sdshdr8的实例:

在sds.h/sds.c源码中可清楚地看到sds完整的实现细节,本文就不展开了要否则篇幅就过长了,快速进入主题说下sds的优点:

  • O(1)获取长度: C字符串须要遍历而sds中有len能够直接得到;
  • 防止缓冲区溢出bufferoverflow: 当sds须要对字符串进行修改时,首先借助于len和alloc检查空间是否知足修改所需的要求,若是空间不够的话,SDS会自动扩展空间,避免了像C字符串操做中的覆盖状况;
  • 有效下降内存分配次数:C字符串在涉及增长或者清除操做时会改变底层数组的大小形成从新分配、sds使用了空间预分配和惰性空间释放机制,说白了就是每次在扩展时是成倍的多分配的,在缩容是也是先留着并不正式归还给OS,这两个机制也是比较好理解的;
  • 二进制安全:C语言字符串只能保存ascii码,对于图片、音频等信息没法保存,sds是二进制安全的,写入什么读取就是什么,不作任何过滤和限制;

老规矩上一张黄健宏大神总结好的图:

Q3:Redis的字典是如何实现的?简述渐进式rehash的过程。
字典算是Redis5中经常使用数据类型中的明星成员了,前面说过字典能够基于ziplist和hashtable来实现,咱们只讨论基于hashtable实现的原理。

字典是个层次很是明显的数据类型,如图:

有了个大概的概念,咱们看下最新的src/dict.h源码定义:

//哈希节点结构
typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

//封装的是字典的操做函数指针
typedef struct dictType {
    uint64_t (*hashFunction)(const void *key);
    void *(*keyDup)(void *privdata, const void *key);
    void *(*valDup)(void *privdata, const void *obj);
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    void (*keyDestructor)(void *privdata, void *key);
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
//哈希表结构 该部分是理解字典的关键
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

//字典结构
typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;

C语言的好处在于定义必须是由最底层向外的,所以咱们能够看到一个明显的层次变化,因而笔者又画一图来展示具体的层次概念

  • 关于dictEntry

dictEntry是哈希表节点,也就是咱们存储数据地方,其保护的成员有:key,v,next指针。key保存着键值对中的键,v保存着键值对中的值,值能够是一个指针或者是uint64_t或者是int64_t。next是指向另外一个哈希表节点的指针,这个指针能够将多个哈希值相同的键值对链接在一次,以此来解决哈希冲突的问题。

如图为两个冲突的哈希节点的链接关系:

  • 关于dictht

从源码看哈希表包括的成员有table、size、used、sizemask。table是一个数组,数组中的每一个元素都是一个指向dictEntry结构的指针, 每一个dictEntry结构保存着一个键值对;size 属性记录了哈希表table的大小,而used属性则记录了哈希表目前已有节点的数量。sizemask等于size-1和哈希值计算一个键在table数组的索引,也就是计算index时用到的。

如上图展现了一个大小为4的table中的哈希节点状况,其中k1和k0在index=2发生了哈希冲突,进行开链表存在,本质上是先存储的k0,k1放置是发生冲突为了保证效率直接放在冲突链表的最前面,由于该链表没有尾指针。

  • 关于dict

从源码中看到dict结构体就是字典的定义,包含的成员有type,privdata、ht、rehashidx。其中dictType指针类型的type指向了操做字典的api,理解为函数指针便可,ht是包含2个dictht的数组,也就是字典包含了2个哈希表,rehashidx进行rehash时使用的变量,privdata配合dictType指向的函数做为参数使用,这样就对字典的几个成员有了初步的认识。

    • 字典的哈希算法
//伪码:使用哈希函数,计算键key的哈希值
hash = dict->type->hashFunction(key);
//伪码:使用哈希表的sizemask和哈希值,计算出在ht[0]或许ht[1]的索引值
index = hash & dict->ht[x].sizemask;
//源码定义
#define dictHashKey(d, key) (d)->type->hashFunction(key)

redis使用MurmurHash算法计算哈希值,该算法最初由Austin Appleby在2008年发明,MurmurHash算法的不管数据输入状况如何均可以给出随机分布性较好的哈希值而且计算速度很是快,目前有MurmurHash2和MurmurHash3等版本。

  • 普通Rehash从新散列

哈希表保存的键值对数量是动态变化的,为了让哈希表的负载因子维持在一个合理的范围以内,就须要对哈希表进行扩缩容。

扩缩容是经过执行rehash从新散列来完成,对字典的哈希表执行普通rehash的基本步骤为分配空间->逐个迁移->交换哈希表,详细过程以下:

  1. 为字典的ht[1]哈希表分配空间,分配的空间大小取决于要执行的操做以及ht[0]当前包含的键值对数量:
    扩展操做时ht[1]的大小为第一个大于等于ht[0].used*2的2^n;
    收缩操做时ht[1]的大小为第一个大于等于ht[0].used的2^n ;
    扩展时好比h[0].used=200,那么须要选择大于400的第一个2的幂,也就是2^9=512。
  2. 将保存在ht[0]中的全部键值对从新计算键的哈希值和索引值rehash到ht[1]上;
  3. 重复rehash直到ht[0]包含的全部键值对所有迁移到了ht[1]以后释放 ht[0], 将ht[1]设置为 ht[0],并在ht[1]新建立一个空白哈希表, 为下一次rehash作准备。
  • 渐进Rehash过程

Redis的rehash动做并非一次性完成的,而是分屡次、渐进式地完成的,缘由在于当哈希表里保存的键值对数量很大时, 一次性将这些键值对所有rehash到ht[1]可能会致使服务器在一段时间内中止服务,这个是没法接受的。

针对这种状况Redis采用了渐进式rehash,过程的详细步骤:

  1. 为ht[1]分配空间,这个过程和普通Rehash没有区别;
  2. 将rehashidx设置为0,表示rehash工做正式开始,同时这个rehashidx是递增的,从0开始表示从数组第一个元素开始rehash。
  3. 在rehash进行期间,每次对字典执行增删改查操做时,顺带将ht[0]哈希表在rehashidx索引上的键值对rehash到 ht[1],完成后将rehashidx加1,指向下一个须要rehash的键值对。
  4. 随着字典操做的不断执行,最终ht[0]的全部键值对都会被rehash至ht[1],再将rehashidx属性的值设为-1来表示 rehash操做已完成。

渐进式 rehash的思想在于将rehash键值对所需的计算工做分散到对字典的每一个添加、删除、查找和更新操做上,从而避免了集中式rehash而带来的阻塞问题。

看到这里不由去想这种捎带脚式的rehash会不会致使整个过程很是漫长?若是某个value一直没有操做那么须要扩容时因为一直不用因此影响不大,须要缩容时若是一直不处理可能形成内存浪费,具体的还没来得及研究,先埋个问题吧!

Q4:跳跃链表了解吗?Redis的Zset如何使用跳表实现的?
ZSet这种数据类型也很是有用,在作排行榜需求时很是有用,笔者就曾经使用这种数据类型来实现某日活2000w的app的排行榜,因此了解下ZSet的底层实现颇有必要,以前笔者写过两篇文章介绍跳跃链表和ZSet的实现,所以查阅便可。
深刻理解跳跃链表[一]
深刻理解跳表在Redis中的应用

Q5:Redis为何使用单线程?讲讲Redis网络模型以及单线程如何协调各类事件运行起来的?
Redis在新版本中并非单纯的单线程服务,一些辅助工做会有BIO后台线程来完成,而且Redis底层使用epoll来实现了基于事件驱动的反应堆模式,在整个主线程运行工程中不断协调时间事件和文件事件来完成整个系统的运行,笔者以前写过两篇相关的文章,查阅便可获得更深层次的答案。
理解Redis单线程运行模式
理解Redis的反应堆模式
浅析Redis 4.0新特性之LazyFree

5.巨人的肩膀

相关文章
相关标签/搜索