缓存处理流程
前台请求,后台先从缓存中取数据,取到直接返回结果,取不到时从数据库中取,数据库取到更新缓存,并返回结果,数据库也没取到,那直接返回空结果。
缓存雪崩
出现过程
1、缓存层由于某种原因宕机或失效
2、大量缓存数据同时过期
假设有如下一个系统,高峰期请求为5000次/秒,4000次走了缓存,只有1000次落到了数据库上,数据库每秒1000的并发是一个正常的指标,完全可以正常工作,但如果缓存宕机了,或者缓存设置了相同的过期时间,导致缓存在同一时刻同时失效,每秒5000次的请求会全部落到数据库上,数据库立马就死掉了,因为数据库一秒最多抗2000个请求,如果DBA重启数据库,立马又会被新的请求打死了,这就是缓存雪崩。
缓存雪崩是指缓存中数据大批量到过期时间(大量缓存中数据一起到期),而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是,缓存击穿指并发查同一条数据(热点数据),缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方法
1、事前:①redis高可用,主从+哨兵,redis cluster,避免全盘崩溃,②如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。③缓存的失效时间设置为随机值,避免同时失效/设置热点数据永远不过期。
2、事中:ehcache本地缓存 + hystrix限流&客户端降级,避免MySQL被打死
客户端降级场景距离:降级机制在高并发系统中是非常普遍的:比如推荐服务中,如果【个性化推荐服务】不可用,可以降级应用为【推荐热点数据】,不至于造成前端页面是开天窗。
扩展阅读:
- 综合:http://www.360doc.com/content/17/1123/15/16915_706456568.shtml
- hystrix(java依赖隔离开发工具包,Hystrix提供了熔断、隔离、Fallback、cache、监控等功能,能够在一个、或多个依赖同时出现问题时保证系统依然可用。将服务的接口使用hystrix线程池做隔离,可以实现限流和熔断的效果。依赖隔离:在实际项目中,我们需要对重要的资源(例如Redis、MySQL、HBase、外部接口)都进行隔离,让每种资源都单独运行在自己的线程池中,即使个别资源出现了问题,对其他服务没有影响。
):https://www.cnblogs.com/cjsblog/p/9391819.html、https://www.e-learn.cn/content/qita/1592490
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
前言对分布式锁不太了解的小伙伴,可以先看一下这篇文章 Redis分布式锁加锁最开始的分布式锁是使用setnx+expire命令来实现的。setnx设置成功返回1,表示获取到锁,返回0,表示没有获取到锁,同时为了避免显示释放锁失败,导致资源永远也不释放,获取到锁后还会用expire命令设置锁超时的时间。
但有个问题就是setnx+expire不是原子性的,有可能获取到锁后,还没执行expire命令,也没执行释放锁的操作,服务就挂了,这样这个资源就永远也不会访问到了。
为了解决这个问题,Redis 2.6.12版本以后,为set命令增加了一系列的参数,我们此时用NX和PX参数就可以解决这个问题。 所以现在Redis分布式锁的加锁命令如下
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)
Redis分布式锁解锁解锁不能简单的使用如下命令 因为有可能节点A加锁后执行超时,锁被释放了。节点B又重新加锁,A正常执行到del命令的话就把节点B的锁给释放了。所以在解锁之前先判断一下是不是自己加的锁,是自己加的锁再释放,不是就不释放。所以伪代码如下
if (random_value .equals(redisClient.get(resource_name))) {
if redis.call("get", KEYS[1]) == ARGV[1] then 到此你已经彻底理解了该如何实现一个分布式锁了,以及为什么要这样做的原因 加锁执行命令 解锁执行脚本 if redis.call("get", KEYS[1]) == ARGV[1] then public class LockUtil { private static final String OK = "OK"; public static boolean tryLock(String key, String value, long expire) { public static boolean releaseLock(String key, String value) {
Lua脚本从Redis2.6开始,内嵌Lua环境,通过EVAL和EVALSHA命令可以执行脚本
Redis执行Lua脚本的命令EVAL命令先演示一下 输出为 1) "key1"
EVALSHA命令EVAL命令是直接执行给定的脚本 EVALSHA命令可以根据给定的sha1校验码,执行缓存在服务器中的脚本 通过 SCRIPT LOAD 命令可以将脚本缓存到服务器,这个命令会返回脚本的sha1值 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
127.0.0.1:6379> eval "return redis.call('set', KEYS[1], ARGV[1])" 1 scriptKey scriptValue
127.0.0.1:6379> SCRIPT LOAD "return redis.call('set', KEYS[1], ARGV[1])" |
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、问题描述
2、问题原因
3、问题分析
- 客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着节点的增多,耗时会不断增大。
- 网络连接数变多,对节点的性能也有一定影响。
4、优化思路
- 命令本身的优化,例如优化SQL语句等。
- 减少网络通信次数。
- 降低接入成本,例如客户端使用长连/连接池、NIO等。
5、优化
这里主要是对【减少网络通信次数】进行说明:
- 客户端n次get:n次网络+n次get命令本身。
- 客户端1次pipeline get:1次网络+n次get命令本身。
- 客户端1次mget:1次网络+1次mget命令本身。
下面结合Redis Cluster的一些特性对四种分布式的批量操作方式进行说明
1.串行命令
2、串行IO
6、总结