Redis 缓存与集群

Published on 2019 - 07 - 01

转载请注明作者小停雨的博客

Redis的缓存设计套路

1. 缓存雪崩

当缓存设置了相同的过期时间,或者当缓存系统挂掉,线上所有请求都直连到了数据库的一种情况

针对缓存雪崩的方法

事发前:

  • 缓存系统做集群
  • 缓存声明周期使用随机值

事发中:

  • 程序中最好事先做好准备,利用本地缓存和请求数限制,尽量减少对数据库的访问,防止数据库被干掉

事发后:

  • Redis持久化:重启缓存系统后自动从磁盘上加载数据,快速恢复缓存数据

2. 缓存穿透

缓存穿透是指,查询一个一定不存在的数据。由于缓存不命中,并且处于容错考虑,如果从数据库查不到数据,则不写入缓存,这将导致这类请求每次都要去数据库查询,失去了缓存的意义

例:

比如我有一张数据表,自增ID字段是从1开始的,但是可能有黑客想把我的数据库搞垮,每次请求的ID都是负数。这会导致我的缓存就没用了,秦秋全部都找数据库去了,但是数据库也没有这个值呀,所以每次检索完都返回空出去,如果大量请求数据库"吃枣药丸"~

针对缓存穿透的方法

  • 程序层使用相应方法,过滤不合法的请求,不让非法请求到达数据库层
  • 当从数据库找不到数据的时候,也将这个数据库返回的空对象设置到缓存里面,下次再请求的时候,就可以从缓存里边获取了
    • 同时要将空对象,设置一个较短的过期时间。

3. 缓存与数据库双写一致

如果只是查询操作,先查询缓存如果缓存没有数据,再查询数据库,然后将查询出来的数据写到缓存中,最后将数据返回给请求。
 
但是当我们更新的时候呢?各种情况很可能就造成数据库和缓存的数据不一致了。

例:

某商品数据库中的库存值是999,但缓存中的库存值是1000,如果短时间来大量购买请求,缓存来不及更新,就造成了超卖问题

对于更新的操作

一般来说,执行更新操作时,我们会有两种选择:

  • 先操作数据库,再操作缓存
  • 先操作缓存,再操作数据库

首先要明确的是,无论选择哪个,都这两个操作要么同时成功,要么同时失败。所以这会演变成一个分布式事务的问题。如果原子性被破坏了,可能会有以下的情况:

  • 数据库操作成功,缓存操作失败
  • 缓存操作成功,数据库操作失败

如果第一步已经失败了,我们直接返回Exception出去就好,第二步根本不会执行。

具体分析,操作缓存

操作缓存主要有两种方案:

  • 更新缓存
  • 删除缓存
  1. 高并发环境下,无论是先操作数据库还是后操作数据库,如果加上更新缓存,那就更加容易导致数据库与缓存数据不一致问题(删除缓存直接和简单很多)
  2. 如果每次更新了数据库,都要更新缓存*【这里指的是频繁更新的场景,这会耗费一定的性能】*,倒不如直接删除掉,等再次读取时,缓存里没有,那我到数据库找,在数据库找到再写到缓存里(体现懒加载)

基于这两点,对于缓存更新而言,都是建议执行删除操作。

1. 先更新数据库,再删除缓存

对于这种策略,其实是一种设计模式:Cache Aside Pattern

正常情况下是这样的:

  • 先操作数据库,成功;
  • 再删除缓存,也成功;

如果原子性被破坏了:

  • 第一步成功,第二步失败,会导致数据库里是新数据,而缓存里是旧数据。
  • 如果第一步失败,可以直接返回错误,不会出现数据不一致

如果在高并发场景下,出现数据库与缓存数据不一致的概率特别低,也不是没有:

 - 缓存刚好失效
 - 线程A查询数据库,得一个旧值
 - 线程B将新值写入数据库
 - 线程B删除缓存
 - 线程A将查到的旧值写入缓存

要出现上述情况,概率特别低:

因为这个条件需要发生在读取缓存时缓存失效,而且并发着有一个写操作.而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而操作必须在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。

