缓存与数据库一致性

缓存与数据库一致性

本文主要讨论独立缓存(redis等)和数据库的问题,需要和多个服务节点的内存缓存和数据库的问题进行区分

后者写入数据库以后,因为服务间rpc代价过高(包括失败代价),没办法再通知全部服务的去修改内存缓存,只能让每个服务的内存缓存自己做定时加载

另外,在正式讨论这个问题之前,必须知道在非强一致性协议下,无法做到完全的一致性,基于缓存的系统必须要容忍有不一致的时刻

对独立缓存(redis等)和数据库的操作可以分为两类:

  • 读更新操作

    先读缓存,读不到从数据库中读数据,成功后写入缓存

  • 写操作

    更新数据库 - 操作缓存,具体还可以继续分类成两种方案:

    • 更新数据库-更新缓存
    • 更新数据库-删除缓存

不管是什么方案,最后都会遇到两种问题

  • 失败重试

    做完一个操作后,另外一个操作失败,如何重试的问题

  • 并发后数据长期不一致

    首先由于缓存和数据库不可能同时更新,因此缓存和数据库的值短期无法保证强一致,需要业务系统对此有一定的容忍度。

    这里的并发一致性问题,主要是指读更新操作-写操作、写操作-写操作出现ABBA问题以后,缓存和数据库的值长期不一致

    由于缓存内一直有值,所以读-更新操作不能正确的更新缓存,

    只有当新的写请求进入来更新缓存,或者当缓存过期造成读更新操作。才有可能恢复一致性。

失败重试问题

由于失败重试不需要分场景分析,因此先进行说明

具体步骤如下:

  1. 把操作生成消息,暂存到消息队列中
  2. 当操作数据库和缓存都操作成功时,把这些消息从消息队列中去除(丢弃),以免重复操作
  3. 当操作数据库或缓存失败时,执行失败策略,重试服务从消息队列中重新读取(消费)这些消息,然后再次进行操作
  4. 操作失败时,需要再次进行重试,重试超过的一定次数,向业务层发送报错信息

并发后数据长期不一致问题

更新数据库-更新缓存

先更新数据库

数据库 缓存
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
2
3
4
cache.delKey
db.update
sleep
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 线程A读取数据库 K1 start(不可能,K1已加锁)
T3 线程A读取数据库 K1 end(不可能,K1已加锁)
T4 线程B更新数据库 K1 end

在读写顺序的前提下,线程B更新数据库+删除缓存两个操作,比线程A更新缓存一个操作更快,才有可能出现上面分析的不一致的情况

读写顺序 数据库
T1 线程A读取数据库start
T2 线程A读取数据库end
T3 线程B更新数据库start
T4 线程B更新数据库end

因此大大降低了发生的概率。

解决办法

延时删除

1
2
3
db.update
sleep
cache.delKey

只需要在删除缓存前稍微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》的背书,是在大规模场景下经受过考验的

参考资料

以下三篇资料或多或少都有我认为不正确或者不完备的地方,不过我也是纸上谈兵而已,期待遇到需要解决这种问题的场合吧。