对使用队列解决缓存一致性的思考(二)
思考
在上篇博客中,有具体的给出了使用队列来解决缓存一致性的方案,就是下面的这个流程:
- 链接: 使用队列保证redis数据的一致性.
在这个方案中,我们在请求过来时,首先绝大部分的查询请求直接通过缓存查到了,就直接返回了,不会进入到队列,只有少部分的查询请求以及少量的跟新请求可以进入到队列里来。然后,在队列的另外一端,都会有一个对应的线程来消费它,并且消费完后通知被阻塞线程处理成功。
这是一个不错的方案,比如有每秒有3000的查询进来,大概有10%没有查到,进入到队列中,还有每秒50的更新请求,那么总共就是每秒350的请求进来,如果再算上查询去重,假如是300请求每秒的量进入到队列中,我们还是可以去消费的,这里主要一个设置的点就是队列的数量,队列的数量多的话,比如每个服务给个50个队列,那就是平均每个队列每秒消费6个请求。
这里对于队列数量的考虑因素:主要是队列的增加会涉及到对应的线程数的增加,而线程资源又是服务器的一个很重要的资源,所以必须要慎重的考虑:
那么,对于一个服务器的线程资源来说,我个人觉考虑因素得如下:
- 每一个线程的创建和销毁都是会需要一定的开销的,所以一般来说,所以尽量用线程池来尽量的优化线程的创建和销毁。
- 每个线程的创建需要大概1M左右的内存,创建的线程数过多也会消耗大量的内存。
- 线程数一多,更加频繁的上下文切换,会消耗CPU性能,使得CPU的真正的工作时间的利用率减少,白白花非时间在等待上下文切换完成上。
- 上面是线程数过多的一些不利因素,那么我们就应该尽量的在不影响性能的前提下多提升线程数,获得一个好的平衡。
- 一般来说,如果所做的任务是一些CPU密集型的工作,比如大量的算法计算等,那我们就不能创建太多的线程。因为即使线程多,可是每个线程花费的时间都比较长,可能会经常处于一个CPU 100%的状态,这样的话会有很多的线程得不到快速的响应。
- 那么,如果是一些IO密集型的任务,我们确实可以适当增多线程的数量,因为这样的话,每个线程执行到IO操作就被阻塞了,CPU不会是瓶颈所在。
根据上面的几点,差不多可以看到,我们这里基本也都是查数据库、查redis的IO操作,所以说线程数设置个50应该没啥问题。
一些想法
这里的队列多少,就关系到程序的性能,相当于像是hashmap一样把锁给细粒度化了,但是这里却一直受到线程数量的限制,那也没见hashmap底层用线程池来干啥呀。
(图逻辑写的不是很清楚)
我这里主要是每个队列都配了一个线程来消费它,这点是直接原因。
那么能不能让原来的线程自己去消费自己的请求呢?首先这个线程的请求先进队列,如果说自己前面没有请求,那么就直接执行请求,如果前面有请求,那就把自己的线程注册到前一个请求里然后阻塞自己,当前一个线程执行完以后就会通知它记录的下一个线程去执行,一直这样循环往复,这样就不需要有专门的线程来消费它了。
可是这怎么看着这么眼熟呢?越看越像AQS,越看越像!
其实这就是AQS啊,那我们我们其实就可以使用利用了AQS的ReentrantLock来这样做了。
新的方案:ReentrantLock
利用ReentrantLock的公平锁来实现:
- 前面的Hash路由的操作都是一样的,然后存个reentrantLock的List表,通过哈希取模后的索引获取list中对应的锁
- 每一个原来要入队列的请求,在那里直接使用 lock.tryLock() 来实现,这样的话,原来是整个请求入队列,现在是线程入AQS队列,按照上面说的逻辑,公平锁的方式,一个一个的互相通知的来。
原来,使用队列的优化,到最后竟然是用锁来实现呀!
流程图如下:
文件图:
源代码如下:
- 请求基类:
public abstract class Request<T> {
String key;
T result;
/**
* 执行请求
*/
abstract void execute();
public String getKey() {
return key;
}
public T getResult() {
return result;
}
}
- 查询请求类:
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() {
if (queryFunction != null) {
result = queryFunction.queryExecution(key);
}
}
}
- 更新请求类
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() {
if (updateFunction != null) {
updateFunction.updateExecution(key, value);
}
}
}
- 查找请求方法的类:
public interface QueryFunction<T> {
/**
* 需要执行查询的动作,一般是查数据库和写入redis的操作
*
* @param key 要查询的key
* @return 查询得到的值
*/
public T queryExecution(String key);
}
- 更新请求方法的类
public interface UpdateFunction<T> {
/**
* 需要执行查询的动作,一般是查数据库和写入redis的操作
*
* @param key 要更新的key
* @param value 要更新的value
*/
public void updateExecution(String key, T value);
}
- 锁管理类
public class LockerManager {
private static final int LOCKER_NUM = 200;
private List<ReentrantLock> lockList;
private static LockerManager manager = new LockerManager();//静态内部类?
public static LockerManager getInstance() {
return manager;
}
private LockerManager() {
initial();
}
private void initial() {
lockList = new ArrayList<>(LOCKER_NUM);
for (int i = 0; i < LOCKER_NUM; i++) {
lockList.add(new ReentrantLock(true));
}
}
/**
* 请求路由,发送队列
*
* @param request 请求
* @return
*/
public ReentrantLock getLocker(Request request) {
int h = request.getKey().hashCode();
int hash = request.getKey() == null ? 0 : h ^ (h >>> 16);
int index = hash & (LOCKER_NUM - 1);
return lockList.get(index);
}
}
- 供业务调用的接口类
@Service
public class RedisQueueService {
/**
* 先从redis缓存获取,获取不到,再执行该方法
*
* @param key 要查询的key
* @param timeout 超时时间,以毫秒为单位
* @param queryFunction 定义查询操作
* @return
*/
public <T> T query(String key, long timeout, QueryFunction<T> queryFunction) throws Throwable {
Request<T> request = new QueryRequest<>(key, queryFunction);
Lock lock = LockerManager.getInstance().getLocker(request);
try {
if (lock.tryLock(timeout, TimeUnit.MILLISECONDS)) {
request.execute();
} else {
System.out.println("超时");
throw new Throwable("查询超时");
}
} finally {
lock.unlock();
}
return request.getResult();
}
/**
* 执行更新操作
*
* @param key 要更新的key
* @param value 要更新的值
* @param timeout 超时时间,以毫秒为单位
* @param updateFunction 定义更新操作
*/
public <T> void update(String key, T value, long timeout, UpdateFunction<T> updateFunction) throws Throwable {
Request<T> request = new UpdateRequest<>(key, value, updateFunction);
Lock lock = LockerManager.getInstance().getLocker(request);
try {
if (lock.tryLock(timeout, TimeUnit.MILLISECONDS)) {
request.execute();
} else {
System.out.println("超时");
throw new Throwable("更新超时");
}
} finally {
lock.unlock();
}
}
}
可以看到,相对于上一个方案,少了一个RedisQueue的类,之前在那里是有查询去重的功能的,现在这里队列交由ReentrantLock的AQS来管理了,这里有一个缺点就是没有查询去重了。不过,这也影响不大,查询请求操作会有一个二次检查,在redis里查询到就返回了,依然不会使得查询数据库的操作增多。
同时原本的更新操作的事务的代码,现在不会自动加上去了,现在可以直接使用事务注解来实现,原来是因为交给线程池线程去做了,需要采用编程式事务,但是现在请求的执行是由原来的这一个线程来执行的,没有涉及到线程池,因此可以放心的使用@transactional注解。
最最重要的一点,原来我考虑加几个队列都得考虑这考虑那的,上个方案50个队列还行,让我上200个队列就感觉不行,现在用这个方案,200把锁,200个AQS队列还是随意的,这样才能算得上是和hashmap一样将锁的粒度细化了。
(小声说一句,这就好比以前在看hystrix的时候,线程的资源隔离也是通过线程池隔离来实现的,所以网上总有把这个新增了线程当做缺点来和其他的方案来对比)
此方案的项目源码放到了我的github地址中:github地址