前言
数据库和缓存,这两个存储介质,其相关的数据如果不一致,可能会引发不可预知的业务风险。如何保持一致,这是一个经典问题,也是开发人员在使用缓存中间件的过程中,怎么都绕不开的一个话题。下面列举常用的几种解决方案,主要从多线程并发的角度上,分析一下其中的优劣。
几种方式
下面我们列举九种机制,来一一分析。
先更新缓存再写库
缓存更新成功之后,如果数据库因为索引冲突或者其他原因,导致更新异常,触发事务回滚操作。那么就会导致两边的数据是不一致的。这种方案应该被直接排除,可用性非常低。【除此以外还有并发问题,后续方案会详细解释】
- 方案不可行
cache.update();
// 保存失败,回滚
db.save();
先写库再更新缓存
这种方案看起来比较可行,因为数据库保存成功是一个大前提,落库之后再更新缓存。但是如果出现缓存客户端链接耗尽,导致数据不能推送到缓存,那么也会导致不一致。【除此以外还有并发问题,后续方案会详细解释】
- 方案不可行
db.save();
// 更新失败
cache.update();
先删除缓存再写库
先删除缓存,再更新数据库,无论数据库是否更新成功,都不影响缓存。
cache.delete();
db.save();
异常场景:缓存删除之后,数据落盘之前,其他线程过来查询缓存,发现没有数据,这时加载DB数据,更新缓存,又会导致缓存不一致。
- 方案不可行
// thread1 ================================
thread1.cache.delete();
// thread2 ================================
thread2.cache.select();
// 查询缓存为空,更新缓存
thread2.db.select();
thread2.cache.update();
// thread2 end
thread1.db.save();
先写库再删除缓存
这种方式可以避免上一个方案的问题吗?咋一看是可以的,毕竟是先落库再删除缓存,如果保存不成功,缓存也不会删除。
db.save();
cache.delete();
异常场景:线程2执行查询的时候,发现没有数据,然后加载数据。这时线程1来执行更新操作,成功后删除了缓存。而此时线程2持有的数据是DB更新之前的,如果直接用此数据来更新缓存,那么势必会造成不一致的情况。
- 方案不可行
// thread2 ================================
thread2.cache.select();
// 查询缓存为空,更新缓存
thread2.db.select();
// thread1 ================================
thread1.db.save();
thread1.cache.delete();
// thread1 end
thread2.cache.update();
双删模型
双删,在数据库更新的前后,各执行一次缓存删除操作,类似于spring中的AOP。这个方案看起来比较成熟,是对上一个方法的进一步优化。
cache.delete();
db.save();
cache.delete();
异常场景:其实可以把上一个方案的异常情况完全迁移过来,因为双删也不能解决多线程穿插并发的异常。
- 方案不可行
// thread2 ================================
thread2.cache.select();
// 查询缓存为空,更新缓存
thread2.db.select();
// thread1 ================================
thread1.cache.delete();
thread1.db.save();
thread1.cache.delete();
// thread1 end
thread2.cache.update();
延迟删除缓存
一种优化后的双删模型,数据落盘之后,删除缓存,然后再开启一个定时任务,5秒之后再次删除缓存。这样操作是为了避免多线程并发的时候,其他线程会把旧数据更新到缓存,保证当前线程落库的数据可以被更新到缓存。
- 方案可行,最终一致
db.save();
cache.delete();
// 延迟删除
cache.deleteDelay(5,TimeUnit.SECOND);
分布式锁更新缓存
这里要分为两种情况,加锁的范围会有区别:
- 缓存会过期,数据库的更新或者缓存的更新,都要加上分布式锁;
- 缓存不过期,数据库需要更新的地方,都加上分布式锁;
- 方案可行,牺牲性能
boolean lock = redis.tryLock(10 , TimeUnit.SECOND);
if(lock){
if(needUpdateDB)
db.save();
cache.update();
redis.unlock();
}
顺序更新缓存
所有需要更新缓存或者数据库的地方,全部放到一个队列中,串行化执行。
- 方案可行,牺牲性能
while(true){
Task info = queue.pop();
if(info.needUpdateDB())
db.save();
cache.update();
}
Canal中间件
阿里的开源组件,canal会同步mysql的binlog到本地服务,通过消息队列的方式,将数据下放到业务端。
此方案的前提是缓存中的数据永久有效,不能设置过期时间,否则就和双删模型有相同的问题。
- 方案可行【有前提条件】
while(true){
Message info = cannel.getWithoutAck(1);
// 此处保证更新成功,重试
cache.update();
}
强一致 VS 最终一致
强一致是说数据库和缓存一定是相同的,分布式锁和顺序更新,这两种方案都可以实现。
绝大部分业务只需要保证最终一致就可以了,在有限的时间之内,能够保证缓存和DB的数据保持一致。