缓存穿透
成因
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求。由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
在流量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击应用,这就是漏洞。
解决方案
- 接口层增加校验,如用户鉴权校验,id 做基础校验,id<=0 的直接拦截;
- 从缓存取不到的数据,在数据库中也没有取到,这时也可以将 key-value 对写为 key-null,缓存有效时间可以设置短点,如 30 秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个 id 暴力攻击
- 布隆过滤器。bloomfilter 就类似于一个 hash set,用于快速判某个元素是否存在于集合中,其典型的应用场景就是快速判断一个 key 是否存在于某容器,不存在就直接返回。
实现
缓存空值
//Redis操作类
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
//redis获取缓存
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//保存,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
//处理缓存穿透,判断命中是否为空值
if (shopJson != null){
//返回错误信息
return Result.fail("店铺不存在");
}
//缓存未命中,查询数据库
Shop shop = getById(id);
if (shop == null) {
//处理缓存穿透问题
//空值写入redis,缓存时效设置较短
stringRedisTemplate.opsForValue()
.set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
//返回错误信息
return Result.fail("店铺不存在");
}
//存在,写入redis
stringRedisTemplate.opsForValue()
.set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
//返回
return Result.ok(shop);
}
缓存击穿
成因
缓存击穿是指高并发数据由于缓存时间到期或其他原因导致缓存中没有,这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
解决方案
1、设置热点数据永远不过期。
2、接口限流与熔断,降级。重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些 服务 不可用时候,进行熔断,失败快速返回机制。
3、加互斥锁,保证在 key 过期的一瞬间,只有一个线程在操作 DB
实现
互斥锁
主要是使用到redis中setnx的唯一性,在高并发的多个请求中,第一个请求会获取互斥锁(即sernx写入redis,仅在目录中未存在的 key可以写入成功),然后去请求数据库数据,后来的请求由于获取不到互斥锁(无法写入redis)进入循环等待,直到数据库数据请求成功且释放锁后依次得到缓存数据。
- 优点:
- 实现简单
- 无额外内存消耗
- 保持一致性
- 缺点
- 线程循环等待,影响性能
- 存在死锁风险
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unLock(String key){
stringRedisTemplate.delete(key);
}
逻辑过期
逻辑过期与互斥锁同样使用了redis的setnx方法,不过在获取锁之后,请求数据库数据的方法交由一个新线程去执行,在获取到数据库数据并保存到缓存前,所有请求返回的都是旧数据。
- 优点
- 线程无需等待
- 缺点
- 不保证一致性
- 存在额外内存消耗
- 代码实现复杂
/**
* 缓存重建
* @param id 商铺id
* @param expireSeconds 过期时间
*/
public void saveShop2Redis(Long id, Long expireSeconds){
//1.查询店铺数据
Shop shop = getById(id);
//2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//3.写入Redis
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
/**
* 缓存重建线程池
*/
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 逻辑过期
*/
public Shop queryWithLogicalExpire(Long id){
//redis获取缓存
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//判断是否存在
if (StrUtil.isBlank(shopJson)) {
//保存,直接返回
return null;
}
//命中,把JSON反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//未过期,直接返回店铺信息
return shop;
}
//已过期,需要缓存重建
//缓存重建
//获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean flag = tryLock(lockKey);
if (flag){
//获取成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
//缓存重建
try {
saveShop2Redis(id, 30L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
}
//返回
return shop;
}
缓存雪崩
成因
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至 down 机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。
- 设置热点数据永远不过期。
缓存污染
成因
缓存污染问题说的是缓存中一些只会被访问一次或者几次的的数据,被访问完后,再也不会被访问到,但这部分数据依然留存在缓存中,消耗缓存空间。
方案
缓存污染会随着数据的持续增加而逐渐显露,随着服务的不断运行,缓存中会存在大量的永远不会再次被访问的数据。缓存空间是有限的,如果缓存空间满了,再往缓存里写数据时就会有额外开销,影响 Redis 性能。这部分额外开销主要是指写的时候判断淘汰策略,根据淘汰策略去选择要淘汰的数据,然后进行删除操作。
缓存设置
系统的设计选择是一个权衡的过程:大容量缓存是能带来性能加速的收益,但是成本也会更高,而小容量缓存不一定就起不到加速访问的效果。一般来说,我会建议把缓存容量设置为总数据量的 15% 到 30%,兼顾访问性能和内存空间开销。
对于 Redis 来说,一旦确定了缓存最大容量,比如 4GB,你就可以使用下面这个命令来设定缓存的大小了:
CONFIG SET maxmemory 4gb
不过,缓存被写满是不可避免的,所以需要数据淘汰策略
缓存淘汰策略
不淘汰
- noeviction (默认)
该策略是 Redis 的默认策略。在这种策略下,一旦缓存被写满了,再有写请求来时,Redis 不再提供服务,而是直接返回错误。这种策略不会淘汰数据,所以无法解决缓存污染问题。一般生产环境不建议使用。
对设置了过期时间的数据淘汰
- 随机:volatile-random
这个算法比较简单,在设置了过期时间的键值对中,进行随机删除。因为是随机删除,无法把不再访问的数据筛选出来,所以可能依然会存在缓存污染现象,无法解决缓存污染问题。
- ttl:volatile-ttl
这种算法判断淘汰数据时参考的指标比随机删除时多进行一步过期时间的排序。Redis 在筛选需删除的数据时,越早过期的数据越优先被选择。
- lru:volatile-lru
LRU 算法:LRU 算法的全称是 Least Recently Used,按照最近最少使用的原则来筛选数据。这种模式下会使用 LRU 算法筛选设置了过期时间的键值对。
- lfu:volatile-lfu
LFU 算法:LFU 缓存策略在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。
全部数据进行淘汰
- 随机:allkeys-random
- lru:allkeys-lru
- lfu:allkeys-lfu
数据库和缓存一致性
问题来源
使用 redis 做一个缓冲操作,让请求先访问到 redis,而不是直接访问 MySQL 等数据库:
读取缓存步骤一般没有什么问题,但是一旦涉及到数据更新:数据库和缓存更新,就容易出现缓存 (Redis) 和数据库(MySQL)间的数据一致性问题。
不管是先写 MySQL 数据库,再删除 Redis 缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。举一个例子:
- 如果删除了缓存 Redis,还没有来得及写库 MySQL,另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据。
- 如果先写了库,在删除缓存前,写库的线程宕机了,没有删除掉缓存,则也会出现数据不一致情况。
因为写和读是并发的,没法保证顺序,就会出现缓存和数据库的数据不一致的问题。
缓存更新策略
- Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案
- Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理
- Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致
操作缓存和数据库时有三个问题需要考虑:
- 删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
假设每次操作数据库后,都操作缓存,但是中间如果没有人查询,那么这个更新动作实际上只有最后一次生效,中间的更新动作意义并不大,可以把缓存删除,等待再次查询时,将缓存中的数据加载出来.
- 如何保证缓存与数据库的操作的同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用 TCC 等分布式事务方案
- 先操作缓存还是先操作数据库?
- 先删除缓存,再操作数据库
- 先操作数据库,再删除缓存
应当是先操作数据库,再删除缓存,原因在于,如果你选择第一种方案,在两个线程并发来访问时,假设线程 1 先来,他先把缓存删了,此时线程 2 过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程 1 再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。
分布式锁
满足分布式系统或集群模式下多进程可见并互斥的锁。
特点
- 多线程可见
- 互斥
- 高可用
- 高性能
- 安全性
实现方式对比
MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | mysql本身的互斥锁机制 | setnx的互斥命令 | 节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接自动释放锁 | 利用锁超时时间到期释放 | 临时节点,断开连接自动释放 |
基于Redis的分布式锁
- 获取锁
//写入
setnx <key> <value>
//设置过期时间
expire <key> <time>
这种写法的问题是在两次命令期间如果出现意外错误会导致锁未设置有效期而长期存储在数据库中,最好还是使用一句命令完成这一步骤:
set <key> <value> ex <time> nx
- 释放锁
del <key>
具体实现
public class SimpleRedisLock implements ILock{
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private String name;
private final StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标识
long threadId = Thread.currentThread().getId();
//获取锁
String key = KEY_PREFIX + name;
String value = ID_PREFIX + threadId + "";
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(key, value, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
//获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
//获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
//判断一致
if (threadId.equals(id)){
//释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}