最近系统性地整理了Redis的知识点,在此和大家做些分享,希望能帮助到大家。
为什么Redis这么受欢迎
时代产物
随着互联网规模的不断扩张,越来越多的企业在技术架构上会采用分布式架构,而且对于系统的吞吐量以及响应速率的要求也有所提升。而缓存作为提升吞吐量和响应速率的一款核心技术,其重要程度也在不断增加。从早期的单机版缓存架构开始衍生至分布式缓存架构,而Redis则是分布式缓存中的佼佼者。
Redis的特性丰富
Redis底层的各项技术实现都做得非常出色,支持各种数据结构(string.list,set,map,zset,geo…) 。除此之外,Redis在内存管理方面几乎做到了极致,而且还支持持久户,内存淘汰策略,分布式集群解决方案。
入门简单,业界案例丰富
Redis的官网上有非常多的使用案例和资料文档,使用起来非常简单。另外业界也有许多大型互联网公司在使用这项技术。
Redis的IO模型是怎样的?
其实我们绝大多数时候使用Redis,都是将它部署在Linux服务器上,而要了解这种环境下Redis的IO模型,我们就有必要先了解下Linux中的IO模型。
阻塞IO模型
阻塞IO主要是在linux底层的accept函数以及read函数两个环节会进行堵塞。
关于accept函数的底层,我们这里面往底层分析,你会发现:当网络建立三次链接的时候,第一次握手且接受了SYN包之后,服务端会将客户端链接放入一个“半连接队列”中。而在第三次握手返回客户端ack,且确认无误之后,才会将“半连接队列”中的链接转移到“全连接队列里”。 而我们所说的accept函数,主要是从”全连接队列“中去取出连接对象。整体流程图如下:
在阻塞IO里面,如果调用accept的时候,全连接队列中没有想要的socket的话,则调用程序就会进入阻塞状态。类似的,当程序调用read方法的时候,其实是在等操作系统内核将网卡缓冲区的数据通过DMA拷贝到内核缓冲,再将内核缓冲的数据拷贝到用户态,如果网卡中没有对应数据抵达,则调用read方法的程序也会进入阻塞状态。
这里整体如下图所示:
非阻塞IO
其实理解了阻塞IO以及accept和read的底层原理之后,我们就很容易去理解非阻塞IO。无非就是当没有对应socket,或者网卡没有对应数据的时候,程序可以立马得到一个结果,而不是在哪干等。如下图所示:
图1-非阻塞accept
图2-非阻塞read
如果一台Linux服务器上同时建立了1w个tcp连接,我们需要监听那个连接有数据可读或者可写,该如何实现这个功能呢?
有人可能会说,设计一个程序,然后循环遍历这1w个连接,采用非阻塞的read方法去试探(反正不会堵塞),如果有数据则接收,没有数据也是继续遍历。
当然,我们可以通过这种简单粗暴的方式去实现,但是性能肯定不高,而且还会长时间占用CPU。那么又是否有一种高效的手段可以帮我们去解决这个问题吗?
当然,其实在Linux中,有select,poll,epoll三种模型,这三种模型就是我们常常说的IO多路复用模型。IO多路复用模型,其实底层在实现上采用了非阻塞IO的一些思想。下边我们来认识下select,poll和epoll模型。
select模型
select模型其实就有些类似我上边提到了的这种遍历执行read的思路,简单粗暴。它的思路是将socket连接集合(本质是一组fd集合)从用户态拷贝到内核态中,然后循环进行调用read判断是否有数据抵达。
但是select模型其实还是有蛮多缺点的,例如
- 单个进程可监视的fd数量被限制,即能监听端口的数量有限(1024个),数值存在如下文件里:
cat /proc/sys/fs/file-max
- 对socket是线性扫描,即采用轮询的方法,效率较低
- select采取了内存拷贝方法来实现内核将FD消息通知给用户空间,这样一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大
poll模型
其实poll模型在底层实现上和selec模型非常相似,几乎可以说是没啥区别,硬要说区别,就是取消了对端口数量(1024)的这个限制。
epoll模型
相比于select和poll来说,epoll模型可以说是性能最好的一种设计了。它的底层主要思想就是,等待数据抵达后再去抢占CPU,然后将数据返回给用户态程序。
首先当我们的accept获取到socket对象之后,会调用一个epoll_create
方法在内核空间中开辟一个位置用于存放socket对象。然后再通过epoll_ctl方法将socket对象塞入到这片内存里的一棵红黑树上。当有数据抵达网卡之后,会触发一个叫做epoll_wait的函数去抢占CPU使用,最后当CPU使用权抢占到之后,才会将数据传输给到用户态。
其实epoll模型有几个特点。
第一个在socket数量多的时候,采用红黑树管理,在查询,修改两个方面的都比较适合。
第二个,在空闲时期,并不会占用CPU使用
好了,现在我们终于理解了Linux中的三种IO模型,那么再来看看Redis的IO模型,其实Redis的IO模型在底层就是采用了Linux中的epoll模型去接收参数的,这也是Redis高性能的一个重要原因。
Redis的IO模型
关于Redis底层的IO模型,我觉得有必要区分版本来介绍,在Redis6.0版本之前,它是采用了单线程架构。这里的单线程主要处理了的包括网络的IO读写以及指令的计算执行。总结起来就是下边这张图:
而这种完全单线架构的设计呢,其实存在一个性能瓶颈。Redis的作者其实曾经对Redis做过压测,发现其实单线程处理指令计算这块绰绰有余,相反在网络的IO处理上却很容易达到瓶颈。
在Redis的6.0版本中提出了多线程这个概念,其实主要针对的是网络IO这块的处理,对于指令的计算部分还是采用了单线程计算。
如果采用多线程处理指令会如何?
假设我们在网络IO处理以及指令的计算上都采用了多线程的设计,那么整体的架构就会如下所示:
看起来,采用了多线程之后,计算的性能会充分提升(这里说的是采用多核CPU计算机的情况下),但是同样会引起很多容易问题,例如:
多线程安全问题:多个线程同时访问一个key,redis底层需要做改动。
线程上下文切换:cpu来回切换线程上下文,会对性能有影响。
代码维护性:原本Redis中并不需要考虑指令处理过程中的线程安全问题,一旦采用多线程之后,底层的代码改动会非常大,整体难度相当于重写整个Redis。
Redis底层的数据结构是怎样的
Redis底层其实是采用了一个类似于字典表一样的结构去设计,由于各个实现类之间的关系比较复杂,这里我用了一张图去描述它们。
Redis里的String结构底层存储是怎样的
其实要搞懂Redis底层对于String类型的设计,我们需要根据不同的编码格式来进行分析。
int类型编码
当我们往redis中存放的数字在 [-9223372036854775808,9223372036854775808] (19位数字)这类编码的底层存储是将RedisObject的元数据和数字一起存放在了一个连续的内存空间中。此时需要注意,RedisObject中的指针位置直接用于存储了数字。
embstr类型编码
这类编码主要是针对于字符长度小于等于 44字节长度的数据,这类情况时,字符数组会被存放在一个叫做sds的结构体中。sds其实是Redis底层定义的一个专门用于存储字符数组的结构体,大概定义如下:
它预先存放了字符数组的长度和剩余字符数组的空间。(一般存放数据的时候,字符数组会有预留部分空间以防字符串要做扩容操作)
这里对于embstr编码有个体积长度不能超过44字节的要求,这是因为如果sds长度在44字节内,可以保证sds+元数据+指针的空间体积之和正好是Linux底层一个缓存行的大小。
raw类型编码
当字符串的体积超过了44字节之后,此时我们的RedisObject的指针指向的sds地址就不再是连续的了,这个时候底层的结构如下图所示:
其实在工作中,Redis的String类型还衍生出了一些其他使用的类型可以供我们使用。例如BitMap类型就是基于String去衍生的,但是我们有一点需要注意的时候,如果BitMap存放的元素太多,很容易形成bigkey在redis中。
Redis的ziplist有什么性能问题吗
Redis的List在元素不多的时候会使用压缩列表去存储元素,ziplist中的每个元素节点定义大概如下:
struct entry {
int<var> prevlen; # 前一个 entry 的字节长度
int<var> encoding; # 元素类型编码
optional byte[] content; # 元素内容
}
这里要注意一个点,当ziplist中任意一个节点的元素体积发生了变化,则后一个节点的prevlen也要发生更新,而如果上一个节点的体积变化太大,prevlen的空间也会变化,从而导致这个队列中元素节点的prevlen都要发生更新。这就是著名的“连续更新”问题。
Redis里存放的数据过期后还会占用内存吗
其实Redis中的内存管理是有一定的回收策略的,搞懂Redis的两个内存回收策略之后,这个问题就很好理解了。
被动删除
Redis在执行读写指令的之前,都去判断当前处理的key是否过期,如果过期了则删除。这一点细节我们可以通过阅读Redis底层的源代码expireIfNeeded函数去理解。
这里要注意,对于key的删除是有一定条件判断的,这些条件分别如下:如果当前节点是从节点,则不会进行删除。如果Redis的配置中开启了lazyfree配置,则会进入一个性能评估的环节,评估的内容是当前这个key是否有必要开启异步线程去删除,如果评估发现没必要,则直接在主线程删除即可。
主动删除
Redis里面还有一种叫做主动删除的策略,这类策略大概是每隔一段时间就去删除部分的key。这个实现逻辑在Redis底层的activeExpireCycle函数中可以看到。由于它的底层源代码实在是太多了,所以我总结了下流程,大致如下:
1、从过期字典中随机20个key
2、删除这20个key中已过期的
3、如果筛出来的key中,有超过25%的key过期,则重复第一步
所以你可以知道,Redis里对于内存的回收可以说是比较“摆烂”的,即使部分数据已经过期了,但是依然会存在内存占用的情况。
如果Redis内部空间存满了,会发生什么
关于这个问题,如果是默认的内存淘汰策略的话,会有OOM异常发生。如果你想在内存满了之后,做一部分数据淘汰的话,其实Redis底层是有许多种策略去支持它的。下边我整理了一张图和大家分享下:
其实大概归纳起来就是两种思路,LRU算法和LFU算法。不过我们要知道,传统的LRU和LFU在Redis里去做落地实现是非常困难的。下边我们来分析下这两种算法要在Redis中实现会遇到哪些问题。
LRU算法
其实传统的LRU算法底层实现上会将缓存的key存放在一条队列上,每次访问一个元素,就按照先进先出的方式放入队列中。这也就意味着,新放入到队列中国元素越多,旧的元素被淘汰的几率也越大。
而且如果Redis要采用这种方式实现,也需要专门维护一副指针去管理这些访问的先后顺序,整体的内存空间会很大。所以呢Redis的底层做了这么一个设计。
在Redis的底层的RedisObject中设计了一个LRU_BITS字段,这个字段专门记录了该Key的上一次访问时间。当Redis要按照LRU策略去进行淘汰的时候,底层会有一个任务,随机去筛选一批key判断它的上一次访问时间,此时只需要判断LRU_BITS中的记录的上一次访问时间即可,然后将这批key中,上一次访问时间最早的key进行淘汰。这类思想在Redis3.0+版本后基本也没太大变化,只不过加入了一个pool去进行管理。
但是呢,这类LRU淘汰策略,其实在性能上有些缺陷。这里要知道LRU策略在随机删选key的时候,这个key的数量是可以通过 maxmemory_samples 参数去进行配置的,官方给出的测试数据是,当 maxmemory_samples 为10的时候,淘汰的效果最为理想。
正因如此,所以后续又推出了LFU的思路进行内存淘汰。
LFU算法
传统的LFU算法实现,大致是如下图所示,会给每个缓存元素都做一个计数器,当某个元素访问过后,计数器就会增加。
而Redis对于LFU算法的设计思路,其实基本相同,只不过对于计数器的增长频率会有所控制。Redis还是利用了RedisObject对象底层的LRU_BITS字段,将这个字段拆分为了两个部分,分别用于记录当前key的访问时间和访问频率,字段拆分后的设计如下图所示,不同的bit部分代表的含义不同。
在RedisObject中,每次访问缓存key,并不一定会立马导致这个LOG_C字段的增加。这里其实是有一个增长速率的,可以通过redis.conf文件中的lfu-log-factor参数和lfu-decay-time去控制。
到这里,相信你应该对Redis底层对于LRU和LFU的优化思想了吧,剩下的几种淘汰策略,其实核心也和这个相同,这里就不展开过多的研究了。
Redis持久化机制
为什么说Redis要支持持久化策略呢?我们可以设想一下这么个场景,假设一台装载了热点数据的Redis机器在运行过程中崩溃了,当我们幸幸苦苦将它恢复好了之后却发现里面的数据完全丢失了,这个时候作为开发者 估计早就崩溃了吧。而如果Redis可以支持持久化的话,这些繁琐的缓存数据恢复问题就不会存在。
Redis的作者其实也早早想到了这点,于是便很早就设计出了两种Redis的持久化方案,RDB和AOF。(混合型这里我们先不提及)
RDB持久化
RDB持久化的思路其实是定时从内存中取出数据,然后写入dump.rdb文件中。在Redis中默认会开启RDB配置,我们可以通过修改redis.conf文件的save n m 项去控制RDB的持久化速率。也可以通过手动调用save方法去触发这个持久化的操作。
RDB持久化的底层原理,我这里用一张图去介绍:
细心的你可能会想,如果子进程在执行持久化的时候,同一个key既要被写入dump.rdb,同时也要被外界执行写操作,此时该怎么办?
其实这类场景,Redis的作者是采用了COW的方式去处理,当一个key在进行持久化的时候,如果外界有写操作,则需拷贝出一个相似的对象,然后对拷贝对象进行修改操作。
RDB这种持久化的方式,通常是定期进行rdb文件的生成。这也就意味着,如果在生成RDB文件的过程中,Redis崩溃了,会导致生成/更新文件这段时间中会有一部分数据丢失。而这个生成/更新RDB文件的时间可能长达数十秒,所以这个损失成本在高并发场景下,可能是巨大的。
但是它的优点也很明显,那就是使用RDB文件来进行恢复数据,效率会更高。
AOF持久化
AOF( append only file )持久化以独立日志文件的方式记录每条写命令,并在 Redis 启动时回放 AOF 文件中的命令以达到恢复数据的目的。
由于AOF会以追加的方式记录每一条redis的写命令,因此随着Redis处理的写命令增多,AOF文件也会变得越来越大,命令回放的时间也会增多,为了解决这个问题,Redis引入了AOF rewrite机制(下文称之为AOFRW)。AOFRW会移除AOF中冗余的写命令,以等效的方式重写、生成一个新的AOF文件,来达到减少AOF文件大小的目的。
而AOFRW的底层实现,其实会存在一些较大的内存开销缺点,关于这方面,其实在Redis的7.0中提出了MP-AOF去进行优化。对于这部分比较感兴趣的朋友,可以看看下边我贴出的一篇技术文章去学习:
点击跳转
另外,其实即使我们将Redis的AOF中的持久化频率设置为appendfsync always,其实也是不能保证100%丢失消息的。这里其实是和Redis底层的持久化源代码有关。
因为Redis底层的持久化其实是在一个循环里面去执行的,也就是说,最差情况会导致一次循环中的数据丢失持久化效果。
Redis的集群架构
主从复制模式
通过持久化功能,Redis保证了即使在服务器重启的情况下也不会丢失(或少量丢失)数据,因为持久化会把内存中数据保存到硬盘上,重启会从硬盘上加载数据。但是由于数据是存储在一台服务器上的,如果这台服务器出现硬盘故障等问题,也会导致数据丢失。
为了避免单点故障,通常的做法是将数据库复制多个副本以部署在不同的服务器上,这样即使有一台服务器出现故障,其他服务器依然可以继续提供服务。
为此, Redis 提供了复制(replication)功能,可以实现当一台数据库中的数据更新后,自动将更新的数据同步到其他数据库上。
在复制的概念中,数据库分为两类,一类是主数据库(master),另一类是从数据库(slave)。主数据库可以进行读写操作,当写操作导致数据变化时会自动将数据同步给从数据库。而从数据库一般是只读的,并接受主数据库同步过来的数据。一个主数据库可以拥有多个从数据库,而一个从数据库只能拥有一个主数据库。
Redis主从复制的原理
- 从数据库启动成功后,连接主数据库,发送 SYNC 命令;
- 主数据库接收到 SYNC 命令后,开始执行 BGSAVE 命令生成 RDB 文件并使用缓冲区记录此后执行的所有写命令;
- 主数据库 BGSAVE 执行完后,向所有从数据库发送快照文件,并在发送期间继续记录被执行的写命令;
- 从数据库收到快照文件后丢弃所有旧数据,载入收到的快照;
- 主数据库快照发送完毕后开始向从数据库发送缓冲区中的写命令;
- 从数据库完成对快照的载入,开始接收命令请求,并执行来自主数据库缓冲区的写命令;(从数据库初始化完成)
- 主数据库每执行一个写命令就会向从数据库发送相同的写命令,从数据库接收并执行收到的写命令(从数据库初始化完成后的操作)
- 出现断开重连后,2.8之后的版本会将断线期间的命令传给重数据库,增量复制。
- 主从刚刚连接的时候,进行全量同步;全同步结束后,进行增量同步。当然,如果有需要,slave 在任何时候都可以发起全量同步。Redis 的策略是,无论如何,首先会尝试进行增量同步,如不成功,要求从机进行全量同步。
整理流程大致如下图所示:
主从架构其实其缺点很明显,就是一旦主节点崩溃,从节点没有自动转主的机制,整体的容错性不强。另外对于存储的容量也不高,毕竟本质上还是单节点存储数据。
哨兵模式
第一种主从同步/复制的模式,当主服务器宕机后,需要手动把一台从服务器切换为主服务器,这就需要人工干预,费事费力,还会造成一段时间内服务不可用。这不是一种推荐的方式,更多时候,我们优先考虑哨兵模式。
哨兵模式是一种特殊的模式,首先 Redis 提供了哨兵的命令,哨兵是一个独立的进程,作为进程,它会独立运行。其原理是哨兵通过发送命令,等待Redis服务器响应,从而监控运行的多个 Redis 实例。
哨兵模式的设计是通过不是一个哨兵节点,用于监控各个Redis服务的健康情况,如果主节点崩溃,可以用从节点去替换掉主节点。整体架构如下:
通常我们在选择使用哨兵架构部署Redis服务的时候,都会在最上层加一层VIP,用于防治一旦主从节点的ip变化,客户端程序ip需要更新的问题。
哨兵模式是基于主从模式的,所有主从的优点,哨兵模式都具有。主从可以自动切换,系统更健壮,可用性更高(可以看作自动版的主从复制)。Redis较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂。
Cluster模式
Redis Cluster是一种服务器 Sharding 技术,3.0版本开始正式提供。Redis 的哨兵模式基本已经可以实现高可用,读写分离 ,但是在这种模式下每台 Redis 服务器都存储相同的数据,很浪费内存,所以在 redis3.0上加入了 Cluster 集群模式,实现了 Redis 的分布式存储,也就是说每台 Redis 节点上存储不同的内容。其整体架构大致如下:
Redis 集群没有使用一致性 hash,而是引入了哈希槽【hash slot】的概念。
Redis 集群有16384 个哈希槽,每个 key 通过 CRC16 校验后对 16384 取模来决定放置哪个槽。集群的每个节点负责一部分hash槽,举个例子,比如当前集群有3个节点,那么:
- 节点 A 包含 0 到 5460 号哈希槽
- 节点 B 包含 5461 到 10922 号哈希槽
- 节点 C 包含 10923 到 16383 号哈希槽
这种结构很容易添加或者删除节点。比如如果我想新添加个节点 D , 我需要从节点 A, B, C 中得部分槽到 D 上。如果我想移除节点 A ,需要将 A 中的槽移到 B 和 C 节点上,然后将没有任何槽的 A 节点从集群中移除即可。由于从一个节点将哈希槽移动到另一个节点并不会停止服务,所以无论添加删除或者改变某个节点的哈希槽的数量都不会造成集群不可用的状态。
在 Redis 的每一个节点上,都有这么两个东西,一个是插槽(slot),它的的取值范围是:0-16383。还有一个就是 cluster,可以理解为是一个集群管理的插件。当我们的存取的 Key到达的时候,Redis 会根据 CRC16 的算法得出一个结果,然后把结果对 16384 求余数,这样每个 key 都会对应一个编号在 0-16383 之间的哈希槽,通过这个值,去找到对应的插槽所对应的节点,然后直接自动跳转到这个对应的节点上进行存取操作。
Redis 集群的主从复制模型
为了保证高可用,redis-cluster集群引入了主从复制模型,一个主节点对应一个或者多个从节点,当主节点宕机的时候,就会启用从节点。当其它主节点 ping 一个主节点 A 时,如果半数以上的主节点与 A 通信超时,那么认为主节点 A 宕机了。如果主节点 A 和它的从节点 A1 都宕机了,那么该集群就无法再提供服务了。
Redis CLuster 集群的特点
所有的 redis 节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽。节点的 fail 是通过集群中超过半数的节点检测失效时才生效。客户端与 Redis 节点直连,不需要中间代理层.客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。