假设如果有杠精一定要解决上述问题:

  • 给缓存设有效时间
  • 使用异步删除,保证请求完成以后再进行删除

同时还是会出现一个问题,删除缓存失败的问题

删除缓存失败的解决思路:

  • 将需要删除的Key发送到消息队列中
  • 自己消费消息,获得需要删除的Key
  • 不断重试删除操作,直到成功

2. 先删除缓存,再更新数据库

正常情况下是这样的:

  • 先删除缓存,成功;
  • 再更新数据库,也成功;

如果原子性被破坏了:

  • 第一步成功,第二步失败,数据库和缓存的数据还是一直的。
  • 第一步失败了,可以直接返回错误,数据库和缓存的数据还是一致的。

这种操作看起来没什么问题,但是如果在高并发场景下,就发现了问题:

- 线程A删除了缓存
- 线程B查询去数据库得到旧值
- 线程B将旧值写入缓存
- 线程A将新值写入数据库

能看到,线程A的值在缓存中在下一次更新前,将永远都是脏数据,也会导致数据和缓存不一致的问题。

并发下解决数据库与缓存不一致的思路:

  • 在上述线程A中,更新完数据库后,sleep几百毫秒,再次增加一次删除缓存操作,如果sleep阻塞影响速度,可以设置成异步的,再起一个线程。
    • 第二次删除失败
    • 如果第二次删除失败也会造成永久脏数据,下一条利用队列也是一种解决方法。
  • 将删除缓存,修改数据库,读取缓存等的操作,积压到队列里边,实现串行化

    此方法还会有一个问题,后文会详述

对比两种策略

可以发现,两种策略各有各的优缺点:

  • 先删除缓存,再更新各自优缺点:
    • 在高并发下表现不如意,在原子性被破坏时表现优异
  • 先更新数据库,再删除缓存(Cache Aside Pattern 设计模式)
    • 在高并发下表现优异,在原子性被破坏时表现不如意

缓存删除失败怎么办

上面提出的两种方式,都存在一个令人担忧的问题,就是缓存删除失败了怎么办,无论是Cache Aside Pattern 还是先操作缓存,都会遇到这个问题,其实在2中已经给出了解决方法

例如:

更新数据库数据
缓存因为种种问题删除失败
将需要删除的Key发送至队列
自己消费消息,获得需要删除的Key
继续重试删除操作,直到成功

然而这个方案,还有一个缺点,对业务代码造成大量的侵入。每一个人写代码的时候,都要写一套这个逻辑出来,并且这套代码还要运行在业务代码之内。

另一个方案:

可以启动一个监听程序去监听城数据的binlog,或缺的需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

具体流程:

更新数据库数据
数据库会将操作信息写入binlog日志当中
监听程序会提取出所需要的数据以及Key
另起一段非业务代码,获得该信息
尝试删除缓存操作,发现删除失败
将这些信息发送至队列
重新从消息队列中获得该数据,重试操作

这个方案有如果数据库为Mysql,有一个现成的中间件Canal可以完成监听binlog日志的功能。

Redis缓存持久化

Redis有两种持久化的方式:

  • AOF(append-only-file)

    当Redis服务器执行写命令的时候,将执行的写命令保存到AOF文件中

  • RDB(基于快照)

    将某一时刻的所有数据保存到一个RDB文件中

RDB持久化 - 详解

RDB持久化可以手动执行,也可以根据配置定期执行。RDB持久化锁生成的RDB文件是一个经过压缩的二进制文件,Redis可以通过这个文件还原数据库的数据。

有两个命令可以生成RDB文件:

  • SAVE会阻塞Rdis服务器进程,服务器不能接收任何请求,直到RDB文件创建完毕为止。
  • BGSAVE创建出一个子进程,由子进程来负责RDB文件,服务器进程可以继续接受请求。

Redis在启动的过程中,如果发现有RDB文件,就会自动载入RDB文件(不需要人工干预),但是要注意的是,在载入过程中,Rdis会进入阻塞状态,直到载入工作完成。

通过配置,完成RDB持久化

