缓存场景问题

常见的redis问题主要有:

  1. 缓存和数据库双写一致性问题
  2. 缓存雪崩问题
  3. 缓存击穿问题
  4. 缓存穿透问题
  5. 缓存的并发竞争问题

双写一致性如何保证

什么一致性

首先是一致性问题,在分布式系统中,可以理解为多个节点中数据的值是一致的。

  • 强一致性:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
  • 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
  • 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型

三个经典缓存模式

缓存可以提升性能、缓解数据库压力,但是使用缓存也会导致数据不一致性的问题。一般使用缓存的方式有三种:

  • Cache-Aside Pattern,旁路缓存模式,主要特点为:
    • 读的时候,先读缓存,缓存命中的话,直接返回数据
    • 缓存没有命中的话,就去读数据库,从数据库取出数据,放入缓存后,同时返回响应。
    • 更新时先更新数据库,然后再删除缓存
  • Read-Through/Write through,读写穿透模式,服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过抽象缓存层完成的。
    • 从缓存读取数据,读到直接返回
    • 如果读取不到的话,从数据库加载,写入缓存后,再返回响应。
    • 与Cache-Aside相似,仅是在它之上增加了一个抽象层
  • Write behind,异步缓存写入模式
    • 与Read-Through/Write-Through类似,都有一个抽象层负责缓存和数据库的读写
    • 不同的是,Write Behind只负责更新缓存,不直接对数据库进行操作,他通过异步的批量操作的方式去更新数据库
    • 这种方式下,缓存和数据库的一致性不强,对一致性要求高的系统要谨慎使用
    • 适合频繁写的场景,MySQL的InnoDB Buffer Pool机制就使用到这种模式。

缓存数据更新

在操作缓存的时候,是应该去更新缓存,还是删除后重建?

一般情况下,我们是删除缓存,再下次读取时,获取到数据库数据后再新建缓存数据。

对于这两种的选择的情况一般如下

删除缓存的场景:

  • 写入场景较多(当然写的场景较多的情况下,也需要考虑是否需要引入缓存)
  • 缓存计算逻辑复杂

更新缓存的场景:

  • 写数据库较少
  • 更新频率低

双写情况下,是先操作数据库还是缓存

Cache-Aside缓存模式中,在写入请求的时候,为什么是先操作数据库呢?为什么不先操作缓存呢?

由于使用的是删除缓存的方式,如果是先操作缓存再操作数据库,此时两个线程并发读写,就会可能出现缓存中的数据与数据库数据不一致的问题。

保证一致性的一些方法

同事务强一致性

对于非分布式系统而言,我们可以使数据库操作与redis操作在同一个事务中,具体流程是:

  1. 开启事务
  2. 修改数据库
  3. 执行redis命令
  4. 提交事务

基于这种方式,可以保证数据库被修改,缓存一定被删除,但这种方式局限较多。

缓存延时双删

什么是延时双删:

写请求->删除缓存->更新数据->休眠一会->删除缓存

休眠时间一般为:读业务逻辑数据的耗时 + 几百毫秒

为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。

删除缓存重试机制

对于延时双删、Cache-Aside的先操作数据库再删除缓存都会存在一个问题:在删除缓存时失败了,导致数据库与缓存不一致

为了解决这一个问题,我们可以引入一个失败重试机制,引入消息队列,当第一次删除缓存失败后,将要删除的key加入消息队列中,后续由消息队列消费者执行删除逻辑,直到删除成功。

161708071616412.png

为保证一致性,这里再添加消息队列的时候,应该使用事务消息,保证消息队列添加与数据库修改在同一个事务中,以此可以进一步保证一致性。

当然,对于消息队列而言,应存在一个失败次数限制,当失败次数达到时,应该通知管理员介入进行处理。

通过binlog异步删除缓存

对于 MySQL 而言,可以通过数据库的binlog来异步淘汰key。具体的可以使用阿里的canal将binlog日志采集发送到MQ队列里面,然后通过ACK机制确认处理这条更新消息,删除缓存,保证数据缓存一致性。

当然对于其他数据库也有其他相应的插件。

缓存雪崩

什么是缓存雪崩

造成缓存雪崩的主要原因有二:

  1. 大量缓存数据同时过期
  2. redis故障

通常我们为了保证缓存中的数据与数据库中的数据一致性,会给 Redis 里的数据设置过期时间,当缓存数据过期后,用户访问的数据如果不在缓存里,业务系统需要重新生成缓存,因此就会访问数据库,并将数据更新到 Redis 里,这样后续请求都可以直接命中缓存。

当大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。

171708146244546.png

针对大量缓存数据同时过期的应对措施是:

  • 均匀设置过期时间:给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。
  • 增加互斥锁
    • 如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁
    • 未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值
    • 实现互斥锁时应该增加一个超时时间,避免程序一直处于阻塞状态
  • 后台更新缓存:业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新

对于后台更新缓存而言,也存在极大的弊端,当系统内存紧张的时候,有些缓存数据会被“淘汰”,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角就以为是数据丢失了。

为解决这一弊端的方法有两个:

  • 后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效,检测到缓存失效了,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并更新到缓存。这种方式也存在弊端,那就是检测时间间隔不能太长,太长也导致用户获取的数据是一个空值而不是真正的数据
  • 通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。

