前言
前段时间找工作,遇到两次面试官都谈到数据库l和redis双写一致性问题,其中一个面试官不将码德,问了我用过redis没就直接旁敲侧击的问:在用redis时,有没有遇到过它和数据库相关麻烦和问题啊?刚好这两天新工作还没全面开展,就抽空详细写写数据库和redis双写一致性问题。
问题现象
在之前的一个项目当中,我们在测试的时候,批量更新了某些数据的数据后,更新之后发现极个别数据并没有更新!当时就再次测试,发现这个问题并不是一定出现的。而且数据库的数据是更新了的,而页面查出来的数据是老的数据,然后就发现是redis的数据没有更新。
问题原因
排查和测试了很久之后,分析发现:在更新的数据和查询数据的极端的并发情况下,会出现数据不一致问题。主要是redis和数据库双写执行的顺序导致的一致性问题。
具体情况:先有个查询线程进来从数据库查到了老的数据,由于此时redis缓存还没有查出来的数据,数据会再写入redis。但是在写入redis之前,此时并发执行了更新操作,新数据更新了mysql和redis后,查询的线程才把老的数据写入redis,导致后面的查询线程直接从redis拿出来的数据是老的数据。这个情况的问题本质是读后写问题。
问题解决思路
网上看了很多类似问题,也有很多解决方案,我觉得还比较好的解决方案是写后延时双删的方案,还有依靠消息队列的方案,但是在我这个项目中,更新本就消费方操作,在再加消息队列也不合适。
最终采用的是方案是使用多线程加队列的方案解决。
如何使用队列解决问题
问题根本原因就是redis和数据库写入的顺序问题,将更新操作或者查询操作压入队列就可以解决两个操作的顺序问题。
但是这样又有新的问题:在高并发下,所有不同的商品的更新查询数据操作都压入队列中,势必会急剧降低系统性能。所以直接将操作放入队列的操作是不可行的。
这个问题就可以引入多线程来解决:
在更新服务里面创建多个队列的集合,跟线程池的线程一一绑定,然后根据更新的id进行hash,然后对队列进行取余,这样就能够把同一个商品的操作装入到同一个队列中,然后由单独的一个线程对队列依次的进行消费。
具体实现方案及代码
创建两个关键类:ThreadPool和ProcessThread。
在ThreadPool中使用 Executors 工厂方法来创建壹個 ExecutorService 实例。创建队列的集合。
private ExecutorService executorService= Executors.newFixedThreadPool(10);
private ArrayList<ArrayBlockingQueue> queueArrayList=new ArrayList<>();
通过饿汉式的单例模式创建线程安全的线程池对象,在线程池对象中再通过一个public的init方法返回 SingleTone.getThreadPool();
private static class SingleTone {
private static ThreadPool threadPool;
static {
threadPool=new ThreadPool();
}
private static ThreadPool getThreadPool(){
return threadPool;
}
}
public static ThreadPool init(){
return SingleTone.getThreadPool();
}
在new 线程池对象的时候就通过构造方法初始化了每个线程绑定的队列。通过submit的方式将线程提交至线程池。
private ThreadPool(){
for (int i = 0; i <10 ; i++) {
ArrayBlockingQueue queue=new ArrayBlockingQueue(500);
ProcessThread thread=new ProcessThread(queue);
queueArrayList.add(queue);
executorService.submit(thread);
}
}
创建ProcessThread实现Callable接口,在构造方法中,添加队列对象
public class ProcessThread implements Callable<TProduct> {
private ArrayBlockingQueue queue=null;
public ProcessThread(ArrayBlockingQueue queue) {
this.queue = queue;
}
重写的call方法中执行业务的更新或者查询操作;
拿出队列中的数据传输对象,在操作完成之后使数据传输对象的标记更改data.setDone(true);