“众里寻他千百度,蓦然回首,那人却在,灯火阑珊处”。多年的IT生涯,一直希望自己写的程序能够有很强的健壮性,也一直希望能找到一个高可用的标杆程序去借鉴学习,不畏惧内存溢出、磁盘满了、断网、断电、机器重启等等情况。但意想不到的是,这个标杆程序竟然就是从一开始就在使用的分布式缓存——Redis。
Redis(Remote Dictionary Server ),即远程字典服务,是 C 语言开发的一个开源的高性能键值对(key-value)的内存数据库。由于它是基于内存的所以它要比基于磁盘读写的数据库效率更快。因此Redis也就成了大家解决数据库高并发访问、分布式读写和分布式锁等首选解决方案。
那么既然它是基于内存的,如果内存满了怎么办?程序会不会崩溃?既然它是基于内存的,如果服务器宕机了怎么办?数据是不是就丢失了?既然它是分布式的,这台Redis服务器断网了怎么办?
今天我们就一起来看看Redis的设计者,一名来自意大利的小伙,是如何打造出一个超强健壮性和高可用性的程序,从而不惧怕这些情况。
一、 Redis的内存管理策略——内存永不溢出
Redis主要有两种策略机制来保障存储的key-value数据不会把内存塞满,它们是:过期策略和淘汰策略。
1、 过期策略
用过Redis的人都知道,我们往Redis里添加key-value的数据时,会有个选填参数——过期时间。如果设置了这个参数的值,Redis到过期时间后会自行把过期的数据给清除掉。“过期策略”指的就是Redis内部是如何实现将过期的key对应的缓存数据清除的。
在Redis源码中有三个核心的对象结构:redisObject、redisDb和serverCron。
- redisObject:Redis 内部使用redisObject 对象来抽象表示所有的 key-value。简单地说,redisObject就是string、hash、list、set、zset的父类。为了便于操作,Redis采用redisObject结构来统一这五种不同的数据类型。
- redisDb:Redis是一个键值对数据库服务器,这个数据库就是用redisDb抽象表示的。redisDb结构中有很多dict字典保存了数据库中的所有键值对,这些字典就叫做键空间。如下图所示其中有个“expires”的字典就保存了设置过期时间的键值对。而Redis的过期策略也是围绕它来进行的。
- serverCron:Redis 将serverCron作为时间事件来运行,从而确保它每隔一段时间就会自动运行一次。因此redis中所有定时执行的事件任务都在serverCron中执行。
了解完Redis的三大核心结构后,咱们回到“过期策略”的具体实现上,其实Redis主要是靠两种机制来处理过期的数据被清除:定期过期(主动清除)和惰性过期(被动清除)。
-
惰性过期(被动清除):就是每次访问的时候都去判断一下该key是否过期,如果过期了就删除掉。该策略就可以最大化地节省CPU资源,但是却对内存非常不友好。因为不实时过期了,原本该过期删除的就可能一直堆积在内存里面!极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
-
定期过期(主动清除):每隔一定的时间,会扫描Redis数据库的expires字典中一定数量的key,并清除其中已过期的 key。Redis默认配置会每100毫秒进行1次(redis.conf 中通过 hz 配置)过期扫描,扫描并不是遍历过期字典中的所有键,而是采用了如下方法:
(1)从过期字典中随机取出20个键;
(server.h文件下ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP
配置20)
(2)删除这20个键中过期的键;
(3)如果过期键的比例超过 25% ,重复步骤 1 和 2;
具体逻辑如下图:
因为Redis中同时使用了惰性过期和定期过期两种过期策略,所以在不同情况下使得 CPU 和内存资源达到最优的平衡效果的同时,保证过期的数据会被及时清除掉。
2、淘汰策略
在Redis可能没有需要过期的数据的情况下,还是会把我们的内存都占满。比如每个key设置的过期时间都很长或不过期,一直添加就有可能把内存给塞满。那么Redis又是怎么解决这个问题的呢?——那就是“淘汰策略”。
官网地址:https://redis.io/topics/lru-cache
Reids官网上面列出的淘汰策略一共有8种,但从实质算法来看只有两种实现算法,分别是LRU和LFU。
LRU(Least Recently Used):翻译过来是最久未使用,根据时间轴来走,淘汰那些距离上一次使用时间最久远的数据。
LRU的简单原理如下图:
从上图我们可以看出,在容器满了的情况下,距离上次读写时间最久远的E被淘汰掉了。那么数据每次读取或者插入都需要获取一下当前系统时间,以及每次淘汰的时候都需要拿当前系统时间和各个数据的最后操作时间做对比,这么干势必会增加CPU的负荷从而影响Redis的性能。Redis的设计者为了解决这一问题,做了一定的改善,整体的LRU思路如下:
(1)、Redis里设置了一个全局变量 server.lruclock 用来存放系统当前的时间戳。这个全局变量通过serverCron 每100毫秒调用一次updateCachedTime()更新一次值。
(2)、每当redisObject数据被读或写的时候,将当前的 server.lruclock值赋值给 redisObject 的lru属性,记录这个数据最后的lru值。
(3)、触发淘汰策略时,随机从数据库中选择采样值配置个数key, 淘汰其中热度最低的key对应的缓存数据。
注:热度就是拿当前的全局server.lruclock 值与各个数据的lru属性做对比,相差最久远的就是热度最低的。
Redis中所有对象结构都有一个lru字段, 且使用了unsigned的低24位,这个字段就是用来记录对象的热度。
LFU(Least Frequently Used):翻译成中文就是最不常用。是按着使用频次来算的,淘汰那些使用频次最低的数据。说白了就是“末尾淘汰制”!
刚才讲过的LRU按照最久未使用虽然能达到淘汰数据释放空间的目的,但是它有一个比较大的弊端,如下图:
如图所示A在10秒内被访问了5次,而B在10秒内被访问了3 次。因为 B 最后一次被访问的时间比A要晚,在同等的情况下,A反而先被回收。那么它就是不合理的。LFU就完美解决了LRU的这个弊端,具体原理如下:
上图是末尾淘汰的原理示意图,仅是按次数这个维度做的末尾淘汰,但如果Redis仅按使用次数,也会有一个问题,就是某个数据之前被访问过很多次比如上万次,但后续就一直不用了,它本身按使用频次来讲是应该被淘汰的。因此Redis在实现LFU时,用两部分数据来标记这个数据:使用频率和上次访问时间。整体思路就是:有读写我就增加热度,一段时间内没有读写我就减少相应热度。
不管是LRU还是LFU淘汰策略,Redis都是用lru这个字段实现的具体逻辑,如果配置的淘汰策略是LFU时,lru的低8位代表的是频率,高16位就是记录上次访问时间。整体的LRU思路如下:
(1)每当数据被写或读的时候都会调用LFULogIncr(counter)方法,增加lru低8位的访问频率数值;具体每次增加的数值在redis.conf中配置默认是10(# lfu-log-factor 10)
(2)还有另外一个配置lfu-decay-time 默认是1分钟,来控制每隔多久没人访问则热度会递减相应数值。这样就规避了一个超大访问次数的数据很久都不被淘汰的漏洞。
小结:“过期策略” 保证过期的key对应的数据会被及时清除;“淘汰策略”保证内存满的时候会自动释放相应空间,因此Redis的内存可以自运行保证不会产生溢出异常。
二、 Redis的数据持久化策略——宕机可立即恢复数据到内存
有了内存不会溢出保障后,我们再来看看Redis是如何保障服务器宕机或重启,原来缓存在内存中的数据是不会丢失的。也就是Redis的持久化机制。
Redis 的持久化策略有两种:RDB(快照全量持久化)和AOF(增量日志持久化)
1、 RDB
RDB 是 Redis 默认的持久化方案。RDB快照(Redis DataBase),当触发一定条件的时候,会把当前内存中的数据写入磁盘,生成一个快照文件dump.rdb。Redis重启会通过dump.rdb文件恢复数据。那那个一定的条件是啥呢?到底什么时候写入rdb 文件?
触发Redis执行rdb的方式有两类:自动触发和手动触发
“自动触发”的情况有三种:达到配置文件触发规则时触发、执行shutdown命令时触发、执行flushall命令时触发。
注:在redis.conf中有个 SNAPSHOTTING配置,其中定义了触发把数据保存到磁盘触发频率。
“手动触发”的方式有两种:执行save 或 bgsave命令。执行save命令在生成快照的时候会阻塞当前Redis服务器,Redis不能处理其他命令。如果内存中的数据比较多,会造成Redis长时间的阻塞。生产环境不建议使用这个命令。
为了解决这个问题,Redis 提供了第二种方式bgsave命令进行数据备份,执行bgsave时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。
具体操作是Redis进程执行fork(创建进程函数)操作创建子进程(copy-on-write),RDB持久化过程由子进程负责,完成后自动结束。它不会记录 fork 之后后续的命令。阻塞只发生在fork阶段,一般时间很短。手动触发的场景一般仅用在迁移数据时才会用到。
我们知道了RDB的实现的原理逻辑,那么我们就来分析下RDB到底有什么优劣势。
优势:
(1)RDB是一个非常紧凑(compact类型)的文件,它保存了redis在某个时间点上的数据集。这种文件非常适合用于进行备份和灾难恢复。
(2)生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。
(3)RDB在恢复大数据集时的速度比AOF的恢复速度要快。
劣势:
RDB方式数据没办法做到实时持久化/秒级持久化。在一定间隔时间做一次备份,所以如果Redis意外down掉的话,就会丢失最后一次快照之后的所有修改
2、 AOF(Append Only File)
AOF采用日志的形式来记录每个写操作的命令,并追加到文件中。开启后,执行更改 Redis数据的命令时,就会把命令写入到AOF文件中。Redis重启时会根据日志文件的内容把写指令从前到后执行一次以完成数据的恢复工作。
其实AOF也不一定是完全实时的备份操作命令,在redis.conf 我们可以配置选择 AOF的执行方式,主要有三种:always、everysec和no
AOF是追加更改命令文件,那么大家想下一直追加追加,就是会导致文件过大,那么Redis是怎么解决这个问题的呢?
Redis解决这个问题的方法是AOF下面有个机制叫做bgrewriteaof重写机制,我们来看下它是个啥
注:AOF文件重写并不是对原文件进行重新整理,而是直接读取服务器现有的键值对,然后用一条命令去代替之前记录这个键值对的多条命令,生成一个新的文件后去替换原来的AOF文件。
我们知道了AOF的实现原理,我们来分析下它的优缺点。
优点:
能最大限度的保证数据安全,就算用默认的配置everysec,也最多只会造成1s的数据丢失。
缺点:
数据量比RDB要大很多,所以性能没有RDB好!
小结:因为有了持久化机制,因此Redis即使服务器宕机或重启了,也可以最大限度的恢复数据到内存中,提供给client继续使用。
三、Redis的哨兵模式——可战到最后一兵一卒的高可用集群
内存满了不会挂,服务器宕机重启也没问题。足见Redis的程序健壮性已经足够强大。但Redis的设计者,在面向高可用面前,仍继续向前迈进了一步,那就是Redis的高可用集群方案——哨兵模式。
所谓的“哨兵模式”就是有一群哨兵(Sentinel)在Redis服务器前面帮我们监控这Redis集群各个机器的运行情况,并且哨兵间相互通告通知,并指引我们使用那些健康的服务。
Sentinel工作原理:
1、 Sentinel 默认以每秒钟1次的频率向Redis所有服务节点发送 PING 命令。如果在down-after-milliseconds 内都没有收到有效回复,Sentinel会将该服务器标记为下线(主观下线)。
2、 这个时候Sentinel节点会继续询问其他的Sentinel节点,确认这个节点是否下线, 如果多数 Sentinel节点都认为master下线,master才真正确认被下线(客观下线),这个时候就需要重新选举master。
Sentinel的作用:
1、监控:Sentinel 会不断检查主服务器和从服务器是否正常运行
2、故障处理:如果主服务器发生故障,Sentinel可以启动故障转移过程。把某台服务器升级为主服务器,并发出通知
3、配置管理:客户端连接到 Sentinel,获取当前的 Redis 主服务器的地址。我们不是直接去获取Redis主服务的地址,而是根据sentinel去自动获取谁是主机,即使主机发生故障后我们也不用改代码的连接!
小结:有了“哨兵模式”只要集群中有一个Redis服务器还健康存活,哨兵就能把这个健康的Redis服务器提供给我们(如上图的1、2两步),那么我们客户端的链接就不会出错。因此,Redis集群可以战斗至最后一兵一卒。
这就是Redis,一个“高可用、强健壮性”的标杆程序!
作者:宜信技术学院 谭文涛