Redis缓存问题:Redis缓存问题:穿透,击穿,雪崩,双写一致性等

在高并发场景下,数据库往往是最薄弱的环节,我们通常选择使用redis来进行缓存,以起到缓冲作用,来降低数据库的压力,但是一旦缓存出现问题,也会导致数据库瞬间压力过大甚至崩溃,从而导致整个系统崩溃.今天就聊聊常见的redis缓存问题.

缓存击穿

缓存击穿一般指redis中的一个热点数据过期,导致大量请求直接访问数据库的情况,导致数据库瞬间压力过大甚至崩溃.

解决方案:

  1. 设置热点数据永不过期,这是一个不错的方案(要考虑业务特性,体量以及成本),前提是热点数据不能频繁发生改变,否则就会出现缓存污染.最好是根据一定的策略进行定时更新
  2. 重要接口限流,做好熔断和降级的准备,sentinel是个不错的选择
  3. 使用互斥锁,保证同一时刻只有一个线程可以访问数据库,这何尝不是一种限流呢

缓存穿透

缓存穿透指缓存和数据库中都没有的数据,用户不断发起请求.这种情况最可能就是有人试图恶意攻击系统

解决方案:

  1. 加校验:拦截非法请求,用户鉴权等
  2. redis缓存一个无效值,以防止对同一个key在数据库中的多次查询,但redis中可能会出现大量无效值,导致缓存污染,所以要将有效时间设置得短一些
  3. 添加布隆过滤器,在对数据库进行查询前,先通过布隆过滤器判断是否存在

一般来说这三种方案是同时使用的,第一层一般是校验,拦截部分非法用户和不合理请求(拦截不可能全部拦截而且如果攻击者通过某些方式掌握了大量合法用户呢),第二层是布隆过滤器,尽量避免对数据库的直接访问,但仍然有误判的可能性,第三层再缓存一个无效值,做到尽可能降低风险

缓存雪崩

缓存雪崩一般指reids中大批量数据在极短时间内(同时)过期,导致大量的查询数据库

解决方案:

  1. 在存储数据时,设置过期时间为一个随机值(也可以理解成给固定的过期时间加上一个随机值,类似密码学中的加盐),尽量保证不会有大量数据在同一时间过期
  2. 将热点数据尽量均匀地分布在不同的数据库中
  3. 多级缓存
  4. 设置热点数据永不过期(同缓存击穿中的)

缓存污染

缓存污染指的是缓存中一些只会被访问一次或者几次的的数据,被访问完后,再也不会被访问到,但这部分数据依然留存在缓存中,消耗缓存空间,也会在一定程度上影响redis的性能

redis缓存的maxmemory应该设置多大,这是一个关乎性能和成本的问题,需要根据实际情况进行权衡,但普遍推荐的是设置为总数据量的15%-30%(其他博客都这么写,而且范围还挺大,应该没什么问题🤔)

缓存淘汰策略

官方文档写了8种,如下图:
avatar

  • noeviction(不驱逐,即不淘汰)

    默认策略,当缓存达到maxmemory时,redis会拒绝所有写请求,并返回错误信息,此时redis已经进入只读模式,无法再进行写操作,但仍然可以进行读操作

  • allkeys-lru
    所有key采用LRU算法进行淘汰,即优先删除最近最少使用的key

  • allkeys-lfu
    所有key采用LFU算法进行淘汰,即优先删除最不常用的key

  • volatile-lru
    只淘汰设置了过期时间的key,采用LRU算法进行淘汰

  • volatile-lfu
    只淘汰设置了过期时间的key,采用LFU算法进行淘汰

  • allkeys-random
    所有key采用随机删除

  • volatile-random
    只淘汰设置了过期时间的key,采用随机删除

  • volatile-ttl
    删除过期字段设置为true和剩余最短生存时间(TTL)值的密钥。

缓存和数据库一致性

不管是先写MySQL数据库,再删除Redis缓存;还是先删除缓存,再写数据库,都有可能出现数据不一致的情况。举一个例子:如果删除了缓存Redis,还没有来得及写库MySQL,另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据。

更新缓存有四种设计模式: Cache aside, Read through, Write through, Write behind caching

缓存更新模式

Cache Aside(旁路缓存)

  • 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从cache中取数据,取到后返回。
  • 更新:更新数据库, 更新/删除缓存

那么到底是更新缓存还是删除缓存呢?

如果读的频率高, 选择更新缓存, 这样可以提高缓存命中率