save 900 1    # 在900秒之后,至少有一个Key发生变化
save 300 10   # 在300秒之后,至少有10个Key发生变化
save 60 10000 # 在60秒之后,至少有10000个Key发生变化

AOF持久化 - 详解

AOF持久化是通过保存Redis服务所执行的写命令来记录数据的

AOF持久化功能的实现,可以分为3步骤:

  • 命令追加:命令写入aof_buf缓冲区
  • 文件写入:调用flushAppendOnlyFile函数,考虑是否要将aof_buf缓冲区写入AOF文件中
  • 文件同步:考虑是否将内存缓冲区的数据真正写入到硬盘

其中flushAppendOnlyFile函数的行为是由服务器配置的appendfsyn来决定的:

appendfsync always      # 每次有数据修改发生时都会写入AOF文件
appendfsync everysec    # 每秒钟同步一次,该策略为AOF的默认策略
appendfsync no              # 从不同步。高效但是数据不会被持久化

AOF重写

如果执行了三条写入命令,AOF文件就会保存三条命令。

如果我们的命令式这样:

RPUSH list "Golang" "Python"
RPUSH list "ZhuTingYu"
RPUSH list "RichZhu"

同样的AOF也会保存3条命令。可以发现,上面的三条命令是可以合并成一条命令的,这样就可以让AOF文件体积变小。

AOF重写由Redis自行触发(参数配置),也可以使用BGREWRITEAOF命令手动触发重写操作。

注:AOF重写并不是对现有的AOF文件进行任何的读取。AOF重写是通过读取服务器当前缓存中的数据来实现的。

AOF是不会阻塞主线程的,它会fork出一个子进程,来完成重写AOF的操作,从而不会影响到主进程。新的写命令请求可能会导致当前数据库和重写后的AOF文件的数据不一致,为了解决不一致的问题,Redis服务器设置了一个AOF重写缓冲区这个缓存区会在服务器创建出子进程之后,使用。

RDB和AOF对过期键的策略

RDB:

  • 执行RDB持或者BGSAVE命令创建出的RDB文件,程序会对数据库中的过期键检查,已过期的键不会保存在RDB文件中。

  • 载入RDB文件时,程序同样会对RDB文件中的键进行检查,过期的键会被忽略。

AOF:

  • 如果数据库的键已过期,但还没被惰性/定期删除,AOF文件不会因为这个过期键产生任何影响(也就是说会保留),当国旗的键被删除了以后,会追加一条DEL命令来记录该键被删除了
  • 重写AOF文件时,程序会对AOF文件中的键进行检查,过期的键会被忽略。

复制模式:

主服务器来控制从服务器统一删除过期键(保证主从服务器数据的一致性)

RDB与AOF使用哪个

RDB和AOF并不冲突,可以同时使用。

  • RDB的优点:载入时恢复数据快、文件体积小。
  • RDB的缺点:会一定程度上丢失数据(如果在持久化之前出现宕机线上,此时还没有来得及写入磁盘的数据会丢失)
  • AOF的有点:丢失数据少(默认配置只丢失一秒的数据)
  • AOF的缺点:回复数据相对较慢,文件体积大

注:如果同时开启了RDB和AOF,Redis会优先使用AOF文件来还原数据。
 
因为AOF更新频率比RDB更新频率高,还原的数据更完善

RDB+AOF配置文件示例

---rdb---
save 900 1    # 在900秒之后,至少有一个Key发生变化
save 300 10   # 在300秒之后,至少有10个Key发生变化
save 60 10000 # 在60秒之后,至少有10000个Key发生变化

stop-writes-on-bgsave-error yes # 忽略错误
rdbcompression yes                          # rdb文件压缩
dbfilename dump.rdb                         # rdb文件名称 
dir /var/rdb/                                   # rdb文件存储位置

---aof---
appendonly no # 是否打开aof日志功能
appendfsync always # 每一个命令都立即同步到aof // 安全,但是速度慢
appendfsync everysec # 每秒钟同步一次
appendfsync no # 写入工作交给操作系统,由操作系统判断缓冲区大小,统一写入到aof,同步频率低,速度快

Redis的集群

