可能有时候你会遇到一个场景,在 Redis 中大量的 key 明明已经过期了,结果发现 Redis 的占用还是很高?如果要明白为什么,那就要知道Redis 的过期策略。策略分为 2 种,定期删除与惰性删除。
假设在 Redis 中存在 10w 个设置了过期的 key,如果此时 Redis 每隔指定毫秒每隔都扫描一遍看下有没有过期,那这个时候 Redis 基本就废了,CPU 负载会贼高,性能全花在了扫描过去可以上了。
而 Redis 使用定期删除,默认每隔 100ms 就随机抽取一些设置了过期时间的 key,检查是否过期,过期就删除。所以你会发现设置了过期时间但是为什么没有在指定时间点删除,就是因为这个。
定期删除会导致可能很多过期 key 没有被删除,那怎么办?所以这里就配合和惰性删除,在你获取某个 key 时候, redis 会检查一些这个 key 如果设置了过期时间并且删除了,那么就会删除掉,不会返回任何东西。
如果 redis 内存占用超过了设置的阀值后,已经当不足以容纳新写入的数据时候,就会进行内存淘汰,策略如下:
最近才知道在 5.0 时候又添加了 LFU (最少使用的)算法,为每个key维护一个计数器。每次key被访问的时候,计数器增大。计数器越大,可以等于访问越频繁。
LUR 算法就是淘汰最少使用的数据,比如历史记录。核心思想就是 如果数据最近被访问过,那么将来被访问的几率也更高
比较常见的实现就是使用链表结构来实现,写数据时候就压栈,访问数据时候,将原本数据取出,再放入链表中的表头。这个时候就能保证最少使用会在表的尾部。如下图:
如果 Redis 要实现高可用,首先需要实现读写分离,为了避免读写分离中出现的 master 挂掉导致 redis 不可用。需要使用 哨兵机制(Sentinel )配合。来达到 master 挂掉后再从 slave 中选取一个作为主节点。此时还要再考虑一个问题,如果 Sentinel 为单节点,也挂掉了,那不就又会造成服务不同啊,所以 Sentinel 也要搭建集群。需要注意的是,redis 与 Sentinel 都要最少三个节点
细节点的流程说明:
在认识到复制过程后,再引入 2 个概念配置
无磁盘化复制
针对上图第 2 步时候,生产 rdb 时,可以在配置文件中指定生成 rdb 时候,是在内存中生成然后直接发送,还是先保存到磁盘中然后在发送过去。通过如下配置:
repl-diskless-sync:
no:会开启一个线程,在内存中生成 rdb
yes:子进程生成 rdb,然后落到磁盘,再发给slave
repl-diskless-sync-delay:等待多时秒后开始给 slave 发送rdb文件,设置这个时间因为,好不容易生成一个 rdb,要等待更多的 salve 重新连接后再复制过去(上图第2步时候,为什么说等待指定时间再给 slave)
内存缓冲区超出
继续看第 2 步,在生成 rdb 快照的过程中,客户端新来的 写命令 都会写到内存缓冲区中,客户端有2种情况会直接将将缓冲区写满(配合文件中设置),导致复制失败。默认为如下:
修改配置内容如下,需要根据实际情况配置:
client-output-buffer-limit slave 256MB 64MB 60
上面是第一次时候复制说明会进行全量复制,如果要是主从突然断开了,然后又重新连接上了,这个时候该怎么同步怎?这里就要明白断点续传的原理了,在 redis 2.8 之前使用的是同步(sync) 之后就是命令传播方式(command propagate)
这种方式性能不是太好,原理比较简单,基本类似于上面全量同步,就是在重新连接后,发送一个全量的 rdb 快照。假如在断开时候, master 就收到 2 个写命令,那这个时候如果弄一个全量的 rdb 文件发过去,那就太 2 了。而且这样代价也非常大。
这里主要说明下命令传播方式,在了解命令传播方式时候,需要明白 3 个概念:
复制偏移量 (replication offset)
执行复制的双方(主、从)两端都会维护一个复制偏移量,从服务器在连接的时候,将 offset 发送过去,以此来判断这个从服务器是否与 master 数据一直,那么说了那么多,这个偏移量到底是啥玩意呢?如下图:
客户端发送添加命令 set key ‘a’,假设 ‘a’ 就 2 个字节,此时 master 的就是偏移量就等于 offset = 2,然后再向从服务器去发送 2 个字节,从服务器同步完后在此时偏移量为 2。
slave1 突然与 master 连接中断了,并且中断过程中 master 又写了 N 条数据。
当 slave1重新连接时候,将自己的 offset 发送给 master,master 一比较,发现从服务数据缺了一部分,然后就从缺的 offset+1 到 6,发送给 slave1 ,以此来达到增量的效果。
所以 offset 就是做用来记录数据量的一个测量标准吧(佩服自己说话都那么专业了),并且可以在主从断点续传时候的偏移量差值获取到少的一部分数据。
复制积压缓冲区 (replication backlog)
复制积压缓冲区就是维护的一个固定长度 先进先出的 队列,默认大小为 1 MB。假设队列长度为3,这个时候客户端发送来了一批请求,需要存储 a b c d,此时如图:
上面只是说明 复制积压缓冲区 究竟是什么,其实就是一个队列,用来存储客户端发来的写命令。其实这个队列又与偏移量绑定,最终存储形式为:
看到这个是不是明了偏移量 与 复制缓冲区的作用,master 中维护着复制缓冲区,然后 slave 维护着自己的 offset,在同步的时候 master 才知道该从哪里开始复制。
那么复制缓冲区该怎么调整?
如果主服务器需要执行大量写命令,又或者主从服务器断线后重连接所需的时间比较长,缓冲区被写满替换掉了一部分数据,那么这个大小也许并不合适。因此,正确估算和设置复制积压缓冲区的大小非常重要。复制积压缓冲区的最小的容量可以根据公式:second * write_size_per_second 来估算
□ 其中second为从服务器断线后重新连接上主服务器所需的平均时间(以秒计算)。
□ 而write_size_per_second则是主服务器平均每秒产生的写命令数据量(协议格式的写命令的长度总和)。
例如,如果主服务器平均每秒产生1 MB的写数据,而从服务器断线之后平均要5秒才能重新连接上主服务器,那么复制积压缓冲区的大小就不能低于5MB。
为了安全起见,可以将复制积压缓冲区的大小设为 2 * second * write_size_per_second,这样可以保证绝大部分断线情况都能用部分重同步来处理。至于复制积压缓冲区大小的修改方法,可以参考配置文件中关于repl-backlog-size选项的说明。
服务器的运行id (run id)
这个就好理解了,无论是 master 还是 slave 都会维护一个 run id,在服务器启动时候就自动生成了,例如:53b9b28df8042fdc9ab5e3fcbbbabff1d5dce2b3。当 slave 对 master 初次复制时候,master 会将自己的 run id 给 slave。
slave 在重新连接 master 时候,会将之前连接的 master 的 run id 也发送过去。当时 master 端收到后,和自己的一比较。如果一样说明之前这个 slave 一直连接的就是自己,只需要增量复制即可。反之就全量复制。
只有主从复制还是不能够达到高可用的,因为一旦 master 挂了之后,还是导致 redis 会崩溃,此时所以的请求全部打到了数据库,很大可能直接把数据库给打死。所以这里需要哨兵机制去监视 master 的状态。
作为监听 master 状态的,那么如果 sentinel 单机的话,挂掉了,此时 master 也挂掉了,那该怎么办?所以这种情况都是一环套一套的,A 依赖 B,如果 B 挂了,那么对 A 那不是又是灾难性的嘛。所以 sentinel 还要搭建集群。
哨兵主要功能如下:
集群监控:负责监控 redis master 和 slave 进程是否正常工作
消息通知:如果某个 redis 示例有故障,那么哨兵负责发送消息作为报警通知给管理员
故障转移:如果 master node 挂掉后,会自动转移到 slave node 上
配置中心:如果故障转移发生了,通知 client 客户端新的 master 地址。
1. sdown 和 odown 转换机制
sdown:
是主观宕机,就是一个哨兵自己觉得 master 宕机了,那么就是主观宕机。所以达成条件很简单,如果一个 ping 一个 master ,超过了 is-master-down-after-milliseconds 指定的毫秒数后,就主观的认为 master 宕机了。
odown:
是客观宕机,如果一个 指定数据(quorum) 的哨兵都觉得一个 master 宕机了,那么就是 客观宕机。所以从 sdown 转换到 odown时候,条件就是一个哨兵在指定时间内,收到了其他哨兵也认为那个 master 主观宕机了,那么就认为客观宕机。
2. 哨兵 和 salve 集群的自动发现机制
哨兵与哨兵之间的发现,是通过 redis 中创建 pub/sub 发布订阅模式实现的,每个哨兵都会向 sentinel:hello 这个 channel 里发送一个消息,内容是自己的 host、ip 和 runid 还有对 master 的监控配置。
3 个定时任务
每隔10s,通过命令连接向被监视的主服务器发送INFO命令,并通过分析INFO命令的回复来获取主服务器的当前信息。
每隔 2 秒,向频道中发送发:当前哨兵的信息 + 当前哨兵对主节点的判断。每隔哨兵都会订阅到该频道,然后去了解其他信息对主节点的判断。(sentinel 同时也是订阅者)
每隔1秒,每个sentinel节点会向主节点、从节点、其余sentinel节点发送一条ping命令做一次心跳检测,来确认这些节点当前是否可达。
当 sentinel 客观认为 master 废了的时候,就会进行选举算法,让一个 slave 做为主节点。而哨兵搭建的需要最少 3 个节点。这些因为客观宕机需要最少 2 个 sentinel 都认为宕机了才行。那么假设现在就 2 个 sentinel,挂了一个,还怎么满足客观宕机的条件?所以说需要最少 3 台机。
哨兵会负责自动纠正 slave 的一些配置,比如 slave 如果要潜在的 master 候选人,哨兵会确保 slave 咋复制现有 master 的数据。
如果一个 slave 连接到一个错误的 master 上,比如故障转移之后,那么哨兵会确保它连接到正确的 master上。
选举leader。当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个 Sentinel 会进行协商,选举出一个领头 Sentinel,并由领头 Sentinel 对下线主服务器执行故障转移操作。选举 leader 规则为:
所有 sentinel 都有资格被设置为 leader
每个发现 master 进入客观下线的 Sentinel 都会要求其他 Sentinel 将自己设置为局部领头 Sentinel。
一旦称为了 leader 后,就有权利将某个 sentinel 设置为局部领头的机会,一旦设置就无法更改。
当sentinel 被选举为 leader(A) 后,向以另外一个 sentinel(B) 发送一个 is-master-down-by-addr,并且命令中的 runid参数是字节的 run id,表示要设置 B 为局部领头的 sentinel。如果一个 sentinel 被半数多的 sentinel 设置成了局部领导,那么这个 sentinel 下次就会成为 leader
如果在给定时限内,没有一个Sentinel被选举为领头Sentinel,那么各个Sentinel将在一段时间之后再次进行选举,直到选出领头Sentinel为止。
选举slave。如果一个 master 被认为 客观宕机了,而且多数哨兵都允许了主备切换,那么某个哨兵就会执行主备切换操作,此时首先要选举一个 slave 来会经历以下步骤:
上面的情况看着是解决了高可用很美好,很强。但是细品下好像还是会有点问题,在master 节点收到了 N 个写的命令,还在内存中,没有去做同步给 slave 节点,恰恰就在此时,主节点嘎嘣挂了。而 Sentinel 检测到有新选择了个主节点,此时就会导致数据丢失。
这个脑裂就要留意下这种情况的发生了。再假设一个场景,还是一主双从,但是有点不一样的是,主节点 与 从节点不在一个局域网中。在 sentinel 检查 master 是否挂时候,出现了网络问题导致 sentinal 误认为 master 挂了,然后又选举了个主节点,这个时候可就存在了 2 个主节点啦。
然后更恶心的是,一个客户端还在向老的 master 写数据。
通过下面配置来解决这两种情况的丢失问题
min-slaves-to-write 1
min-slaves-max-lag 10
配置说明:要求至少有1个slave,在数据复制和同步的延迟不能超过10秒。如果说一旦所有的slave,数据复制和同步的延迟都超过了10秒钟,那么这个时候,master就不会再接收任何请求了
此时无论是异步复制 还是 脑裂情况,都会丢失的10s 的数据。丢失数据情况不能百分之解决,但是可以避免减少这种情况
在执行 SAVE 命令或者 BGSAVE 命令创建一个新的 RDB 文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的 RDB 文件中。RDB 文件会对应多个,每个数据文件都代表了某一个时刻中的 redis 数据。子进程只需要 fork 一个子进程,让子进程执行磁盘 IO 操作来进行持久化。对于已过期的键不会假造到 RDB 中
服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入工作完成为止
Redis允许用户通过设置服务器配置的save选项,让服务器每隔一段时间自动执行一次BGSAVE命令。用户可以通过save选项设置多个保存条件,但只要其中任意一个条件被满足,服务器就会执行BGSAVE命令。
save 900 1
save 300 10
save 60 10000
那么只要满足以下三个条件中的任意一个,BGSAVE命令就会被执行:
❑服务器在900秒之内,对数据库进行了至少1次修改。
❑服务器在300秒之内,对数据库进行了至少10次修改。
❑服务器在60秒之内,对数据库进行了至少10000次修改。
举个例子,以下是Redis服务器在60秒之内,对数据库进行了至少10000次修改之后,服务器自动执行BGSAVE命令时打印出来的日志:
[5085] 03 Sep 17:09:49.463 * 10000 changes in 60 seconds. Saving...
[5085] 03 Sep 17:09:49.463 * Background saving started by pid 5189
[5189] 03 Sep 17:09:49.522 * DB saved on disk
[5189] 03 Sep 17:09:49.522 * RDB: 0 MB of memory used by copy-on-write
[5085] 03 Sep 17:09:49.563 * Background saving terminated with success
自动保存实现原理
记录更改次数与时间: redis 里面有 2 个变量 dirty 计数器 和 lastsave属性:
假设 set test “xxx” 时候计数器就会 +1, 而 SADD
testKey a b c 此时计数器增加 3。
检查是否满足保存条件:Redis的服务器周期性操作函数serverCron默认每隔100毫秒就会执行一次,该函数用于对正在运行的服务器进行维护,它的其中一项工作就是检查save选项所设置的保存条件是否已经满足,如果满足的话,就执行BGSAVE命令。
RDB 文件结构总分为 5 部分,为了方便区分变量、数据、常量,图10-10中用全大写单词标示常量,用全小写单词标示变量和数据。本章展示的所有RDB文件结构图都遵循这一规则:
REDIS:
RDB文件的最开头是REDIS部分,这个部分的长度为5字节,保存着“REDIS”五个字符。通过这五个字符,程序可以在载入文件时,快速检查所载入的文件是否RDB文件。说白了,就是在读取时候,如果数据文件里面以这个开头就表示是一个 RDB 文件。
db_version:
长度为4字节,它的值是一个字符串表示的整数,这个整数记录了RDB文件的版本号
database:
包含着零个或多个 Redis 数据库,以及各个数据库中的键值对数据。如果所有数据库都是空的,那么这个部分也为空,长度为0字节。如果至少一个数据库非空,那么这个部分也为非空,根据数据库所保存键值对的数量、类型和内容不同,这个部分的长度也会有所不同。
EOF:
常量的长度为1字节,这个常量标志着RDB文件正文内容的结束,当读入程序遇到这个值的时候,它知道所有数据库的所有键值对都已经载入完毕了。
check_sum:
是一个8字节长的整数,保存着一个校验和,这个校验和是程序通过对REDIS、db_version、databases、EOF四个部分的内容进行计算得出的。服务器在载入RDB文件时,会将载入数据所计算出的校验和与check_sum所记录的校验和进行对比,以此来检查RDB文件是否有出错或者损坏的情况出现
aof 方式是以日志形式记录,在收到客户端的写操作时候,将写操作按顺序记录在日志中,启动时候在执行一遍日志中的命令。针对过期键会生成一个 DEL 命令。因此,数据库中包含过期键不会对AOF重写造成影响。当 RDB 与 AOF 都开启是会先加载 AOF
为了提高文件的写入效率,在现代操作系统中,当用户调用write函数,将一些数据写入到文件的时候,操作系统通常会将写入数据暂时保存在一个内存缓冲区里面,等到缓冲区的空间被填满、或者超过了指定的时限之后,才真正地将缓冲区中的数据写入到磁盘里面。这种做法虽然提高了效率,但也为写入数据带来了安全问题,因为如果服务器突然宕机,那么保存在内存缓冲区里面的写入数据将会丢失。为此,系统提供了fsync和fdatasync两个同步函数,它们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面,从而确保写入数据的安全性。
AOF日志分为三种同步方式:
appendfsync=always:
将aof_buf缓冲区中的所有内容写入到AOF文件,并且同步AOF文件,所以always的效率是appendfsync选项三个值当中最慢的一个,但从安全性来说,always也是最安全的。网上很多说不会丢失数据,其实还是会丢一个事件循环中产生的数据(虽然对事件循环不太了解,留给后面的自己再去参悟吧[笑脸])
appendfsync=everysec:
将aof_buf缓冲区中的所有内容写入到AOF文件,并且每隔一秒就要在子线程中对 AOF 文件进行一次同步。从效率上来讲,everysec模式足够快,并且就算出现故障停机,数据库也只丢失一秒钟的命令数据。
appendfsync=no:
将命令写入到 aof_buf 缓冲区中,而何时同步到磁盘,则由操作系统控制。当出现故障停机时,使用no模式的服务器将丢失上次同步AOF文件之后的所有写命令数据。
服务器在某个时间点,因为某些原因导致缓存不可用了。此时所有的请求都会打到 DB,很可能导致 DB 挂掉。或者大量热点数据在同一时间失效了,流量都打到了 DB,这个时候很可能把 BD 给打挂。
解决方案:
请求的 key 在数据不存在,没有将 null 放入缓存,导致会一直查数据库。如果被人利用恶意的攻击,发送大量查询这个 key 的请求,就会导致数据库直接挂掉
当 testkey 不存时候,每秒请求 5k 次去请求这个key,此时如果不做处理,会给数据库造成较大压力,还有一种情况,攻击者会根据 id 获取数据时候,传入 -1, -2, -3 这些不存的数据,需要防范
解决方案:
请求的 key 存在,但是这个数据是热点数据,在缓存过期的一小段时间内。大量请求瞬间打到 DB,将 DB 打垮。
解决方案:
能不能设置为不过期,如果更新数据,就发送到消息队列,由消费者去更新数据。
加锁,使用分布式锁,防止击穿。因为本身就是热点数据,如果加锁,可能会造成性能瓶颈。(个人感觉应该限流 + 锁 + 降级,限流避免请求大量在阻塞,造成严重后果,而且保证一部分人此次还是可以看到接口数据的。另外一部分人走降级策略吧,再刷新下又可以看到了)
(个人想法)热点数据如果硬件资源比较好一点,可以使用 内存缓存 + Redis。例如 Redis 为 100s 过期,那么内存缓存设置 120s。如果 redis 失效了,此时就走内存缓存,并且同时去查询数据库(需要保证只会查一次数据库)。这样可以实现在 key 过期时候,内存缓存去抗下高流量。查完后同时再将 redis 与 内存缓存的数据都更新
一般我们操作缓存时候大致的场景为以下场景:
1. 先更新数据库,然后获取最新的数据,再对应的 修改/删除。
当接收到一个请求时候,首先去改了数据库,此时数据库修改成功了。但是去修改缓存时候失败了。导致数据不一致情况。所以说将修改缓存放入数据库操作后,会出现一些不可意料的问题。
2. 先删缓存,然后再更新数据库 (Cache Aside Pattern)
Cache Aside Pattern 原则其实就是一下2点:
可以看出第一点的方式会出现不一致问题,那么为什么先删除缓存然后再更新就不会出问题了?
这是因为删除缓存后,去更新数据库,不管更新数据库成功还是失败了。反正下次查询缓存时候,发现没有缓存都会查询一新的数据。
| | |
客户端 | redis | 数据库 |
---|---|---|
发送扣库存 num - 1 | – | – |
– | 删除key num | – |
– | – | 修改 num -1 = 99 |
客户端1查询 num key | – | – |
– | 发现没有num,然后去查询 num = 99。所以就算数据库之前修改错误也不慌 | – |
这种方案问题:
其实这种方案还是存在问题的,那就是删除完缓存后,当正在更新数据库,这个时候来了查询缓存的,直接查出来又设置进去缓存了,又出现了一致性请求,情况流程如下:
客户端 | redis | 数据库 |
---|---|---|
发送扣库存 num - 1 | – | – |
– | 删除key num | – |
– | – | 修改数据库正在进行中 |
客户端1查询 num key | – | – |
– | 发现没有num,然后去查询 num = 100 | – |
– | – | 修改数据库完毕!num = 99 |
畅想解决方案:
我们在这里总结下,其实导致上面 2 个解决方案不可用的最根本问题就是,并发时候的又读又写,而且没有保证到原子性。如果说真的就是要做到那么强一致性,那就要设计一套缓存存储了。需要强一致性的地方的数据,保证原子性,或者说需要 读写互斥。其实可以借助队列在写的时候就放入队列,然后读时候查看队列中是否存在这个 id。
并发竞争有些场景下多个客户端都需要修改某个key,正常情况下按发送请求的时间来执行结果为 1、 2、3、4,但是并发出现了:4、 3、 2、1。也就是说,最后结果应该是 4 的,但是成了 1 了。
这种情况下使用以下解决方案:
使用 分布式锁+时间戳,通过上锁来执行。如以下:
1 10:00:00
2 10:00:01
3 10:00:02
4 10:00:03
假设2、 3、 4 都执行了,现在执行到了 1 了,这个时候查看下 reids 中存的时间戳与 1 的时间戳是否一致,如果不一致那就不更新了
使用消息队列串行执行
针对上面的这些问题,还是建议实现一套自己的缓存系统。无论是并发环境下各种 key 的写入问题,还是热点数据 key 查询出现时候的一系列问题。将问题归纳整理后,问题的本质都是相似的,而这个系统就作为这些场景的一系列解决方案或者不同的解决方案。(有感而发,其实我也没做过[笑脸],只是感觉应该这样去做,而不应该零零散散的去解决这些问题,这样只会让系统越来越臃肿,应该化零为整)