Redis-缓存雪崩,缓存穿透,缓存击穿出现的原因及解决方案、补充无底洞问题

缓存处理流程

      前台请求,后台先从缓存中取数据,取到直接返回结果,取不到时从数据库中取,数据库取到更新缓存,并返回结果,数据库也没取到,那直接返回空结果。

      

缓存雪崩

出现过程

1、缓存层由于某种原因宕机或失效

2、大量缓存数据同时过期

假设有如下一个系统,高峰期请求为5000次/秒,4000次走了缓存,只有1000次落到了数据库上,数据库每秒1000的并发是一个正常的指标,完全可以正常工作,但如果缓存宕机了,或者缓存设置了相同的过期时间,导致缓存在同一时刻同时失效,每秒5000次的请求会全部落到数据库上,数据库立马就死掉了,因为数据库一秒最多抗2000个请求,如果DBA重启数据库,立马又会被新的请求打死了,这就是缓存雪崩。

缓存雪崩是指缓存中数据大批量到过期时间(大量缓存中数据一起到期),而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是,缓存击穿指并发查同一条数据(热点数据),缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。


解决方法

1、事前:①redis高可用,主从+哨兵,redis cluster,避免全盘崩溃,②如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。③缓存的失效时间设置为随机值,避免同时失效/设置热点数据永远不过期。

2、事中:ehcache本地缓存 + hystrix限流&客户端降级,避免MySQL被打死

客户端降级场景距离:降级机制在高并发系统中是非常普遍的:比如推荐服务中,如果【个性化推荐服务】不可用,可以降级应用为【推荐热点数据】,不至于造成前端页面是开天窗。

扩展阅读:

3、事后:redis持久化RDB+AOF,快速恢复缓存数据

缓存穿透

出现过程

假如客户端每秒发送5000个请求,其中4000个为黑客的恶意攻击,即在数据库中也查不到。举个例子,用户id为正数,黑客构造的用户id为负数,如果黑客每秒一直发送这4000个请求,缓存就不起作用,数据库也很快被打死。

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。

问题发现

通常可以在程序中分别统计【总调用数】、【缓存层命中数】、【存储层命中数】,如果发现大量存储层空命中,可能就是出现了缓存穿透问题
 

解决方法

1、请求参数进行校验:对请求参数进行校验,不合理直接返回(接口层增加校验,如用户鉴权校验,id基础校验,id<=0的直接拦截)
2、缓存空对象:查询不到的数据也放到缓存,value为空,如 set -999 “”(从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击)
3、使用布隆过滤器拦截在访问缓存层和存储层之前,将【存在的key】用布隆过滤 器提前保存起来,做第一层拦截,快速判断key是否在数据库中存在,不存在直接返回

第一种是最基本的策略,第二种其实并不常用,第三种比较常用。

为什么第二种并不常用呢?

因为如果黑客构造的请求id是随机数,第二种并不能起作用,反而由于缓存的清空策略,(例如清除最近没有被访问的缓存)导致有用的缓存被清除了。

下面给出2,3两种方案的适用场景——来自《redis开发与运维》709

缓存击穿


出现过程

设置了过期时间的key,承载着高并发,是一种热点数据。从这个key过期到重新从MySQL加载数据放到缓存的一段时间,缓存失效到重新加载到缓存这段时间,大量的请求有可能把数据库打死。缓存雪崩是指大量缓存失效,缓存击穿是指热点数据的缓存失效。

缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期的热点数据,这些数据设置了过期时间的key,承载着高并发,是一种热点数据),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力

解决方法

1、设置热点key永远不过期:

