实现缓存
源码参考https://gitee.com/genj/cache
本文主要讲缓存中相关问题的理论以及实际项目中的部分实现。理论部分与语言无关,无知识要求。实现部分需要掌握Java并发基础知识。主要参考《并发编程实战 》、EhCache和网上一些资料。
缓存相关的问题: 缓存逻辑与业务代码分离、 缓存预热、缓存更新、缓存污染、缓存淘汰、缓存雪崩、缓存穿透、 缓存降级以及数据写盘。
1. 缓存逻辑与业务代码分离
部分开发人员,以及网上许多示例,都是将缓存的处理与业务代码混合在一起,业务逻辑代码复用性差。
可以使用装饰者模式将二者分离。
2. 缓存预热
在用户请求数据前先将数据加载到缓存系统中,用户查询事先被预热的缓存数据,以提高系统查询效率。
具体时机有系统启动加载和定时加载。
3. 缓存更新
在数据发生变化后即时将变化后的数据更新到缓存中。关于是先更新数据库还是先更新缓存,差别不大,可以根据具体业务逻辑选择。有数据实时强一致要求的可以先写库。
实现方式:
定时更新:使用定时任务将底层数据更新到缓存中,实现方式简单,适合需要缓存的数据量不大的情景。
写请求更新:在用户有写请求时先写数据库同时更新缓存,这适用于用户对缓存数据和数据库的数据有实时强一致的情况。
读请求更新:在用户有读请求时,先判断该请求数据的缓存是否存在过期,如果不存在或已过期,则进行底层数据库查询并将查询结果更新到缓存中,同时将查询结果返回给用户。
过期更新:定时将缓存中过期的数据更新为最新数据并更新缓存的过期时间。只更新过期部分。
4. 缓存污染
缓存污染是把不常用的数据读取到缓存中的现象。
解决方案:即时将数据从缓存中移除。
4. 缓存淘汰
在缓存数据过多时需要使用某种淘汰算法决定淘汰哪些数据。
实现方式有:
FIFO(先进先出):判断被存储的时间,离目前最远的数据优先被淘汰。使用队列可以实现。
LRU(最近最少使用):判断缓存最近被使用的时间,距离当前时间最远的数据优先被淘汰。可使用双向链表可以实现。只使用双向链表可以实现;双向链表+哈希表性能较好;使用内置的库最好。
LFU(最不经常使用):在一段时间内,被使用次数最少的缓存先被淘汰。使用复杂点的双向链表,在原始双向链表的基础上,链表中的结点又是一个双向链表。实现较为复杂。
5. 缓存雪崩
同一时刻由于大量缓存失效,导致大量原本应该访问缓存的请求都去查询数据库,对数据库造成巨大压力,严重的话会导致数据库宕机,从而形成一系列连锁反应,使整个系统崩溃。
原因是大量缓存的过期时间相同。
解决方案:
请求加锁:对于并发量不是很多的应用,使用请求加锁排队的方案防止过多请求数据库。在高并发场景不推荐,因为串行化了请求。
失效更新:为每一个缓存数据都增加过期标记来记录缓存数据是否失效,如果缓存标记失效,则更新数据缓存。
设置随机失效时间:为不同的数据设置不同的缓存失效时间,防止在同一时刻有大量的数据失效。推荐。
6. 缓存穿透
由于缓存系统故障或者用户频繁查询系统中不存在的数据,而这时请求穿过缓存不断被发送到数据库,导致数据库过载,进而引发一连串并发问题。
解决方案:
缓存null:在缓存中记录一个短暂的(数据过期时间内)数据在系统中是否存在的状态,如果不存在,则直接返回null,不再查询数据库,从而避免缓存穿透到数据库上。
布隆过滤器:将所有可能存在的数据都映射到一个足够大的Bitmap中,在用户发起请求时首先经过布隆过滤器的拦截,一个一定不存在的数据会被这个布隆过滤器拦截,从而避免对底层存储系统带来查询上的压力。
业务代码中暂时没有想到优雅处理缓存系统故障(比如Redis宕机)的方式,使用的try-catch。架构上比较好处理,比如Redis哨兵模式。
7. 缓存降级
缓存降级指由于访问量剧增导致服务出现问题(一般是底层数据库,如宕机、响应时间慢或不响应)时,优先保障核心业务的运行,减少或关闭非核心业务对资源的使用。
分为:
读降级:在数据库服务负载过高或数据库系统故障时,可以只对cache进行读取并将结果返回给用户,在数据库服务正常后再去查询数据库,即将读请求从数据库降级为cache。这种方式适用于对数据实时性要求不高的场景,保障了在系统发生故障的情况下用户依然能够访问到数据,只是访问到的数据相对有延迟。
写降级:在写请求增大时,可以只进行Cache的更新,然后将数据异步更新到数据库中,保证最终一致性即可,即将写请求从数据库降级为写cache。
8. 数据写盘
可选操作。将数据存储在磁盘上,保障服务重启后内存数据能够重新从磁盘上加载,效率较低。
9. 在实际项目中的部分实现
主要参考《并发编程实战 》和网上一些资料。
package com.jerry.compute;
/**
* 使用装饰者模式将缓存从业务代码中解耦
* 用来表示耗时的数据处理操作
*/
public interface Computable<K, V>{
V compute(K k) throws Exception;
}
package com.jerry;
import com.jerry.compute.Computable;
import java.util.Map;
import java.util.concurrent.*;
/**
* 本地缓存的实现
*/
public class Cache<K, V> implements Computable<K, V> {
/**
* 使用装饰者模式将缓存从业务代码中解耦
*/
public interface Computable<K, V>{
V compute(K k) throws Exception;
}
/**
* 表示一个耗时计算,实现了Computable接口,不具备缓存能力
*/
public class ExpensiveFunction implements com.jerry.compute.Computable<String, Integer> {
@Override
public Integer compute(String s) throws Exception {
TimeUnit.SECONDS.sleep(5);
return Integer.valueOf(s);
}
}
/**
* final修饰:防止其他人错误修改,提高安全性。
* ConcurrentHashMap:使用内置高校性能的Map。
* 问题说明:
* 1. 如果使用HashMap+synchronized或者是读写锁,性能没有ConcurrentHashMap好,可以避免扩容时可能的死循环,分段锁,使用更简单。
* 2. 缓存Future,而不是具体值,是因为实际的计算任务(如调用第三方)都有延迟,转换缓存维度,可以避免并发时的部分重复计算。
*/
private final Map<K, Future<V>> cache = new ConcurrentHashMap<>();
//用于实现装饰者模式,分离缓存逻辑和业务逻辑
private final Computable<K, V> c;
public Cache(Computable<K, V> c) {
this.c = c;
}
@Override
public V compute(K k) throws Exception {
//对业务比较熟悉,假设任务肯定能返回,就可以用while(true)
while (true){
//先从缓存中查找计算任务
Future<V> f = cache.get(k);
if(f == null){//没有进行相同计算任务
FutureTask<V> task = new FutureTask<V>(()->{
return c.compute(k);
});
/*
如果使用下例的代码,多线程下存在同时put()相同任务,执行相同任务的问题
f = cache.put(k, task);
f = task;
task.run();
*/
//避免了多线程时,创建重复计算任务的可能
f = cache.putIfAbsent(k, task);
if(f == null){
f = task;
task.run();
}
}
//捕获异常是为了针对不同异常进行不同的处理,比如人为取消、数据库异常
try{
//有相同的计算任务,阻塞式
return f.get();
} catch (CancellationException e){
//对于手动取消Future,可以即时从缓存中移除,避免缓存污染
cache.remove(k);
throw e;
} catch (InterruptedException e){
cache.remove(k);
throw e;
}catch (ExecutionException e){
//处理缓存污染
cache.remove(k);
//假设该异常可以进行重试,继续计算
e.printStackTrace();
}
}
}
//用于定时清理过期缓存
public final static ScheduledExecutorService executor = Executors.newScheduledThreadPool(8);
//缓存过期
public V compute(K k, long expire) throws Exception {
if(expire > 0){
executor.schedule(()->{
expire(k);
}, expire, TimeUnit.SECONDS);
}
return compute(k);
}
//随机缓存时间,预防缓存雪崩
//最终的缓存方法
public V computeRandomExpire(K k) throws Exception {
long randomExpire = (long) (Math.random() * 10000);
return compute(k, randomExpire);
}
//清理过期缓存
public synchronized void expire(K key){
Future<V> future = cache.get(key);
if(future != null){
//处理任务正在计算的情况
if(!future.isDone()){
System.out.println("任务被取消");
future.cancel(true);
}
System.out.println("过期时间到");
cache.remove(key);
}
}
}