目录
一、缓存原理
高并发情境下首先考虑到的第一层优化方案就是增加缓存,尤其是通过Redis将原本在数据库中的数据复制一份放到内存中,可以减少对数据库的读操作,数据库的压力降低,同时也会加快系统的响应速度,但是同样的也会带来其他的问题,比如需要考虑数据的一致性、还需要预防可能的缓存击穿、穿透和雪崩问题等等。
1.实现步骤
先查询缓存中有没有要的数据,如果有,就直接返回缓存中的数据。如果缓存中没有要的数据,才去查询数据库,将得到数据更新到缓存再返回,如果数据库中也没有就可以返回空。
考虑数据一致性,缓存处的代码逻辑都较为标准化,首先取Redis,击中则返回,未击中则通过数据库来进行查询和同步。
public Result query(String id) {
Result result = null;
//1.从Redis缓存中取数据
result = (Result)redisTemplate.opsForValue().get(id);
if (null != result){
System.out.println("缓存中得到数据");
return result;
}
//2.通过DB查询,有则同步更新redis,否则返回空
System.out.println("数据库中得到数据");
result = Dao.query(id);
if (null != result){
redisTemplate.opsForValue().set(id,result);
redisTemplate.expire(id,20000, TimeUnit.MILLISECONDS);
}
return result;
}
其他的新增、删除和更新操作,可以直接采用先清空该Key下的缓存值再进行DB操作,这样逻辑清晰简单,维护的复杂度会降低,而付出代价就是多查询一次。
public void update(Entity entity) {
redisTemplate.delete(entity.getId());
Dao.update(entity);
return entity;
}
public Entity add(Entity entity) {
redisTemplate.delete(entity.getId());
Dao.insert(entity);
return entity;
}
2.缓存更新策略
适用于做缓存的场景一般都是:访问频繁、读场景较多而写场景少、对数据一致性要求不高。如果上面三个条件都不符合,那维护一套缓存数据的意义并不大了,实际应用中通常都需要针对业务场景来选择合适的缓存方案,下面给出了四种缓存策略,由上到下就是按照一致性由强到弱的顺序。
更新策略 | 特点 | 适用场景 |
---|---|---|
实时更新 | 同步更新保证强一致性,与业务强侵入强耦合 | 金融转账业务等 |
弱实时 | 异步更新(MQ/发布订阅/观察者模式),业务解耦,弱一致性存在延迟 | 不适合写频繁场景 |
失效机制 | 设置缓存失效,有一定延迟,可能存在雪崩 | 适用读多写少,能接受一定的延时 |
任务调度 | 通过定时任务进行全量更新 | 统计类业务,访问频繁且定期更新 |
关于缓存MQ异步更新策略,可参考:《实现缓存最终一致性的两种方案》
二、缓存雪崩和击穿
1. 缓存雪崩概念
缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案:
- 将缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
- 用加锁或者队列的方式保证缓存的单线程(进程)写,从而避免失效时大量的并发请求落到底层存储系统上。
第一种方案比较容易实现,第二种的思路主要是从加阻塞式的排它锁来实现,在缓存查询不到的情况下,每此只允许一个线程去查询DB,这样可避免同一个ID的大量并发请求都落到数据库中。
public Result query(String id) {
// 1.从缓存中取数据
Result result = null;
result = (Result)redisTemplate.opsForValue().get(id);
if (result ! = null) {
logger.info("缓存中得到数据");
return result;
}
//2.加锁排队,阻塞式锁
doLock(id);//多少个id就可能有多少把锁
try{
//一次只有一个线程
//双重校验,第一次获取到后面的都可以从缓存中直接击中
result = (Result)redisTemplate.opsForValue().get(id);
if (result != null) {
logger.info("缓存中得到数据");
return result;//第二个线程,这里返回
}
result = dao.query(id);
// 3.从数据库查询的结果不为空,则把数据放入缓存中,方便下次查询
if (null != result){
redisTemplate.opsForValue().set(id,result);
redisTemplate.expire(id,20000, TimeUnit.MILLISECONDS);
}
return provinces;
}catch(Exception e){
return null;
}finally{
//4.解锁
releaseLock(provinceid);
}
}
private void releaseLock(String userCode) {
ReentrantLock oldLock = (ReentrantLock) locks.get(userCode);
if(oldLock !=null && oldLock.isHeldByCurrentThread()){
oldLock.unlock();
}
}
private void doLock(String lockcode) {
//id有不同的值
//id相同的,加一个锁,不是同一个key,不能用同一个锁
ReentrantLock newLock = new ReentrantLock();//创建一个锁
//若已存在,则newLock直接丢弃
Lock oldLock = locks.putIfAbsent(lockcode, newLock);
if(oldLock == null){
newLock.lock();
}else{
oldLock.lock();
}
}
注意:加锁排队的解决方式在处理分布式环境的并发问题,有可能还要解决分布式锁的问题;线程还会被阻塞,用户体验很差!因此,在真正的高并发场景下很少使用!
2. 缓存击穿概念
一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到DB,造成瞬时DB请求量大、压力骤增。
解决方案:
- 在访问key之前,采用SETNX(set if not exists)来设置另一个短期key来锁住当前key的访问,访问结束再删除该短期key。
- 最容易想到的是这些热点数据设置永不过期,就不存在此问题。这样就会出现一个问题,那缓存怎么去更新呢?
-
可以做一个后台服务,专门用来做数据缓存的更新。每隔30分钟更新一下这些热点缓存数据。不过这样就增加了系统复杂度。
-
方法一过于复杂,我们可以改造一下redis的缓存结构,在缓存业务数据的时候,再保存跟业务数据关联的过期时间key。每次请求过来时,判断一下此业务数据的是否要到过期时间了,如:此值还有1分钟就过期了,这样就服务本身主动去数据库查询一次,做一个缓存的更新。
三、缓存穿透
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
解决方案:
1.布隆过滤器
最常见的方法就是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的map中,一定不存在的数据会被这个map过滤掉,这样就避免了访问到数据库。
布隆过滤器的使用方法,类似java的SET集合,用来判断某个元素(key)是否在某个集合中。和一般的hash set不同的是,这个算法无需存储key的值,对于每个key,只需要k个比特位,每个存储一个标志,用来判断key是否在集合中。
使用步骤:1、将List数据装载入布隆过滤器中
private BloomFilter<String> bf =null;
//PostConstruct注解对象创建后,自动调用本方法
@PostConstruct
public void init(){
//在bean初始化完成后,实例化bloomFilter,并加载数据
List<Entity> entities= initList();
//初始化布隆过滤器
bf = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), entities.size());
for (Entity entity : entities) {
bf.put(entity.getId());
}
}
2、访问经过布隆过滤器,存在才可以往db中查询
public Provinces query(String id) {
//先判断布隆过滤器中是否存在该值,值存在才允许访问缓存和数据库
if(!bf.mightContain(id)){
Log.info("非法访问"+System.currentTimeMillis());
return null;
}
Log.info("数据库中得到数据"+System.currentTimeMillis());
Entity entity= super.query(id);
return entity;
}
这样当外界有恶意攻击时,不存在的数据请求就可以直接拦截在过滤器层,而不会影响到底层数据库系统。
2.缓存空值
就是把空值也缓存起来,如商品id=10000,数据库查询虽然没有找到,但会在redis中保存一个空值。
这种方案就会碰到2个问题:
- 空值做了缓存,这样就无形了增加了很多没有意义的缓存,内存吃紧。比较有效的方式就是在这些值上面设置一个过期时间,让起自动删除
- 有可能与业务存在不一致的窗口时间,如:虽然商品ID=10000现在不存在,我们现在在redis中保存了一个空值,但业务后来就真的有了商品id为10000的数据,这样数据库和缓存的信息就不一致,导致业务出现问题。此时就可以利用消息中间件等方式系统剔除掉此缓存数据
实现缓存最终一致性的两种方案
一、重客户端
写入缓存:
- 应用同时更新数据库和缓存
- 如果数据库更新成功,则开始更新缓存,否则如果数据库更新失败,则整个更新过程失败。
- 判断更新缓存是否成功,如果成功则返回
- 如果缓存没有更新成功,则将数据发到MQ中
- 应用监控MQ通道,收到消息后继续更新Redis。
问题点:如果更新Redis失败,同时在将数据发到MQ之前的时间,应用重启了,这时候MQ就没有需要更新的数据,如果Redis对所有数据没有设置过期时间,同时在读多写少的场景下,只能通过人工介入来更新缓存。
读缓存:
如何来解决这个问题?那么在写入Redis数据的时候,在数据中增加一个时间戳插入到Redis中。在从Redis中读取数据的时候,首先要判断一下当前时间有没有过期,如果没有则从缓存中读取,如果过期了则从数据库中读取最新数据覆盖当前Redis数据并更新时间戳。具体过程如下图所示:
二、客户端数据库与缓存解耦
上述方案对于应用的研发人员来讲比较重,需要研发人员同时考虑数据库和Redis是否成功来做不同方案,如何让研发人员只关注数据库层面,而不用关心缓存层呢?请看下图:
- 应用直接写数据到数据库中。
- 数据库更新binlog日志。
- 利用Canal中间件读取binlog日志。
- Canal借助于限流组件按频率将数据发到MQ中。
- 应用监控MQ通道,将MQ的数据更新到Redis缓存中。
可以看到这种方案对研发人员来说比较轻量,不用关心缓存层面,而且这个方案虽然比较重,但是却容易形成统一的解决方案。