永不过期包括两个层面的含义:

  • ①物理层面,从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期 后产生的缓存击穿问题
  • ②逻辑层面,为了保证能够得到更新,为每个value设置一个【逻辑过期时间】,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存,通过另一个异步线程重新设置key(缺点是重构缓存期间,会出现数据不一致的情况

2、当从缓存拿到的数据为null,重新从数据库加载数据的过程使用分布式锁,保证只有一个线程执行缓存重建过程,其他客户端可以选择等待+周期重新请求缓存,流程例如下面伪代码:

两种方案优缺点:

作为一个并发量较大的应用,在使用缓存时有三个目标:
  • 第一,加快用户访问速度,提高用户体验。
  • 第二,降低后端负载,减少潜在的风险,保证系统平稳。
  • 第三,保证数据“尽可能”及时更新。
述两种解决方案进行分析。
  • 互斥锁(mutex key):这种方案思路比较简单,但是存在一定的隐患,如果构建缓存过程出现问题或者时间较长,可能会存在死锁和线程池阻塞的风险,但是这种方法能够较好地降低后端存储负载,并在一致性上做得比较好,mzj:这里的一致性比较好是建立在设置的缓存过期时间是被应用所容忍不一致的时间范围,因此超过这个容忍时间自动过期,然后下次请求立马构建缓存,所以相对来说一致性好一些,但是如果需要保证强一致性,就得在数据变化时主动更新缓存了。
  • 永远不过期:这种方案由于没有设置真正的过期时间,实际上已经不存在热点key产生的缓存击穿危害,但是会存在数据不一致的情况,mzj:这里的数据不一致是相对互斥锁方案来说的,也就是虽然逻辑过期时间可以设置成应用容忍的时间,但是发现过期到重新构建缓存之间数据是允许继续被访问的,重构完成期间数据是不一致的,同时代码复杂度会增大(每次请求缓存时判断时间如果超过过期时间)。

下表总结两种解决缓存击穿问题方式的优缺点:

下面写个分布式锁实现的demo

Redis实现分布式锁

我之前的文章写到了Redis实现分布式锁的原理,这里就不再详细概述了

Redis分布式锁为什么要这样写?请看下面框框里的内容,来自:https://blog.csdn.net/zzti_erlie/article/details/102884025?ops_request_misc=%7B%22request_id%22:%22158331078619726869014180%22,%22scm%22:%2220140713.130056874..%22%7D&request_id=158331078619726869014180&biz_id=0&utm_source=distribute.pc_search_result.none-task

前言

对分布式锁不太了解的小伙伴,可以先看一下这篇文章
https://mp.weixin.qq.com/s/8fdBKAyHZrfHmSajXT_dnA

Redis分布式锁加锁

最开始的分布式锁是使用setnx+expire命令来实现的。setnx设置成功返回1,表示获取到锁,返回0,表示没有获取到锁,同时为了避免显示释放锁失败,导致资源永远也不释放,获取到锁后还会用expire命令设置锁超时的时间。

 

但有个问题就是setnx+expire不是原子性的,有可能获取到锁后,还没执行expire命令,也没执行释放锁的操作,服务就挂了,这样这个资源就永远也不会访问到了。

 

为了解决这个问题,Redis 2.6.12版本以后,为set命令增加了一系列的参数,我们此时用NX和PX参数就可以解决这个问题。

所以现在Redis分布式锁的加锁命令如下

 

SET resource_name random_value NX PX 30000


NX只会在key不存在的时候给key赋值,PX通知Redis保存这个key 30000ms,当资源被锁定超过这个时间时,锁将自动释放

random_value最好是全局唯一的值,保证释放锁的安全性

 

# 设置成功返回

OK

127.0.0.1:6379> SET lock1 100 NX PX 30000

OK

127.0.0.1:6379> SET lock1 100 NX PX 30000

(nil)


当某个key不存在时才能设置成功。这就可以让多个并发线程同时去设置同一个key,只有一个能设置成功。而其他线程设置失败,也就是获得锁失败

 

Redis分布式锁解锁

解锁不能简单的使用如下命令

del resource_name 

因为有可能节点A加锁后执行超时,锁被释放了。节点B又重新加锁,A正常执行到del命令的话就把节点B的锁给释放了。所以在解锁之前先判断一下是不是自己加的锁,是自己加的锁再释放,不是就不释放。所以伪代码如下

 

if (random_value .equals(redisClient.get(resource_name))) {
    del(key)
}

因为判断和解锁是2个独立的操作,不具有原子性,还是有可能会出问题。所以解锁的过程要执行如下的Lua脚本
,通过Lua脚本来保证判断和解锁具有原子性。

 

if redis.call("get", KEYS[1]) == ARGV[1] then 
    return redis.call("del", KEYS[1]) 
else 
    return 0 
end

如果key对应的value一致,则删除这个key,通过这个方式释放锁是为了避免Client释放了其他Client申请的锁

到此你已经彻底理解了该如何实现一个分布式锁了,以及为什么要这样做的原因

加锁执行命令

SET resource_name random_value NX PX 30000

解锁执行脚本

if redis.call("get", KEYS[1]) == ARGV[1] then 
    return redis.call("del", KEYS[1]) 
else 
    return 0 
end

一个分布式锁的工具类写法如下

public class LockUtil {

    private static final String OK = "OK";
    private static final Long LONG_ONE = 1L;
    private static final String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

    public static boolean tryLock(String key, String value, long expire) {
        Jedis jedis = RedisPool.getJedis();
        SetParams setParams = new SetParams();
        setParams.nx().px(expire);
        return OK.equals(jedis.set(key, value, setParams));
    }

    public static boolean releaseLock(String key, String value) {
        Jedis jedis = RedisPool.getJedis();
        return LONG_ONE.equals(jedis.eval(script, 1, key, value));
    }
}

 


可能有小伙伴对Lua脚本不太熟悉,所以下面就是介绍Lua脚本的部分

Lua脚本

从Redis2.6开始,内嵌Lua环境,通过EVAL和EVALSHA命令可以执行脚本

EVAL script numkeys key [key...] arg [arg...]
参数解释
script脚本
numkeys键的个数
key [key…]key列表,键名通过全局变量 KEYS 数组,用 1 为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)
arg [arg…]参数列表,参数通过全局变量 ARGV 数组,用 1 为基址的形式访问( ARGV[1] , ARGV[2] ,以此类推)

