缓存综合解决方案

予早 2025-02-21 01:08:23
Categories: Tags:

https://qinjianmin.github.io/2023/12/19/cache-consistency/

缓存模式

Cache Aside,缓存旁路

读请求,和写请求的处理,其中写请求也可以考虑更新缓存

缓存一致性方案

以Redis与MySQL数据一致性保证为例。

本质上缓存一致性问题就是并发问题在缓存场景下的体现,所以解决缓存执行性问题要从可见性、原子性、有序性三个角度分别分析。

通常缓存模式采用Cache Aside,接下来分别分析缓存一致性方案。

可见性

缓存旁路中,从MySQL查询数据保存到Redis,或者,更新MySQL数据然后更新(或删除)Redis数据,整个过程中,并发线程使用各自上下文,无可见性问题。

原子性

关于缓存旁路,读操作没有任何问题,写操作则需要分析两个问题:

  1. 先操作数据库还是先操作缓存
  2. 操作缓存时是应该更新缓存还是删除缓存(就是数据库更新后,立即由写线程更新缓存,还是写线程删除缓存,由读线程重建缓存)

先操作数据库再操作缓存

先操作缓存再操作数据库:若缓存操作成功,数据库操作失败,这时需要反过来重建缓存;在一些场景下,最新数据只有经过数据库执行之后才能获得,例如update staff set score= score + 10 where is = 5;或者调用了数据库函数,这时无法先更新缓存

先操作数据库再操作缓存:若数据库操作失败,事务回滚,缓存保持不变即可

另外,数据库与缓存两者中主体是数据库,缓存应当与数据库保持一致

操作缓存时应当删除缓存

方案一

  1. Redis设置带TTL的缓存
  2. 读线程,首先尝试获取缓存
    1. 获取到直接返回结果
    2. 获取不到则认为过期,尝试加锁lock:id
      1. 加锁失败,读线程阻塞(两种方案,直接返回空数据,或者阻塞等待可以获取锁,业务上是希望返回数据的)
      2. 加锁成功,重新查询缓存看是否有其他线程重建缓存,若有直接返回缓存数据,若无则查询数据库然后重建缓存,最后释放锁
  3. 写线程,首先加分布式锁lock:id,然后更新数据库,更新缓存,释放锁

通过分布式锁的方式将读线程重建缓存写线程操作数据库及缓存的两个系列操作分别变成可打断但不可同时执行的操作,保证了MySQL与Redis数据的一致性。

写线程操作完数据库后也可以选择删除缓存而不是更新缓存。

写线程采用删除缓存的方式有一个优点,就是在不加分布式锁的情况下,并发写操作删除缓存不会存在有序性问题,而更新缓存会存在顺序性问题。

若采用删除缓存的方式,可以去掉写线程的分布式锁,但是为了保证数据一致,写线程中从数据库查数据采用当前读,先当于用数据库锁负责保证重建缓存和更新数据库的互斥操作,读线程的重建缓存的分布式锁则用于降低数据库加锁的压力。

方案二

方案一核心思想是将重建缓存和更新数据库两个操作达成互斥来进行的,这个思路保持不变,在方案一的基础上进行一些高并发适应改造。

由于高并发请求,写是一定会加锁的,这个没有什么优化空间,主要是删除缓存比更新缓存更好一点。主要是对读线程的优化,实际执行过程中,当一个读线程重建缓存,其他若干读线程都需要阻塞等待,这一点不利于高并发,所以可以采用逻辑过期方式。

  1. Redis设置不带TTL的两个缓存,cache:id:datacache:id:ttl
  2. 读线程,首先尝试获取缓存(基于Lua脚本完成获取以及TTL的判断)
    1. 获取到直接返回结果
    2. 获取不到则认为过期,尝试加锁lock:id
      1. 加锁失败,返回旧数据
      2. 加锁成功,重新查询缓存看是否有其他线程重建缓存,若有直接返回缓存数据,若无则查询数据库然后重建缓存,最后释放锁
  3. 写线程,首先更新数据库,然后使得缓存逻辑过期,就是更新cache:id:ttl为当前时间

注意,上面读线程依然是当前读,不过快照读也会存在问题。

上面的问题在于,若读线程重建缓存时,查询完数据库,在更新缓存之前,一个写线程完成了更新数据库和删除缓存的流程,就会导致重建完缓存之后数据不一致

然后使用Canal兜底即可。

方案三

读线程和方案二一样,写线程只负责修改数据,Canal负责将修改之后的数据同步到缓存