我们大多数听说到的都是一种说法,Redis 是单线程的,也经常会被问到,为什么 Redis 单线程还那么快?当然了,这需要分情况说的,Redis4.0 之后就引入了多线程去异步处理大键值对的删除操作,Redis6.0 的时候就引入了多线程模型处理网络 IO 请求了,我们一点一点说。
Redis 的单线程模型
其实说 Redis 的单线程,都是指的是它处理网络 IO 和数据读写的操作是单线程的,Redis 使用的是 Reactor 模式构建的一种事件驱动模型,对应到 Redis 中就是文件事件处理器(file event handler
),著名的 Netty 也是使用这个基于 Reactor 模型,而文件事件处理器是单线程的,所以我们通常都会说 Redis 是单线程模型的。
Reactor 模型的厉害之处是一种反应堆模式,它是不断的监听你套接字的事件,一旦有事件发生就会调用对应的回调函数,举个生活中的例子,就像去医院挂号,不同的号对应不同的科室,到时候去那边检查就行了。
对于 Redis 来说 CPU 不是它的瓶颈,它是基于内存并且用了很多非常优秀的数据结构去优化,所以 CPU 并不是考虑的重点,考虑最多的还是网络 IO ,它对于高并发下的网络 IO 处理采用的就是多路复用技术,这个之前在我的文章聊聊Java-IO模型那些事已经进行讲解,这里就不再细说了。
而在 Redis4.0 版本引入多线程相关也是跟清理 key-value 相关,和 IO 也没什么关系
小总结:所以在 Redis6.0 之前一直使用单线程维护的原因可能是下面这样:
单线程更加的利于维护,多线程还要考虑并发带来的问题,同时上下文切换和锁开销都会影响性能,而对于 Redis 来说它的瓶颈在于网络和 IO ,不在 CPU 上面。
Redis6.0 为什么要引入多线程模型呢?
上面我们也说了瓶颈在于网络和 IO ,推出这个的目的一定是 QPS 达到上限了,但是需求还很大,所以引入多线程,同样的多线程还是处理网络数据读写和一些协议的,真正执行命令还是使用单线调用的,所以不需要担心线程安全问题,而且这个和我们 Redis 所在机器的核心数相关,同时要 Redis 实例抗不太住流量才考虑用多线程,默认这个是关闭的。
需要设置io-threads>1
的时候才会生效,一旦设置不能通过 config
动态修改,如果使用了 ssl
,那么这个也不生效。
Redis 后台线程
这个当然也是存在的,比如清理临时文件啊,刷盘啊等等的操作都是后台线程完成的,这个很多数据库都是类似的机制,没什么可说的,有兴趣的自己了解了解去
Redis过期策略
这个是我们用 Redis 最常见的一个特性,我们不可能把所有的数据都放到 Redis 中,那样你内存得啥样啊,所以我们一般都是缓存的热点数据,而热点数据是不断更新的,热点过一段可能就凉了,所以过期策略在 Redis 中是非常重要的。
Redis是如何判断 key 过期的?
Redis 是有维护一个过期字典的,字典的 key 就是数据的 key ,value 就是键的过期时间,当我们设置一个 key 的过期时间的时候,就会存到这里面。那么是怎么清理这样的数据的呢?
Redis 过期 key 的删除策略
- 惰性删除: 只有在取出 key 的时候进行过期检查,对 CPU 很友好,但是会造成大量过期未删除的 key。
- 定期删除: 定期取出一批 key 执行删除过期操作,并且 Redis 会限制删除的时长和频率来降低对 CPU 的影响。
Redis 采用的就是定期删除+惰性删除来进行的删除策略,检查到的过期 key 不会立马删除,而是标记成已过期,然后放入一个链表中,当 Redis 的内存使用率到达一定的阈值之后会对过期 key 进行内存回收操作。
Redis 的内存淘汰机制
当 Redis 内存满的时候,会进行内存淘汰策略,内存淘汰策略有好多,通过配置文件 maxmemory-policy
设置。
- no-eviction: 内存满了就满了,直接报错,无法写入。
- volatile-lru: 从已设置过期时间的数据中找到最近最少未被使用的数据淘汰。
- volatile-ttl: 从已设置过期时间的数据中找到将要过期的数据进行淘汰。
- volatile-random: 从已设置过期时间的数据中挑选任意数据淘汰。
- allkeys-lru: 选择最近最少未被使用的 key 删除掉,这个很常用
- allkeys-random: 从数据集中任意挑选数据淘汰
- volatile-lfu: 从设置过期时间的数据中挑选最不经常使用的数据淘汰。
- allkeys-lfu: 选择最不经常使用的 key 淘汰。
Redis 持久化
持久化就是将数据存储到硬盘中,用于数据备份,数据恢复等,也用于主从节点同步数据等,在 Redis 中主要有三种持久化方式,RDB ,AOF 与 RDB+AOF 混合模式,混合模式是 Redis 4.0 新增的。接下来我来介绍一下这三种持久化方式。
RDB快照持久化
Redis 可以通过创建快照来获得存储在内存中的数据在某一个时间点上的副本,创建快照之后可以进行备份,主从同步和以后重启服务来使用,快照持久化是 Redis 默认的持久化方式,有两个命令执行,一个是 save ,这个会阻塞 Redis 主线程,还有一个是 bgsave ,Redis 会 fork 出一个子进程去干活,这个也是默认的选项。
AOF 只追加文件
这个方式就很像 MySQL 中的日志写入了,看一下过程
- 所有的写命令追加到 AOF 缓冲区。
- 调用 write 命令将 AOF 缓冲区数据写入到文件缓存系统
- AOF 根据设置的同步方式将数据刷到硬盘中
- 随着 AOF 文件越来越大,定期对 AOF 文件重写
- 当 Redis 重启时,可以加载 AOF 文件进行数据恢复
其实和大多数的数据库系统刷盘策略差不多。第三步中设置的同步方式分三种:
appendfsync always:
主线程调用完 write 执行写操作之后,AOF 执行线程会立刻调用 fsync 进行刷盘,这个会严重降低 Redis 的性能。appendfsync everysec:
主线程调用完 write 执行写操作之后立刻返回,然后 AOF 执行线程每秒调用 fsync 进行刷盘appendfsync no:
主线程调用完 write 执行写操作之后立刻返回,然后让操作系统决定何时进行刷盘,linux 系统一般问 30s。
推荐第二种,顶多就是丢失 1s 的数据
上面还说了一个 AOF 重写的事,它就是将 AOF 文件进行压缩,同时还要保证数据状态是一致的,但是性能比较差,写入吃性能很多。
混合模式
在 Redis4.0 的时候支持混合持久化,默认是关闭的,它就是在 AOF 重写的时候直接把 RDB 内容写到 AOF 的文件开头,这样的优点就是快速加载避免丢失更多的数据,缺点就是 AOF 里面的 RDB 部分是压缩格式,不是 AOF 格式,可读性比较差
如何选择持久化方式呢
RDB 存储的是压缩过后的二进制数据,文件很小,非常适用于数据备份,灾难恢复。AOF 存储的是每一行命令,类似 MySQL 的 binlog ,通常比 RDB 大很多,恢复大数据集的时候 RDB 优秀很多,速度更快。但是 RDB 生成的过程是比较繁重的,对机器的 CPU 和内存影响比较大,容易把服务器干宕机。 所以,如果数据丢失一些没什么影响的话,那么使用 RDB 持久化,AOF 不建议单独使用,最好配合 RDB 组合使用作为数据备份等等。
Redis集群方案
对于数据库系统来说,使用集群无非就是两个原因,一个是并发量太大,需要分担流量。另一个是数据量太大,影响查询效率。
在 Redis 中常见的两种方案是主从复制和Redis Sentinel
,本质就是增加主从节点的数量来提高吞吐量,但是这样并不能解决数据量大的问题,因为每一个节点都存储着全部的数据。具体看一下这两个方案
主从复制
核心就是主写从读,然后主同步数据到从节点,这个也是 Redis 集群领域的核心,主从节点的数据同步也是经过了多次的改进,大致可以分为三个阶段。
第一阶段:Redis2.8 之前的同步方案
- slave 节点向 master 发送 SYNC 命令,请求复制数据。
- master 收到命令在之后执行 BGSAVE 生成 RDB 文件。
- master 将生成的 RDB 文件发送到 salve 上面
- salve 接受到 RDB 开始解析然后更新到本地数据
- 更新完之后,其实master 相当于执行完 BGSAVE 的状态,那么这中间还会存在一些新的写的数据,master 会将给每一个 salve 开辟一个空间存储这段时间产生的所有的写命令,然后 master 将写命令发送给 salve ,salve 执行然后同步到 master最新状态
- 之后 slave 就创建完了,之后就和 master 创建一个长连接,同步 master 上面执行的写命令。
而 master 给 slave 开辟的空间不是无限大的,如果超过了阈值就会断开和 slave 的连接。
这就是 redis2.8 的方案,它有什么问题呢?第一个就是 slave 解析更新 RDB 文件的时候是不能对外提供服务的,第二个人就是 slave 和 master 断开连接之后需要进行全量同步。
第二阶段: Redis2.8 的 PSYNC 方案
就是PSYNC replicationid offset
看名字就能发现点门道了。它解决了断开连接之后进行全量同步的问题,但是如果 slave 宕机或者重启依旧需要进行全量同步。
它的工作原理就是 slave 会记录 master 的 运行 id 与写入数据的偏移量,这个运行 id 是每一个 redis 服务启动的时候生成的,这样的话根据 slave 的偏移量对比 master 上的偏移量就能准确的找到没有同步的数据,进行同步就可以了。如果 slave 重连那么就比较 runid 是否相等来决定进行全量更新还是增量更新。slave 启动之后都会设置 master 的 runid为? ,offset 为 -1。
它有什么问题呢?如果 slave 宕机或者重启,那么 runid 和 offset 都丢失了,如果 master 宕机,那么新选出来的 master 的 runid 和偏移量都发生变化了,还会全量同步。
第三阶段: Redis 4.0 PSYNC2.0 方案
这个的出现就是为了解决住节点切换之后依然有可能进行增量同步而不是进行全量同步的问题。所以在 PSYNC2.0 里面直接舍弃了 runid 的概念,而是使用 replid 和 replid2 ,对于 master 来说 replid 就是自己的 id ,没有发生主节点切换的时候,replid2 为空,一旦发生主节点切换,那么新 master 的 replid2 存储的就是老 master 的 replid。
与此同时,偏移量也不能单一的了,新增一个偏移量,思路类似,second_replid_offset
,没发生主节点切换的时候这个为 -1 ,发生之后记录的就是老的 master 的复制偏移量。
所以这样新老都记录了就可以让主节点切换之后也可以进行增量同步的方式得以实现了。
主从是最基础的集群方式,问题也是非常显而易见的,一个就是缓存的数据量太大,并发量太大就不太行了,你的内存得多大吧,你想想,然后就是当主节点宕机,需要手动指定 slave 然后当做主节点,这个人工干预就增加了出问题的可能性和解决问题的时效性了,而自动选择 slave 的方案就是接下来要说的第二种集群方式,也就是主从的升级版,Redis Sentinel
。
Redis Sentinel哨兵模式
这个哨兵机制就是在主从复制的基础上增加一个 Sentinel 角色,然后监控 Redis 的节点运行状态,并且完成自动故障转移的。它根据一个规则,在 master 节点出现故障的时候选出一个 slave 成为新的 master,避免人工介入。
Redis Sentinel
本身的设计就是一个分布式系统,所以建议配置多个 Sentinel ,这样可以有效的避免误判。
Sentinel是怎么进行判定的?
这里有两个概念,主观下线和客观下线
- 主观下线: Sentinel 认为某个 Redis 节点已经下线了,然后需要其他 Sentinel 节点投票。
- 客观下线: 法定数量(可以配置,通常过半)的 Sentinel 节点认为某个 Redis 节点已经下线。
两者的区别就在于主观动作是由 Sentinel 节点发起的。注意,Sentinel 节点也是节点,也需要被监控。
每个 Sentinel 节点会每秒钟一次的频率向集群中的 master ,slave ,以及其他 Sentinel 节点发送 ping 命令,如果在规定是时间内没有回复,那么就认为是主观下线。
如果认为主观下线的是 slave 的话,那么 Sentinel 不会做什么的,因为 Slave 节点对集群影响不大,如果是 master 的话,那么就需要所有 Sentinel 节点以每秒一次的频率确认 master 是否下线了,当到达法定数量的时候就被判定是客观下线,所以主观和客观是组合拳。
所以我们也就清楚了为什么 Sentinel 推荐设置集群的原因,这样可以有效减少误判,如果只有一个 Sentinel 节点的话,那么一定不通,那么就下线,对主从切换的开销是非常大的。
之后 Sentinel 会有一个作为 Leader 负责进行故障转移,通知 slave 节点信息 master 信息,然后让它们执行 replicaof 成为新的 master 的 slave。
Sentinel怎么选择的master?
主要有三个依据
- slave 节点的优先级: 这个可以在
redis.conf
设置,默认值为 100 ,值越小优先级越高,越有机会成为 master ,0 特殊,代表着没有参与成为 master 的资格。这是第一个判断,如果没有优先级最高的,或者说有多个 slave 的节点的优先级相同的话看复制进度。 - slave 节点复制进度: 这个就是与原 master 数据最接近的意思,这样的话复制的速度最快,得分越高,越有机会成为 master。
- runid: 这个时候基本已经确定了新的 master 但是如果意外的发现前两者都相等的时候,那么就比较 runid ,这个 id 小的成为 master。
Sentinel 的leader怎么选?
从上面我们已经知道了怎么判定 master 下线,怎么选择新的 master ,那么接下来就是怎么选择进行故障转移的 leader 了。
这就需要用到分布式领域里面的共识算法,让多个节点达成共识,决定谁是 leader 。具体的实现是基于 Raft 算法。 关于 Redis Sentinel
中 Raft
算法的相关知识推荐大家看这篇文章 Raft协议实战之Redis Sentinel的选举Leader源码解析
Redis Cluster
上面两种集群方式只能帮我们缓解 QPS 的压力,而如果在缓存使用度很重的项目,缓存的数据量是非常大呢,那么对于这样的情况来说就需要横向扩展来提高性能,所以 Redis Cluster
切片集群的方式。这种方式就是多主多从,每一套主从都是整个 Redis
集群的一个分片。
一个基本的 Redis Cluster 的基础架构是什么样的?
通常情况下,一个高可用的 Redis Cluster
是需要 3 个 master 和3 个 slave 的,每一个 master 必须有一个 slave ,但是 slave 不对外提供读服务,而是作为 master 的备选项,一旦 master 不可用,那么直接进行切换,这样保证整个集群的高可用。如果有多个,那么进行切换的时候就找数据最接近的。
如何进行分片的?
既然是多个主,那么就一定面临着数据分片的问题,Redis Cluster
没有使用一致性 hash 算法,而是使用了哈希槽分区的方式,每一个键值对都属于一个哈希槽。通常情况下,哈希槽有 16384 个,至于为什么是这个数有一些解释,16384 = 2^14 ,正常的心跳包会携带一个节点的完整信息,包含哈希槽的信息,如果是 16384 那么就是 2k ,如果是 65536 那么就是 8k ,所以第一个原因就是节省一些带宽,然后就是哈希槽越少,对存储哈希槽的信息的 bitmap 压缩效果越好,最后就是通常 Redis Cluster
的主节点不会扩展太多,16384 已经足够用了。我往下接着说分片的事就能理解了。
对 key 计算 CRC-16
的校验码,然后校验码对 16384 取模得到的就是哈希槽,16384 会均匀的分到各个节点上。所以动态扩容就意味着重新分片,那么要怎么保证在迁移过程中保证正常的提供服务呢?这就有一个概念为重定向。Redis Cluster
提供了两种类型的重定向,分别 ASK
临时重定向,和 MOVED
永久重定向。看一下过程。
- 如果请求的 key 对应的哈希槽在当前节点中,那么就响应客户端请求。
- 如果请求的 key 对应的哈希槽正在迁移过程中,但是 key 还没有迁移走,那么可以响应客户端请求。
- 如果请求的 key 对应的哈希槽正在迁移过程中,并且 key 已经被迁移走,那么就返回
ASK
重定向错误,返回客户端新的目标节点。 - 客户端接收到
ASK
错误之后会向新节点发送一个ASKING
命令,也就是告诉新节点,我要这个 key 你必须给我。 - 新节点接收到
ASKING
之后,会返回TRYAGAIN
错误,因为可能请求的 key 正在导入还没导入完成呢。 - 客户端发送真正需要请求的命令。
ASK
重定向不会同步更新客户端缓存的哈希槽的分配信息- 如果请求的 key 对应的哈希槽已经迁移完成,那么就返回
MOVED
重定向错误,然后告诉客户端新的几点信息,然后客户端更新自己这边缓存的哈希槽信息。
所以当进行迁移过程中进行请求就是看 key 的归属问题,只要正在迁移呢,那么就都是临时重定向,每次的新请求还是打到老的节点上,只有迁移完了,才会更新自己这边的节点信息。
Redis Cluster的通信
在 Redis Cluster
中相当于内置了 Sentinel
,各个节点直接会通过 Gossip
协议进行通信,比如节点上线,下线,心跳等。当有节点不通的时候,在规定时间内有半数以上的节点都不通,那么就广播告诉所有的节点,然后进行切换。