缓存数据一致性问题(转载)

1. 缓存数据不一致

数据库的瓶颈是大家有目共睹的,高并发的环境下,很容易 I/O 锁死。当务之急,就是把常用的数据,给捞到速度更快的存储里去。

这个更快的存储,就有可能是分布式的,比如 Redis,也有可能是单机的,比如 Caffeine。

但一旦加入缓存,就不得不面对一个蛋疼的问题:数据的一致性。

数据不一致的问题,人世间多了去了。进修过 Java 多线程的同学,肯定会对 JMM 的模型记忆犹新。一个数值,只要同时在两个地方存储,那就会产生问题。

但缓存系统和数据库,比 JMM 更加的不可靠。因为分布式组件更加的脆弱,它随时都可能发生问题。

2. Cache Aside Pattern

2.1. 读过程

怎样保证数据在 DB 和缓存中的一致性呢?现在一个比较好的最佳实践方案,就是 Cache Aside Pattern。

先来看一下数据的读取过程,规则是:先读 Cache,再读 DB。

详细步骤如下:

  1. 每次读取数据,都从 Cache 里读。
  2. 如果读到了,则直接返回,称作 cache hit。
  3. 如果读不到 Cache 的数据,则从 DB 里面捞一份,称作 cache miss。
  4. 将读取到的数据,塞入到缓存中,下次读取的时候,就可以直接命中。

2.2. 写过程

规则是:先更新 DB,再删除缓存 。详细步骤如下:

  1. 将变更写入到数据库中
  2. 删除缓存里对应的数据

说到这里,我看着有几个人皱起了眉头。我知道,肯定会有人不服气,认为自己那一套是对的。

比如,为什么是删除缓存,不是更新缓存呢?效率会不会更低?为什么不先删除缓存再更新数据库?

2.3. 为什么是删除缓存,而不是更新缓存?

这个比较好理解。当多个更新操作同时到来的时候,删除动作,产生的结果是确定的;而更新操作,则可能会产生不同的结果。

如上图,两个请求 A 和 B,请求 B 在请求 A 之后,数据是最新的。

由于缓存的存在,如果在保存的时许发生稍许的偏差,就会造成 A 的缓存值覆盖了 B 的值,那么数据库中的记录值,和缓存中的就产生了不一致,直到下一次数据变更。

而使用删除的方式,由于缓存会 Miss,所以会每次都会从 DB 中获取最新的数据进行填充,与缓存操作的时机关系不大

2.4. 为什么不先删缓存,再更新数据库?

这个问题是类似的。我们甚至都不需要并发写的场景就能发现问题。

我们上面提到的缓存删除动作,和数据库的更新动作,明显是不在一个事务里的。

如果一个请求删除了缓存,同时有另外一个请求到来,此时发现没有相关的缓存项,就从数据库里加载了一份到缓存系统。

接下来,数据库的更新操作也完成了,此时数据库的内容和缓存里的内容,就产生了不一致。

如上图,写请求首先删除了缓存。结果在这个时候,有其他的读请求,将数据库的旧值,读取到数据库中,此时缓存中的数据是 0。

接下来更新了 DB,将数据库记录改为了 100。经过这么一哆嗦,数据库和缓存中的数据,就产生了不一致。

还有 Read Through Pattern,Write Through Pattern,Write Behind Caching Pattern 等其他常见的缓存同步模式,这些方式使用的也非常广泛,但由于对业务大多数是无感知的,所以很多人都忽略了。换句话说,这几个模式,大多数是在一些中间件,或者比较底层的数据库中实现的,写业务代码可能接触不到这些东西。

  • Read Through,其实就是让你对读操作感知不到缓存层的存在。通常情况下,你会手动实现缓存的载入,但 Read Through 可能就有代理层给你捎带着做了。
  • Write Through,你不用再考虑数据库和缓存是不是同步了,代理层都给你做了,你只管往里塞数据就行。Read Through 和 Write Through 是不冲突的,它们可以同时存在,这样业务层的代码里就没有同步这个概念了。
  • 至于 Write Behind Caching,意思就是先落地到缓存,然后有异步线程缓慢的将缓存中的数据落地到 DB 中。

要用这个东西,你得评估一下你的数据是否可以丢失,以及你的缓存容量是否能够经得起业务高峰的考验。

现在的操作系统、DB、甚至消息队列如 Kafka 等,都会在一定程度上践行这个模式。但它现在和我们的业务需求没半点关系。

Cache Aside Pattern 也有问题

2.5. 数据库更新但删缓存失败的不一致问题

如果数据库更新成功了,但缓存删除失败了,也会造成缓存不一致。

这个问题问的好啊,故障大多数就是由于这些极端情况造成的。这个时候就有意思了,我们要拼概率,毕竟没有 100% 的安全套。总监笑了。

  • 方法一:将数据更新和缓存删除动作,放在一个事务里,同进退。

  • 方法二:缓存删除动作失败后,重试一定的次数。如果还是不行,大概率是缓存服务的故障,这时候要记录日志,在缓存服务恢复正常的时候将这些 key 删除掉。

  • 方法三:再多一步操作,先删缓存,再更新数据,再删缓存。这样虽然操作多一些,但也更保险一些。

上面那张看起来正确的图,其实是错误的。为什么呢?因为数据在从数据库读到缓存中的操作,并不是原子性的。

比如上图,当缓存失效(或者被删除)的时候,有一个读请求正好到来。这个读请求,拿到了旧的数据库值,但它由于多方面的原因(比如网络抽风),没有立马写入到缓存中,而是发生了延迟。

在它打算写入到缓存的这段时间,发生了很多事情,有另外一个请求,将数据库的值更新为 200,并删除了缓存。

直到第二个请求全部完成,第一个请求写入缓存的操作,才真正落地。但其实,这时候数据库和缓存的值,已经不是同步的了。

那么为什么大家在平常的设计中,几乎把这个场景给忽略掉了呢?因为它发生的概率实在太低了。

它要求在读取数据的时候,有两个或者多个并发写操作(或者发生了数据失效),这在实际的应用场景中实在是太少了。

而且,我们要注意虚线所持续的周期,是一个数据库的更新操作,加上一个 Cache 的删除操作,这个操作一般情况下,也会比缓存的设置持续的时间长,所以进一步降低了概率。


转载自同事乱用缓存,CTO发飙了

发表评论

邮箱地址不会被公开。 必填项已用*标注