Redis执行Lua脚本的命令

EVAL命令

先演示一下

EVAL "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 2 key1 key2 first second

输出为

1) "key1"
2) "key2"
3) "first"
4) "second"

 

EVALSHA命令

EVAL命令是直接执行给定的脚本

EVALSHA命令可以根据给定的sha1校验码,执行缓存在服务器中的脚本
首先要将Lua脚本加载到Redis服务端,得到该脚本的SHA1校验和,EVALSHA命令使用SHA1作为参数可以直接执行对应Lua脚本,避免每次发送Lua脚本的开销。而脚本也会常驻在服务端,脚本功能得到了复用。

通过 SCRIPT LOAD 命令可以将脚本缓存到服务器,这个命令会返回脚本的sha1值

SCRIPT LOAD script
EVALSHA sha1 numkeys key [key ...] arg [arg ...] 

sha1为脚本sha1值

 

在Lua脚本中调用Redis方法

有2种方式redis.call()和redis.pcall()

redis.call()与redis.pcall()非常类似,唯一的区别是,如果Redis命令调用发生了错误,redis.call() 将抛出一个Lua类型的错误,再强制EVAL命令把错误返回给命令的调用者,而redis.pcall()将捕获错误并返回表示错误的Lua表类型

 

127.0.0.1:6379> set testKey testValue
OK
127.0.0.1:6379> get testKey
"testValue"

用脚本实现上述功能

 

127.0.0.1:6379> eval "return redis.call('set', KEYS[1], ARGV[1])" 1 scriptKey scriptValue
OK
127.0.0.1:6379> eval "return redis.call('get', KEYS[1])" 1 scriptKey
"scriptValue"

用SCRIPT LOAD命令缓存脚本

 

127.0.0.1:6379> SCRIPT LOAD "return redis.call('set', KEYS[1], ARGV[1])"
"55b22c0d0cedf3866879ce7c854970626dcef0c3"
127.0.0.1:6379> evalsha 55b22c0d0cedf3866879ce7c854970626dcef0c3 1 sha1Key sha1Value
OK
127.0.0.1:6379> get sha1Key
"sha1Value"

