本文考虑的数据库与缓存一致性问题是缓存侧模式的缓存一致性问题。关于缓存的设计模式可以参考这篇文章:缓存更新的套路
首先明确一点,给缓存设置expire time那么缓存和数据库是满足最终一致性的。所以的方案都可以通过设置expire time来实现最终一致性。
现在,我们讨论三种更新策略:
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
先更新数据库,再更新缓存
在并发环境下这种模式存在着顺序性的问题,多个线程同时更新数据库和缓存此时存在竞争,例如:
- 线程A修改数据库
- 线程B修改数据库
- 线程B修改缓存
- 线程A修改缓存
此时无法保证线程的执行顺序。
先删除缓存,再更新数据库
我们来看看这种情况下会不会存在顺序问题。
- 线程A删除缓存,并写数据库
- 线程A写数据库的时候,线程B读取缓存,未命中
- 线程B读数据库并重建缓存
- 线程A写数据库完成
这种模式下依旧会出现脏数据的问题。
如何解决上述问题?
通常使用两次删除法:
- 线程A删除缓存,并写数据库
- 写入数据库之后,线程A经过一定的时延再次执行删除缓存
伪代码如下:
private static final int WAIT_MILLSECONDS = 1000;
public void write(String key,Object data){
redis.delKey(key);
db.updateData(data);
Thread.sleep(WAIT_MILLSECONDS); //时延毫秒数
// 这里的delKey可能会失败哦,例如网络抖动
redis.delKey(key);
}
延时一段时间之后再做删除操作的目的是清理这段时间内其他线程写入缓存的脏数据。那么问题来了,延时时间如何确定?因为是防止其他线程写入脏数据,所以延时时间是基于业务的读取业务执行的时间来确定。同时需要考虑到数据库的读写分离,在读写分离的情况下,写入主库的数据同步到从库也需要百毫秒。所以延时时间=业务读执行时间+数据库主从同步时间。
同时采用这种方式的时候如果线程每次更新都要进行sleep的话是对吞吐量造成很大影响。所以需要执行异步的删除操作。至于异步的方式可以a)开启一个线程,b)生产者消费者模式,c)使用消息队列。其实分析到这里使用这种解决方案已经比较笨重了。
另外一个问题:**如果第二次删除缓存执行失败如何处理?**这个问题我们留到方案三之中讨论。
先更新数据库,再删除缓存
首先说下老外提出了一种缓存设计模式:Cache_Aside_Pattern
其逻辑如下:
- 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
- 命中:应用程序从cache中取数据,取到后返回。
- 更新:先把数据存到数据库中,成功后,再让缓存失效。
我们看下这种情况之下会不会有不一致的问题:
在这种情况之下,由于写线程并未在写之前删除缓存,所以只有在缓存正好失效或者不存在缓存的情况之下会引起竞争问题。我们来看看这种情况下的竞争问题。
- 线程B读取缓存未命中,查询数据库取得旧值
- 线程A更新数据库,将新值写入数据库
- 线程A执行删除缓存操作
- 线程B将旧值写入缓存
在这种情况下出现了依旧出现了数据不一致的问题。
但是仔细考虑下这种场景的情况发生概率非常之低,因为需要第1步先于第2步而第3步又先于第4步,也就是说一个读取请求先达,之后一个写请求到达,然后写请求先于读请求完成。而通常写请求肯定是要比读请求慢的。所以发生概率极低。
如果非要抬杠来解决这个问题那就用方案二的延时删除。这种方案就是接受这种极低概率的不一致情况。
和方案二一样,如果缓存删除操作执行失败怎么办?
删除缓存失败解决方案
删除缓存失败的解决思路是提供一个有保障的重试方式,主要两种方案:消息队列、binlog监控
消息队列
队列可以借助于消息中间件(rabbitmq,kafka)或者在业务代码中定义一个队列。
然而,该方案有一个缺点,对业务线代码造成大量的侵入。所以为了删除缓存操作和业务代码进行解耦可以进行对mysql bin log的监听来执行删除缓存的操作。
binlog监控
总结
通常情况下缓存和更新操作需要选用先更新数据库,再删除缓存(同时带有缓存过期时间),注意是删除而不是更新。如果对一致性要求非常高的话,建议选择监听binlog的形式来实现缓存的同步。