写点什么

缓存与数据库一致性策略

发布于: 2020 年 11 月 16 日
缓存与数据库一致性策略

Redis缓存由于其优秀的性能、丰富的数据结构常用于提高查询效率,但需要保证Redis缓存与数据库数据一致。本文分析缓存数据库一致性及可能发生的问题,说说一些常见解决思路。

背景

开发常用套路是先查缓存,如有则直接返回;没有则查库返回并写到缓存中。在数据变更的时候,则更新库然后更新缓存(一般是采用淘汰缓存,因为如果业务复杂情况下,由读操作从DB读取数据组装后更新缓存更方便、易维护)。

读操作流程逻辑不存在一致问题,在并发场景下,即使多个线程同时发现没有缓存,读库后更新缓存也能保证数据一致。



写操作一般存在这样两种策略:先更新库再淘汰缓存,先淘汰缓存再更新库。这两种策略会有不一样的表现。

先更新库再淘汰缓存

假设更新库update DB后,在淘汰缓存del cache时失败,即未淘汰缓存,则缓存是旧数据,会有数据不一致问题。



先淘汰缓存再更新库

假设先淘汰缓存,后更新数据库时失败,此时数据库是旧数据,由于缓存已淘汰,在读操作从库读到缓存时仍是旧数据,不存在一致问题。



因此,最佳实践是先淘汰缓存、再更新库

数据不一致原因

据上面的分析,我们的缓存采用方案的是先淘汰缓存、再更新库。但即使这样,在某些情况下如并发较高的场景下,数据可能仍面临一致性问题。

假设在集群系统下,写请求在淘汰缓存成功后,由于诸如CPU调度、FULL GC等原因导致并更新库比平常慢了一些,也即在两个操作之间系统突然“卡顿”了一下;此时另外一个读请求刚好在这个时间间隔中,发现no cache于是从库中读取到更新之前的旧数据,从而导致数据不一致。





当然这种情况发生的概率极低,但是在并发较高或系统性能并不太好的情况,不得不考虑这样的异常情形。

解决思路

现在我们知道在使用缓存时采取先淘汰后更新库的策略,同时也了解到极端情况这种策略仍存在不一致的问题,接下来讨论下几个解决方案。

缓存过期时间

在使用Redis缓存时,可以设置有效时间,来防止旧缓存数据无法更新。这样即使在发生上述淘汰缓存失败时,在缓存过期后,读请求仍然可以从DB中读取最新数据并更新缓存。虽然在一定时间范围内数据有差异,但在缓存过期后,数据最终是一致的。

总结,给缓存加个过期时间,可减小数据不一致的影响范围。

双删

给缓存设置过期时间,可保证数据最终一致。

既然在淘汰缓存与更新库两操作之间,可能会产生缓存旧数据,那么可以采取在更新库表之后再淘汰一次缓存,那么可以很大程度上解决以上问题,这种策略称为双删。但如果在第二次淘汰缓存失败,相当于双删策略失效。

public void write() {
redis.del(key);
db.update(record);
// ...
Thread.sleep(1000);
redis.del(key);
}
1234567

总结,缓存过期时间 + 双删,进一步可减小数据不一致影响。

分布式读写锁

以上分析得知要解决的关键点是淘汰缓存与更新库表两操作之间,其它请求产生的数据不一致问题。我们可以采用锁,将淘汰缓存与更新库表放入同一把写锁中,与其它读请求互斥,防止其间产生旧数据。读写互斥、写写互斥、读读共享,可满足读多写少的场景数据一致,也保证了并发性。







红色区为临界区,读写之间互斥,数据一致性得到保证。

值得一提的是,基于Redis分布式锁一般需要设置过期时间,否则在锁其间如果发生宕机则锁得不到释放,导致系统阻塞。过期时间应当根据逻辑平均运行时间、响应超时时间来确定。

伪代码

public void write() {
Lock writeLock = redis.getWriteLock(lockKey);
writeLock.lock();
try {
redis.delete(key);
db.update(record);
} finally {
writeLock.unlock();
}
}

public void read() {
if (caching) {
return;
}
// no cache
Lock readLock = redis.getReadLock(lockKey);
readLock.lock();
try {
record = db.get();
} finally {
readLock.unlock();
}
redis.set(key, record);
}
12345678910111213141516171819202122232425

总结,在高并发读多写少的场景下,采用分布式读写锁可避免多种极端情况下数据不一致问题。

其它方案

另外一种方案是采用串行化解决,即读写请求入队列,工作线程从队列中取任务来依次执行,这种借鉴了无锁思想。

具体可参考:缓存与数据库一致性保证

总结

通过一系列的分析,在缓存与数据库一致性方案中,最佳方案是采用先淘汰缓存后更新库表策略,通过缓存过期机制、双删可以满足绝大部分场景需求,但在高并发、对于数据一致性时延要求较高的场景中,可采用分布式读写锁策略。

当然,脱离业务谈技术的都是耍流氓,具体选型还是得根据具体业务来分析。



用户头像

我们始于迷惘,终于更高的迷惘. 2020.03.25 加入

一个酷爱计算机技术、健身运动、悬疑推理的极客狂人,大力推荐安利Java官方文档:https://docs.oracle.com/javase/specs/index.html

评论 (1 条评论)

发布
用户头像
读写锁部分有问题吧
Lock readLock = redis.getReadLock(lockKey);
readLock.lock();
try {
record = db.get();
} finally {
readLock.unlock();
}

---- 这个位置如果有write(),会产生不一致问题吧


redis.set(key, record);

展开
2020 年 11 月 25 日 22:41
回复
没有更多了
缓存与数据库一致性策略