通常来说,我们写代码的时候,都是优先返回缓存值,如果有,则返回缓存的值;如果没有,则查数据库,然后把数据放到缓存,然后再把数据返回。但本例子有很多问题,在高并发读的情况下,缓存失效了,会导致大量的请求查询数据库,导致数据库压力过大崩掉(也就是缓存击穿问题)。请求方一直在请求一个缓存没有且数据库也没有的数据,会导致大量的请求穿透到数据库(也就是缓存穿透问题,可以理解为缓存起不到保护后端持久层,就像被穿透了一样)。还有双写不一致等问题。
如下面的例子有各种各样的问题:
public class ShopService {
@Autowired
private ShopDao shopDao;
@Autowired
private RedisUtil redisUtil;
@Autowired
private RedissonClient redissonClient;
/**
* 商品的redis key前缀
*/
private String shopKey = "shop:";
/**
* 商品的redis 读写锁 key前缀
*/
private String SHOP_UPDATE_KEY = "shop:update:";
/**
* 商品为空时的空字符
*/
private String EMP_SHOP = "{}";
/**
* 过期时间,秒
*/
private Integer expireTime = 30 * 60;
/**
* 获取商品信息
* 普遍写法
* @param shopId 商品Id
* @return
*/
public Shop get(Long shopId) {
Shop shop = null;
String redisKey = shopKey + shopId;
String shopStr = redisUtil.get(redisKey);
if (StringUtils.isNotEmpty(shopStr)) {
shop = JacksonUtil.jsonStr2Bean(shopStr, Shop.class);
return shop;
}
shop = shopDao.getById(shopId);
if (shop != null) {
redisUtil.set(redisKey, JacksonUtil.bean2JsonStr(shop));
}
return shop;
}
}
缓存击穿与缓存雪崩,指的都是缓存失效,然后请求到了数据库。缓存击穿指某个热点 key,在缓存过期的一瞬间,同时有大量的请求打进来,最终请求到了数据库。缓存雪崩指的是同一时间,大量的热点key集中过期了。
对于缓存击穿问题如何解决:
1.对于热点数据缓存同时失效,可以用热点数据的方案,让热点数据永不过期,即每次访问热点数据时都延长热点数据的过期时间即可。
2.用分布式锁对key加锁,就是让并发请求改为串行请求,同时只让一个请求到达数据库,然后把查询结果放到缓存,其他请求从缓存取值。
如下例子为热点数据永不过期的方案:
public class ShopService {
@Autowired
private ShopDao shopDao;
@Autowired
private RedisUtil redisUtil;
@Autowired
private RedissonClient redissonClient;
/**
* 商品的redis key前缀
*/
private String shopKey = "shop:";
/**
* 商品的redis 读写锁 key前缀
*/
private String SHOP_UPDATE_KEY = "shop:update:";
/**
* 商品为空时的空字符
*/
private String EMP_SHOP = "{}";
/**
* 过期时间,秒
*/
private Integer expireTime = 30 * 60;
/**
* 获取商品信息
* 保留热点数据,且对过期时间生成随机数,避免出现缓存击穿问题(同一时间缓存大规模失效,大量请求到数据库)
* @param shopId 商品Id
* @return
*/
public Shop get2(Long shopId) {
Shop shop = null;
String redisKey = shopKey + shopId;
String shopStr = redisUtil.get(redisKey);
if (StringUtils.isNotEmpty(shopStr)) {
// 设置缓存过期时间,即保留热点数据,避免大量的商品信息数据把redis内存撑爆。
// getExpireTime() 随机过期时间 和热点数据 也能解决缓存击穿问题,如果用户一直访问热点数据,热点数据一直不过期
redisUtil.expire(redisKey, getExpireTime());
shop = JacksonUtil.jsonStr2Bean(shopStr, Shop.class);
return shop;
}
shop = shopDao.getById(shopId);
if (shop != null) {
redisUtil.set(redisKey, JacksonUtil.bean2JsonStr(shop));
redisUtil.expire(redisKey, getExpireTime());
}
return shop;
}
}
缓存穿透,指的是redis缓存没有,数据库也没有。请求一直在请求这种数据,导致请求穿透到数据库。
缓存穿透问题如何解决?
1.布隆过滤器,布隆过滤器是一种数据结构,对所有可能查询到的参数都是以 hash 的方式存储,如果有则表示可能有,也可能没有。如果没有则真的是没有。利用这个特性,对所有的数据先放到布隆过滤器,如果布隆过滤器都查询不到,则不用再查询数据库了。
2.如果数据库查询后发现没有数据,就可以缓存一个空对象,然后设置过期时间。
public class ShopService {
@Autowired
private ShopDao shopDao;
@Autowired
private RedisUtil redisUtil;
@Autowired
private RedissonClient redissonClient;
/**
* 商品的redis key前缀
*/
private String shopKey = "shop:";
/**
* 商品的redis 读写锁 key前缀
*/
private String SHOP_UPDATE_KEY = "shop:update:";
/**
* 商品为空时的空字符
*/
private String EMP_SHOP = "{}";
/**
* 过期时间,秒
*/
private Integer expireTime = 30 * 60;
/**
* 获取商品信息
* 解决缓存穿透问题,避免客户端一直访问不存在的key
* @param shopId 商品Id
* @return
*/
public Shop get3(Long shopId) {
Shop shop = null;
String redisKey = shopKey + shopId;
String shopStr = redisUtil.get(redisKey);
if (StringUtils.isNotEmpty(shopStr)) {
if (Objects.equals(EMP_SHOP, shopStr)) {
redisUtil.expire(redisKey, getExpireTime());
return shop;
}
// 设置缓存过期时间,即保留热点数据,避免大量的商品信息数据把redis内存撑爆。
// 且也能解决缓存击穿问题,如果用户一直访问热点数据,热点数据一直不过期
redisUtil.expire(redisKey, getExpireTime());
shop = JacksonUtil.jsonStr2Bean(shopStr, Shop.class);
return shop;
}
shop = shopDao.getById(shopId);
if (shop != null) {
redisUtil.set(redisKey, JacksonUtil.bean2JsonStr(shop));
redisUtil.expire(redisKey, getExpireTime());
} else {
// 即使是数据库里面查询id不存在,也创建一个空对象放到redis缓存里面,避免缓存穿透问题
// (缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求。由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义)
redisUtil.set(redisKey, EMP_SHOP);
redisUtil.expire(redisKey, getExpireTime());
}
return shop;
}
}
如下例子为:解决缓存击穿问题的一种思路
public class ShopService {
@Autowired
private ShopDao shopDao;
@Autowired
private RedisUtil redisUtil;
@Autowired
private RedissonClient redissonClient;
/**
* 商品的redis key前缀
*/
private String shopKey = "shop:";
/**
* 商品的redis 读写锁 key前缀
*/
private String SHOP_UPDATE_KEY = "shop:update:";
/**
* 商品为空时的空字符
*/
private String EMP_SHOP = "{}";
/**
* 过期时间,秒
*/
private Integer expireTime = 30 * 60;
/**
* 获取商品信息
* 解决热点数据缓存重建问题(用户短时间内查询冷门商品,这时候大量的请求同时达到数据库)
* @param shopId 商品Id
* @return
*/
public Shop get4(Long shopId) {
Shop shop = null;
String redisKey = shopKey + shopId;
shop = getShopFromCache(shopId);
if (shop != null) {
return shop;
}
// 利用分布式锁 + DCL双重检查锁方式来实现只能请求一次到数据库
RLock lock = redissonClient.getLock(redisKey);
lock.lock();
try {
// 第一次请求的时候是空,则继续向数据库请求;第二次请求过来发现非空,则直接返回,不再查数据库。
shop = getShopFromCache(shopId);
if (shop != null) {
return shop;
}
shop = shopDao.getById(shopId);
if (shop != null) {
redisUtil.set(redisKey, JacksonUtil.bean2JsonStr(shop));
redisUtil.expire(redisKey, getExpireTime());
} else {
// 即使是数据库里面查询id不存在,也创建一个空对象放到redis缓存里面,避免缓存穿透问题
// (缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求。由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义)
redisUtil.set(redisKey, EMP_SHOP);
redisUtil.expire(redisKey, getExpireTime());
}
} finally {
lock.unlock();
}
return shop;
}
private Shop getShopFromCache(Long shopId) {
Shop shop = null;
String redisKey = shopKey + shopId;
String shopStr = redisUtil.get(redisKey);
if (StringUtils.isNotEmpty(shopStr)) {
if (Objects.equals(EMP_SHOP, shopStr)) {
redisUtil.expire(redisKey, getExpireTime());
// 返回一个表示不存在的shop对象,
shop = new Shop();
return shop;
}
// 设置缓存过期时间,即保留热点数据,避免大量的商品信息数据把redis内存撑爆。
// 且也能解决缓存击穿问题,如果用户一直访问热点数据,热点数据一直不过期
redisUtil.expire(redisKey, getExpireTime());
shop = JacksonUtil.jsonStr2Bean(shopStr, Shop.class);
}
return shop;
}
/**
* 返回过期时间随机数
* @return
*/
private int getExpireTime() {
// new Random().nextInt(1 * 60);设置随机数是为了解决缓存击穿问题
// 如果是批量上架商品,则这个随机时间对解决缓存击穿问题是有生效的,如果没有批量上架商品,则出现大量商品同一时间过期是小概率事件
// 缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
return expireTime + new Random().nextInt(1 * 60);
}
}
双写不一致问题的解决方案,有很多人都是先删除缓存再写数据库,延时双删等等方案,这些方案都是降低了出现双写不一致的概率,实际上还是有可能出现的。如果要彻底解决这个问题,就是要令读写串行化即可。读写串行化后,如果要写数据,就要等全部读完之后再写。如果要读数据就要写完之后再读。
如本例子在读多写少的场景下,用分布式读写锁,让读写串行化,解决双写不一致问题。
public class ShopService {
@Autowired
private ShopDao shopDao;
@Autowired
private RedisUtil redisUtil;
@Autowired
private RedissonClient redissonClient;
/**
* 商品的redis key前缀
*/
private String shopKey = "shop:";
/**
* 商品的redis 读写锁 key前缀
*/
private String SHOP_UPDATE_KEY = "shop:update:";
/**
* 商品为空时的空字符
*/
private String EMP_SHOP = "{}";
/**
* 过期时间,秒
*/
private Integer expireTime = 30 * 60;
/**
* 获取商品信息
* 解决缓存数据库双写不一致的问题
* @param shopId 商品Id
* @return
*/
public Shop get5(Long shopId) {
Shop shop = null;
String redisKey = shopKey + shopId;
String updateKey = SHOP_UPDATE_KEY + shopId;
shop = getShopFromCache(shopId);
if (shop != null) {
return shop;
}
// 利用分布式锁 + DCL双重检查锁方式来实现只能请求一次到数据库
RLock lock = redissonClient.getLock(redisKey);
lock.lock();
try {
// 第一次请求的时候是空,则继续向数据库请求;第二次请求过来发现非空,则直接返回,不再查数据库。
shop = getShopFromCache(shopId);
if (shop != null) {
return shop;
}
// 在读的时候使用读写锁的读锁,然后在写的时候需要使用读写锁的写锁,解决双写不一致问题。
// 用ReadWriteLock而不是ReentrantLock,提高了锁的效率
/**
* 更新商品信息时的demo
* RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(updateKey);
* RLock rLock = readWriteLock.writeLock();
* rLock.lock();
* try {
* shopDao.update(shopObject)
* } finally {
* rLock.unlock();
* }
* rLock.unlock();
*/
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(updateKey);
RLock rLock = readWriteLock.readLock();
rLock.lock();
try {
shop = shopDao.getById(shopId);
if (shop != null) {
redisUtil.set(redisKey, JacksonUtil.bean2JsonStr(shop));
redisUtil.expire(redisKey, getExpireTime());
} else {
// 即使是数据库里面查询id不存在,也创建一个空对象放到redis缓存里面,避免缓存穿透问题
// (缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求。由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义)
redisUtil.set(redisKey, EMP_SHOP);
redisUtil.expire(redisKey, getExpireTime());
}
} finally {
rLock.unlock();
}
} finally {
lock.unlock();
}
return shop;
}
private Shop getShopFromCache(Long shopId) {
Shop shop = null;
String redisKey = shopKey + shopId;
String shopStr = redisUtil.get(redisKey);
if (StringUtils.isNotEmpty(shopStr)) {
if (Objects.equals(EMP_SHOP, shopStr)) {
redisUtil.expire(redisKey, getExpireTime());
// 返回一个表示不存在的shop对象,
shop = new Shop();
return shop;
}
// 设置缓存过期时间,即保留热点数据,避免大量的商品信息数据把redis内存撑爆。
// 且也能解决缓存击穿问题,如果用户一直访问热点数据,热点数据一直不过期
redisUtil.expire(redisKey, getExpireTime());
shop = JacksonUtil.jsonStr2Bean(shopStr, Shop.class);
}
return shop;
}
/**
* 返回过期时间随机数
* @return
*/
private int getExpireTime() {
// new Random().nextInt(1 * 60);设置随机数是为了解决缓存击穿问题
// 如果是批量上架商品,则这个随机时间对解决缓存击穿问题是有生效的,如果没有批量上架商品,则出现大量商品同一时间过期是小概率事件
// 缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
return expireTime + new Random().nextInt(1 * 60);
}
}
一般来说,写到了demo5,已经比较全了,但还要考虑一个问题超大并发读导致redis撑爆的问题出现缓存雪崩问题,比如每秒10万以上的请求打到redis。
这时候可以考虑用多级缓存来解决这个问题,Map,Guava,Caffeine等jvm缓存方案。比如先从Map获取数据,拿不到再去redis拿。然后再对服务器进行限流设置,比如每个机器只允许2万的请求过来,这样就不会把服务器弄挂了。
题外话:
缓存雪崩的解决方案
解决方案:
1.缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
2.如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
3.设置热点数据永远不过期。
4.在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
具体可参考上述例子。