缓存与数据库一致性
缓存与数据库一致性
本文主要讨论独立缓存(redis等)和数据库的问题,需要和多个服务节点的内存缓存和数据库的问题进行区分
后者写入数据库以后,因为服务间rpc代价过高(包括失败代价),没办法再通知全部服务的去修改内存缓存,只能让每个服务的内存缓存自己做定时加载
另外,在正式讨论这个问题之前,必须知道在非强一致性协议下,无法做到完全的一致性,基于缓存的系统必须要容忍有不一致的时刻
对独立缓存(redis等)和数据库的操作可以分为两类:
读更新操作
先读缓存,读不到从数据库中读数据,成功后写入缓存
写操作
更新数据库 - 操作缓存,具体还可以继续分类成两种方案:
- 更新数据库-更新缓存
- 更新数据库-删除缓存
不管是什么方案,最后都会遇到两种问题
失败重试
做完一个操作后,另外一个操作失败,如何重试的问题
并发后数据长期不一致
首先由于缓存和数据库不可能同时更新,因此缓存和数据库的值短期无法保证强一致,需要业务系统对此有一定的容忍度。
这里的并发一致性问题,主要是指读更新操作-写操作、写操作-写操作出现ABBA问题以后,缓存和数据库的值长期不一致
由于缓存内一直有值,所以读-更新操作不能正确的更新缓存,
只有当新的写请求进入来更新缓存,或者当缓存过期造成读更新操作。才有可能恢复一致性。
失败重试问题
由于失败重试不需要分场景分析,因此先进行说明
具体步骤如下:
- 把操作生成消息,暂存到消息队列中
- 当操作数据库和缓存都操作成功时,把这些消息从消息队列中去除(丢弃),以免重复操作
- 当操作数据库或缓存失败时,执行失败策略,重试服务从消息队列中重新读取(消费)这些消息,然后再次进行操作
- 操作失败时,需要再次进行重试,重试超过的一定次数,向业务层发送报错信息
并发后数据长期不一致问题
更新数据库-更新缓存
先更新数据库
数据库 | 缓存 | |
---|---|---|
T1 | 线程A更新数据库(value = 1) | |
T2 | 线程B更新数据库(value = 2) | |
T3 | 线程B更新缓存(value = 2) | |
T4 | 线程A更新缓存(value = 1) | |
最终 | value = 2 | value = 1 |
解决办法
这里只能上分布式锁了,由于已经上了缓存系统,一定是对性能有一些要求的,因此只能引入乐观锁
写入数据库后得到数据的版本号,设定缓存数据只允许高版本号覆盖低版本号即可
先更新缓存
数据库 | 缓存 | |
---|---|---|
T1 | 线程A更新缓存(value = 1) | |
T2 | 线程B更新缓存(value = 2) | |
T3 | 线程B更新数据库(value = 2) | |
T4 | 线程A更新数据库(value = 1) | |
最终 | value = 1 | value = 2 |
解决办法
和先更新数据库类似,需要引入乐观锁。
但是这里由于先写缓存,缓存的数据又是易失的,因此从缓存实现的全局自增版本号会更复杂一些。
更新数据库-删除缓存(推荐)
先删除缓存
数据库 | 缓存 | |
---|---|---|
T1 | 线程A删除缓存 | |
T2 | 线程B读取缓存,读到空值 | |
T3 | 线程B读取数据库更新缓存(value = 2) | |
T4 | 线程A更新数据库(value = 1) | |
最终 | value = 1 | value = 2 |
这个方案线程B什么时候更新缓存没区别,只要线程A的写操作两步骤之间有线程B的读操作,就一定会导致并发后数据长期不一致的问题
解决方案
延时双删
1 | cache.delKey |
数据库 | 缓存 | |
---|---|---|
T1 | 线程A删除缓存 | |
T2 | 线程B读取缓存,读到空值 | |
T3 | 线程B读取数据库更新缓存(value = 2) | |
T4 | 线程A更新数据库(value = 1) | |
T5 | 线程A sleep后删除缓存 | |
T6 | 线程C读取缓存,读到空值 | |
T7 | 线程C读取数据库更新缓存(value = 1) | |
最终 | value = 1 | value = 1 |
先更新数据库(推荐)
数据库 | 缓存 | |
---|---|---|
T1 | 线程A读取缓存,读到空值 | |
T2 | 线程A读取数据库(value = 1) | |
T3 | 线程B更新数据库(value = 2) | |
T4 | 线程B删除缓存 | |
T5 | 线程A更新缓存(value = 1) | |
最终 | value = 2 | value = 1 |
比起先更新缓存,这个场景的触发条件苛刻的多,必须要ABBA场景才能触发。
由于线程B更新数据库必须要加锁,因此不会线程B更新操作到一半的时候,线程A读取数据库
见下面表格,读取乱序是不可能出现的。
读写乱序 | 数据库 |
---|---|
T1 | 线程B更新数据库 K1 start |
T2 | |
T3 | |
T4 | 线程B更新数据库 K1 end |
在读写顺序的前提下,线程B更新数据库+删除缓存两个操作,比线程A更新缓存一个操作更快,才有可能出现上面分析的不一致的情况
读写顺序 | 数据库 |
---|---|
T1 | 线程A读取数据库start |
T2 | 线程A读取数据库end |
T3 | 线程B更新数据库start |
T4 | 线程B更新数据库end |
因此大大降低了发生的概率。
解决办法
延时删除
1 | db.update |
只需要在删除缓存前稍微sleep一下,就可以解决问题了
数据库 | 缓存 | |
---|---|---|
T1 | 线程A读取缓存,读到空值 | |
T2 | 线程A读取数据库(value = 1) | |
T3 | 线程B更新数据库(value = 2) | |
T5 | 线程A更新缓存(value = 1) | |
T6 | 线程B sleep后删除缓存 | |
最终 | value = 2 | value = nullptr(将在下次读穿透到数据库后更新缓存) |
总结
首先需要解决操作缓存和操作数据库,双写带来的失败重试问题
然后在具体的方案选型中
更新数据库 - 更新缓存不推荐,这个方案不太符合直觉,缓存就应该是易失的,LRU淘汰和超时淘汰的,不应该去强制维护
如果一定要使用这个方案,那么为了解决,引入乐观锁来解决并发后长期不一致的问题,先更新数据库对于实现乐观锁来说更为简单
更新数据库 - 删除缓存的方案(推荐)
先更新数据库的方案更为简单,对存储的影响也小(另外一个方案的延时双删需要操作两次数据库)
并且这个方案有facebook论文《Scaling Memcache at Facebook》的背书,是在大规模场景下经受过考验的
参考资料
以下三篇资料或多或少都有我认为不正确或者不完备的地方,不过我也是纸上谈兵而已,期待遇到需要解决这种问题的场合吧。
-
这一篇的无并发情况对应我文章的失败重试,他的解决方案消息队列+异步重试。只把一个操作写入消息队列了,没有考虑到写消息队列失败的场景,我认为不太合适,需要将操作写入消息队列以后,拉消息一起操作才行。
-
这一篇没有区分失败重试问题和并发后数据长期不一致问题
-
这一篇不是围绕缓存-数据库系统来写的,只介绍了Cache Aside Pattern也就是先更新数据库再删除缓存,没有分析为什么要这么实现
-
2022-07-17
在翻看C++标准库源码的时候,突然发现std::sort的实现和我想的很不一样,并不是简单的快排
在查询了一些资料以后,大致搞明白了这个introsort的原理
这篇写的很好:知无涯之std::sort源码剖析
另外就是STL源码分析那一本写的很好,不是本文的重点,简单过一下,我的gcc版本是5.4.0
introsort原理