缓存穿透
概念
缓存穿透是指查询一个一定不存在的数据,由于缓存不会命中,需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都去数据库查询,造成缓存穿透。
在流量小时没有问题,如果流量非常大或有恶意攻击(意用户模拟请求很多缓存中不存在的数据,由于缓存中都没有,导致这些请求短时间内直接落在了数据库上,导致数据库异常),就会利用这个漏洞,使服务端(尤其是数据库)的压力增大,严重会导致系统崩溃。
解决方案
方案一:前端过滤
在从redis缓存查询数据以及查询数据库之前,对组成key的查询条件进行过滤,通常采取如下措施:
1. 非法请求过滤:首先根据业务,对请求的key为非法key(如key不满足约定的格式)进行拦截和过滤。
2. 对一定不存在查询结果的查询条件进行过滤:对所有可能查询的参数(即一定能查询到结果的查询条件参数,通过这些参数能构造出redis缓存key)以hash形式存储,在控制层进行校验,若不符合查询条件则丢弃。最常见的则是采用布隆过滤器,将所有可能存在的数据(一定能查到数据的查询条件)哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。
问题:如何构造hash值或布隆过滤器,当实际数据量很大时,所有数据都得通过布隆过滤器,代价是否较大,另外有数据更新(新增、修改、删除)时,如何同步更新hash或布隆过滤器以保持缓存和数据的同步?
另外布隆过滤器不准确(通过布隆过滤器推断出来不存在的数据一定是不存在的;但推断出存在的数据有可能不存在,另外布隆过滤器无法删除元素;此时有一定的误差,但由于已经过过滤掉了大部分不合法请求,因而可以在推断出存在时,再通过查询redis缓存和数据库来进行二次确认查询)。
有数据更新(新增、修改、删除)时,修改布隆过滤器有问题吗?是否对不经常修改的数据才适合用布隆过滤器?
方案二:查询redis及数据库时进行过滤
也可以采用一个更为简单粗暴的方法,如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。
存在的问题及解决办法:
–缓存太多空值,占用更多空间。如果是攻击,问题更严重,优化办法:给个空值较短的过期时间,最多不超过5分钟,过期自动个剔除
–存储层更新代码了,缓存层还是空值。(优化:后台更新值,更新对应的key为最新内容,使缓存和数据库保持一致) 。
炸一看,该方案与布隆过滤器方案有冲突,因为通过布隆过滤,已经将不存在查询结果的请求直接返回了,此时没有必要再缓存空值。但由于布隆过滤器不准确特性,即通过布隆过滤器推断出存在的数据有可能在数据库中不存在,因此在这种情况下,有可能从数据库中查询不到数据,此时,也可以将null作为值,缓存到redis中,可以进一步减少对数据库的穿透。
方案三:接口限流与熔断、降级
除了前述方案外,作为最后的保险措施,重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些服务不可用时候,进行熔断,失败快速返回机制。
缓存穿透处理流程:
缓存穿透的问题,肯定是再大并发情况下。依此为前提,我们分析缓存穿透的原因如下:
1、恶意攻击,猜测你的key命名方式,然后估计使用一个你缓存中不会有的key进行访问。
2、第一次数据访问,这时缓存中还没有数据,则并发场景下,所有的请求都会压到数据库。
3、数据库的数据也是空,这样即使访问了数据库,也是获取不到数据,那么缓存中肯定也没有对应的数据。这样也会导致穿透。
缓存穿透在于一步步规避穿透的原因,如图:
如上图所示,解决的步骤如下:
1、在web服务器启动时,提前将有可能被频繁并发访问的数据写入缓存。—这样就规避大量的请求在第3步出现排队阻塞,解决第一次数据访问缓存穿透问题。另外也可以在此步骤构建布隆过滤器,将所有存在查询结果的key存入布隆过滤器。
2、规范key的命名,并且统一缓存查询和写入的入口。这样,在入口处,对key的规范进行检测,这样保存恶意的key被拦截。也可以通过布隆过滤器,对不存在的查询结果的请求直接返回空。
3、Synchronized双重检测机制,这时我们就需要使用同步(Synchronized)机制(单机环境),在同步代码块前查询一下缓存是否存在对应的key,然后同步代码块里面再次查询缓存里是否有要查询的key。 这样“双重检测”的目的,还是避免并发场景下导致的没有意义的数据库的访问(也是一种严格避免穿透的方案)。集群环境下,需要使用分布式锁。
下例是分布式环境的代码逻辑:
/**
UserService
*/
public class UserServiceImpl{
/**
* 查询用户信息的service方法,先判断userId格式是否合法,不合法则直接返回null,之后通过布隆过滤器
判断用户信息是否存在,不存在也返回null,之后再调用getUserWithLock,从redis缓存或数据库中查询用
户信息。
*/
public User queryUser(String userId){
if(isValid(userId){
return null;
}
//若启用布隆过滤器对数据进行了缓存,则先从布隆过滤器查看数据是否存在,不存在直接返回'null'值。若这一步在该调用前已经判断过了,则无需再在此处判断
if (!bloomFilter.mightContain(key)) {
return null;
}
String userInfo = getUserWithLock3(userIdKey,jedis,lockKey, uniqueId,expireTime);
return String2User(userInfo);
}
/**
业界比价普遍的一种做法,即根据key获取value值为空时,锁上,从数据库中load数据后再释放锁。
若其它线程获取锁失败,则等待一段时间后重试。这里要注意,分布式环境中要使用分布式锁,
单机的话用普通的锁(synchronized、Lock)就够了。
这样做思路比较清晰,也从一定程度上减轻数据库压力,但是锁机制使得逻辑的复杂度增加,吞吐量也降低了,有点治标不治本。
*/
private String getUserWithLock1(String key, Jedis jedis, String lockKey, String uniqueId, long expireTime) {
// 通过key获取value
String value = redisService.get(key);
if (StringUtil.isEmpty(value)) {
// 分布式锁,详细可以参考https://blog.csdn.net/fanrenxiang/article/details/79803037
//封装的tryDistributedLock包括setnx和expire两个功能,在低版本的redis中不支持
try {
boolean locked = redisService.tryDistributedLock(jedis, lockKey, uniqueId, expireTime);
if (locked) {
value = userService.getById(key);
if(null ==value){
//查询结果为null,但仍然放到缓存中,超时时间要设置的短一些,不超过5分钟
redisService.set(key, 'null', 5min);
}else{
//查询结果不为null,超时时间30分钟或者更长(根据业务情况而定)
redisService.set(key, value, 30min);
}
redisService.del(lockKey);
return value;
} else {
// 其它线程进来了没获取到锁便等待50ms后重试
Thread.sleep(50);
getWithLock(key, jedis, lockKey, uniqueId, expireTime);
}
} catch (Exception e) {
log.error("getWithLock exception=" + e);
return value;
} finally {
redisService.releaseDistributedLock(jedis, lockKey, uniqueId);
}
}
return value;
}
/**
优化:降低锁的粒度,只对要查询的key加锁。由于前面通过格式校验或布隆过滤器过滤了一部分请求,以及预
先加载机制已经加载了一部分缓存,因此能够进入数据库查询数据的请求已经被大大降低,因此只对查询的key
进行枷锁,不会导致过多的穿透。
但这样做还会出现缓存雪崩问题。
**/
private String getUserWithLock2(String key, Jedis jedis, String lockKey, String uniqueId, long expireTime) {
// 通过key获取value
String value = redisService.get(key);
if (StringUtil.isEmpty(value)) {
// 分布式锁,详细可以参考https://blog.csdn.net/fanrenxiang/article/details/79803037
//封装的tryDistributedLock包括setnx和expire两个功能,在低版本的redis中不支持
try {
//优化,降低锁粒度
lockKey="lock_"+key;
boolean locked = redisService.tryDistributedLock(jedis, lockKey, uniqueId, expireTime);
if (locked) {
value = userService.getById(key);
//有可能查询为null,但仍然放到缓存中
redisService.set(key, value);
redisService.del(lockKey);
return value;
} else {
// 其它线程进来了没获取到锁便等待50ms后重试
Thread.sleep(50);
getWithLock(key, jedis, lockKey, uniqueId, expireTime);
}
} catch (Exception e) {
log.error("getWithLock exception=" + e);
return value;
} finally {
redisService.releaseDistributedLock(jedis, lockKey, uniqueId);
}
}
return value;
}
}
4、不管数据库中是否有数据,都在缓存中保存对应的key,值为空就行。这样是为了避免数据库中没有这个数据,导致的平凡穿透缓存对数据库进行访问。这一步会导致排队,但是第一步中我们说过,为了避免大量的排队,可以提前将可以预知的大量请求提前写入缓存。
5、第4步中的空值如果太多,也会导致内存耗尽。导致不必要的内存消耗。这样就要定期的清理空值的key(或将key的过期时间短一些,最长5分钟)。避免内存被恶意占满,导致正常的功能不能缓存数据。
6、当对数据进行更新(插入、修改、删除)时,同步更新Redis缓存,使缓存数据与数据库保持一致。
缓存雪崩
概念
如果缓存集中在一段时间内失效,发生大量的缓存穿透,所有的查询都落在数据库上,则造成了缓存雪崩。
因为缓存层承载了大量的请求,有效的保护了存储 层,但是如果缓存由于某些原因,整体不能够提供服务,于是所有的请求,就会到达存储层,存储层的调用量就会暴增,造成存储层也会挂掉的情况。缓存雪崩的英文解释是奔逃的野牛,指的是缓存层当掉之后,并发流量会像奔腾的野牛一样,打向后端存储。
存在这种问题的一个场景是:当缓存服务器重启、崩溃或者大量缓存集中在某一个时间段失效,这样在失效的时候,大量数据会去直接访问DB,此时给DB很大的压力。
解决方案
方案一:设置redis集群和DB集群的高可用
如果redis出现宕机情况,可以立即由别的机器顶替上来。这样可以防止一部分的风险
该方案可以和其它方案同时采用。
方案二:数据预热
在系统上线前,或在即将发生大并发访问前,可以通过缓存reload机制,预先去更新缓存,手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。
该方案可以和其它方案同时采用。
方案三:设置缓存永远不过期
两种方法:
方法一:缓存双备(不推荐)
建立备份缓存,缓存A和缓存B,A设置超时时间,B不设值超时时间,先从A读缓存,A没有读B,并且更新A缓存和B缓存。问题:成本高,不推荐。
方法二:物理不过期,逻辑过期(推荐)
(1) 从缓存上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。
(2) 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期.
从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。
永远不过期代码如下:
String get(final String key) {
V v = redis.get(key);
String value = v.getValue();
long timeout = v.getTimeout();
//快过期了,设定的时间,这里设定的是60秒,可以根据业务进行调整
long willExpireTime = 600000;
//已经过期了,需要从数据库重新加载数据,并更新到Redis缓存中。
//问题:在访问该key的时候,可能缓存已经过期很长时间了,返回的数据太旧。
//优化:在系统启动时,启动一个后台异步线程,一直扫描缓存中所有即将过期的key,
//并对快过期的可以,执行以下线程的相同逻辑。而在该方法中,直接返回缓存中的key即可
if (v.timeout<=System.currentTimeMillis()) {
// 异步更新后台异常执行
threadPool.execute(new Runnable() {
public void run() {
String keyMutex = "mutex:" + key;
//加锁,防止其它线程操作当前key
if (redis.setnx(keyMutex, "1")) {
// 3 min timeout to avoid mutex holder crash
redis.expire(keyMutex, 3 * 60);
String dbValue = db.get(key);
v.setValue(dbValue);
v.setTimeout(System.currentTimeMillis()+
logicExpireTimeDuration);
redis.set(key, v);
//解锁
redis.delete(keyMutex);
}
}
});
}
return value;
}
方案四:使失效时间尽量均匀,防止大量key同时失效
不同的key,设置不同的过期时间,具体值可以根据业务决定,让缓存失效的时间点尽量均匀
可以给缓存设置过期时间时加上一个随机值时间(如0~60秒),使得每个key的过期时间均匀分布开来,不会集中在同一时刻失效,这样不会出现同时穿透,也即雪崩的问题。
如果这个key的访问频率频繁(如何判断是否频繁?)的时候,我们可以让它每查一次就给它加点有效时间。这样就能解决雪崩问题了。
存在问题:redis崩溃时,还是会雪崩,此时只能靠最后的防御手段:降级、限流或熔断。
该方案与方案三冲突,二选一,推荐方案三。
方案五:使用互斥锁
在缓存失效后,通过加锁(代码参见方案三的方法二)或者消息队列来控制读数据库写缓存的线程数量,防止失效时大量线程请求数据库。
比如对某个key只允许一个线程查询数据和写缓存,其他线程等待(代码参见方案三的方法二)。
存在问题:缓存崩溃时,所有key会同时失效。若对一个key只允许一个线程查询数据和写缓存,若大量请求同时访问不同的key,还是会雪崩。
方案六:资源保护
使用netflix的hystrix,可以做各种资源的线程池隔离,从而保护主线程池。
依赖隔离组件为后端限流并降级。如Hystrix。
方案七:
1. 加锁排队. 限流-- 限流算法. 1.计数 2.滑动窗口 3. 令牌桶Token Bucket 4.漏桶 leaky bucket [1]
在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
热点key问题
出现原因
我们通常使用 缓存 + 过期时间的策略来帮助我们加速接口的访问速度,减少了后端负载,同时保证功能的更新,一般情况下这种模式已经基本满足要求了。
但是有两个问题如果同时出现,可能就会对系统造成致命的危害:
(1) 这个key是一个热点key(例如一个重要的新闻,一个热门的八卦新闻等等),所以这种key访问量可能非常大。
(2) 缓存的构建是需要一定时间的。(可能是一个复杂计算,例如复杂的sql、多次IO、多个依赖(各种接口)等等)
于是就会出现一个致命问题:在缓存失效的瞬间,有大量线程来构建缓存(见下图),造成后端负载加大,甚至可能会让系统崩溃 。
解决方案
方案一:使用互斥锁(mutex key)
这种解决方案思路比较简单,就是只让一个线程构建缓存,其他线程等待构建缓存的线程执行完,重新从缓存获取数据就可以了(如下图)
如果是单机,可以用synchronized或者lock来处理,如果是分布式环境可以用分布式锁就可以了(分布式锁,可以用memcache的add, redis的setnx, zookeeper的添加节点操作)。
代码如下:
String get(String key) {
String value = redis.get(key);
if (value == null) {
if (redis.setnx(key_mutex, "1")) {
// 3 min timeout to avoid mutex holder crash
redis.expire(key_mutex, 3 * 60)
value = db.get(key);
redis.set(key, value);
redis.delete(key_mutex);
} else {
//其他线程休息50毫秒后重试
Thread.sleep(50);
get(key);
}
}
}
方案二:"提前"使用互斥锁(mutex key)
在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。
相对于方案一
优点:避免cache失效时刻大量请求获取不到mutex并进行sleep?--根据代码,只是提前获取最新数据而已,没有达到这种效果啊?
缺点:代码复杂性增大,因此一般场合用方案一也已经足够。
伪代码如下:
v = memcache.get(key);
if (v == null) {
if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
value = db.get(key);
memcache.set(key, value);
memcache.delete(key_mutex);
} else {
sleep(50);
retry();
}
} else {
if (v.timeout <= now()) {
if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
// extend the timeout for other threads
v.timeout += 3 * 60 * 1000;
memcache.set(key, v, KEY_TIMEOUT * 2);
// load the latest value from db
v = db.get(key);
v.timeout = KEY_TIMEOUT;
memcache.set(key, value, KEY_TIMEOUT * 2);
memcache.delete(key_mutex);
} else {
sleep(50);
retry();
}
}
}
方案三:设置缓存永远不过期
同缓存雪崩
方案四:降级熔断
比如用hystrix,可以做资源的隔离保护主线程池。
四种方案对比:
作为一个并发量较大的互联网应用,我们的目标有3个:
1. 加快用户访问速度,提高用户体验。
2. 降低后端负载,保证系统平稳。
3. 保证数据“尽可能”及时更新(要不要完全一致,取决于业务,而不是技术。)
所以第二节中提到的四种方法,可以做如下比较,还是那就话:没有最好,只有最合适。
解决方案 | 优点 | 缺点 |
简单分布式锁(Tim yang) | 1. 思路简单 2. 保证一致性 | 1. 代码复杂度增大 2. 存在死锁的风险 3. 存在线程池阻塞的风险 |
加另外一个过期时间(Tim yang) | 1. 保证一致性 相对于方案一 | 同上 |
不过期(本文) | 1. 异步构建缓存,不会阻塞线程池 | 1. 不保证一致性。 2. 代码复杂度增大(每个value都要维护一个timekey)。 3. 占用一定的内存空间(每个value都要维护一个timekey)。 |
资源隔离组件hystrix(本文) | 1. hystrix技术成熟,有效保证后端。 2. hystrix监控强大。
| 1. 部分访问存在降级策略。 |
总结
1. 热点key + 过期时间 + 复杂的构建缓存过程 => mutex key问题
2. 构建缓存一个线程做就可以了。
3. 四种解决方案:没有最佳只有最合适。
缓存击穿
概念
对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。
缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
解决方案
方案一
加互斥锁:
同本文缓存雪崩-方案一
方案二
缓存不过期:
- 从缓存上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。
- 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期。
(四)缓存并发问题
这里的并发指的是多个redis的client同时set key引起的并发问题。其实redis自身就是单线程操作,多个client并发操作,按照先到先执行的原则,先到的先执行,其余的阻塞。当然,另外的解决方案是把redis.set操作放在队列中使其串行化,必须的一个一个执行,具体的代码就不上了,当然加锁也是可以的,至于为什么不用redis中的事务,留给各位看官自己思考探究。
缓存数据的淘汰
缓存淘汰的策略有两种: (1) 定时去清理过期的缓存。 (2)当有用户请求过来时,再判断这个请求所用到的缓存是否过期,过期的话就去底层系统得到新数据并更新缓存。
两者各有优劣,第一种的缺点是维护大量缓存的key是比较麻烦的,第二种的缺点就是每次用户请求过来都要判断缓存失效,逻辑相对比较复杂,具体用哪种方案,大家可以根据自己的应用场景来权衡。
1. 预估失效时间 2. 版本号(必须单调递增,时间戳是最好的选择)3. 提供手动清理缓存的接口。
淘汰机制
保存在内存中的缓存数据如果过期或失效,为了更合理利用内存空间,提高内存使用效率。
什么是淘汰机制
在内存中保存的Key被清除掉,
(1)定时去清理过期的缓存,使用Expire;
(2) LRU、LFU、FIFO算法剔除;
(3)主动更新:当有用户请求过来时,再判断这个请求所用到的缓存是否过期,过期的话就去底层系统得到新数据并更新缓存。
可以通过redis.conf设置# maxmemory <bytes>这个值来开启内存淘汰功能
redis数据淘汰策略
volatile-lru 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰
volatile-ttl 从已设置过期时间的数据集中挑选将要过期的数据淘汰
volatile-random 从已设置过期时间的数据集中任意选择数据淘汰
allkeys-lru 从所有数据集中挑选最近最少使用的数据淘汰
allkeys-random 从所有数据集中任意选择数据进行淘汰
noeviction 禁止驱逐数据
缓存是为了有效加速应用的读写速度,同时为后端降低负载,对日常访问量大的系统至关重要。如果缓存出现问题,不只不能降低压力,还会给后端服务造成更大的问题。所以在开发过程中,要避免预防这些问题出现。