在分布式缓存架构中,随着并发量的提高,一致性和可用性会出现各种各样的问题
1、为了防止冷门数据占据内存,加超时时间,让缓存尽量只保留热门数据---- 数据冷热分离
2、若很多缓存被设置了同一个超时时间,在同一时刻超时被清理,此时若大量请求访问这些过期的key,将会因为没有缓存而直接访问数据库,对数据库造成压力。------被称为缓存失效、缓存击穿
解决:超时时间设置一小段的随机时间,防止同一时刻过期
3、若管理端删除了某个数据,前端还停留在被删数据页面,此时前端请求了已经不存在的数据,缓存查找没有,会访问数据库,前端若一直请求,会持续对数据库造成压力。 或是黑客行为也会造成这种情况。------缓存穿透
解决:当经过数据库查询该数据不存在时, 在缓存中存入一个标记,表明该数据不存在,并设定一个较短的过期时间,防止占据内存。
4、若大量并发瞬间访问了一个冷门数据(无缓存的),请求发现无缓存,会进行缓存重建,大量请求进行缓存重建,对数据库压力较大。------------缓存重建
解决:增加分布式锁,只允许一个线程重建缓存,如同多线程编程中的单例创建的双重检测
5、在update操作时,需要更新数据库,更新缓存两个操作,这两个操作是非原子性的,在两个操作之间,数据库与缓存数据是不一致的,此时的不一致时间还不是很长。 如果在更新数据库时,进行了缓存重建,缓存重建线程拿到了旧数据,并且覆盖了缓存,将会造成一直不一致。--------双写不一致
解决:删缓存更新,并不能解决根本问题,需要在读写操作之间加锁。
加锁后会变成串行执行,效率很差,可以用读写锁进行优化, 缓存重建用读锁,更新数据库用写锁,当有更新数据库时,会变成互斥锁
6、当热点数据所在的redis节点不可访问时,会造成连锁阻塞,从而影响整个系统 ------缓存雪崩
解决:使用多级缓存,redis不可访问时,暂时使用本地缓存。或是业务端增加熔断降级
示例代码
public class CurdService {
static class Bag{
public Bag(Long id, String name) {
this.id = id;
this.name = name;
}
public Long id;
public String name;
}
static class BagDao{
public void insert(Bag bag){
}
public void update(Bag bag){
}
public void delete(Long id){
}
public Bag get(Long id){
return new Bag(id,"name");
}
}
private BagDao bagDao;
private RedissonClient redissonClient;
private Map<String, Object> localCache = new HashMap<>();
public void save(Bag bag){
bagDao.insert(bag);
RMap<String, Object> map = redissonClient.getMap("bag:"+bag.id);
addCache(map, bag);
}
public void update(Bag bag){
//⑤ 双写一致
//如果全部都在读缓存,也就不会出现一致性问题;
//在mysql读与写操作时,因为是并发操作,此时数据会有短暂的不一致,
// 若此时进行了缓存重建: update 更新mysql 写缓存; 缓存重建 读mysql 写缓存
//因为读写的不一致,从而影响到缓存更新的不一致
//因此,需要在更新数据和重建缓存的地方加锁, 防止数据不一致
//加锁后会变成串行执行,效率很差
//由于缓存重建和写库同时进行的概率很小,可以用读写锁进行优化, 缓存重建用读锁,更新数据库用写锁,当有更新数据库时,会变成互斥锁。
//通过读写锁实现
RReadWriteLock rwLock = redissonClient.getReadWriteLock("rwlock:bag:"+bag.id);
RLock lock = rwLock.writeLock();
lock.lock(10, TimeUnit.SECONDS);
try {
bagDao.update(bag);
RMap<String, Object> map = redissonClient.getMap("bag:" + bag.id);
addCache(map, bag);
}finally {
lock.unlock();
}
}
public void delete(Long id){
bagDao.delete(id);
redissonClient.getMap("bag:"+id).delete();
localCache.remove(id);
}
public Bag get(Long id){
//⑥ 当热点数据所在的redis节点不可访问时,会造成连锁阻塞,从而影响整个系统 ------缓存雪崩
//解决:业务端增加熔断降级
//缓存这边可使用多级缓存, 在jvm进程中缓存少量热点数据
//todo ** 使用本地缓存,在集群服务中会存在不一致的情况,需要通过mq redis stream 进行数据同步
Bag localBag = (Bag)localCache.get(id.toString());
if(localBag!=null){
return localBag;
}
RMap<String, Object> map = redissonClient.getMap("bag:"+id);
if(!map.isEmpty()){
Bag bag = new Bag((Long)map.get("id"), (String) map.get("name"));
//① 延长热门数据的过期时间
map.expire(100L, TimeUnit.MINUTES);
return bag;
}
//==========未命中,重建缓存===========
Bag bag = null;
//④ 缓存重建, 防止大量请求同时重建缓存,造成数据库压力
//增加分布式锁, 只允许一个线程进行缓存重建
//双重检测
RLock lock = redissonClient.getLock("lock:bag:"+id);
lock.lock(10, TimeUnit.SECONDS);
try {
//④ 再次判断缓存是否已重建
if(!map.isEmpty()) {
addCache(map, bag);
}
//⑤ 为了提高一致性, 增加双写一致
//加读锁
RReadWriteLock rwLock = redissonClient.getReadWriteLock("rwlock:bag:"+id);
RLock rlock = rwLock.readLock();
try{
rlock.lock(10, TimeUnit.SECONDS);
bag = bagDao.get(id);
}finally {
rlock.unlock();
}
//③ 访问了不存在的数据,缓存未命中, 继而访问数据库 ----- 缓存穿透
//缓存增加空值, 使用一个较短的过期时间,防止长时间占用内存
if(bag == null){
map.put("id", null);
map.put("name", null);
map.expire(100L, TimeUnit.SECONDS);
}else {
}
}finally {
lock.unlock();
}
return bag;
}
private void addCache(RMap<String, Object> map, Bag bag){
map.put("id", bag.id);
map.put("name", bag.name);
//① 加超时,节约内存,防止冷门数据占用空间
// map.expire(100L, TimeUnit.MINUTES);
//② 防止同一时期过期,缓存命中失败,对数据库造成压力 ----- 缓存失效、缓存击穿
map.expire(100L + new Random().nextInt(10), TimeUnit.MINUTES);
//⑥ 多级缓存本地更新和广播
if(true){ //热点数据判断
//放入本地缓存, 并加上过期时间
localCache.put(bag.id.toString(), bag);
//mq.push(bag);
}
}
}