如果写的频率高, 选择删除缓存, 等到要读的时候再写入缓存, 可以减少无效的写操作和减少缓存数据量

Read Through(读穿)

Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候,Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。

Write Through(写穿)

Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)

Write Behind Caching(写回)

在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。但是,其带来的问题是,数据不是强一致性的,而且可能会丢失

一般而言(即不是特殊的业务场景), 我们选用Cache Aside(旁路缓存)模式, 下面我们的讨论也基于此模式.

操作缓存 & 操作数据库,哪个先?

针对删除缓存的情况:

如果采用先删除缓存,在更新数据库,会有什么后果?

  1. 线程A先把缓存的A数据删除,发生上下文切换
  2. 线程B想要读取A数据,发现缓存失效,从数据库读取A数据,并且更新缓存,
  3. 切换回线程A,线程A更新数据库数据。

至此,数据不一致的情况已经出现,数据库的写入较慢,容易出现此现象

先更新数据库,再删除缓存 怎么样?

  1. 线程A读取缓存A数据,发现缓存失效,所以去数据库读取,此时还未写入缓存,发生上下文切换
  2. 线程B更新数据库,并且删除对应的缓存,
  3. 切换回线程A,线程A将读取到的旧值,写入缓存

又出现了数据不一致的情况,但是相比先删除缓存,发生概率小很多,必须同时满足:

  • 读请求的时候,缓存刚好失效
  • 读请求 与 写请求 并发

所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。所以推荐先更新数据库,再删除缓存

还是存在不一致的可能,怎么实现最终的一致性呢?

  1. 延迟双删策略
    延迟双删策略是分布式系统中数据库存储和缓存数据保持一致性的常用策略,但它不是强一致。其实不管哪种方案,都避免不了Redis存在脏数据的问题,只能减轻这个问题,要想彻底解决,得要用到同步锁和对应的业务逻辑层面解决。

不采用先更新数据库,在删除缓存的方案, 而是用先删除缓存,再更新数据库,里面的不一致问题,用延迟双删解决
1、先删除缓存 2、再写数据库 3、休眠500ms(统计线程读取数据和写缓存的时间) (休眠的作用是当前线程等其他线程读完了数据后写入缓存后,删除缓存) 4、再删除缓存

  1. 设置较短的缓存过期时间
    沿用先更新数据库,再删除缓存,就给缓存设置一个较短的过期时间(只要它够短,就不容易读到错误数据)

针对更新缓存的情况:(面试说上面那种即可)

如果采用先更新缓存,再更新数据库

  1. 线程A更新了缓存,此时发生上下文切换
  2. 线程B更新缓存,更新数据库,此时切换回线程A
  3. 线程A更新数据库

此时就出现了数据不一致的现象,数据库的写入较慢,容易出现此现象。

反过来,先更新数据库,再更新缓存呢?

  1. 线程A更新了数据库,还未写入缓存,发生上下文切换
  2. 线程B更新了数据库,并且写入缓存,此时切换到线程A
  3. 线程A把缓存写入,覆盖了之前线程B的缓存

至此,数据不一致的情况,又出现了。缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且更新了缓存,请求 A 才更新完缓存的情况。所以推荐先更新数据库,再更新缓存

保证操作原子性

操作数据库和操作缓存其实是两个操作,前面的所有分析都是建立在这两个操作都能同时执行成功,只要出现一个执行成功,一个执行失败的情况,就会造成数据库和缓存数据不一致的问题,会对敏感业务造成影响。下面就是两种解决方案.

队列+重试机制

avatar

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

该方案有一个缺点,会对业务线代码造成大量的侵入。

基于订阅binlog的同步机制

avatar

本方案启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。关键是使用canal框架订阅binlog

  • 要开启mysql的binlog,需要设置binlog_format为ROW模式,并且设置server_id,保证唯一性。修改my.cnf配置文件,重启mysql服务。

    1
    2
    3
    4
    [mysqld]
    log-bin=mysql-bin # 开启 binlog
    binlog-format=ROW # 选择 ROW 模式
    server_id=1 # 配置 MySQL replaction 需要定义,不要和 canal 的 slaveId 重复
  • 查看是否修改 Binlog 成功。

    1
    2
    # 查看 binlog 日志是否开启
    show variables like 'log_%';
  • MySQL 执行 SQL 语句创建 canal 单独使用的账号,用来进行 Binlog 的同步和监听

1
2
3
CREATE USER canal IDENTIFIED BY 'canal';  
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
FLUSH PRIVILEGES;

Reference:

  1. 缓存更新的套路
  2. Java全栈知识体系