针对redis故障的常用的解决方法是:

  • 服务熔断或请求限流机制
  • 构建 Redis 缓存高可靠集群

服务熔断或请求限流机制: 暂停业务应用对缓存服务的访问,直接返回错误,使得业务线程不在继续访问数据库,从而保障数据库的正常运行。但是直接粗暴处理,会使得业务功能受限,在此可以增加限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。

构建 Redis 缓存高可靠集群: 主从节点的方式构建 Redis 缓存高可靠集群。集群构建参考:redis主从复制

缓存击穿

缓存击穿主要是热点缓存数据过期。对于一个业务系统通常会有一些数据被频繁访问,对于访问频次高的数据称为热点数据。

在一个系统中,如果缓存中的某个热点数据过期了,此时大量的请求访问到该热点数据,就会出现缓存无法提供,所有的请求都直接访问数据库,从而数据库被高并发的请求冲垮,此类问题就被称为缓存击穿问题。

171708174681488.png

稍稍细心一点,不难发现,缓存击穿与缓存雪崩类似,我们可以将缓存击穿认为是缓存雪崩的一个子集。

所以对于缓存击穿,可以使用缓存雪崩的两种方式去解决:

  • 增加互斥锁
  • 后台更新缓存数据

缓存穿透

当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。

缓存穿透是数据既不在缓存也不在数据库

当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。

171708175362082.png

缓存穿透的发生一般有这两种情况:

  • 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
  • 黑客恶意攻击,故意大量访问某些读取不存在数据的业务;

应对缓存穿透的方案,常见的方案有三种:

  • 非法请求的限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
  • 增加缓存空值或者默认值:针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
  • 使用布隆过滤器进行判断:在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。

171708176147710.png

缓存的并发竞争

缓存的并发竞争指的是多个客户端同时操作同一个key的值,同时进行set操作所引发的并发问题。

解决这一问题的方法也简单,只需要添加一个分布式锁即可,对于redis我们可以直接使用redis的setnx实现分布式锁,具体逻辑为:

1
2
3
4
5
6
7
8
9
10
11
12
13
业务逻辑处理
for 1 ... 5 { 限制最多重试 5 次

获取分布式锁
if 锁获取成功 {
操作缓存
处理业务逻辑
break
}
获取锁失败,休眠一下继续重试
}

如果重试获取锁失败,需要增加失败的逻辑逻辑

对于并发较大的分布式系统,我们还可以使用分消息队列来处理这类问题,利用消息队列的顺序消息功能,将对同一个 key 的操作顺序化

181708234316890.png

布隆过滤器

布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。

布隆过滤器会通过 3 个操作完成标记:

  1. 使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
  2. 将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
  3. 将每个哈希值在位图数组的对应位置的值设置为 1;

举个例子,假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器。

171708175706690.png

在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中。

布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。

所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据

在redis中可以使用bitmaps实现布隆过滤器,通过setbit key offset value(设置值)、getbit key offset(获取值)、bitcount key [start end](获取位图指定范围值为1的个数)命令可以完成对数据的存储。

Redisson实现布隆过滤器

在java应用中,可以通过引入Redisson框架很容易的实现布隆过滤器功能,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedissonBloomFilter {

public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
config.useSingleServer().setPassword("123");
//构造Redisson
RedissonClient redisson = Redisson.create(config);

RBloomFilter<String> bloomFilter = redisson.getBloomFilter("phoneList");
//初始化布隆过滤器:预计元素为100000000L,误差率为3%
bloomFilter.tryInit(100000000L,0.03);
//将号码10086插入到布隆过滤器中
bloomFilter.add("10086");

//判断下面号码是否在布隆过滤器中
System.out.println(bloomFilter.contains("123456"));//false
System.out.println(bloomFilter.contains("10086"));//true
}
}

guava实现布隆过滤器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import com.google.common.base.Charsets;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnel;
import com.google.common.hash.Funnels;

public class GuavaBloomFilter {
public static void main(String[] args) {
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8),100000,0.01);

bloomFilter.put("10086");

System.out.println(bloomFilter.mightContain("123456"));
System.out.println(bloomFilter.mightContain("10086"));
}
}

redis 布隆过滤器插件

redis官方基于bitmaps数据结构实现了布隆过滤器,并提供了官方插件,下载地址为:https://github.com/RedisLabsModules/rebloom(推荐使用redis 6.x+集成)

安装步骤如下:

1
2
3
4
5
6
7
8
# 下载插件 
wget https://github.com/RedisLabsModules/rebloom/archive/v2.2.6.tar.gz

# 解压
tar -zxvf v2.2.6.tar.gz

# 编译插件
cd RedisBloom-2.2.6/

编译完成后会得到一个redisbloom.so文件,修改redis.conf将这个文件加入到配置文件中

1
loadmodule /usr/local/soft/RedisBloom-2.2.6/redisbloom.so

最后重启redis即完成插件安装。

插件安装完成后,redis的命令将会得到扩展:

  • bf.add 添加一个元素
  • bf.exists 判断一个元素是否存在
  • bf.madd 添加多个元素
  • bf.mexists 判断多个元素是否存在

详细的说明参考:https://redis.io/docs/data-types/probabilistic/bloom-filter/

如果不想自己安装,也可以使用官方提供的镜像:

1
docker run -p 6379:6379 -it --rm redis/redis-stack-server:latest

性能调优

TODO

spring 集成

TODO