【Cache System】缓存一致性 - Solution Discussion

Posted by 西维蜀黍 on 2023-02-10, Last Modified on 2023-09-05

策略1 - 先更新数据库,再更新缓存

分析一(线程安全角度)

同时有请求A和请求B进行更新操作,那么会出现

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

一般来说,请求A更新缓存应该比请求B更新缓存早才对,但理论上,也可能因为网络等原因(虽然概率相对较低),B却比A更早更新了缓存。这就导致了脏数据。

Potential Solution

  1. 在更新缓存时,加锁

分析二(业务场景角度)

如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求。而且,如果你写入数据库的值,并不等于要接写入缓存的值,而是后者是根据前者,经过一系列复杂的计算才得到的。那么,每次写入数据库后,为了写入缓存而进行复杂的运算,无疑是浪费性能的。显然,删除缓存更为适合。

这一点在 https://swsmile.info/post/cache-invalidation-vs-cache-update/ 有讨论到。

策略2 - 先更新数据库,再删缓存

这其实就是 Cache Aside pattern,refer to https://swsmile.info/post/cache-aside/

是不是Cache Aside就一定不会有并发问题了?不是的,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让 cache 失效。

然后,由于某些原因(比如网络拥塞了),读操作读到的旧数据被写入到了 cache。

这时,仍然出现了脏数据。

但,这个case理论上会出现。不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时,出现缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还可能锁表,而读操作必需在写操作前进行数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。

当然,还有其他的可能也会导致脏数据,比如

  • 如果使用了MySQL Master-Slave,当出现DB delay的时候,当完成写操作且触发 invalidate cache 操作后,从Slave DB读取数据,且读取到的数据是更新前的旧数据(由于 DB delay),从而使得重新写入 cache 中的数据仍然是旧数据。

Solution

Solution 1

如果把 invalidate/purge cache 改成 update cache,则可以避免这个问题

Solution 2 - 延时双删策略(缓存双淘汰法)

采用延时双删策略(缓存双淘汰法),可以将前面所造成的缓存脏数据,再次删除,即:

  1. 先写数据库

  2. 删除缓存

  3. 休眠若干时间,再次删除缓存

    • 这一步可以这样实现:

      • Solution 1:在第一次删除缓存后,开启一个线程,并让这个线程在1s后,执行再次删除
      • Solution 2:通过读取DB的binlog和一个消息队列来实现再次删除
        • 这样做的好处是对于业务的侵入性更小

Analysis

  • single source of truth 为 DB
  • 这里具体休眠多久要结合业务情况考虑
  • 如果考虑到删除可能失败,再增加删除失败时的重试机制

策略3 - 先删缓存,再更新数据库

先写数据库 vs 先删缓存(Purger Cache)

OK,当写操作发生时,假设删缓存(purge)作为对缓存通用的处理方式,又面临两种抉择:

  1. 先写数据库,再删缓存
  2. 先删缓存,再写数据库

究竟采用哪种时序呢?

对于一个不能保证事务性的操作,一定涉及“哪个任务先做,哪个任务后做”的问题,解决这个问题的方向是:

如果出现不一致,谁先做对业务的影响较小,就谁先执行。

由于写数据库与删缓存不能保证原子性,谁先谁后同样要遵循上述原则。

假设先写数据库,再删缓存:第一步写数据库操作成功,第二步删缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。


假设先删缓存,再写数据库:第一步删缓存成功,第二步写数据库失败,则只会引发一次Cache miss。

但是需要注意的是,可能存在这样的race condition(特别是当读QPS很高的时候)

  1. App1 invalidates cache
  2. App2 reads cache, cache miss
  3. Thus App2 reads from DB, and gets the old value
  4. App2 saves into cache with the old value
  5. App1 writes DB with the new value

As a result, stale data occurs.

Possible Solution

  1. 用 distristibuted lock, 当然,就可能suffer performance了

Better Solution

“Servilise” Storage Layer

上述缓存架构有一个缺点:业务方需要同时关注缓存与DB,有没有进一步的优化空间呢?有两种常见的方案,一种主流方案,一种非主流方案(一家之言,勿拍)。

主流优化方案是服务化:加入一个服务层,向上游提供帅气的数据访问接口,向上游屏蔽底层数据存储的细节,这样业务线不需要关注数据是来自于cache还是DB。

异步缓存更新

非主流方案是异步缓存更新:业务线所有的写操作都走数据库,所有的读操作都走总缓存,由一个异步的工具来做数据库与缓存之间数据的同步,具体细节是:

  1. 要有一个init cache的过程,将需要缓存的数据全量写入cache
  2. 如果DB有写操作,异步更新程序读取binlog,更新cache

在(1)和(2)的合作下,cache中有全部的数据,这样:

  • 业务线读cache,一定能够hit(很短的时间内,可能有脏数据),无需关注数据库
  • 业务线写DB,cache中能得到异步更新,无需关注缓存

这样将大大简化业务线的调用逻辑,存在的缺点是,如果缓存的数据业务逻辑比较复杂,async-update(异步更新)的逻辑可能也会比较复杂。

Reference