集群类型

  • Redis Sentinel
  • Redis Cluster

Redis Sentinel:

中文哨兵模式是客户端和redis之间的桥梁,所有的客户端都通过Sentinel程序获取Redis的Master访问,如果master挂了,会及时从slave中选出新的mater,但是要注意sentinel本身也许是需要做集群的,如果sentinel进程挂了,将无法实现集群的主备切换;

Sentinel的特点

  • 即使有一些sentinel进程宕掉了,依然可以进行redis集群的主备切换;
  • 如果只有一个sentinel进程,如果这个进程运行出错,或者是网络堵塞,那么将无法实现redis集群的主备切换(单点问题);
  • 如果有多个sentinel,redis的客户端可以随意地连接任意一个sentinel来获得关于redis集群中的信息。

Sentinel 的主备切换过程

​ 当一个master被sentinel集群监控时,需要为他指定一个参数,这个参数指定了当需要判决master为不可用,并且进行failover时,所需要的sentinel数量,下面称之为票数。当failover主备切换真正被触发后,failover并不会马上执行,还需要sentinel中的大多数授权后才可以进行failover。

​ Failover一旦被触发,尝试去进行failover的sentinel会去获得"大多数"sentinel的授权,如果票数比大多数还要大的时候,则询问更多的sentinel。

例:

集群中有5个sentinel,票数被设置为2,当2个sentinel认为一个master已经不可用了后,将会触发failover;
但是进行failover的那个sentinel必须再获取到至少3个sentinel的授权才可以实行failover。
如果触发failover的票数设置为5,则必须所有5个sentinel都认为master为不可用;要进行failover需要获得所有5个sentinel的授权。

Redis Cluster:

在Redis3.0中新推出的集群方案,有效解决了Redis分布式方面的需求。当遇到单机内存、并发、流量等瓶颈时,可以采用Cluster架构达到负载均衡的目的。

简介

分布式集群首先把数据集按照分区规则映射到多个节点,每个节点负载整个数据集的一个子集。Redis Cluster采用哈希分区规则中的虚拟槽分区

虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把所有的数据映射到 一个固定的范围内的整数集合,(0 - 16383)整数定义为槽。

槽是集群内数据管理和迁移的基本单位,每个节点负责维护一定数量的槽,以及槽所映射的键值数据。

Redis的过期淘汰策略

  • 定时删除
  • 懒惰删除
  • 定期删除

定时删除:

  • 含义:在设置Key过期时间的同时,为该Key创建一个定时器,让定时器在Key的过期时间来临时,对Key进行删除。
  • 优点:保证内存被尽快删除
  • 缺点:
    • 若过期Key很多,删除这些Key会占用很多的CPU时间,例如10000个Key,将会有10000个定时器在运行

懒惰删除:

  • 含义:Key过期的时候不删除,每次通过Key获取值的时候去检查是否过期,若过期,则删除,返回Null。
  • 优点:删除操作只发生在通过Key取值的时候,而且只删除当前Key,所以对CPU的时间占用是比较少的,而且此时的删除已经是到了非做不可的地步。
  • 缺点:若大量的Key在超出超时时间后,很久一段时间内,都没有被或去过,那么可能发生内存泄漏,无用的垃圾key占用了大量的内存。

定期删除:

  • 含义:每隔一段时间执行一次删除过期Key操作
  • 优点:通过限制删除操作的时长和频率,来减少删除操作对CPU的占用,并可避免懒惰删除对内存的浪费

  • 缺点:内存友好方面不如定时删除会造成一定的内存占用,但是没有懒汉式那么占用。在CPU时间友好方面,不如懒惰删除因为会定期的去进行比较和删除操作,但是比定时删除

  • 难点:1. 合理设置删除操作的执行时长(每次删除执行多长时间)和执行频率(每隔多长时间做一次删除)
    1. 每次进行定期删除后,需要记录循环到了哪个标志位,以便下一次执行定期时,从上次位置开始

**memcached只是用了惰性删除,而redis同时使用了惰性删除与定期删除,这也是二者的一个不同点(可以看做是redis优于memcached的一点);

转载请注明作者小停雨的博客