缓存与数据库双写,不一致问题及解决方案
本文最后更新于 1569 天前,其中的信息可能已经有所发展或是发生改变。

参考文章https://www.cnblogs.com/yanglang/p/9098661.htmlyanglang

为什么写这篇文章?

因为缓存相对于数据库,有更好的性能和并发能力,所以我们会使用缓存将数据分为热数据和冷数据,将热数据放在缓存中,加快数据的读取并减小数据库的压力。但是使用缓存就随之伴随着缓存与数据库双写的问题。公司的项目最近因为更新策略的选择失误,导致了比较大个问题。导致一个活动的所有数据都是脏数据,所以需要调研目前的更新策略的优势和劣势。

对于缓存读取的,应该只有一种策略,那就是先读取缓存,如果缓存数据不在会读取数据库的数据写入缓存。其实相对来说,或者有些业务场景会直接读取数据库的数据来保持强一致性,这就不在我们的讨论范围了,因为这种情况属于跳过缓存直接读取数据库,等于没有用缓存的情况。

我们主要讨论的就是更新策略,对于更新完数据库,是更新缓存呢,还是删除缓存。又或者是先删除缓存,再更新数据库。这都是需要讨论的点。

更新策略

其实缓存更新策略就分为两大类四种

  1. 先更新缓存,后更新数据库(我们使用的方式)
  2. 先更新数据库,后更新缓存
  3. 先删除缓存,后更新数据库
  4. 先更新数据库,后删除缓存

首先,我们的前提条件是,是可以接受数据的短暂不一致,不是强一致,而是最终一致性。这样我们才有讨论的空间,因为数据库修改相对于缓存慢得多,不管选择哪种策略,缓存和数据库中的数据都会不一致。

其实相对来说,保持最终一致性的最有效的武器就是设置缓存的失效时间,这样会使得缓存和数据库最终一致,但是业务场景是千变万化,有时候等不到缓存失效的时间,所以才会选择更新策略。

1.先更新缓存,后更新数据库(我们使用的方式)

这种情况,非常不建议采用。

原因1

  1. 线程1修改了缓存
  2. 线程2修改了缓存
  3. 线程2更新了数据库
  4. 线程1更新了数据库

这种情况就是导致缓存的数据和数据库的数据不一致,导致数据错误。而且这种情况相对于策略2来说出现的概率更高,因为数据库的更新要比缓存慢得多,从而导致步骤1和步骤2的间隔变大。而缓存更好的读写性能从而增大了数据错误的概率。所谓成也萧何,败也萧何。

原因2

正常我们是要保证数据库中的数据一定正确。如果我们按照上面的步骤的话,会不会导致修改数据的顺序和实际的顺序不同呢,而且如果更新缓存成功后更新数据库失败也会导致数据错误。

为什么之前没有出错

但是为什么我们使用这个更新策略,一直没问题,直到最近才出问题呢。是因为我们缓存的数据只用于展示数据,服务端这边如果要先读后写的操作都是直接读取数据,这样就算缓存里的数据是错误的,只是影响数据的展示,并不影响数据的修改。但是因为并发量的增加,导致两个功能负载太高。所以一个同事就将数据改为缓存读取,最后导致所有数据都错乱了。

2.先更新数据库,后更新缓存

这个方案同样不建议采用。

原因

  1. 线程1更新了数据库
  2. 线程2更新了数据库
  3. 线程2更新了缓存
  4. 线程1更新了缓存

这就会导致数据错误,本来应该线程1先更新缓存,但是因为其他原因导致线程2先更新缓存,从而导致数据不同步

番外.为什么不建议采用更新缓存

  1. 如果导致了缓存数据和数据库数据不一致的情况。直到缓存被淘汰之前,数据其实一直都是错误的。
  2. 对于写操作比较多的情况,会导致每次修改数据之后都需要更新缓存,其实我们并不需要频繁更新数据。这样反而浪费性能。
  3. 如果我们想要的数据是经过计算得出的,那我们一般会把最终结果存入缓存,这样的每次更新数据之后,都需要进行一次计算,这样也会导致性能的浪费。

那我们接下来就是讨论删除缓存的情况。那么我们应该先删缓存还是先更新数据库呢。

3.先删除缓存,再更新数据库

其实这种情况会导致数据的不一致,原因如下:

原因

  1. 线程1进行写操作
  2. 线程1删除缓存
  3. 线程2进行读操作
  4. 线程2读取数据库旧数据
  5. 线程2将数据写入缓存
  6. 线程1更新数据库

上述情况就会导致数据最终也是不一致的。

其实这种情况是有一个不错的解决方式的,就是延迟双删策略

  1. 删除缓存
  2. 更新数据
  3. 休眠线程
  4. 删除缓存

这样的话可以将休眠时间之间造成脏数据删除。

那么,这个时间怎么确定的,具体该休眠多久呢?
针对上面的情形,读者应该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

采用这种同步淘汰策略,吞吐量降低怎么办?
其实如果你仔细想过这个策略的话,我们会发现,其实第二次删除完全不用当前线程来做。我们可以异步来进行数据的删除。这样当前线程就不用阻塞了。

4.先更新数据库,再删除缓存

我看了网上大多数的博文,都是比较建议这种更新策略的。而且《Cache-Aside pattern》。其中就指出

失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。

命中:应用程序从cache中取数据,取到后返回。

更新:先把数据存到数据库中,成功后,再让缓存失效。

这种情况就是完美的吗,如果你是位老司机的话,你就会知道世界上并没有完美的东西。这种策略也会有并发问题。

缓存中存在数据

这种情况我没有想到会出现脏数据的情况,其实会出现短暂的数据不一致。但是我在文章开头就说了。我们是要保证最终一致性,而不是强一致性。

缓存中不存在数据或缓存中的数据被淘汰

这样就会有这种情况

  1. 线程1查询数据库,得到旧值
  2. 线程2修改数据库
  3. 线程2删除缓存
  4. 线程1将旧址写入缓存

这样就会产生数据错误,导致脏数据产生。

但是其实仔细分析的上述情况的话,就会发现这是一个比较极端的情况。就是修改数据库要比读取数据库快,这样才会导致删除缓存早于线程1将数据写入数据库。但是我们都知道,数据库的读取是要比写入快的。所以这种情况只有在比较极端的情况才会产生。

其实上述情况也是可以解决的,就是使用异步延时删除策略,保证读请求完成以后,再进行删除操作。

所以相对来说这种更新策略是比较适用于大多数场景的。

番外.删除缓存存在的问题

问题1

这个番外是在查资料的时候才想到确实有这个问题。原博文我已经放在文章开头了。

如果缓存删除失败就会产生错误的数据,这也是需要考虑的问题。

如何解决?
我们需要设计一个重试模块,当我们删除数据的时候,如果删除失败,我们将重试删除操作。
方案
如下图所示

image-20200201204033815

流程如下所示

  1. 更新数据库
  2. 删除缓存数据失败
  3. 将需要删除的缓存key发送到消息队列
  4. 独立的删除模块进行缓存的删除
  5. 如果删除模块删除失败,会继续删除操作,直到成功

其实我认为这种情况应该适用于大多数情况,我引用的那篇博文还写了订阅binlog的情况,这种我觉得不是非常实用,有兴趣的同学可以去看一下我引用的博文。

问题2

这个问题就是如果我们用了读写分离的话,相对来说。每个从库同步主库的数据都会有一些延时,如果我们用了一个公共缓存的话,就会导致主库已经更新了数据,从库还是旧数据,从而导致:

  1. 线程1修改数据库
  2. 线程1删除缓存
  3. 线程2读取到从库的旧数据
  4. 线程2写入缓存

这样就会导致缓存数据错误。这种情况需要的前提条件就是读取操作早于主从同步完成的时间。其实策略3和策略4都有这种问题。

如何解决?

这种情况就是需要采用异步延时删除策略,这个就可以解决这种情况。我们首先获得同步时间,然后延迟合适的时间进行第二次删除,这样就可以解决这种问题。

总结

最后分析四种策略我们得出的最优策略就是先更新数据库,后删除缓存的策略。但是这个也不是绝对的。每种业务场景都有不同的解决方式。有些情况就不能删除缓存,只能使用更新缓存的场景。例如排行榜。这种场景一般都是更新缓存而不是删除缓存。

原创声明
本文由 makese 于2020年02月01日发表在 我的博客
如未特殊声明,本站所有文章均为原创;你可以在保留作者及原文地址的情况下转载
转载请注明:缓存与数据库双写,不一致问题及解决方案 | 我的博客
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