0%

3种常用的缓存读写策略

缓存的目的是为了减少数据库的压力,提升性能,但由于2个数据源之间是没有事务的,使用缓存可能会导致数据不一致的问题。

从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。

常见的缓存读写策略有:

  • Cache Aside Pattern(旁路缓存模式)
  • Read/Write Through Pattern(读写穿透模式)
  • Write Behind Pattern(异步缓存写入模式)

Cache Aside Pattern(旁路缓存模式)

Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。

写流程

  1. 先更新DB
  2. 再删除缓存

读流程

  1. 从 cache 中读取数据,读取到就直接返回
  2. cache中读取不到的话,就从 DB 中读取数据返回
  3. 再把数据放到 cache 中

缺点

  • 首次请求数据一定不在 cache 的问题

    解决办法:

    • 可以将热点数据可以提前放入cache 中。
  • 写操作比较频繁的话导致cache中的数据会被频繁被删除,这样会影响缓存命中率 。

    解决办法:

    • 数据库和缓存数据强一致场景 :更新DB的时候同样更新cache,不过我们需要加一个锁/分布式锁来保证更新cache的时候不存在线程安全问题。
    • 可以短暂地允许数据库和缓存数据不一致的场景 :更新DB的时候同样更新cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。

方案分析比较

写流程理论上有四种方案:

  1. 先更新缓存,在更新数据库
  2. 先更新数据库,再更新缓存
  3. 先删除缓存,再更新数据库
  4. 先更新数据库,再删除缓存

先更新缓存,在更新数据库

问题:如果更新缓存后,数据库回滚了,那缓存如何处理?

解决思路:数据发生了回滚,即出现异常,这里做一个异常回调,删除对应的缓存。

总结:不推荐,代码的侵入性太大,首先你要记下来 redis 之前的值,回滚的时候再写回去,如果是 insert,你得做一次 delete,如果是update,你需要update回去,如果delete你得insert,并且如果回调中发生了异常怎么办,非常麻烦。

先更新数据库,再更新缓存

问题一(线程安全角度),同时有请求A和请求B进行更新操作,可能会出现:

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

请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据。

解决思路:加锁,比如修改id为1的学生姓名,更新数据库之前,先对id=1的资源加锁,更新完数据库和缓存后,再释放锁。

总结:不推荐,加锁降低了系统的并发度,也使得系统更复杂。

问题二(业务场景角度),主要有两点:

  1. 如果写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
  2. 如果写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。

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

这样更容易造成数据不一致,如下场景:

  1. 线程A先删除缓存
  2. 线程B读取数据,从缓存中读取失败,然后从 DB 中读取数据并放入缓存中
  3. 线程A更新DB

最终,缓存和DB中的数据不一致。

解决思路:延迟双删策略,伪代码如下:

1
2
3
4
5
6
public void write(String key,Object data){
redis.delKey(key);
db.updateData(data);
Thread.sleep(1000);
redis.delKey(key);
}

转化为中文描述就是

  1. 先淘汰缓存
  2. 再写数据库(这两步和原来一样)
  3. 休眠1秒,再次淘汰缓存。这么做,可以将1秒内所造成的缓存脏数据,再次删除

那么,这个1秒怎么确定的,具体该休眠多久呢?

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

采用这种同步淘汰策略,吞吐量降低怎么办?

那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。

如果第二次删除,如果删除失败怎么办?

采用重试机制,详见方案4。

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

依然会存在数据不一致,但概率相对较小,如下场景:

  1. 线程A从DB中读取数据
  2. 线程B先更新DB,然后删除缓存
  3. 线程A将数据放入缓存中

最终,缓存和DB中的数据也不一致。

仔细对比一下方案3和方案4发生数据不一致的场景:

方案3:在删除缓存之后,更新数据库之前,如果有其他线程进行查询操作

方案4:在查询数据库之后,写入缓存之前,如果有其他线程进行更新操作

由于写缓存要比写DB要快,所以先更新DB,再删除缓存这种方式数据不一致的概率相对较小。

那么如何解决上述由于并发导致的数据不一致问题呢?

  1. 使用锁/分布式锁,读写串行执行(强一致性)

    性能低,不推荐。

  2. 异步延迟双删+失败重试(最终一致性)

    • 结合MQ实现,流程如下:

      1. 更新数据库数据
      2. 将需要删除的key发送至消息队列
      3. 消费MQ中的消息,删除缓存后提交ACK

      该方案有一个缺点:对业务线代码造成大量的侵入

    • 结合 Canal + MQ 实现,流程如下:

      1. 更新数据库数据
      2. canal订阅程序提取出所需要的数据并发送至消息队列
      3. 消费MQ中的消息,删除缓存后提交ACK

      这种方式在成功删除缓存之前,仍存在数据不一致的问题,并且实现较为复杂。

  3. 给缓存设置一个比较短的过期时间(最终一致性)

    实现简单,推荐使用。

Read/Write Through Pattern(读写穿透模式)

Read/Write Through Pattern中,服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过抽象缓存层完成的。

Read/Write Through Pattern 平时开发中非常少见。抛去性能方面的影响,还有就是常见的分布式缓存(如Redis)都没有直接和数据库交互的方案。

写流程

  1. 先查 cache,cache 中不存在,直接更新 DB
  2. cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(同步更新 cache 和 DB

读流程

  1. 从 cache 中读取数据,读取到就直接返回
  2. cache 中读取不到的话,cache 服务自己从 DB 加载,写入到 cache 后并返回

Read-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。

和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中。

Write Behind Pattern(异步缓存写入)

Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。

但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。

很明显,这种方式对数据一致性带来了更大的挑战,比如cache数据可能还没异步更新DB的话,cache服务可能就就挂掉了。

这种策略在我们平时开发过程中也非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 InnoDB Buffer Pool 机制都用到了这种策略。

Write Behind Pattern 下 DB 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。

坚持原创技术分享,您的支持将鼓励我继续创作!