简介
- 在使用redis的时候,前面介绍了,由于操作数据库和操作redis缓存不是一个原子操作,且还会存在多个CPU之间并行执行的情况,所以就会有一个线程在操作数据库和缓存的时间节点之间,另外一个线程也在执行操作数据库和缓存,这样就会导致数据可以与缓存之间会存在数据不一致的情况。
- 并且,无论使用何种更新策略,都无法保证数据的一致性,那么,如果某些数据在业务场景下不能出现数据库与缓存的长时间的不同步情况,就需要考虑,如何才能保证数据的一致性。
- 具体要了解在不同更新策略下发生缓存与数据库数据不一致的各种情况,可以看我的上篇博客介绍
- 博客链接: Redis中的几种更新策略.
分析及制定解决方案
这个最初实现思路是从龙果学院的亿级电商大型分布式缓存架构实战的课程里面来得,但是也不一样。
- 上面我们也说了,之所以会数据不一致,就是因为出现了多个线程的数据库和缓存操作同时执行的情况,那么,要解决这个问题,就要从这点入手,就不能有多个线程的数据库和缓存操作同时执行的时候,而请求过来的时候,都是tomcat自己的线程池里分发的线程来执行这个请求的,请求可以多线程同时运行,但是,更新数据库和缓存的操作就不行。
- 因此,可以考虑以下方案:
- 加锁,对执行数据库和缓存操作的那部分代码进行加锁,每当多个线程代码运行到这里的时候,如果此时已经有线程在执行了,下一个线程就会被阻塞,乍一看,确实已经实现了功能,并且能够确保解决问题,但是一想想每秒有上万或者更高的请求打过来的时候,就算只有几百的写请求,但是上万的读查询在缓存失效后也会有大量的请求进入到锁的代码块中,你就能马上意识到,这样的解决方案是行不通的,性能太差。
- 使用队列串行化执行数据库与缓存更新的操作,首先,最简单的,把这些操作放到一个队列里面,然后有个线程取出来执行,这样的机制是不行的,这样的话它和reentrantLock锁利用AQS和LockSupport.park的机制是一毛一样的,相当于变相的自己实现了个锁。所以我们这里是要用到多个队列的。
- 我们这里之所以还要用到队列,是因为可以在使用队列的基础上进行灵活的修改。
使用队列的详细方案
多线程多队列
- 首先,从队列中取出请求去执行的线程不能只有一个线程,要保证每个线程都有自己对应的队列,否则就失去了多队列的意义了。
- 通过路由请求到不同的队列来实现,同一个键的请求,不会出现并发执行数据库和缓存操作的行为,而这里的路由请求的策略,我们就采用最老土的hash算法,因为我们在代码中的队列数量和线程数量是不会去实时改变的,通过该请求的键的hashcode对队列数量进行取余操作,将其路由分配到不同的队列里面。
查询去重
- 再者,仅仅只是增加队列数量是不够的,这也只能是提高了队列数量倍的性能而已,应该要有更多的优化操作,,,比如,所有进入队列的相同的查询操作,其实都可以优化为一个操作,这样,只要该队列中存在查询该键的请求,就不会把这个键放入到队列里面去了,尤其是对于redis来说,查询的操作数量要远大于写入的操作数量,这个是一个很值得去优化的地方。
等待策略
- 好了,这样就能够大概的把解决方案给描述出来了,那么,还有一点就是,在一个线程把这部分请求发送到队列后,需要执行什么样的等待策略呢?
- 比如:通过while去轮询,在redis里面查找,找到数据了就break,没找到就继续找,然后再设定一个超时时间,超时了就执行超时的处理程序返回。首先,这样是不太好的,比如你在这个while里面加上了thread.sleep(),你sleep时间小了吧,老是上下文切换,引起不必要的性能损耗,再者就是,你查的时候,说不定你发送到队列里的查请求刚执行完,又有更新写入请求给把缓存删除了,你就不一定能找到刚好redis里有缓存的时间点,尤其是并发量比较大的时候,应该是会经常查询超时把,再者,就是你这样又给redis增加了压力,又凭空多出来这么多查询的请求。所以说,我看网上很多的人在说这样的做法,我也不知道他们有没有思考过,到底有没有真正的在生产环境中使用过,还是说复制过来,贴个原创标签就完事了。
- 在网上有看到有人设置个变量用while刷,while完以后才去读redis,他干脆就不在while里面加thread.sleep()了,直接让CPU裸奔,这样就更加不用说了,这样是一定会有内存可见性的问题的,而且那么多的查询请求,很多个线程在那里while,非得让每一个线程使用完自己的CPU时间片了才将CPU让出来,这样就不用想了,不仅自己这个服务有问题,电脑上的其他程序依然分配不到CPU资源,肯定就卡死了。
- 所以说,这里是一定要使用可以阻塞和释放的操作的,比如object.wait()或者LockSupport.park()的,本来想着是使用LockSupport.park()的,不用像object.wait()一样还要去用个synchronized同步块,你进入队列里面的请求记录一个入队时的线程就好了,但是后来做查询去重的时候发现,还得有一个list来存放多个线程,还得去维护这个list的并发安全性,还得遍历它去unpark,比较麻烦,我这里又不需要单独操作某个特定的线程,干脆就用object.wait()完事了。
总结一下实现步骤:
(1)请求封装类
(2)线程和队列初始化管理
(3)请求路由分发的详细设计
(4)查询去重和入队列的详细设计
(5)给实际业务调用的服务如查询、更新的封装,通过它来生成请求,发送队列和等待执行完成
(6)使用泛型和接口方法来对其做一个通用的架构,使得具体业务和这个方案代码分离
方案代码
这是方案的文件的图。
首先,forRedisQueue里面是对请求的封装,RedisServiceImpl是对redis基本操作的封装,ResdisQueueService是对使用redis队列进行操作的封装,代码如下:
- 请求基类:
public abstract class Request<T> {
String key;
T result;
Exception requestException;
/**
* 判断请求是否完成
*/
boolean isDone;
/**
* 执行请求
*/
abstract void execute();
public boolean isDone() {
return isDone;
}
public String getKey() {
return key;
}
public T getResult() {
return result;
}
public Exception getRequestException() {
return requestException;
}
}
- 查询请求类:
public class QueryRequest<T> extends Request<T> {
private QueryFunction<T> queryFunction;
public QueryRequest(String key, QueryFunction<T> queryFunction) {
this.key = key;
this.queryFunction = queryFunction;
}
@Override
public void execute() {
try {
if (queryFunction != null) {
result = queryFunction.queryExecution(key);
}
} catch (Exception e) {
this.requestException = e;
} finally {
synchronized (this) {
isDone = true;
this.notifyAll();
}
}
}
}
- 更新请求类(注意这个更新和写入什么的都是用的这个类):
public class UpdateRequest<T> extends Request<T> {
private T value;
private UpdateFunction<T> updateFunction;
public UpdateRequest(String key, T value, UpdateFunction<T> updateFunction) {
this.key = key;
this.value = value;
this.updateFunction = updateFunction;
}
@Override
public void execute() {
try {
if (updateFunction!=null){
updateFunction.updateExecution(key, value);
}
} catch (Exception e) {