本文只介绍了一个Lua脚本的使用,Lua脚本的流程控制(循环,判断)就不再介绍,很快就能学会。当你有一些操作Redis的功能想和其他人共享,或者实现命令的原子性时,就可以考虑用lua脚本来实现。

 

1.加锁执行命令

SET resource_name random_value NX PX 30000

2.解锁执行脚本

if redis.call("get", KEYS[1]) == ARGV[1] then 
    return redis.call("del", KEYS[1]) 
else 
    return 0 
end

写一个分布式锁工具类

public class LockUtil {

    private static final String OK = "OK";
    private static final Long LONG_ONE = 1L;
    private static final String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

    public static boolean tryLock(String key, String value, long expire) {
        Jedis jedis = RedisPool.getJedis();
        SetParams setParams = new SetParams();
        setParams.nx().px(expire);
        return OK.equals(jedis.set(key, value, setParams));
    }

    public static boolean releaseLock(String key, String value) {
        Jedis jedis = RedisPool.getJedis();
        return LONG_ONE.equals(jedis.eval(script, 1, key, value));
    }
}


工具类写起来还是挺简单的

示例代码(以下示例代码来自另一篇文章https://blog.csdn.net/kongtiao5/article/details/82771694,原文中的示例感觉逻辑不太对):

无底洞问题——来自《redis开发与运维》11.5

1、问题描述

2010 年, Facebook Memcache 节点已经达到了 3000 个,承载着 TB 级别的缓存数据。但开发和运维人员发现了一个问题,为了满足业务要求添加了 大量新Memcache 节点,但是发现性能不但没有好转反而下降了,当时将这种现象称为缓存的“ 无底洞 现象。

2、问题原因

键值数据库由于通常采用哈希函数将 key映射到各个节点上,造成 key 的分布与业务无关,但是由于数据量和访问 量的持续增长,造成需要添加大量节点做水平扩容,导致键值分布到更多的节点上,所以无论是Memcache 还是 Redis 的分布式,批量操作通常需要从不同节点上获取,相比于单机批量操作只涉及一次网络操作,分布式批量操作会涉及多次网络时间。

3、问题分析

  • 客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着节点的增多,耗时会不断增大。
  • 网络连接数变多,对节点的性能也有一定影响。

4、优化思路

分布式条件下优化批量操作。在介绍具体的方法之前, 我们来看一下常见的IO 优化思路
  • 命令本身的优化,例如优化SQL语句等。
  • 减少网络通信次数。
  • 降低接入成本,例如客户端使用长连/连接池、NIO等。

5、优化

这里主要是对【减少网络通信次数】进行说明:

Redis 批量获取 n 个字符串为例,有三种实现方法
  • 客户端ngetn次网络+nget命令本身。
  • 客户端1pipeline get1次网络+nget命令本身。
  • 客户端1mget1次网络+1mget命令本身。

下面结合Redis Cluster的一些特性对四种分布式的批量操作方式进行说明

1.串行命令

2、串行IO

Redis Cluster 使用 CRC16 算法计算出散列值,再取对 16383 的余数就可以算出slot 值,同时 10.5 节我们提到过 Smart 客户端会保存 slot 和节点的对应关系,有了这两个数据就可以将属于 同一个节点的key进行归档 ,得到每个节点的key 子列表, 之后对每个节点执行mget或者Pipeline操作 ,它的操作时间=node次网络时间 +n 次命令时间,网络次数是 node 的个数
3、并行IO
此方案是将方案 2 中的最后一步改为多线程执行,网络次数虽然还是节点个数,但由于使用多线程网络时间变为O (max_slow ),即执行最慢的线程时间,这种方案会增加编程的复杂度
4、hash_tag
Redis Cluster hash_tag 功能,它可以将多个 key 强制分配到一个节点上,它的操作时间=1 次网络时间 +n 次命令时间
 

6、总结

实际开发中可以根据表 11-4 给出的优缺点进行分析,没有最好的方案只有最合适的方案

 

​​​​​​​

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值